feat: add AI knowledge analysis MVP
This commit is contained in:
226
modules/ai/components/ElderAiAnalysisDialog.tsx
Normal file
226
modules/ai/components/ElderAiAnalysisDialog.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Brain, RefreshCw, Sparkles } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import type { ElderAiAnalysisHistoryItem } from "@/modules/ai/types";
|
||||
import type { ApiResult } from "@/modules/core/server/api";
|
||||
import type { Elder } from "@/modules/elders/types";
|
||||
|
||||
type ElderAiAnalysisDialogProps = {
|
||||
elder: Elder | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const riskLabels: Record<string, string> = {
|
||||
low: "低",
|
||||
medium: "中",
|
||||
high: "高",
|
||||
critical: "紧急",
|
||||
unknown: "未知",
|
||||
};
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Date(value).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisDialogProps): React.ReactElement {
|
||||
const [history, setHistory] = useState<ElderAiAnalysisHistoryItem[]>([]);
|
||||
const [message, setMessage] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
async function loadHistory(target: Elder): Promise<void> {
|
||||
setIsLoading(true);
|
||||
setMessage("");
|
||||
const response = await fetch(`/api/ai/elders/${target.id}/analyses`);
|
||||
const result = (await response.json()) as ApiResult<{ history: ElderAiAnalysisHistoryItem[] }>;
|
||||
setIsLoading(false);
|
||||
if (!result.success) {
|
||||
setMessage(result.reason);
|
||||
return;
|
||||
}
|
||||
setHistory(result.history);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open && elder) {
|
||||
void loadHistory(elder);
|
||||
}
|
||||
}, [elder, open]);
|
||||
|
||||
async function generate(): Promise<void> {
|
||||
if (!elder) {
|
||||
return;
|
||||
}
|
||||
setIsGenerating(true);
|
||||
setMessage("");
|
||||
const response = await fetch(`/api/ai/elders/${elder.id}/analyses`, { method: "POST" });
|
||||
const result = (await response.json()) as ApiResult<{ analysis: ElderAiAnalysisHistoryItem }>;
|
||||
setIsGenerating(false);
|
||||
if (!result.success) {
|
||||
setMessage(result.reason);
|
||||
await loadHistory(elder);
|
||||
return;
|
||||
}
|
||||
setHistory((current) => [result.analysis, ...current]);
|
||||
setMessage(result.reason);
|
||||
}
|
||||
|
||||
const latest = history[0];
|
||||
const latestResult = latest && !latest.restricted ? latest.result : undefined;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
description={elder ? `${elder.name} 的授权数据范围内 AI 分析历史和最新结论。` : undefined}
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
title="AI 分析"
|
||||
width="wide"
|
||||
>
|
||||
{elder ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex flex-col gap-3 rounded-md border bg-secondary/30 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-lg font-semibold">{elder.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{elder.age} 岁 · {elder.careLevel} · {elder.status} · {elder.room && elder.bed ? `${elder.room}-${elder.bed}` : "未绑定床位"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={isLoading || isGenerating} onClick={() => void loadHistory(elder)} type="button" variant="outline">
|
||||
<RefreshCw className="size-4" aria-hidden="true" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button disabled={isGenerating} onClick={() => void generate()} type="button">
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
{isGenerating ? "生成中" : "生成分析"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{latestResult ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>最新分析</CardTitle>
|
||||
<CardDescription>{latest ? formatDateTime(latest.createdAt) : ""}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={latestResult.overallRiskLevel === "critical" || latestResult.overallRiskLevel === "high" ? "destructive" : "secondary"}>
|
||||
风险:{riskLabels[latestResult.overallRiskLevel] ?? latestResult.overallRiskLevel}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<p className="text-sm leading-6">{latestResult.summary}</p>
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-sm font-semibold">关键发现</h3>
|
||||
<div className="grid gap-2">
|
||||
{latestResult.keyFindings.map((finding, index) => (
|
||||
<div className="rounded-md border p-3 text-sm" key={`${finding.category}-${index}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{finding.category}</span>
|
||||
<Badge variant={finding.severity === "critical" ? "destructive" : "secondary"}>{finding.severity}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">{finding.evidence}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-sm font-semibold">建议动作</h3>
|
||||
<div className="grid gap-2">
|
||||
{latestResult.recommendations.map((recommendation, index) => (
|
||||
<div className="rounded-md border p-3 text-sm" key={`${recommendation.title}-${index}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{recommendation.title}</span>
|
||||
<Badge variant={recommendation.priority === "urgent" ? "destructive" : "secondary"}>{recommendation.priority}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">{recommendation.rationale}</p>
|
||||
<p className="mt-1">{recommendation.suggestedNextStep}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{latestResult.dataGaps.length > 0 ? (
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-sm font-semibold">数据缺口</h3>
|
||||
<ul className="grid gap-1 text-sm text-muted-foreground">
|
||||
{latestResult.dataGaps.map((gap) => (
|
||||
<li key={gap}>{gap}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-sm font-semibold">引用来源</h3>
|
||||
<div className="grid gap-2">
|
||||
{latestResult.citations.map((citation) => (
|
||||
<div className="rounded-md bg-secondary/40 p-3 text-xs" key={citation.id}>
|
||||
<p className="font-medium">
|
||||
[{citation.id}] {citation.title}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">{citation.excerpt}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : latest ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>最新记录</CardTitle>
|
||||
<CardDescription>{formatDateTime(latest.createdAt)}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
{latest.restricted ? "当前角色缺少该分析所使用数据范围的读取权限。" : latest.errorReason ?? "暂无可展示分析。"}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
|
||||
{isLoading ? "正在加载分析历史" : "暂无 AI 分析历史"}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<section className="grid gap-2">
|
||||
<h3 className="text-sm font-semibold">历史记录</h3>
|
||||
<div className="grid gap-2">
|
||||
{history.map((item) => (
|
||||
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm sm:flex-row sm:items-center sm:justify-between" key={item.id}>
|
||||
<div>
|
||||
<p className="font-medium">{formatDateTime(item.createdAt)}</p>
|
||||
<p className="text-xs text-muted-foreground">范围:{item.dataScopes.join(", ") || "-"}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.restricted ? <Badge variant="secondary">受限</Badge> : null}
|
||||
<Badge variant={item.status === "failed" ? "destructive" : "secondary"}>{item.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
|
||||
<Brain className="mr-2 size-4" aria-hidden="true" />
|
||||
未选择老人档案
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
353
modules/ai/components/KnowledgeManagementClient.tsx
Normal file
353
modules/ai/components/KnowledgeManagementClient.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { Edit3, Plus, Search, Trash2 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Input, Textarea } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Table } from "@/components/ui/table";
|
||||
import type { AiKnowledgeScope, AiKnowledgeStatus, KnowledgeEntry, KnowledgeEntryInput } from "@/modules/ai/types";
|
||||
import type { ApiResult } from "@/modules/core/server/api";
|
||||
import type { Permission } from "@/modules/core/types";
|
||||
|
||||
type KnowledgeManagementClientProps = {
|
||||
initialEntries: KnowledgeEntry[];
|
||||
permissions: Permission[];
|
||||
};
|
||||
|
||||
type KnowledgeFormState = {
|
||||
scope: AiKnowledgeScope;
|
||||
title: string;
|
||||
category: string;
|
||||
tags: string;
|
||||
body: string;
|
||||
status: AiKnowledgeStatus;
|
||||
};
|
||||
|
||||
const emptyForm: KnowledgeFormState = {
|
||||
scope: "organization",
|
||||
title: "",
|
||||
category: "",
|
||||
tags: "",
|
||||
body: "",
|
||||
status: "enabled",
|
||||
};
|
||||
|
||||
const scopeOptions = [
|
||||
{ value: "organization", label: "机构私有" },
|
||||
{ value: "platform", label: "平台共享" },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "enabled", label: "启用" },
|
||||
{ value: "disabled", label: "停用" },
|
||||
];
|
||||
|
||||
function entryToForm(entry: KnowledgeEntry): KnowledgeFormState {
|
||||
return {
|
||||
scope: entry.scope,
|
||||
title: entry.title,
|
||||
category: entry.category,
|
||||
tags: entry.tags,
|
||||
body: entry.body,
|
||||
status: entry.status,
|
||||
};
|
||||
}
|
||||
|
||||
function matchesEntry(entry: KnowledgeEntry, query: string): boolean {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return true;
|
||||
}
|
||||
return [entry.title, entry.category, entry.tags, entry.body].join(" ").toLowerCase().includes(normalized);
|
||||
}
|
||||
|
||||
export function KnowledgeManagementClient({ initialEntries, permissions }: KnowledgeManagementClientProps): React.ReactElement {
|
||||
const [entries, setEntries] = useState(initialEntries);
|
||||
const [query, setQuery] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<KnowledgeEntry | null>(null);
|
||||
const [form, setForm] = useState<KnowledgeFormState>(emptyForm);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const canManage = permissions.includes("knowledge:manage");
|
||||
const filteredEntries = useMemo(() => entries.filter((entry) => matchesEntry(entry, query)), [entries, query]);
|
||||
const isEditing = editingId !== null;
|
||||
|
||||
function updateField<K extends keyof KnowledgeFormState>(key: K, value: KnowledgeFormState[K]): void {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function beginCreate(): void {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
setMessage("");
|
||||
setIsDialogOpen(true);
|
||||
}
|
||||
|
||||
function beginEdit(entry: KnowledgeEntry): void {
|
||||
setEditingId(entry.id);
|
||||
setForm(entryToForm(entry));
|
||||
setMessage("");
|
||||
setIsDialogOpen(true);
|
||||
}
|
||||
|
||||
function closeDialog(): void {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
setIsDialogOpen(false);
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
}
|
||||
|
||||
async function refreshEntries(): Promise<void> {
|
||||
const response = await fetch("/api/ai/knowledge");
|
||||
const result = (await response.json()) as ApiResult<{ entries: KnowledgeEntry[] }>;
|
||||
if (result.success) {
|
||||
setEntries(result.entries);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!canManage) {
|
||||
setMessage("当前角色无权维护知识库");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
setMessage("");
|
||||
const payload: KnowledgeEntryInput = {
|
||||
...form,
|
||||
};
|
||||
const response = await fetch(isEditing ? `/api/ai/knowledge/${editingId}` : "/api/ai/knowledge", {
|
||||
method: isEditing ? "PATCH" : "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
|
||||
setIsPending(false);
|
||||
if (!result.success) {
|
||||
setMessage(result.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshEntries();
|
||||
closeDialog();
|
||||
setMessage(result.reason);
|
||||
}
|
||||
|
||||
async function remove(): Promise<void> {
|
||||
if (!deleteTarget || !canManage) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPending(true);
|
||||
setMessage("");
|
||||
const response = await fetch(`/api/ai/knowledge/${deleteTarget.id}`, { method: "DELETE" });
|
||||
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
|
||||
setIsPending(false);
|
||||
if (!result.success) {
|
||||
setMessage(result.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshEntries();
|
||||
setDeleteTarget(null);
|
||||
setMessage(result.reason);
|
||||
}
|
||||
|
||||
async function toggleStatus(entry: KnowledgeEntry): Promise<void> {
|
||||
const nextStatus: AiKnowledgeStatus = entry.status === "enabled" ? "disabled" : "enabled";
|
||||
const response = await fetch(`/api/ai/knowledge/${entry.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...entry, status: nextStatus }),
|
||||
});
|
||||
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
|
||||
if (!result.success) {
|
||||
setMessage(result.reason);
|
||||
return;
|
||||
}
|
||||
await refreshEntries();
|
||||
setMessage(result.reason);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-normal">知识库</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">维护平台共享和机构私有知识,保存后会重建检索向量。</p>
|
||||
</div>
|
||||
<Button disabled={!canManage} onClick={beginCreate} type="button">
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
新增知识
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<CardTitle>知识条目</CardTitle>
|
||||
<CardDescription>共 {filteredEntries.length} 条匹配记录。</CardDescription>
|
||||
</div>
|
||||
<label className="relative block w-full md:max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input className="pl-9" onChange={(event) => setQuery(event.target.value)} placeholder="搜索标题、分类、标签、内容" value={query} />
|
||||
</label>
|
||||
</div>
|
||||
{message ? (
|
||||
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table className="w-full min-w-[860px] text-sm">
|
||||
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||||
<Table.Row>
|
||||
<Table.Head className="px-4 py-3 font-medium">标题</Table.Head>
|
||||
<Table.Head className="px-4 py-3 font-medium">范围</Table.Head>
|
||||
<Table.Head className="px-4 py-3 font-medium">分类</Table.Head>
|
||||
<Table.Head className="px-4 py-3 font-medium">标签</Table.Head>
|
||||
<Table.Head className="px-4 py-3 font-medium">状态</Table.Head>
|
||||
<Table.Head className="px-4 py-3 text-right font-medium">操作</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body className="divide-y bg-card">
|
||||
{filteredEntries.map((entry) => (
|
||||
<Table.Row key={entry.id}>
|
||||
<Table.Cell className="px-4 py-3">
|
||||
<p className="font-medium">{entry.title}</p>
|
||||
<p className="max-w-md truncate text-xs text-muted-foreground">{entry.body}</p>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3">{entry.scope === "platform" ? "平台共享" : "机构私有"}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3 text-muted-foreground">{entry.category || "-"}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3 text-muted-foreground">{entry.tags || "-"}</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3">
|
||||
<Badge variant={entry.status === "enabled" ? "success" : "secondary"}>
|
||||
{entry.status === "enabled" ? "启用" : "停用"}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button disabled={!canManage} onClick={() => void toggleStatus(entry)} size="sm" type="button" variant="outline">
|
||||
{entry.status === "enabled" ? "停用" : "启用"}
|
||||
</Button>
|
||||
<Button disabled={!canManage} onClick={() => beginEdit(entry)} size="sm" type="button" variant="outline">
|
||||
<Edit3 className="size-4" aria-hidden="true" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button disabled={!canManage} onClick={() => setDeleteTarget(entry)} size="sm" type="button" variant="destructive">
|
||||
<Trash2 className="size-4" aria-hidden="true" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
{filteredEntries.length === 0 ? (
|
||||
<Table.Row>
|
||||
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
||||
暂无知识库条目
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
) : null}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
description="保存时会写入数据库、重建分块和 embedding。"
|
||||
onClose={closeDialog}
|
||||
open={isDialogOpen}
|
||||
title={isEditing ? "编辑知识" : "新增知识"}
|
||||
width="lg"
|
||||
>
|
||||
<form className="grid gap-3" onSubmit={submit}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5 text-sm font-medium">
|
||||
范围
|
||||
<Select
|
||||
aria-label="知识范围"
|
||||
onValueChange={(value) => updateField("scope", value as AiKnowledgeScope)}
|
||||
options={scopeOptions}
|
||||
value={form.scope}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5 text-sm font-medium">
|
||||
状态
|
||||
<Select
|
||||
aria-label="知识状态"
|
||||
onValueChange={(value) => updateField("status", value as AiKnowledgeStatus)}
|
||||
options={statusOptions}
|
||||
value={form.status}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
标题
|
||||
<Input onChange={(event) => updateField("title", event.target.value)} required value={form.title} />
|
||||
</label>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
分类
|
||||
<Input onChange={(event) => updateField("category", event.target.value)} value={form.category} />
|
||||
</label>
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
标签
|
||||
<Input onChange={(event) => updateField("tags", event.target.value)} placeholder="逗号分隔" value={form.tags} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="grid gap-1.5 text-sm font-medium">
|
||||
内容
|
||||
<Textarea className="min-h-56" onChange={(event) => updateField("body", event.target.value)} required value={form.body} />
|
||||
</label>
|
||||
{message ? (
|
||||
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
||||
<Button disabled={isPending} onClick={closeDialog} type="button" variant="outline">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={isPending || !canManage} type="submit">
|
||||
<Plus className="size-4" aria-hidden="true" />
|
||||
保存知识
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
description={deleteTarget ? `确认删除「${deleteTarget.title}」?对应分块也会删除。` : undefined}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
open={deleteTarget !== null}
|
||||
title="删除确认"
|
||||
width="sm"
|
||||
>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button disabled={isPending} onClick={() => setDeleteTarget(null)} type="button" variant="outline">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={isPending || !canManage} onClick={() => void remove()} type="button" variant="destructive">
|
||||
删除知识
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
314
modules/ai/server/analysis.ts
Normal file
314
modules/ai/server/analysis.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import "server-only";
|
||||
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
|
||||
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
|
||||
import { buildElderAiContext } from "@/modules/ai/server/elder-context";
|
||||
import { retrieveKnowledge } from "@/modules/ai/server/knowledge";
|
||||
import type {
|
||||
AiCitation,
|
||||
AiErrorCategory,
|
||||
ElderAiAnalysisHistoryItem,
|
||||
ElderAiAnalysisOutput,
|
||||
} from "@/modules/ai/types";
|
||||
import {
|
||||
canViewAnalysisScopes,
|
||||
parseDataScopes,
|
||||
validateElderAiAnalysisOutput,
|
||||
} from "@/modules/ai/types";
|
||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||
import { getDatabase } from "@/modules/core/server/db";
|
||||
import { elderAiAnalyses } from "@/modules/core/server/schema";
|
||||
import type { AuthContext } from "@/modules/core/types";
|
||||
|
||||
type ServiceResult<T> = { success: true; data: T } | { success: false; reason: string; status: number };
|
||||
type AnalysisRow = typeof elderAiAnalyses.$inferSelect;
|
||||
|
||||
function createChatModel(config: { baseUrl: string; apiKey: string; chatModel: string }) {
|
||||
const configuration = config.baseUrl ? { baseURL: config.baseUrl } : undefined;
|
||||
return new ChatOpenAI({
|
||||
apiKey: config.apiKey,
|
||||
model: config.chatModel,
|
||||
temperature: 0.2,
|
||||
configuration,
|
||||
});
|
||||
}
|
||||
|
||||
function toIsoString(value: Date): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function mapErrorCategory(error: unknown): AiErrorCategory {
|
||||
if (error instanceof Error && error.name.toLowerCase().includes("timeout")) {
|
||||
return "timeout";
|
||||
}
|
||||
return "provider_error";
|
||||
}
|
||||
|
||||
function getBriefErrorReason(category: AiErrorCategory): string {
|
||||
if (category === "missing_config") {
|
||||
return "AI 服务未配置";
|
||||
}
|
||||
if (category === "schema_validation_failed") {
|
||||
return "AI 返回结构不符合固定分析格式";
|
||||
}
|
||||
if (category === "retrieval_failed") {
|
||||
return "知识库检索失败";
|
||||
}
|
||||
if (category === "timeout") {
|
||||
return "AI 服务响应超时";
|
||||
}
|
||||
return "AI 服务暂时不可用";
|
||||
}
|
||||
|
||||
function safeJsonParse(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractJsonFromText(value: string): unknown {
|
||||
const trimmed = value.trim();
|
||||
const direct = safeJsonParse(trimmed);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const start = trimmed.indexOf("{");
|
||||
const end = trimmed.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) {
|
||||
return null;
|
||||
}
|
||||
return safeJsonParse(trimmed.slice(start, end + 1));
|
||||
}
|
||||
|
||||
function normalizeCitations(output: ElderAiAnalysisOutput, citations: AiCitation[]): ElderAiAnalysisOutput {
|
||||
const allowed = new Map(citations.map((citation) => [citation.id, citation]));
|
||||
const merged = output.citations.filter((citation) => allowed.has(citation.id));
|
||||
const seenIds = new Set(merged.map((citation) => citation.id));
|
||||
const missing = citations.filter((citation) => !seenIds.has(citation.id));
|
||||
return {
|
||||
...output,
|
||||
citations: [...merged, ...missing],
|
||||
};
|
||||
}
|
||||
|
||||
async function saveFailedAnalysis(input: {
|
||||
context: AuthContext;
|
||||
organizationId: string;
|
||||
elderId: string;
|
||||
dataScopes: string[];
|
||||
category: AiErrorCategory;
|
||||
}): Promise<void> {
|
||||
const database = getDatabase();
|
||||
await database.insert(elderAiAnalyses).values({
|
||||
organizationId: input.organizationId,
|
||||
elderId: input.elderId,
|
||||
actorAccountId: input.context.account.id,
|
||||
status: "failed",
|
||||
dataScopes: input.dataScopes,
|
||||
errorCategory: input.category,
|
||||
errorReason: getBriefErrorReason(input.category),
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: input.context.account,
|
||||
organizationId: input.organizationId,
|
||||
action: "ai.elder_analysis.generate",
|
||||
targetType: "elder",
|
||||
targetId: input.elderId,
|
||||
result: "failure",
|
||||
reason: getBriefErrorReason(input.category),
|
||||
});
|
||||
}
|
||||
|
||||
function rowToHistoryItem(row: AnalysisRow, permissions: AuthContext["permissions"]): ElderAiAnalysisHistoryItem {
|
||||
const scopes = parseDataScopes(row.dataScopes);
|
||||
const restricted = !canViewAnalysisScopes(scopes, permissions);
|
||||
if (restricted) {
|
||||
return {
|
||||
id: row.id,
|
||||
elderId: row.elderId,
|
||||
status: row.status,
|
||||
dataScopes: scopes,
|
||||
createdAt: toIsoString(row.createdAt),
|
||||
restricted: true,
|
||||
};
|
||||
}
|
||||
|
||||
const result = validateElderAiAnalysisOutput(row.resultJson);
|
||||
const errorCategory = row.errorCategory as AiErrorCategory;
|
||||
return {
|
||||
id: row.id,
|
||||
elderId: row.elderId,
|
||||
status: row.status,
|
||||
dataScopes: scopes,
|
||||
createdAt: toIsoString(row.createdAt),
|
||||
restricted: false,
|
||||
result: result ?? undefined,
|
||||
errorCategory: errorCategory || undefined,
|
||||
errorReason: row.errorReason || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listElderAiAnalyses(
|
||||
context: AuthContext,
|
||||
elderId: string,
|
||||
): Promise<ServiceResult<{ history: ElderAiAnalysisHistoryItem[] }>> {
|
||||
const organizationId = context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return { success: false, reason: "请选择机构后查看 AI 分析", status: 400 };
|
||||
}
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.select()
|
||||
.from(elderAiAnalyses)
|
||||
.where(and(eq(elderAiAnalyses.organizationId, organizationId), eq(elderAiAnalyses.elderId, elderId)))
|
||||
.orderBy(desc(elderAiAnalyses.createdAt))
|
||||
.limit(20);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
history: rows.map((row) => rowToHistoryItem(row, context.permissions)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateElderAiAnalysis(
|
||||
context: AuthContext,
|
||||
elderId: string,
|
||||
): Promise<ServiceResult<{ analysis: ElderAiAnalysisHistoryItem }>> {
|
||||
const runtimeConfig = getAiRuntimeConfig();
|
||||
if (!runtimeConfig.success) {
|
||||
const organizationId = context.organization?.id;
|
||||
if (organizationId) {
|
||||
await saveFailedAnalysis({
|
||||
context,
|
||||
organizationId,
|
||||
elderId,
|
||||
dataScopes: ["elder"],
|
||||
category: "missing_config",
|
||||
});
|
||||
}
|
||||
return { success: false, reason: runtimeConfig.reason, status: 500 };
|
||||
}
|
||||
|
||||
const residentContext = await buildElderAiContext(context, elderId);
|
||||
if (!residentContext.success) {
|
||||
return { success: false, reason: residentContext.reason, status: residentContext.status };
|
||||
}
|
||||
|
||||
const knowledgeResults = context.permissions.includes("knowledge:read")
|
||||
? await retrieveKnowledge(
|
||||
residentContext.context.organizationId,
|
||||
`${residentContext.context.elderName}\n${residentContext.context.promptContext}`,
|
||||
)
|
||||
: { success: true as const, data: { results: [] } };
|
||||
|
||||
if (!knowledgeResults.success) {
|
||||
await saveFailedAnalysis({
|
||||
context,
|
||||
organizationId: residentContext.context.organizationId,
|
||||
elderId,
|
||||
dataScopes: residentContext.context.dataScopes,
|
||||
category: "retrieval_failed",
|
||||
});
|
||||
return { success: false, reason: knowledgeResults.reason, status: knowledgeResults.status };
|
||||
}
|
||||
|
||||
const dataScopes = knowledgeResults.data.results.length > 0
|
||||
? [...residentContext.context.dataScopes, "knowledge" as const]
|
||||
: residentContext.context.dataScopes;
|
||||
const knowledgeCitations: AiCitation[] = knowledgeResults.data.results.map((item, index) => ({
|
||||
id: `kb-${index + 1}`,
|
||||
sourceType: "knowledge",
|
||||
sourceId: item.chunkId,
|
||||
title: item.title,
|
||||
excerpt: item.content.slice(0, 240),
|
||||
}));
|
||||
const citations = [...residentContext.context.citations, ...knowledgeCitations];
|
||||
|
||||
const model = createChatModel(runtimeConfig.config);
|
||||
const prompt = [
|
||||
"你是养老机构运营辅助分析智能体。只输出 JSON,不要 Markdown。",
|
||||
"分析必须是建议性、非诊断性,不能创建业务记录或承诺操作。",
|
||||
"只能使用提供的上下文和知识库片段,证据必须引用 citation id。",
|
||||
"输出 JSON 字段:overallRiskLevel(low|medium|high|critical|unknown), summary, keyFindings, recommendations, dataGaps, citations, confidence, modelSummary。",
|
||||
"keyFindings[] 字段:category, severity(info|warning|critical), evidence, citationIds。",
|
||||
"recommendations[] 字段:title, priority(low|normal|high|urgent), rationale, suggestedNextStep, citationIds。",
|
||||
`modelSummary 固定为 {"provider":"openai-compatible","chatModel":"${runtimeConfig.config.chatModel}","embeddingModel":"${runtimeConfig.config.embeddingModel}"}。`,
|
||||
`可用 citations:${JSON.stringify(citations)}`,
|
||||
`住民上下文:\n${residentContext.context.promptContext}`,
|
||||
`知识库片段:\n${knowledgeResults.data.results.map((item, index) => `[kb-${index + 1}] ${item.title}\n${item.content}`).join("\n\n") || "无"}`,
|
||||
].join("\n\n");
|
||||
|
||||
let rawOutput: unknown;
|
||||
try {
|
||||
const response = await model.invoke(prompt);
|
||||
const content = Array.isArray(response.content)
|
||||
? response.content.map((item) => (typeof item === "string" ? item : "")).join("\n")
|
||||
: String(response.content);
|
||||
rawOutput = extractJsonFromText(content);
|
||||
} catch (error) {
|
||||
const category = mapErrorCategory(error);
|
||||
await saveFailedAnalysis({
|
||||
context,
|
||||
organizationId: residentContext.context.organizationId,
|
||||
elderId,
|
||||
dataScopes,
|
||||
category,
|
||||
});
|
||||
return { success: false, reason: getBriefErrorReason(category), status: 502 };
|
||||
}
|
||||
|
||||
const output = validateElderAiAnalysisOutput(rawOutput);
|
||||
if (!output) {
|
||||
await saveFailedAnalysis({
|
||||
context,
|
||||
organizationId: residentContext.context.organizationId,
|
||||
elderId,
|
||||
dataScopes,
|
||||
category: "schema_validation_failed",
|
||||
});
|
||||
return { success: false, reason: getBriefErrorReason("schema_validation_failed"), status: 502 };
|
||||
}
|
||||
|
||||
const normalizedOutput = normalizeCitations(output, citations);
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.insert(elderAiAnalyses)
|
||||
.values({
|
||||
organizationId: residentContext.context.organizationId,
|
||||
elderId,
|
||||
actorAccountId: context.account.id,
|
||||
status: "completed",
|
||||
dataScopes,
|
||||
resultJson: normalizedOutput,
|
||||
citationsJson: normalizedOutput.citations,
|
||||
modelSummaryJson: normalizedOutput.modelSummary,
|
||||
})
|
||||
.returning();
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return { success: false, reason: "AI 分析保存失败", status: 500 };
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: context.account,
|
||||
organizationId: residentContext.context.organizationId,
|
||||
action: "ai.elder_analysis.generate",
|
||||
targetType: "elder",
|
||||
targetId: elderId,
|
||||
result: "success",
|
||||
reason: "AI 分析已生成",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
analysis: rowToHistoryItem(row, context.permissions),
|
||||
},
|
||||
};
|
||||
}
|
||||
38
modules/ai/server/config.ts
Normal file
38
modules/ai/server/config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { AI_KNOWLEDGE_EMBEDDING_DIMENSIONS } from "@/modules/core/server/schema";
|
||||
|
||||
export type AiRuntimeConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
chatModel: string;
|
||||
embeddingModel: string;
|
||||
embeddingDimensions: number;
|
||||
};
|
||||
|
||||
export type AiRuntimeConfigResult =
|
||||
| { success: true; config: AiRuntimeConfig }
|
||||
| { success: false; reason: string };
|
||||
|
||||
const DEFAULT_CHAT_MODEL = "gpt-4.1-mini";
|
||||
const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
|
||||
|
||||
function readOptionalEnv(name: string): string {
|
||||
return process.env[name]?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function getAiRuntimeConfig(): AiRuntimeConfigResult {
|
||||
const apiKey = readOptionalEnv("AI_API_KEY");
|
||||
if (!apiKey) {
|
||||
return { success: false, reason: "AI_API_KEY 未配置" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
config: {
|
||||
baseUrl: readOptionalEnv("AI_BASE_URL"),
|
||||
apiKey,
|
||||
chatModel: readOptionalEnv("AI_CHAT_MODEL") || DEFAULT_CHAT_MODEL,
|
||||
embeddingModel: readOptionalEnv("AI_EMBEDDING_MODEL") || DEFAULT_EMBEDDING_MODEL,
|
||||
embeddingDimensions: AI_KNOWLEDGE_EMBEDDING_DIMENSIONS,
|
||||
},
|
||||
};
|
||||
}
|
||||
331
modules/ai/server/elder-context.ts
Normal file
331
modules/ai/server/elder-context.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
|
||||
import type { AiCitation, ElderAiDataScope } from "@/modules/ai/types";
|
||||
import { getDatabase } from "@/modules/core/server/db";
|
||||
import {
|
||||
admissions,
|
||||
alertTriggers,
|
||||
beds,
|
||||
careTasks,
|
||||
chronicConditions,
|
||||
elders,
|
||||
familyFeedback,
|
||||
healthAnomalyReviews,
|
||||
healthProfiles,
|
||||
rooms,
|
||||
systemIncidents,
|
||||
vitalRecords,
|
||||
} from "@/modules/core/server/schema";
|
||||
import type { AuthContext } from "@/modules/core/types";
|
||||
|
||||
type ElderAiContextResult =
|
||||
| { success: true; context: ElderAiResidentContext }
|
||||
| { success: false; reason: string; status: number };
|
||||
|
||||
export type ElderAiResidentContext = {
|
||||
organizationId: string;
|
||||
elderId: string;
|
||||
elderName: string;
|
||||
dataScopes: ElderAiDataScope[];
|
||||
sections: string[];
|
||||
citations: AiCitation[];
|
||||
promptContext: string;
|
||||
};
|
||||
|
||||
function hasPermission(context: AuthContext, permission: string): boolean {
|
||||
return context.permissions.includes(permission as never);
|
||||
}
|
||||
|
||||
function toIsoString(value: Date | null): string {
|
||||
return value ? value.toISOString() : "";
|
||||
}
|
||||
|
||||
function pushCitation(citations: AiCitation[], input: Omit<AiCitation, "id">): string {
|
||||
const id = `ctx-${citations.length + 1}`;
|
||||
citations.push({ id, ...input });
|
||||
return id;
|
||||
}
|
||||
|
||||
function compactText(value: string): string {
|
||||
return value.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function appendSection(parts: string[], title: string, lines: string[]): void {
|
||||
const compactLines = lines.map(compactText).filter(Boolean);
|
||||
if (compactLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
parts.push(`## ${title}\n${compactLines.join("\n")}`);
|
||||
}
|
||||
|
||||
export async function buildElderAiContext(context: AuthContext, elderId: string): Promise<ElderAiContextResult> {
|
||||
const organizationId = context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return { success: false, reason: "请选择机构后生成 AI 分析", status: 400 };
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
const elderRows = await database
|
||||
.select()
|
||||
.from(elders)
|
||||
.where(and(eq(elders.id, elderId), eq(elders.organizationId, organizationId)))
|
||||
.limit(1);
|
||||
const elder = elderRows[0];
|
||||
if (!elder) {
|
||||
return { success: false, reason: "老人档案不存在", status: 404 };
|
||||
}
|
||||
|
||||
const dataScopes: ElderAiDataScope[] = ["elder"];
|
||||
const citations: AiCitation[] = [];
|
||||
const sections: string[] = [];
|
||||
const elderCitationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: elder.id,
|
||||
title: "老人基础档案",
|
||||
excerpt: `${elder.name},${elder.age} 岁,照护等级 ${elder.careLevel},状态 ${elder.status}`,
|
||||
});
|
||||
|
||||
appendSection(sections, "老人基础档案", [
|
||||
`[${elderCitationId}] 姓名:${elder.name};性别:${elder.gender};年龄:${elder.age};照护等级:${elder.careLevel};状态:${elder.status}`,
|
||||
`主要联系人:${elder.primaryContact};电话:${elder.phone}`,
|
||||
elder.medicalNotes ? `医疗备注:${elder.medicalNotes}` : "",
|
||||
]);
|
||||
|
||||
const [
|
||||
admissionRows,
|
||||
healthProfileRows,
|
||||
vitalRows,
|
||||
conditionRows,
|
||||
reviewRows,
|
||||
careTaskRows,
|
||||
alertRows,
|
||||
feedbackRows,
|
||||
incidentRows,
|
||||
] = await Promise.all([
|
||||
hasPermission(context, "admission:read") || hasPermission(context, "facility:read")
|
||||
? database
|
||||
.select({
|
||||
admission: admissions,
|
||||
bedCode: beds.code,
|
||||
roomCode: rooms.code,
|
||||
})
|
||||
.from(admissions)
|
||||
.leftJoin(beds, eq(beds.id, admissions.bedId))
|
||||
.leftJoin(rooms, eq(rooms.id, beds.roomId))
|
||||
.where(and(eq(admissions.organizationId, organizationId), eq(admissions.elderId, elderId)))
|
||||
.orderBy(desc(admissions.admittedAt))
|
||||
.limit(8)
|
||||
: Promise.resolve([]),
|
||||
hasPermission(context, "health:read")
|
||||
? database
|
||||
.select()
|
||||
.from(healthProfiles)
|
||||
.where(and(eq(healthProfiles.organizationId, organizationId), eq(healthProfiles.elderId, elderId)))
|
||||
.limit(1)
|
||||
: Promise.resolve([]),
|
||||
hasPermission(context, "health:read")
|
||||
? database
|
||||
.select()
|
||||
.from(vitalRecords)
|
||||
.where(and(eq(vitalRecords.organizationId, organizationId), eq(vitalRecords.elderId, elderId)))
|
||||
.orderBy(desc(vitalRecords.recordedAt))
|
||||
.limit(8)
|
||||
: Promise.resolve([]),
|
||||
hasPermission(context, "health:read")
|
||||
? database
|
||||
.select()
|
||||
.from(chronicConditions)
|
||||
.where(and(eq(chronicConditions.organizationId, organizationId), eq(chronicConditions.elderId, elderId)))
|
||||
.orderBy(desc(chronicConditions.updatedAt))
|
||||
.limit(8)
|
||||
: Promise.resolve([]),
|
||||
hasPermission(context, "health:read")
|
||||
? database
|
||||
.select()
|
||||
.from(healthAnomalyReviews)
|
||||
.where(and(eq(healthAnomalyReviews.organizationId, organizationId), eq(healthAnomalyReviews.elderId, elderId)))
|
||||
.orderBy(desc(healthAnomalyReviews.createdAt))
|
||||
.limit(8)
|
||||
: Promise.resolve([]),
|
||||
hasPermission(context, "care:read")
|
||||
? database
|
||||
.select()
|
||||
.from(careTasks)
|
||||
.where(and(eq(careTasks.organizationId, organizationId), eq(careTasks.elderId, elderId)))
|
||||
.orderBy(desc(careTasks.scheduledAt))
|
||||
.limit(12)
|
||||
: Promise.resolve([]),
|
||||
hasPermission(context, "alert:read")
|
||||
? database
|
||||
.select()
|
||||
.from(alertTriggers)
|
||||
.where(and(eq(alertTriggers.organizationId, organizationId), eq(alertTriggers.elderId, elderId)))
|
||||
.orderBy(desc(alertTriggers.createdAt))
|
||||
.limit(8)
|
||||
: Promise.resolve([]),
|
||||
hasPermission(context, "family:read")
|
||||
? database
|
||||
.select()
|
||||
.from(familyFeedback)
|
||||
.where(and(eq(familyFeedback.organizationId, organizationId), eq(familyFeedback.elderId, elderId)))
|
||||
.orderBy(desc(familyFeedback.createdAt))
|
||||
.limit(8)
|
||||
: Promise.resolve([]),
|
||||
hasPermission(context, "incident:read")
|
||||
? database
|
||||
.select()
|
||||
.from(systemIncidents)
|
||||
.where(eq(systemIncidents.organizationId, organizationId))
|
||||
.orderBy(desc(systemIncidents.createdAt))
|
||||
.limit(6)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
if (admissionRows.length > 0) {
|
||||
if (hasPermission(context, "admission:read")) {
|
||||
dataScopes.push("admission");
|
||||
}
|
||||
if (hasPermission(context, "facility:read")) {
|
||||
dataScopes.push("facility");
|
||||
}
|
||||
appendSection(
|
||||
sections,
|
||||
"入住与床位",
|
||||
admissionRows.map((row) => {
|
||||
const citationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: row.admission.id,
|
||||
title: "入住记录",
|
||||
excerpt: `${row.admission.status},床位 ${row.roomCode ?? ""}-${row.bedCode ?? ""}`,
|
||||
});
|
||||
return `[${citationId}] 状态:${row.admission.status};房间/床位:${row.roomCode ?? "未知"}/${row.bedCode ?? "未知"};入住:${toIsoString(row.admission.admittedAt)};退住:${toIsoString(row.admission.dischargedAt)};备注:${row.admission.notes}`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPermission(context, "health:read")) {
|
||||
dataScopes.push("health");
|
||||
const profile = healthProfileRows[0];
|
||||
appendSection(sections, "健康档案", [
|
||||
profile
|
||||
? `[${pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: profile.id,
|
||||
title: "健康档案",
|
||||
excerpt: profile.medicalHistory || profile.medicationNotes || profile.emergencyNotes || "健康档案",
|
||||
})}] 过敏:${profile.allergyNotes};病史:${profile.medicalHistory};用药:${profile.medicationNotes};照护限制:${profile.careRestrictions};紧急备注:${profile.emergencyNotes}`
|
||||
: "暂无健康档案",
|
||||
...vitalRows.map((vital) => {
|
||||
const citationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: vital.id,
|
||||
title: "生命体征",
|
||||
excerpt: `${toIsoString(vital.recordedAt)} 心率 ${vital.heartRate ?? "-"} 血压 ${vital.systolicBp ?? "-"}/${vital.diastolicBp ?? "-"}`,
|
||||
});
|
||||
return `[${citationId}] ${toIsoString(vital.recordedAt)};来源:${vital.source};血压:${vital.systolicBp ?? "-"}/${vital.diastolicBp ?? "-"};心率:${vital.heartRate ?? "-"};体温:${vital.temperatureTenths ? vital.temperatureTenths / 10 : "-"};血氧:${vital.spo2 ?? "-"};备注:${vital.notes}`;
|
||||
}),
|
||||
...conditionRows.map((condition) => {
|
||||
const citationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: condition.id,
|
||||
title: "慢病记录",
|
||||
excerpt: `${condition.name} ${condition.status}`,
|
||||
});
|
||||
return `[${citationId}] 慢病:${condition.name};状态:${condition.status};治疗:${condition.treatmentNotes};随访:${condition.followUpNotes}`;
|
||||
}),
|
||||
...reviewRows.map((review) => {
|
||||
const citationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: review.id,
|
||||
title: "健康异常复核",
|
||||
excerpt: `${review.severity} ${review.title}`,
|
||||
});
|
||||
return `[${citationId}] 异常:${review.title};严重度:${review.severity};状态:${review.status};说明:${review.description};处理:${review.resolutionNotes}`;
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
if (hasPermission(context, "care:read")) {
|
||||
dataScopes.push("care");
|
||||
appendSection(
|
||||
sections,
|
||||
"护理任务",
|
||||
careTaskRows.map((task) => {
|
||||
const citationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: task.id,
|
||||
title: "护理任务",
|
||||
excerpt: `${task.priority} ${task.title}`,
|
||||
});
|
||||
return `[${citationId}] ${task.title};类型:${task.careType};优先级:${task.priority};状态:${task.status};计划:${toIsoString(task.scheduledAt)};执行备注:${task.executionNotes}`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPermission(context, "alert:read")) {
|
||||
dataScopes.push("alert");
|
||||
appendSection(
|
||||
sections,
|
||||
"预警触发",
|
||||
alertRows.map((alert) => {
|
||||
const citationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: alert.id,
|
||||
title: "预警触发",
|
||||
excerpt: `${alert.title} ${alert.status}`,
|
||||
});
|
||||
return `[${citationId}] ${alert.title};状态:${alert.status};来源:${alert.source};说明:${alert.description};处理:${alert.handlingNotes}`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPermission(context, "family:read")) {
|
||||
dataScopes.push("family");
|
||||
appendSection(
|
||||
sections,
|
||||
"家属反馈",
|
||||
feedbackRows.map((feedback) => {
|
||||
const citationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: feedback.id,
|
||||
title: "家属反馈",
|
||||
excerpt: `${feedback.feedbackType} ${feedback.status}`,
|
||||
});
|
||||
return `[${citationId}] 类型:${feedback.feedbackType};状态:${feedback.status};内容:${feedback.content};回复:${feedback.responseNotes}`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPermission(context, "incident:read")) {
|
||||
dataScopes.push("incident");
|
||||
appendSection(
|
||||
sections,
|
||||
"近期机构事件",
|
||||
incidentRows.map((incident) => {
|
||||
const citationId = pushCitation(citations, {
|
||||
sourceType: "resident_context",
|
||||
sourceId: incident.id,
|
||||
title: "系统/应急事件",
|
||||
excerpt: `${incident.severity} ${incident.title}`,
|
||||
});
|
||||
return `[${citationId}] ${incident.title};严重度:${incident.severity};状态:${incident.status};来源:${incident.source};说明:${incident.description}`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const uniqueScopes = [...new Set(dataScopes)];
|
||||
return {
|
||||
success: true,
|
||||
context: {
|
||||
organizationId,
|
||||
elderId,
|
||||
elderName: elder.name,
|
||||
dataScopes: uniqueScopes,
|
||||
sections,
|
||||
citations,
|
||||
promptContext: sections.join("\n\n"),
|
||||
},
|
||||
};
|
||||
}
|
||||
364
modules/ai/server/knowledge.ts
Normal file
364
modules/ai/server/knowledge.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import "server-only";
|
||||
|
||||
import { OpenAIEmbeddings } from "@langchain/openai";
|
||||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
|
||||
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
|
||||
import type {
|
||||
AiKnowledgeScope,
|
||||
KnowledgeEntry,
|
||||
KnowledgeEntryInput,
|
||||
KnowledgeRetrievalResult,
|
||||
} from "@/modules/ai/types";
|
||||
import { getDatabase } from "@/modules/core/server/db";
|
||||
import { aiKnowledgeChunks, aiKnowledgeEntries } from "@/modules/core/server/schema";
|
||||
import type { AuthContext } from "@/modules/core/types";
|
||||
|
||||
type KnowledgeRow = typeof aiKnowledgeEntries.$inferSelect;
|
||||
|
||||
type ServiceResult<T> = { success: true; data: T } | { success: false; reason: string; status: number };
|
||||
|
||||
const CHUNK_TARGET_SIZE = 900;
|
||||
const CHUNK_OVERLAP_SIZE = 160;
|
||||
const DEFAULT_RETRIEVAL_LIMIT = 6;
|
||||
|
||||
function toIsoString(value: Date): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function toKnowledgeEntry(row: KnowledgeRow): KnowledgeEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
scope: row.scope,
|
||||
organizationId: row.organizationId ?? undefined,
|
||||
title: row.title,
|
||||
category: row.category,
|
||||
tags: row.tags,
|
||||
body: row.body,
|
||||
status: row.status,
|
||||
createdAt: toIsoString(row.createdAt),
|
||||
updatedAt: toIsoString(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function createEmbeddings() {
|
||||
const configResult = getAiRuntimeConfig();
|
||||
if (!configResult.success) {
|
||||
return configResult;
|
||||
}
|
||||
const configuration = configResult.config.baseUrl ? { baseURL: configResult.config.baseUrl } : undefined;
|
||||
return {
|
||||
success: true as const,
|
||||
config: configResult.config,
|
||||
embeddings: new OpenAIEmbeddings({
|
||||
apiKey: configResult.config.apiKey,
|
||||
model: configResult.config.embeddingModel,
|
||||
dimensions: configResult.config.embeddingDimensions,
|
||||
configuration,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function chunkKnowledgeText(input: KnowledgeEntryInput): string[] {
|
||||
const prefix = [input.title, input.category, input.tags].filter(Boolean).join("\n");
|
||||
const text = `${prefix}\n\n${input.body}`.trim();
|
||||
if (text.length <= CHUNK_TARGET_SIZE) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
let start = 0;
|
||||
while (start < text.length) {
|
||||
const end = Math.min(start + CHUNK_TARGET_SIZE, text.length);
|
||||
chunks.push(text.slice(start, end).trim());
|
||||
if (end >= text.length) {
|
||||
break;
|
||||
}
|
||||
start = Math.max(end - CHUNK_OVERLAP_SIZE, start + 1);
|
||||
}
|
||||
return chunks.filter(Boolean);
|
||||
}
|
||||
|
||||
function ensureWritableScope(context: AuthContext, input: KnowledgeEntryInput): ServiceResult<{ organizationId: string | null }> {
|
||||
if (input.scope === "platform") {
|
||||
if (!context.permissions.includes("platform:manage")) {
|
||||
return { success: false, reason: "只有平台管理员可以维护平台知识", status: 403 };
|
||||
}
|
||||
return { success: true, data: { organizationId: null } };
|
||||
}
|
||||
|
||||
const organizationId = context.organization?.id;
|
||||
if (!organizationId) {
|
||||
return { success: false, reason: "请选择机构后维护知识库", status: 400 };
|
||||
}
|
||||
if (input.organizationId && input.organizationId !== organizationId) {
|
||||
return { success: false, reason: "不能维护其他机构的知识库", status: 403 };
|
||||
}
|
||||
return { success: true, data: { organizationId } };
|
||||
}
|
||||
|
||||
function getVisibleKnowledgeWhere(organizationId?: string) {
|
||||
const platformFilter = and(eq(aiKnowledgeEntries.scope, "platform"), isNull(aiKnowledgeEntries.organizationId));
|
||||
if (!organizationId) {
|
||||
return platformFilter;
|
||||
}
|
||||
return or(
|
||||
platformFilter,
|
||||
and(eq(aiKnowledgeEntries.scope, "organization"), eq(aiKnowledgeEntries.organizationId, organizationId)),
|
||||
);
|
||||
}
|
||||
|
||||
function getEnabledChunkWhere(organizationId: string) {
|
||||
return and(
|
||||
eq(aiKnowledgeEntries.status, "enabled"),
|
||||
or(
|
||||
and(eq(aiKnowledgeChunks.scope, "platform"), isNull(aiKnowledgeChunks.organizationId)),
|
||||
and(eq(aiKnowledgeChunks.scope, "organization"), eq(aiKnowledgeChunks.organizationId, organizationId)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function buildKnowledgeChunks(input: KnowledgeEntryInput): Promise<ServiceResult<{ chunks: string[]; vectors: number[][] }>> {
|
||||
const embeddingsResult = createEmbeddings();
|
||||
if (!embeddingsResult.success) {
|
||||
return { success: false, reason: embeddingsResult.reason, status: 500 };
|
||||
}
|
||||
|
||||
const chunks = chunkKnowledgeText(input);
|
||||
const vectors = await embeddingsResult.embeddings.embedDocuments(chunks);
|
||||
return { success: true, data: { chunks, vectors } };
|
||||
}
|
||||
|
||||
export async function listKnowledgeEntries(context: AuthContext): Promise<KnowledgeEntry[]> {
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.select()
|
||||
.from(aiKnowledgeEntries)
|
||||
.where(getVisibleKnowledgeWhere(context.organization?.id))
|
||||
.orderBy(desc(aiKnowledgeEntries.updatedAt));
|
||||
return rows.map(toKnowledgeEntry);
|
||||
}
|
||||
|
||||
export async function createKnowledgeEntry(
|
||||
context: AuthContext,
|
||||
input: KnowledgeEntryInput,
|
||||
): Promise<ServiceResult<{ entry: KnowledgeEntry }>> {
|
||||
const scope = ensureWritableScope(context, input);
|
||||
if (!scope.success) {
|
||||
return scope;
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
const build = await buildKnowledgeChunks(input);
|
||||
if (!build.success) {
|
||||
return build;
|
||||
}
|
||||
|
||||
const row = await database.transaction(async (transaction) => {
|
||||
const rows = await transaction
|
||||
.insert(aiKnowledgeEntries)
|
||||
.values({
|
||||
scope: input.scope,
|
||||
organizationId: scope.data.organizationId,
|
||||
title: input.title,
|
||||
category: input.category,
|
||||
tags: input.tags,
|
||||
body: input.body,
|
||||
status: input.status,
|
||||
createdByAccountId: context.account.id,
|
||||
updatedByAccountId: context.account.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
const entry = rows[0];
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (build.data.chunks.length > 0) {
|
||||
await transaction.insert(aiKnowledgeChunks).values(
|
||||
build.data.chunks.map((content, index) => ({
|
||||
entryId: entry.id,
|
||||
organizationId: entry.organizationId,
|
||||
scope: entry.scope,
|
||||
chunkIndex: index,
|
||||
content,
|
||||
embedding: build.data.vectors[index] ?? [],
|
||||
sourceTitle: entry.title,
|
||||
sourceCategory: entry.category,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
return { success: false, reason: "知识库创建失败", status: 500 };
|
||||
}
|
||||
|
||||
return { success: true, data: { entry: toKnowledgeEntry(row) } };
|
||||
}
|
||||
|
||||
async function updateEntryWithChunks(
|
||||
id: string,
|
||||
context: AuthContext,
|
||||
input: KnowledgeEntryInput,
|
||||
organizationId: string | null,
|
||||
): Promise<KnowledgeRow | null> {
|
||||
const build = await buildKnowledgeChunks(input);
|
||||
if (!build.success) {
|
||||
throw new Error(build.reason);
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
return database.transaction(async (transaction) => {
|
||||
const rows = await transaction
|
||||
.update(aiKnowledgeEntries)
|
||||
.set({
|
||||
scope: input.scope,
|
||||
organizationId,
|
||||
title: input.title,
|
||||
category: input.category,
|
||||
tags: input.tags,
|
||||
body: input.body,
|
||||
status: input.status,
|
||||
updatedByAccountId: context.account.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(aiKnowledgeEntries.id, id))
|
||||
.returning();
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await transaction.delete(aiKnowledgeChunks).where(eq(aiKnowledgeChunks.entryId, row.id));
|
||||
if (build.data.chunks.length > 0) {
|
||||
await transaction.insert(aiKnowledgeChunks).values(
|
||||
build.data.chunks.map((content, index) => ({
|
||||
entryId: row.id,
|
||||
organizationId: row.organizationId,
|
||||
scope: row.scope,
|
||||
chunkIndex: index,
|
||||
content,
|
||||
embedding: build.data.vectors[index] ?? [],
|
||||
sourceTitle: row.title,
|
||||
sourceCategory: row.category,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateKnowledgeEntry(
|
||||
context: AuthContext,
|
||||
id: string,
|
||||
input: KnowledgeEntryInput,
|
||||
): Promise<ServiceResult<{ entry: KnowledgeEntry }>> {
|
||||
const scope = ensureWritableScope(context, input);
|
||||
if (!scope.success) {
|
||||
return scope;
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
const existingRows = await database
|
||||
.select()
|
||||
.from(aiKnowledgeEntries)
|
||||
.where(and(eq(aiKnowledgeEntries.id, id), getVisibleKnowledgeWhere(context.organization?.id)))
|
||||
.limit(1);
|
||||
const existing = existingRows[0];
|
||||
if (!existing) {
|
||||
return { success: false, reason: "知识库条目不存在", status: 404 };
|
||||
}
|
||||
|
||||
if (existing.scope === "platform" && !context.permissions.includes("platform:manage")) {
|
||||
return { success: false, reason: "只有平台管理员可以维护平台知识", status: 403 };
|
||||
}
|
||||
|
||||
let row: KnowledgeRow | null;
|
||||
try {
|
||||
row = await updateEntryWithChunks(id, context, input, scope.data.organizationId);
|
||||
} catch {
|
||||
return { success: false, reason: "AI_API_KEY 未配置或知识库向量生成失败", status: 500 };
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return { success: false, reason: "知识库更新失败", status: 500 };
|
||||
}
|
||||
return { success: true, data: { entry: toKnowledgeEntry(row) } };
|
||||
}
|
||||
|
||||
export async function deleteKnowledgeEntry(context: AuthContext, id: string): Promise<ServiceResult<{ entry: KnowledgeEntry }>> {
|
||||
const database = getDatabase();
|
||||
const existingRows = await database
|
||||
.select()
|
||||
.from(aiKnowledgeEntries)
|
||||
.where(and(eq(aiKnowledgeEntries.id, id), getVisibleKnowledgeWhere(context.organization?.id)))
|
||||
.limit(1);
|
||||
const existing = existingRows[0];
|
||||
if (!existing) {
|
||||
return { success: false, reason: "知识库条目不存在", status: 404 };
|
||||
}
|
||||
if (existing.scope === "platform" && !context.permissions.includes("platform:manage")) {
|
||||
return { success: false, reason: "只有平台管理员可以维护平台知识", status: 403 };
|
||||
}
|
||||
|
||||
const rows = await database.delete(aiKnowledgeEntries).where(eq(aiKnowledgeEntries.id, id)).returning();
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return { success: false, reason: "知识库删除失败", status: 500 };
|
||||
}
|
||||
return { success: true, data: { entry: toKnowledgeEntry(row) } };
|
||||
}
|
||||
|
||||
export async function retrieveKnowledge(
|
||||
organizationId: string,
|
||||
query: string,
|
||||
options?: { limit?: number },
|
||||
): Promise<ServiceResult<{ results: KnowledgeRetrievalResult[] }>> {
|
||||
const embeddingsResult = createEmbeddings();
|
||||
if (!embeddingsResult.success) {
|
||||
return { success: false, reason: embeddingsResult.reason, status: 500 };
|
||||
}
|
||||
|
||||
const [embedding] = await embeddingsResult.embeddings.embedDocuments([query]);
|
||||
if (!embedding) {
|
||||
return { success: false, reason: "知识库向量生成失败", status: 500 };
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
const distance = sql<number>`${aiKnowledgeChunks.embedding} <=> ${JSON.stringify(embedding)}::vector`;
|
||||
const rows = await database
|
||||
.select({
|
||||
entryId: aiKnowledgeChunks.entryId,
|
||||
chunkId: aiKnowledgeChunks.id,
|
||||
title: aiKnowledgeChunks.sourceTitle,
|
||||
category: aiKnowledgeChunks.sourceCategory,
|
||||
content: aiKnowledgeChunks.content,
|
||||
distance,
|
||||
})
|
||||
.from(aiKnowledgeChunks)
|
||||
.innerJoin(aiKnowledgeEntries, eq(aiKnowledgeEntries.id, aiKnowledgeChunks.entryId))
|
||||
.where(getEnabledChunkWhere(organizationId))
|
||||
.orderBy(distance)
|
||||
.limit(options?.limit ?? DEFAULT_RETRIEVAL_LIMIT);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
results: rows.map((row) => ({
|
||||
entryId: row.entryId,
|
||||
chunkId: row.chunkId,
|
||||
title: row.title,
|
||||
category: row.category,
|
||||
content: row.content,
|
||||
score: 1 - row.distance,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeKnowledgeScope(scope: AiKnowledgeScope, organizationId?: string): string {
|
||||
return scope === "platform" ? "platform" : `organization:${organizationId ?? ""}`;
|
||||
}
|
||||
330
modules/ai/types.ts
Normal file
330
modules/ai/types.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import type { Permission } from "@/modules/core/types";
|
||||
|
||||
export const AI_KNOWLEDGE_SCOPES = ["platform", "organization"] as const;
|
||||
export type AiKnowledgeScope = (typeof AI_KNOWLEDGE_SCOPES)[number];
|
||||
|
||||
export const AI_KNOWLEDGE_STATUSES = ["enabled", "disabled"] as const;
|
||||
export type AiKnowledgeStatus = (typeof AI_KNOWLEDGE_STATUSES)[number];
|
||||
|
||||
export const ELDER_AI_ANALYSIS_STATUSES = ["completed", "failed"] as const;
|
||||
export type ElderAiAnalysisStatus = (typeof ELDER_AI_ANALYSIS_STATUSES)[number];
|
||||
|
||||
export const ELDER_AI_DATA_SCOPES = [
|
||||
"elder",
|
||||
"health",
|
||||
"care",
|
||||
"family",
|
||||
"admission",
|
||||
"facility",
|
||||
"alert",
|
||||
"incident",
|
||||
"knowledge",
|
||||
] as const;
|
||||
export type ElderAiDataScope = (typeof ELDER_AI_DATA_SCOPES)[number];
|
||||
|
||||
export const ELDER_AI_RISK_LEVELS = ["low", "medium", "high", "critical", "unknown"] as const;
|
||||
export type ElderAiRiskLevel = (typeof ELDER_AI_RISK_LEVELS)[number];
|
||||
|
||||
export const ELDER_AI_SEVERITIES = ["info", "warning", "critical"] as const;
|
||||
export type ElderAiSeverity = (typeof ELDER_AI_SEVERITIES)[number];
|
||||
|
||||
export const ELDER_AI_RECOMMENDATION_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
|
||||
export type ElderAiRecommendationPriority = (typeof ELDER_AI_RECOMMENDATION_PRIORITIES)[number];
|
||||
|
||||
export const AI_ERROR_CATEGORIES = [
|
||||
"missing_config",
|
||||
"provider_error",
|
||||
"timeout",
|
||||
"schema_validation_failed",
|
||||
"retrieval_failed",
|
||||
"unknown",
|
||||
] as const;
|
||||
export type AiErrorCategory = (typeof AI_ERROR_CATEGORIES)[number];
|
||||
|
||||
export type AiCitation = {
|
||||
id: string;
|
||||
sourceType: "resident_context" | "knowledge";
|
||||
sourceId: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
};
|
||||
|
||||
export type ElderAiFinding = {
|
||||
category: string;
|
||||
severity: ElderAiSeverity;
|
||||
evidence: string;
|
||||
citationIds: string[];
|
||||
};
|
||||
|
||||
export type ElderAiRecommendation = {
|
||||
title: string;
|
||||
priority: ElderAiRecommendationPriority;
|
||||
rationale: string;
|
||||
suggestedNextStep: string;
|
||||
citationIds: string[];
|
||||
};
|
||||
|
||||
export type ElderAiModelSummary = {
|
||||
provider: "openai-compatible";
|
||||
chatModel: string;
|
||||
embeddingModel: string;
|
||||
};
|
||||
|
||||
export type ElderAiAnalysisOutput = {
|
||||
overallRiskLevel: ElderAiRiskLevel;
|
||||
summary: string;
|
||||
keyFindings: ElderAiFinding[];
|
||||
recommendations: ElderAiRecommendation[];
|
||||
dataGaps: string[];
|
||||
citations: AiCitation[];
|
||||
confidence: number;
|
||||
modelSummary: ElderAiModelSummary;
|
||||
};
|
||||
|
||||
export type ElderAiAnalysisHistoryItem = {
|
||||
id: string;
|
||||
elderId: string;
|
||||
status: ElderAiAnalysisStatus;
|
||||
dataScopes: ElderAiDataScope[];
|
||||
createdAt: string;
|
||||
restricted: boolean;
|
||||
result?: ElderAiAnalysisOutput;
|
||||
errorCategory?: AiErrorCategory;
|
||||
errorReason?: string;
|
||||
};
|
||||
|
||||
export type KnowledgeEntryInput = {
|
||||
scope: AiKnowledgeScope;
|
||||
organizationId?: string;
|
||||
title: string;
|
||||
category: string;
|
||||
tags: string;
|
||||
body: string;
|
||||
status: AiKnowledgeStatus;
|
||||
};
|
||||
|
||||
export type KnowledgeEntry = KnowledgeEntryInput & {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type KnowledgeRetrievalResult = {
|
||||
entryId: string;
|
||||
chunkId: string;
|
||||
title: string;
|
||||
category: string;
|
||||
content: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
const DATA_SCOPE_PERMISSION_MAP: Record<Exclude<ElderAiDataScope, "elder" | "knowledge">, Permission> = {
|
||||
health: "health:read",
|
||||
care: "care:read",
|
||||
family: "family:read",
|
||||
admission: "admission:read",
|
||||
facility: "facility:read",
|
||||
alert: "alert:read",
|
||||
incident: "incident:read",
|
||||
};
|
||||
|
||||
export function isElderAiDataScope(value: string): value is ElderAiDataScope {
|
||||
return (ELDER_AI_DATA_SCOPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function getPermissionForDataScope(scope: ElderAiDataScope): Permission {
|
||||
if (scope === "elder") {
|
||||
return "elder:read";
|
||||
}
|
||||
if (scope === "knowledge") {
|
||||
return "knowledge:read";
|
||||
}
|
||||
return DATA_SCOPE_PERMISSION_MAP[scope];
|
||||
}
|
||||
|
||||
export function canViewAnalysisScopes(scopes: readonly ElderAiDataScope[], permissions: readonly Permission[]): boolean {
|
||||
return scopes.every((scope) => permissions.includes(getPermissionForDataScope(scope)));
|
||||
}
|
||||
|
||||
export function parseDataScopes(value: unknown): ElderAiDataScope[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((item): item is ElderAiDataScope => typeof item === "string" && isElderAiDataScope(item));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const items = value.filter((item): item is string => typeof item === "string");
|
||||
return items.length === value.length ? items : null;
|
||||
}
|
||||
|
||||
function validateCitation(value: unknown): AiCitation | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const sourceType = value.sourceType;
|
||||
if (sourceType !== "resident_context" && sourceType !== "knowledge") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof value.id !== "string" ||
|
||||
typeof value.sourceId !== "string" ||
|
||||
typeof value.title !== "string" ||
|
||||
typeof value.excerpt !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: value.id,
|
||||
sourceType,
|
||||
sourceId: value.sourceId,
|
||||
title: value.title,
|
||||
excerpt: value.excerpt,
|
||||
};
|
||||
}
|
||||
|
||||
function validateFinding(value: unknown): ElderAiFinding | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const citationIds = readStringArray(value.citationIds);
|
||||
if (
|
||||
typeof value.category !== "string" ||
|
||||
typeof value.evidence !== "string" ||
|
||||
!ELDER_AI_SEVERITIES.includes(value.severity as ElderAiSeverity) ||
|
||||
!citationIds
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
category: value.category,
|
||||
severity: value.severity as ElderAiSeverity,
|
||||
evidence: value.evidence,
|
||||
citationIds,
|
||||
};
|
||||
}
|
||||
|
||||
function validateRecommendation(value: unknown): ElderAiRecommendation | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const citationIds = readStringArray(value.citationIds);
|
||||
if (
|
||||
typeof value.title !== "string" ||
|
||||
typeof value.rationale !== "string" ||
|
||||
typeof value.suggestedNextStep !== "string" ||
|
||||
!ELDER_AI_RECOMMENDATION_PRIORITIES.includes(value.priority as ElderAiRecommendationPriority) ||
|
||||
!citationIds
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: value.title,
|
||||
priority: value.priority as ElderAiRecommendationPriority,
|
||||
rationale: value.rationale,
|
||||
suggestedNextStep: value.suggestedNextStep,
|
||||
citationIds,
|
||||
};
|
||||
}
|
||||
|
||||
function validateModelSummary(value: unknown): ElderAiModelSummary | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
value.provider !== "openai-compatible" ||
|
||||
typeof value.chatModel !== "string" ||
|
||||
typeof value.embeddingModel !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: "openai-compatible",
|
||||
chatModel: value.chatModel,
|
||||
embeddingModel: value.embeddingModel,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateElderAiAnalysisOutput(value: unknown): ElderAiAnalysisOutput | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const keyFindings = Array.isArray(value.keyFindings) ? value.keyFindings.map(validateFinding) : null;
|
||||
const recommendations = Array.isArray(value.recommendations) ? value.recommendations.map(validateRecommendation) : null;
|
||||
const citations = Array.isArray(value.citations) ? value.citations.map(validateCitation) : null;
|
||||
const dataGaps = readStringArray(value.dataGaps);
|
||||
const modelSummary = validateModelSummary(value.modelSummary);
|
||||
|
||||
if (
|
||||
!ELDER_AI_RISK_LEVELS.includes(value.overallRiskLevel as ElderAiRiskLevel) ||
|
||||
typeof value.summary !== "string" ||
|
||||
typeof value.confidence !== "number" ||
|
||||
!keyFindings ||
|
||||
keyFindings.includes(null) ||
|
||||
!recommendations ||
|
||||
recommendations.includes(null) ||
|
||||
!dataGaps ||
|
||||
!citations ||
|
||||
citations.includes(null) ||
|
||||
!modelSummary
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
overallRiskLevel: value.overallRiskLevel as ElderAiRiskLevel,
|
||||
summary: value.summary,
|
||||
keyFindings: keyFindings.filter((item): item is ElderAiFinding => Boolean(item)),
|
||||
recommendations: recommendations.filter((item): item is ElderAiRecommendation => Boolean(item)),
|
||||
dataGaps,
|
||||
citations: citations.filter((item): item is AiCitation => Boolean(item)),
|
||||
confidence: Math.max(0, Math.min(1, value.confidence)),
|
||||
modelSummary,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateKnowledgeEntryInput(value: unknown): { success: true; input: KnowledgeEntryInput } | { success: false; reason: string } {
|
||||
if (!value || typeof value !== "object") {
|
||||
return { success: false, reason: "请求数据格式无效" };
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const scope = typeof record.scope === "string" ? record.scope : "";
|
||||
const title = typeof record.title === "string" ? record.title.trim() : "";
|
||||
const category = typeof record.category === "string" ? record.category.trim() : "";
|
||||
const tags = typeof record.tags === "string" ? record.tags.trim() : "";
|
||||
const body = typeof record.body === "string" ? record.body.trim() : "";
|
||||
const status = typeof record.status === "string" ? record.status : "enabled";
|
||||
const organizationId = typeof record.organizationId === "string" && record.organizationId.trim() ? record.organizationId.trim() : undefined;
|
||||
|
||||
if (!AI_KNOWLEDGE_SCOPES.includes(scope as AiKnowledgeScope)) {
|
||||
return { success: false, reason: "知识库范围无效" };
|
||||
}
|
||||
if (!AI_KNOWLEDGE_STATUSES.includes(status as AiKnowledgeStatus)) {
|
||||
return { success: false, reason: "知识库状态无效" };
|
||||
}
|
||||
if (!title) {
|
||||
return { success: false, reason: "知识标题不能为空" };
|
||||
}
|
||||
if (!body) {
|
||||
return { success: false, reason: "知识内容不能为空" };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
input: {
|
||||
scope: scope as AiKnowledgeScope,
|
||||
organizationId,
|
||||
title,
|
||||
category,
|
||||
tags,
|
||||
body,
|
||||
status: status as AiKnowledgeStatus,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user