feat: complete ops dashboard AI board

This commit is contained in:
2026-07-08 19:47:28 -07:00
parent 6a1add3422
commit a8776f9d79
14 changed files with 1401 additions and 189 deletions

View File

@@ -5,7 +5,7 @@ import type { AiRuntimeConfigResult } from "@/modules/ai/server/config";
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
import type { ElderAiResidentContext } from "@/modules/ai/server/elder-context";
import { buildElderAiContext } from "@/modules/ai/server/elder-context";
import { generateElderAiAnalysis, listElderAiAnalyses } from "@/modules/ai/server/analysis";
import { generateElderAiAnalysis, listAiAnalysisBoard, listElderAiAnalyses } from "@/modules/ai/server/analysis";
import { retrieveKnowledge } from "@/modules/ai/server/knowledge";
import type { AiCitation, ElderAiAnalysisOutput } from "@/modules/ai/types";
import { recordAuditLog } from "@/modules/core/server/audit";
@@ -65,6 +65,11 @@ type AnalysisRow = {
updatedAt: Date;
};
type ElderNameRow = {
id: string;
name: string;
};
type AnalysisInsertPayload = {
organizationId?: unknown;
elderId?: unknown;
@@ -258,8 +263,9 @@ function createAnalysisRow(values: AnalysisInsertPayload, index: number): Analys
};
}
function createDatabaseFake(selectRows: AnalysisRow[] = []): AnalysisDatabaseFake {
function createDatabaseFake(selectRows: AnalysisRow[] = [], elderRows: ElderNameRow[] = []): AnalysisDatabaseFake {
const insertedValues: AnalysisInsertPayload[] = [];
const orderedSelectRows = [...selectRows].sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime());
const insert = vi.fn(() => ({
values: vi.fn((values: AnalysisInsertPayload) => {
insertedValues.push(values);
@@ -268,15 +274,23 @@ function createDatabaseFake(selectRows: AnalysisRow[] = []): AnalysisDatabaseFak
};
}),
}));
const select = vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
orderBy: vi.fn(() => ({
limit: vi.fn(async () => selectRows),
})),
const select = vi.fn((selection?: unknown) => {
const selectsElderNames = selection !== null && typeof selection === "object" && "name" in selection;
return {
from: vi.fn(() => ({
where: vi.fn(() => {
if (selectsElderNames) {
return elderRows;
}
return {
orderBy: vi.fn(() => ({
limit: vi.fn(async (rowLimit: number) => orderedSelectRows.slice(0, rowLimit)),
})),
};
}),
})),
})),
}));
};
});
return { insertedValues, insert, select };
}
@@ -442,6 +456,130 @@ describe("elder AI analysis service", () => {
});
});
it("lists board analysis rows with elder display names and permitted result content", async () => {
const analysisResult = createModelOutput({
overallRiskLevel: "high",
summary: "Night wandering requires a care-plan review.",
});
const completedRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-2",
actorAccountId: "account-1",
status: "completed",
dataScopes: ["elder", "health"],
resultJson: analysisResult,
citationsJson: analysisResult.citations,
modelSummaryJson: analysisResult.modelSummary,
}, 1);
useDatabase(createDatabaseFake([completedRow], [{ id: "elder-2", name: "李建国" }]));
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read", "health:read"]), 5);
expect(result).toEqual({
success: true,
data: {
items: [
expect.objectContaining({
id: "analysis-2",
elderId: "elder-2",
elderName: "李建国",
status: "completed",
dataScopes: ["elder", "health"],
createdAt: "2026-07-02T01:00:00.000Z",
restricted: false,
result: analysisResult,
}),
],
},
});
});
it("redacts board analysis rows when a stored data scope is not permitted", async () => {
const restrictedResult = createModelOutput({
summary: "Health details should not be visible without health permission.",
});
const restrictedRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-3",
actorAccountId: "account-1",
status: "completed",
dataScopes: ["elder", "health"],
resultJson: restrictedResult,
citationsJson: restrictedResult.citations,
modelSummaryJson: restrictedResult.modelSummary,
errorCategory: "provider_error",
errorReason: "provider detail should stay hidden",
}, 2);
useDatabase(createDatabaseFake([restrictedRow], [{ id: "elder-3", name: "周玉珍" }]));
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read"]), 5);
expect(result.success).toBe(true);
if (result.success !== true) {
return;
}
expect(result.data.items).toEqual([
{
id: "analysis-3",
elderId: "elder-3",
elderName: "周玉珍",
status: "completed",
dataScopes: ["elder", "health"],
createdAt: "2026-07-02T02:00:00.000Z",
restricted: true,
},
]);
expect(result.data.items[0]).not.toHaveProperty("result");
expect(result.data.items[0]).not.toHaveProperty("errorCategory");
expect(result.data.items[0]).not.toHaveProperty("errorReason");
});
it("returns board rows in newest-first order up to the requested limit", async () => {
const newestRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-4",
actorAccountId: "account-1",
status: "completed",
dataScopes: ["elder"],
resultJson: createModelOutput({ summary: "Newest analysis" }),
}, 3);
const olderRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-1",
actorAccountId: "account-1",
status: "completed",
dataScopes: ["elder"],
resultJson: createModelOutput({ summary: "Older analysis" }),
}, 0);
const middleRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-2",
actorAccountId: "account-1",
status: "completed",
dataScopes: ["elder"],
resultJson: createModelOutput({ summary: "Middle analysis" }),
}, 1);
useDatabase(createDatabaseFake(
[olderRow, newestRow, middleRow],
[
{ id: "elder-1", name: "王阿姨" },
{ id: "elder-2", name: "李建国" },
{ id: "elder-4", name: "陈桂兰" },
],
));
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read"]), 2);
expect(result.success).toBe(true);
if (result.success !== true) {
return;
}
expect(result.data.items.map((item) => ({ id: item.id, elderName: item.elderName, createdAt: item.createdAt }))).toEqual([
{ id: "analysis-4", elderName: "陈桂兰", createdAt: "2026-07-02T03:00:00.000Z" },
{ id: "analysis-2", elderName: "李建国", createdAt: "2026-07-02T01:00:00.000Z" },
]);
});
it("rejects model output that cites IDs outside resident and knowledge citations", async () => {
const database = createDatabaseFake();
useDatabase(database);

View File

@@ -9,6 +9,7 @@ import { retrieveKnowledge } from "@/modules/ai/server/knowledge";
import type {
AiCitation,
AiErrorCategory,
ElderAiAnalysisBoardItem,
ElderAiAnalysisHistoryItem,
ElderAiAnalysisOutput,
} from "@/modules/ai/types";
@@ -19,7 +20,7 @@ import {
} 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 { elderAiAnalyses, elders } 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 };
@@ -202,6 +203,48 @@ export async function listElderAiAnalyses(
};
}
function rowToBoardItem(row: AnalysisRow, elderName: string, permissions: AuthContext["permissions"]): ElderAiAnalysisBoardItem {
return {
elderName,
...rowToHistoryItem(row, permissions),
};
}
export async function listAiAnalysisBoard(
context: AuthContext,
limit = 8,
): Promise<ServiceResult<{ items: ElderAiAnalysisBoardItem[] }>> {
if (!context.permissions.includes("ai:read")) {
return { success: false, reason: "无权查看 AI 分析", status: 403 };
}
const organizationId = context.organization?.id;
if (!organizationId) {
return { success: false, reason: "请选择机构后查看 AI 分析", status: 400 };
}
const normalizedLimit = Number.isFinite(limit) ? Math.trunc(limit) : 8;
const rowLimit = Math.min(20, Math.max(1, normalizedLimit));
const database = getDatabase();
const [rows, elderRows] = await Promise.all([
database
.select()
.from(elderAiAnalyses)
.where(eq(elderAiAnalyses.organizationId, organizationId))
.orderBy(desc(elderAiAnalyses.createdAt))
.limit(rowLimit),
database.select({ id: elders.id, name: elders.name }).from(elders).where(eq(elders.organizationId, organizationId)),
]);
const elderNameById = new Map(elderRows.map((elder) => [elder.id, elder.name]));
return {
success: true,
data: {
items: rows.map((row) => rowToBoardItem(row, elderNameById.get(row.elderId) ?? "未知老人", context.permissions)),
},
};
}
export async function generateElderAiAnalysis(
context: AuthContext,
elderId: string,

View File

@@ -93,6 +93,10 @@ export type ElderAiAnalysisHistoryItem = {
errorReason?: string;
};
export type ElderAiAnalysisBoardItem = ElderAiAnalysisHistoryItem & {
elderName: string;
};
export type KnowledgeEntryInput = {
scope: AiKnowledgeScope;
organizationId?: string;