feat: MedSchedule модуль — семейный медицинский помощник с Telegram/MAX ботом, n8n OCR и /med UI
This commit is contained in:
289
server/medschedule/ai.service.ts
Normal file
289
server/medschedule/ai.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user