feat: add AI knowledge analysis MVP

This commit is contained in:
2026-07-05 00:47:52 -07:00
parent 6aa72d3b43
commit e204974b57
32 changed files with 8705 additions and 4 deletions

View 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),
},
};
}

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

View 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"),
},
};
}

View 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 ?? ""}`;
}