290 lines
10 KiB
TypeScript
290 lines
10 KiB
TypeScript
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;
|
||
}
|
||
}
|