feat: MedSchedule модуль — семейный медицинский помощник с Telegram/MAX ботом, n8n OCR и /med UI

This commit is contained in:
2026-07-15 09:42:55 +03:00
parent d35d0cd1ef
commit f519ce95db
15 changed files with 2960 additions and 2 deletions

View File

@@ -0,0 +1,289 @@
import { generateSummary } from "../services/llm-provider";
import { resolveChatConfig } from "../services/embedding.service";
import type { MedFamilyMember, MedMedication, MedMedicalRecord } from "@shared/schema";
export interface ParsedMedication {
name: string;
dosage?: string;
form?: string;
schedule: {
type: "daily" | "weekly" | "interval" | "as_needed";
times: string[];
days?: number[];
intervalHours?: number;
startDate: string;
endDate?: string;
};
instructions?: string;
}
export interface ParsedPurchase {
name: string;
dosage?: string;
form?: string;
quantity?: number;
unit?: string;
indication?: string;
}
export interface ParsedMedicalRecord {
title: string;
recordType: string;
summary: string;
findings: Array<{ name: string; value: string; unit?: string; isNormal?: boolean | null; referenceRange?: string | null }>;
diagnoses?: string[];
recommendations?: string[];
recordDate?: string | null;
confidence?: number | null;
rawText?: string;
}
export interface N8nOcrResult {
title: string;
recordType: string;
summary: string;
findings: Array<{ name: string; value: string; unit?: string; isNormal?: boolean | null; referenceRange?: string | null }>;
diagnoses?: string[];
recommendations?: string[];
recordDate?: string | null;
confidence?: number | null;
rawText?: string;
}
export interface DailyQuestionResult {
question: string;
contextHint?: string;
}
export interface CheckinSummaryResult {
summary: string;
alert?: string;
}
async function chat(organizationId: number, prompt: string): Promise<string | null> {
try {
const config = await resolveChatConfig(organizationId);
if (!config.summarizationEnabled) return null;
if (config.provider !== "ollama" && !config.apiKey) return null;
return generateSummary(prompt, config);
} catch (err) {
console.error("[MedSchedule AI] chat error:", err);
return null;
}
}
export async function analyzeMedicalImageWithN8n(fileUrl: string): Promise<N8nOcrResult | null> {
const webhookUrl = process.env.MED_N8N_OCR_WEBHOOK_URL;
if (!webhookUrl) {
console.error("[MedSchedule AI] MED_N8N_OCR_WEBHOOK_URL is not set");
return null;
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
const res = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileUrl, language: "ru" }),
signal: controller.signal,
});
clearTimeout(timeout);
if (!res.ok) {
console.error("[MedSchedule AI] n8n OCR webhook error:", res.status, await res.text());
return null;
}
const data = (await res.json()) as N8nOcrResult | { error?: string };
if ("error" in data && data.error) {
console.error("[MedSchedule AI] n8n OCR returned error:", data.error);
return null;
}
return data as N8nOcrResult;
} catch (err) {
console.error("[MedSchedule AI] n8n OCR request error:", err);
return null;
}
}
export async function parseMedicationFromText(
organizationId: number,
text: string
): Promise<ParsedMedication | null> {
const prompt = `Извлеки из текста информацию о лекарстве и верни ТОЛЬКО JSON объект без markdown:
{
"name": "название лекарства",
"dosage": "дозировка",
"form": "форма (таблетка, капсула, мл и т.д.)",
"schedule": {
"type": "daily|weekly|interval|as_needed",
"times": ["08:00", "20:00"],
"days": [1,3,5],
"intervalHours": 8,
"startDate": "2026-07-14",
"endDate": "2026-07-28"
},
"instructions": "инструкции"
}
days — дни недели: 0=вс, 1=пн, 2=вт, 3=ср, 4=чт, 5=пт, 6=сб. Используй только для weekly.
intervalHours — только для interval.
Если время не указано, используй ["08:00"].
Текст: """${text}"""`;
const raw = await chat(organizationId, prompt);
if (!raw) return null;
return safeJsonParse<ParsedMedication>(raw);
}
export async function parsePurchaseFromText(
organizationId: number,
text: string
): Promise<ParsedPurchase | null> {
const prompt = `Извлеки из текста (OCR с фото лекарства) информацию и верни ТОЛЬКО JSON объект без markdown:
{
"name": "название лекарства",
"dosage": "дозировка",
"form": "форма",
"quantity": 30,
"unit": "шт/таб/уп",
"indication": "для чего назначено"
}
Если количество не указано, quantity = 1, unit = "шт".
Текст: """${text}"""`;
const raw = await chat(organizationId, prompt);
if (!raw) return null;
return safeJsonParse<ParsedPurchase>(raw);
}
export async function parseMedicalRecordFromText(
organizationId: number,
text: string
): Promise<ParsedMedicalRecord | null> {
const prompt = `Ты медицинский ассистент. Проанализируй текст медицинского обследования/анализа и верни ТОЛЬКО JSON объект без markdown:
{
"title": "краткое название обследования",
"recordType": "lab_test|imaging|doctor_note|prescription|other",
"summary": "краткое резюме для неспециалиста",
"findings": [
{ "name": "название показателя", "value": "значение", "unit": "единица измерения", "isNormal": true, "referenceRange": "референсные значения" }
],
"diagnoses": ["список диагнозов, если указаны"],
"recommendations": ["рекомендации врача, если указаны"],
"recordDate": "YYYY-MM-DD или null",
"confidence": 0.95,
"rawText": "исходный текст"
}
Если дата не указана, используй null. Если референсный диапазон не указан, используй null.
Текст: """${text}"""`;
const raw = await chat(organizationId, prompt);
if (!raw) return null;
return safeJsonParse<ParsedMedicalRecord>(raw);
}
export async function generateDailyCheckinQuestion(
organizationId: number,
members: MedFamilyMember[],
medications: MedMedication[],
recentRecords: MedMedicalRecord[]
): Promise<DailyQuestionResult | null> {
const memberNames = members.map((m) => m.profileName).join(", ");
const medList = medications
.filter((m) => m.isActive)
.map((m) => `- ${m.name} (${m.dosage || "без дозировки"}) — ${members.find((x) => x.id === m.memberId)?.profileName || "неизвестно"}`)
.join("\n");
const recordSummary = recentRecords
.slice(0, 3)
.map((r) => `- ${r.title}: ${r.aiSummary || r.rawText?.slice(0, 100) || ""}`)
.join("\n");
const prompt = `Ты заботливый семейный медицинский ассистент. Каждый день ты спрашиваешь у семьи, как дела.
Члены семьи: ${memberNames || "семья"}.
Активные лекарства:
${medList || "нет"}
Недавние обследования:
${recordSummary || "нет"}
Сформулируй один короткий, дружелюбный вопрос на русском языке, на который семья может ответить текстом. Вопрос должен учитывать лекарства и обследования, если они есть. Не диагностируй, не назначай лечение.
Верни ТОЛЬКО JSON без markdown:
{
"question": "текст вопроса",
"contextHint": "почему именно такой вопрос (для админа)"
}`;
const raw = await chat(organizationId, prompt);
if (!raw) return { question: "Как вы себя чувствуете сегодня? Есть ли изменения в самочувствии?" };
const parsed = safeJsonParse<DailyQuestionResult>(raw);
if (!parsed?.question) {
return { question: "Как вы себя чувствуете сегодня? Есть ли изменения в самочувствии?" };
}
return parsed;
}
export async function summarizeCheckinAnswer(
organizationId: number,
question: string,
answer: string,
members: MedFamilyMember[],
medications: MedMedication[],
recentRecords: MedMedicalRecord[]
): Promise<CheckinSummaryResult | null> {
const memberNames = members.map((m) => m.profileName).join(", ");
const medList = medications
.filter((m) => m.isActive)
.map((m) => `- ${m.name} (${m.dosage || "без дозировки"})`)
.join("\n");
const prompt = `Ты медицинский ассистент. Проанализируй ответ семьи на ежедневный вопрос.
Вопрос: """${question}"""
Ответ: """${answer}"""
Члены семьи: ${memberNames || "семья"}.
Активные лекарства:
${medList || "нет"}
Верни ТОЛЬКО JSON без markdown:
{
"summary": "краткое резюме ответа",
"alert": "если есть тревожные сигналы (сильное ухудшение, побочные эффекты, пропуск важных лекарств) — опиши кратко. Иначе пустая строка."
}
Не ставь диагнозов. Рекомендуй обратиться к врачу при тревожных симптомах.`;
const raw = await chat(organizationId, prompt);
if (!raw) return null;
const parsed = safeJsonParse<CheckinSummaryResult>(raw);
if (!parsed) return null;
return {
summary: parsed.summary || answer.slice(0, 200),
alert: parsed.alert || undefined,
};
}
function safeJsonParse<T>(text: string): T | null {
try {
const cleaned = text
.replace(/^```json\s*/, "")
.replace(/```\s*$/, "")
.trim();
return JSON.parse(cleaned) as T;
} catch {
return null;
}
}