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 { 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 { 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 { 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(raw); } export async function parsePurchaseFromText( organizationId: number, text: string ): Promise { 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(raw); } export async function parseMedicalRecordFromText( organizationId: number, text: string ): Promise { 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(raw); } export async function generateDailyCheckinQuestion( organizationId: number, members: MedFamilyMember[], medications: MedMedication[], recentRecords: MedMedicalRecord[] ): Promise { 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(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 { 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(raw); if (!parsed) return null; return { summary: parsed.summary || answer.slice(0, 200), alert: parsed.alert || undefined, }; } function safeJsonParse(text: string): T | null { try { const cleaned = text .replace(/^```json\s*/, "") .replace(/```\s*$/, "") .trim(); return JSON.parse(cleaned) as T; } catch { return null; } }