Files
iistwin/server/medschedule/ai.service.ts

290 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}