diff --git a/deploy/preview/deploy-local.sh b/deploy/preview/deploy-local.sh
index e77ccbf..90e220f 100644
--- a/deploy/preview/deploy-local.sh
+++ b/deploy/preview/deploy-local.sh
@@ -67,8 +67,8 @@ deploy() {
# The policy itself contains no endpoint or credential, but a configured
# transport without a policy would otherwise silently become the disabled
- # "[]" configuration below. Flatten formatted JSON before writing the
- # Compose dotenv file and fail before replacing a preview with rule-only AI.
+ # "[]" configuration below. Flatten formatted JSON before handing it to
+ # Compose and fail before replacing a preview with rule-only AI.
ai_providers_json="$(printf '%s' "${ANGUI_AI_PROVIDERS_JSON:-}" | tr -d '\r\n')"
ai_policy_without_whitespace="$(printf '%s' "${ai_providers_json}" | tr -d '[:space:]')"
if { [[ -z "${ai_policy_without_whitespace}" ]] || [[ "${ai_policy_without_whitespace}" == "[]" ]]; } \
@@ -76,6 +76,14 @@ deploy() {
die "ANGUI_AI_PROVIDERS_JSON is required when ANGUI_PREVIEW_AI_ENDPOINT or ANGUI_PREVIEW_AI_KEY is set"
fi
+ # Keep AI values out of the Compose dotenv file. A dotenv file is line based,
+ # so even a valid formatted JSON policy can be split into unrelated entries
+ # before Compose resolves the api environment. Process environment values take
+ # precedence over --env-file values during Compose interpolation.
+ export ANGUI_AI_PROVIDERS_JSON="${ai_providers_json:-[]}"
+ export ANGUI_PREVIEW_AI_ENDPOINT="${ANGUI_PREVIEW_AI_ENDPOINT:-}"
+ export ANGUI_PREVIEW_AI_KEY="${ANGUI_PREVIEW_AI_KEY:-}"
+
PREVIEW_DIR="${PREVIEW_ROOT}/${PREVIEW_ID}"
ensure_preview_dir
[[ -f "${PREVIEW_BACKEND_DIR}/angui" ]] || die "backend artifact angui is missing"
@@ -83,6 +91,10 @@ deploy() {
[[ -f "${PREVIEW_BACKEND_DIR}/migration" ]] || die "backend artifact migration is missing"
[[ -d "${PREVIEW_FRONTEND_DIR}" ]] || die "frontend artifact directory is missing"
+ if ! "${PREVIEW_BACKEND_DIR}/angui" validate-ai-config; then
+ die "ANGUI_AI_PROVIDERS_JSON failed application configuration validation"
+ fi
+
ensure_runtime_image
ensure_proxy
install -d -m 700 "${PREVIEW_DIR}/runtime"
@@ -105,9 +117,6 @@ PREVIEW_ORIGIN=${PREVIEW_ORIGIN}
PREVIEW_DEMO_PASSWORD=${PREVIEW_DEMO_PASSWORD}
PREVIEW_PROXY_NETWORK=${PREVIEW_PROXY_NETWORK:-angui-proxy}
AMAP_WEBSERVICE_KEY=${AMAP_WEBSERVICE_KEY:-}
-ANGUI_AI_PROVIDERS_JSON=${ai_providers_json:-[]}
-ANGUI_PREVIEW_AI_ENDPOINT=${ANGUI_PREVIEW_AI_ENDPOINT:-}
-ANGUI_PREVIEW_AI_KEY=${ANGUI_PREVIEW_AI_KEY:-}
EOF
# A preview is a fresh, disposable environment. Removing its named SQLite
diff --git a/docs/API.md b/docs/API.md
index fda4cb0..beba2de 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -51,6 +51,9 @@
| `PATCH` | `/api/admin/users/{user_id}/status` | `200` | 管理员启用、停用或锁定账号 |
| `POST` | `/api/admin/archive-drafts/{draft_id}/deidentify` | `200` | 管理员记录归档草稿的人工脱敏确认或拒绝 |
| `PATCH` | `/api/admin/archive-drafts/{draft_id}/review` | `200` | 管理员发布、拒绝或撤回已脱敏归档草稿 |
+| `GET` | `/api/admin/archive-drafts/{draft_id}/review-materials` | `200` | 管理员查看不可变 review material 版本链和当前 AI 输入版本 |
+| `GET` | `/api/admin/archive-drafts/{draft_id}/review-materials/diff/{from_version}/{to_version}` | `200` | 管理员比较两个脱敏材料版本 |
+| `POST` | `/api/admin/archive-drafts/{draft_id}/review-materials/{version}/restore` | `200` | 管理员从历史已脱敏版本创建新的恢复版本 |
除健康检查和登录外,所有接口都要求:
@@ -322,6 +325,8 @@ Example:
`POST /api/admin/archive-drafts/{draft_id}/deidentify` 只接受人工 `confirm` 或 `reject` 结果、长度受限的理由,以及在确认时提交的人工脱敏文本。确认会创建新的已脱敏材料版本,再以该版本作为唯一允许交给 AI 的输入,并把草稿转为 `pending_review` 和 `deidentified`;拒绝会转为 `rejected`,不能发布。每次结果记录操作者、时间、版本和理由长度,审计不保存理由原文。
`PATCH /api/admin/archive-drafts/{draft_id}/review` 只允许已确认脱敏的 `pending_review` 草稿 `publish` 或 `reject`,以及将 `published` 草稿 `withdraw`。发布仅把该受控版本标记为 `learning_resource`;当前不会创建知识问答/RAG 索引、导出、打印、公开读取或跨案件读取接口。撤回立即将用途恢复为 `internal_archive` 并标记 `withdrawn`,同时保留版本和审计历史;拒绝或撤回绝不修改或删除原案件。
+
+管理员可通过 `GET /api/admin/archive-drafts/{draft_id}/review-materials` 查看不可变的 review material 版本链、来源范围、审核状态和当前 AI 输入版本;`GET .../review-materials/diff/{from_version}/{to_version}` 提供两个版本的增删行差异。`POST .../review-materials/{version}/restore` 只能恢复管理员已确认的 `deidentified` 版本,恢复操作会生成带 `parent_material_id` 的新版本、切换草稿的 AI 输入指针并将草稿重新置为 `pending_review`,不会修改任何历史版本。审计只记录版本、ID 和理由长度,不记录材料原文。
# Controlled AI draft endpoints
The following authenticated endpoints produce only drafts; none confirms a clue, publishes a summary, dispatches a task, or inserts material into a knowledge base automatically.
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index e2983a6..a1e095c 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -1693,6 +1693,87 @@ paths:
"404": { $ref: "#/components/responses/NotFound" }
"409": { $ref: "#/components/responses/Conflict" }
+ /api/admin/archive-drafts/{draft_id}/review-materials:
+ parameters:
+ - name: draft_id
+ in: path
+ required: true
+ schema: { type: string, format: uuid }
+ get:
+ tags: [Admin]
+ operationId: listArchiveReviewMaterials
+ summary: List immutable archive review-material versions
+ description: Admin capability only. Returns the version chain, source-scope metadata, review state, and the de-identified material currently selected as the AI input. Raw source records are never returned by this endpoint.
+ security: [{ bearerAuth: [] }]
+ x-account-types: [member]
+ x-global-capabilities: [admin]
+ x-data-classification: restricted-admin
+ responses:
+ "200": { description: Archive review-material versions, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/ArchiveReviewMaterial" } } } } }
+ "401": { $ref: "#/components/responses/Unauthorized" }
+ "403": { $ref: "#/components/responses/Forbidden" }
+ "404": { $ref: "#/components/responses/NotFound" }
+
+ /api/admin/archive-drafts/{draft_id}/review-materials/diff/{from_version}/{to_version}:
+ parameters:
+ - name: draft_id
+ in: path
+ required: true
+ schema: { type: string, format: uuid }
+ - name: from_version
+ in: path
+ required: true
+ schema: { type: integer, minimum: 1 }
+ - name: to_version
+ in: path
+ required: true
+ schema: { type: integer, minimum: 1 }
+ get:
+ tags: [Admin]
+ operationId: diffArchiveReviewMaterials
+ summary: Compare two archive review-material versions
+ description: Admin capability only. Returns line-level additions and removals without mutating either immutable material version.
+ security: [{ bearerAuth: [] }]
+ x-account-types: [member]
+ x-global-capabilities: [admin]
+ x-data-classification: restricted-admin
+ responses:
+ "200": { description: Material version diff, content: { application/json: { schema: { $ref: "#/components/schemas/ArchiveReviewMaterialDiff" } } } }
+ "400": { $ref: "#/components/responses/ValidationError" }
+ "401": { $ref: "#/components/responses/Unauthorized" }
+ "403": { $ref: "#/components/responses/Forbidden" }
+ "404": { $ref: "#/components/responses/NotFound" }
+
+ /api/admin/archive-drafts/{draft_id}/review-materials/{version}/restore:
+ parameters:
+ - name: draft_id
+ in: path
+ required: true
+ schema: { type: string, format: uuid }
+ - name: version
+ in: path
+ required: true
+ schema: { type: integer, minimum: 1 }
+ post:
+ tags: [Admin]
+ operationId: restoreArchiveReviewMaterial
+ summary: Restore an approved material version as a new immutable version
+ description: Admin capability only. The selected de-identified version is copied into a new version with a parent link, the archive draft points to that new version, and the draft returns to pending_review. No historical row is mutated.
+ security: [{ bearerAuth: [] }]
+ x-account-types: [member]
+ x-global-capabilities: [admin]
+ x-data-classification: restricted-admin
+ requestBody:
+ required: true
+ content: { application/json: { schema: { $ref: "#/components/schemas/RestoreArchiveReviewMaterialRequest" } } }
+ responses:
+ "200": { description: Archive draft regenerated from the restored approved material, content: { application/json: { schema: { $ref: "#/components/schemas/ArchiveDraft" } } } }
+ "400": { $ref: "#/components/responses/ValidationError" }
+ "401": { $ref: "#/components/responses/Unauthorized" }
+ "403": { $ref: "#/components/responses/Forbidden" }
+ "404": { $ref: "#/components/responses/NotFound" }
+ "409": { $ref: "#/components/responses/Conflict" }
+
components:
securitySchemes:
bearerAuth:
@@ -3225,6 +3306,42 @@ components:
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
+ ArchiveReviewMaterial:
+ type: object
+ additionalProperties: false
+ required: [id, case_id, version, parent_material_id, content, source_scope, status, created_by_user_id, reviewed_by_user_id, reviewed_at, review_reason, created_at, selected_for_ai]
+ properties:
+ id: { type: string, format: uuid }
+ case_id: { type: string, format: uuid }
+ version: { type: integer, minimum: 1 }
+ parent_material_id: { type: [string, "null"], format: uuid }
+ content: { type: string, description: Administrator-visible de-identified review material or the initial controlled material awaiting de-identification. }
+ source_scope: { type: array, items: { type: string } }
+ status: { type: string, enum: [draft, deidentified, rejected] }
+ created_by_user_id: { type: string, format: uuid }
+ reviewed_by_user_id: { type: [string, "null"], format: uuid }
+ reviewed_at: { type: [string, "null"], format: date-time }
+ review_reason: { type: [string, "null"], description: Review reason is returned only within the administrator boundary. }
+ created_at: { type: string, format: date-time }
+ selected_for_ai: { type: boolean }
+
+ ArchiveReviewMaterialDiff:
+ type: object
+ additionalProperties: false
+ required: [from_version, to_version, added, removed]
+ properties:
+ from_version: { type: integer, minimum: 1 }
+ to_version: { type: integer, minimum: 1 }
+ added: { type: array, items: { type: string } }
+ removed: { type: array, items: { type: string } }
+
+ RestoreArchiveReviewMaterialRequest:
+ type: object
+ additionalProperties: false
+ required: [reason]
+ properties:
+ reason: { type: string, minLength: 1, maxLength: 1000 }
+
DeidentifyArchiveDraftRequest:
type: object
additionalProperties: false
diff --git a/frontend/src/api/cases.ts b/frontend/src/api/cases.ts
index 8b5c493..a3029b3 100644
--- a/frontend/src/api/cases.ts
+++ b/frontend/src/api/cases.ts
@@ -365,6 +365,27 @@ export interface ArchiveDraft {
created_at: string;
updated_at: string;
}
+export interface ArchiveReviewMaterial {
+ id: string;
+ case_id: string;
+ version: number;
+ parent_material_id: string | null;
+ content: string;
+ source_scope: string[];
+ status: "draft" | "deidentified" | "rejected";
+ created_by_user_id: string;
+ reviewed_by_user_id: string | null;
+ reviewed_at: string | null;
+ review_reason: string | null;
+ created_at: string;
+ selected_for_ai: boolean;
+}
+export interface ArchiveReviewMaterialDiff {
+ from_version: number;
+ to_version: number;
+ added: string[];
+ removed: string[];
+}
export type TaskStatus =
| "pending_claim"
| "assigned"
@@ -904,6 +925,43 @@ export function reviewArchiveDraft(
);
}
+export function listArchiveReviewMaterials(
+ token: string,
+ draftId: string,
+): Promise {
+ return apiRequest(
+ `/admin/archive-drafts/${draftId}/review-materials`,
+ {},
+ token,
+ );
+}
+
+export function diffArchiveReviewMaterials(
+ token: string,
+ draftId: string,
+ fromVersion: number,
+ toVersion: number,
+): Promise {
+ return apiRequest(
+ `/admin/archive-drafts/${draftId}/review-materials/diff/${fromVersion}/${toVersion}`,
+ {},
+ token,
+ );
+}
+
+export function restoreArchiveReviewMaterial(
+ token: string,
+ draftId: string,
+ version: number,
+ reason: string,
+): Promise {
+ return apiRequest(
+ `/admin/archive-drafts/${draftId}/review-materials/${version}/restore`,
+ { method: "POST", body: JSON.stringify({ reason }) },
+ token,
+ );
+}
+
export function listCaseClues(
token: string,
caseId: string,
diff --git a/frontend/src/pages/DashboardPage.test.tsx b/frontend/src/pages/DashboardPage.test.tsx
index 541658a..49a89c1 100644
--- a/frontend/src/pages/DashboardPage.test.tsx
+++ b/frontend/src/pages/DashboardPage.test.tsx
@@ -1,9 +1,15 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import { DashboardPage } from "./DashboardPage";
const mocked = vi.hoisted(() => ({
+ deidentifyArchiveDraft: vi.fn(),
+ reviewArchiveDraft: vi.fn(),
getCase: vi.fn(),
+ listAdminArchiveDrafts: vi.fn(),
+ listArchiveReviewMaterials: vi.fn(),
+ diffArchiveReviewMaterials: vi.fn(),
+ restoreArchiveReviewMaterial: vi.fn(),
listCases: vi.fn(),
listCommandIntake: vi.fn(),
globalCapabilities: [] as string[],
@@ -22,15 +28,42 @@ vi.mock("../auth/useAuth", () => ({
}),
}));
vi.mock("../api/cases", () => ({
+ deidentifyArchiveDraft: (...args: unknown[]) =>
+ mocked.deidentifyArchiveDraft(...args),
+ reviewArchiveDraft: (...args: unknown[]) => mocked.reviewArchiveDraft(...args),
getCase: (...args: unknown[]) => mocked.getCase(...args),
listCases: (...args: unknown[]) => mocked.listCases(...args),
listCommandIntake: (...args: unknown[]) => mocked.listCommandIntake(...args),
+ listAdminArchiveDrafts: (...args: unknown[]) => mocked.listAdminArchiveDrafts(...args),
+ listArchiveReviewMaterials: (...args: unknown[]) =>
+ mocked.listArchiveReviewMaterials(...args),
+ diffArchiveReviewMaterials: (...args: unknown[]) =>
+ mocked.diffArchiveReviewMaterials(...args),
+ restoreArchiveReviewMaterial: (...args: unknown[]) =>
+ mocked.restoreArchiveReviewMaterial(...args),
}));
vi.mock("../components/ServiceStatus", () => ({
ServiceStatus: () => 服务状态,
}));
describe("DashboardPage", () => {
+ beforeEach(() => {
+ mocked.globalCapabilities = [];
+ mocked.listCases.mockResolvedValue([]);
+ mocked.deidentifyArchiveDraft.mockResolvedValue({});
+ mocked.reviewArchiveDraft.mockResolvedValue({});
+ mocked.listCommandIntake.mockResolvedValue([]);
+ mocked.listAdminArchiveDrafts.mockResolvedValue([]);
+ mocked.listArchiveReviewMaterials.mockResolvedValue([]);
+ mocked.diffArchiveReviewMaterials.mockResolvedValue({
+ from_version: 1,
+ to_version: 2,
+ added: [],
+ removed: [],
+ });
+ mocked.restoreArchiveReviewMaterial.mockResolvedValue({});
+ });
+
it("warns when the 20-detail statistics limit truncates the visible case list", async () => {
mocked.listCases.mockResolvedValue(
Array.from({ length: 21 }, (_, index) => ({
@@ -81,4 +114,162 @@ describe("DashboardPage", () => {
expect(screen.getByText("待受理")).toBeInTheDocument();
expect(mocked.listCommandIntake).toHaveBeenCalledWith("test-session");
});
+
+ it("shows administrator material-version state for archive drafts", async () => {
+ mocked.globalCapabilities = ["admin"];
+ mocked.listAdminArchiveDrafts.mockResolvedValue([
+ {
+ id: "archive-1",
+ case_id: "case-1",
+ status: "pending_review",
+ content: "Timeline draft",
+ source_scope: ["confirmed_clue_review_material"],
+ review_material_id: "material-2",
+ deidentification_status: "deidentified",
+ template_version: "case-archive-ai-v2",
+ provider_model: null,
+ version: 2,
+ usage_scope: "internal_archive",
+ retention_status: "retained",
+ deidentified_at: "2026-07-25T08:00:00Z",
+ reviewed_at: null,
+ created_at: "2026-07-25T08:00:00Z",
+ updated_at: "2026-07-25T08:00:00Z",
+ },
+ ]);
+ mocked.listArchiveReviewMaterials.mockResolvedValueOnce([
+ {
+ id: "material-2",
+ case_id: "case-1",
+ version: 2,
+ parent_material_id: "material-1",
+ content: "Approved de-identified material",
+ source_scope: ["confirmed_clue_review_material"],
+ status: "deidentified",
+ created_by_user_id: "admin-1",
+ reviewed_by_user_id: "admin-1",
+ reviewed_at: "2026-07-25T08:00:00Z",
+ review_reason: "reviewed",
+ created_at: "2026-07-25T08:00:00Z",
+ selected_for_ai: true,
+ },
+ {
+ id: "material-1",
+ case_id: "case-1",
+ version: 1,
+ parent_material_id: null,
+ content: "Earlier approved material",
+ source_scope: ["confirmed_clue_review_material"],
+ status: "deidentified",
+ created_by_user_id: "admin-1",
+ reviewed_by_user_id: "admin-1",
+ reviewed_at: "2026-07-24T08:00:00Z",
+ review_reason: "reviewed",
+ created_at: "2026-07-24T08:00:00Z",
+ selected_for_ai: false,
+ },
+ ]);
+ mocked.listArchiveReviewMaterials.mockResolvedValueOnce([
+ {
+ id: "material-3",
+ case_id: "case-1",
+ version: 3,
+ parent_material_id: "material-1",
+ content: "Restored approved material",
+ source_scope: ["confirmed_clue_review_material"],
+ status: "deidentified",
+ created_by_user_id: "admin-1",
+ reviewed_by_user_id: "admin-1",
+ reviewed_at: "2026-07-25T09:00:00Z",
+ review_reason: "restored",
+ created_at: "2026-07-25T09:00:00Z",
+ selected_for_ai: true,
+ },
+ {
+ id: "material-2",
+ case_id: "case-1",
+ version: 2,
+ parent_material_id: "material-1",
+ content: "Approved de-identified material",
+ source_scope: ["confirmed_clue_review_material"],
+ status: "deidentified",
+ created_by_user_id: "admin-1",
+ reviewed_by_user_id: "admin-1",
+ reviewed_at: "2026-07-25T08:00:00Z",
+ review_reason: "reviewed",
+ created_at: "2026-07-25T08:00:00Z",
+ selected_for_ai: false,
+ },
+ {
+ id: "material-1",
+ case_id: "case-1",
+ version: 1,
+ parent_material_id: null,
+ content: "Earlier approved material",
+ source_scope: ["confirmed_clue_review_material"],
+ status: "deidentified",
+ created_by_user_id: "admin-1",
+ reviewed_by_user_id: "admin-1",
+ reviewed_at: "2026-07-24T08:00:00Z",
+ review_reason: "reviewed",
+ created_at: "2026-07-24T08:00:00Z",
+ selected_for_ai: false,
+ },
+ ]);
+ mocked.diffArchiveReviewMaterials.mockResolvedValue({
+ from_version: 1,
+ to_version: 2,
+ added: ["Approved de-identified material"],
+ removed: ["Earlier approved material"],
+ });
+ mocked.restoreArchiveReviewMaterial.mockResolvedValue({
+ id: "archive-1",
+ case_id: "case-1",
+ status: "pending_review",
+ content: "Regenerated archive draft",
+ source_scope: ["confirmed_clue_review_material"],
+ review_material_id: "material-3",
+ deidentification_status: "deidentified",
+ template_version: "case-archive-ai-v2",
+ provider_model: null,
+ version: 3,
+ usage_scope: "internal_archive",
+ retention_status: "retained",
+ deidentified_at: "2026-07-25T08:00:00Z",
+ reviewed_at: null,
+ created_at: "2026-07-25T08:00:00Z",
+ updated_at: "2026-07-25T08:00:00Z",
+ });
+
+ render();
+
+ expect(await screen.findByText("审核材料版本")).toBeInTheDocument();
+ expect(
+ await screen.findByText("Approved de-identified material"),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/当前 AI 输入/)).toBeInTheDocument();
+ expect(mocked.listArchiveReviewMaterials).toHaveBeenCalledWith("test-session", "archive-1");
+
+ fireEvent.click(screen.getByRole("button", { name: "与最早版本比较" }));
+ expect(await screen.findByText("差异:v1 与 v2")).toBeInTheDocument();
+ expect(mocked.diffArchiveReviewMaterials).toHaveBeenCalledWith(
+ "test-session",
+ "archive-1",
+ 1,
+ 2,
+ );
+
+ fireEvent.change(screen.getByLabelText(/审核理由/), {
+ target: { value: "restore approved historical version" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "恢复此版本" }));
+ expect(mocked.restoreArchiveReviewMaterial).toHaveBeenCalledWith(
+ "test-session",
+ "archive-1",
+ 1,
+ "restore approved historical version",
+ );
+ expect(await screen.findByText("Restored approved material")).toBeInTheDocument();
+ expect(mocked.listArchiveReviewMaterials).toHaveBeenCalledTimes(2);
+ });
});
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
index 0c60df0..d7e498c 100644
--- a/frontend/src/pages/DashboardPage.tsx
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -9,13 +9,17 @@ import {
import { useCallback, useEffect, useMemo, useState } from "react";
import {
deidentifyArchiveDraft,
+ diffArchiveReviewMaterials,
getCase,
+ listArchiveReviewMaterials,
listAdminArchiveDrafts,
listCases,
listCommandIntake,
reviewArchiveDraft,
+ restoreArchiveReviewMaterial,
} from "../api/cases";
import type {
+ ArchiveReviewMaterial,
ArchiveDraft,
CaseDetail,
CaseListItem,
@@ -362,12 +366,37 @@ function ArchiveReviewCard({
const [deidentifiedMaterial, setDeidentifiedMaterial] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
+ const [materials, setMaterials] = useState([]);
+ const [materialError, setMaterialError] = useState("");
+ const [diff, setDiff] = useState<{ from: number; to: number; added: string[]; removed: string[] } | null>(null);
+
+ const loadMaterials = useCallback(() => {
+ if (!token) return;
+ void listArchiveReviewMaterials(token, draft.id)
+ .then((items) => {
+ setMaterials(items);
+ setMaterialError("");
+ })
+ .catch((cause) =>
+ setMaterialError(
+ cause instanceof Error ? cause.message : "无法加载审核材料版本。",
+ ),
+ );
+ }, [draft.id, token]);
+
+ useEffect(() => {
+ setDiff(null);
+ loadMaterials();
+ }, [loadMaterials]);
+
const action = async (run: () => Promise) => {
if (!token || !reason.trim()) return;
setBusy(true);
setError("");
try {
onChanged(await run());
+ setDiff(null);
+ loadMaterials();
setReason("");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "案例草稿审核失败。");
@@ -392,6 +421,73 @@ function ArchiveReviewCard({
来源范围:{draft.source_scope.join("、")} · 脱敏:
{draft.deidentification_status}
+
+
+ 审核材料版本
+ {materials.length} 个版本
+
+ {materials.length > 0 && (
+
+ {materials.map((material) => (
+
+
+ v{material.version} · {material.status}{material.selected_for_ai ? " · 当前 AI 输入" : ""}
+ {material.status === "deidentified" &&
+ !material.selected_for_ai &&
+ matchesRestorableArchiveDraftStatus(draft.status) && (
+
+ )}
+
+
{material.content}
+
Scope: {material.source_scope.join(", ")}
+ {materials.length > 1 && material.version !== materials[materials.length - 1].version && (
+
+ )}
+
+ ))}
+
+ )}
+ {diff && (
+
+
差异:v{diff.from} 与 v{diff.to}
+ {diff.added.length > 0 &&
新增:{diff.added.join(" | ")}
}
+ {diff.removed.length > 0 &&
删除:{diff.removed.join(" | ")}
}
+ {diff.added.length === 0 && diff.removed.length === 0 &&
没有行级变更。
}
+
+ )}
+ {materialError &&
{materialError}
}
+
{draft.status !== "rejected" && draft.status !== "withdrawn" && (
<>
);
}
+
+function matchesRestorableArchiveDraftStatus(status: ArchiveDraft["status"]): boolean {
+ return status === "pending_review";
+}
diff --git a/src/api/models.rs b/src/api/models.rs
index e0d1ada..2f95265 100644
--- a/src/api/models.rs
+++ b/src/api/models.rs
@@ -124,6 +124,37 @@ pub struct ReviewArchiveDraftRequest {
pub reason: String,
}
+#[derive(Debug, Serialize)]
+pub struct ArchiveReviewMaterialResponse {
+ pub id: String,
+ pub case_id: String,
+ pub version: i32,
+ pub parent_material_id: Option,
+ pub content: String,
+ pub source_scope: Vec,
+ pub status: String,
+ pub created_by_user_id: String,
+ pub reviewed_by_user_id: Option,
+ pub reviewed_at: Option,
+ pub review_reason: Option,
+ pub created_at: String,
+ pub selected_for_ai: bool,
+}
+
+#[derive(Debug, Serialize)]
+pub struct ArchiveReviewMaterialDiffResponse {
+ pub from_version: i32,
+ pub to_version: i32,
+ pub added: Vec,
+ pub removed: Vec,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct RestoreArchiveReviewMaterialRequest {
+ pub reason: String,
+}
+
#[derive(Debug, Serialize)]
pub struct AdminAuditEventResponse {
pub id: String,
diff --git a/src/api/routes/admin.rs b/src/api/routes/admin.rs
index 4b5185a..8ddc64c 100644
--- a/src/api/routes/admin.rs
+++ b/src/api/routes/admin.rs
@@ -27,6 +27,18 @@ pub fn configure(config: &mut web::ServiceConfig) {
.route(
"/archive-drafts/{draft_id}/review",
web::patch().to(review_archive_draft),
+ )
+ .route(
+ "/archive-drafts/{draft_id}/review-materials",
+ web::get().to(list_archive_review_materials),
+ )
+ .route(
+ "/archive-drafts/{draft_id}/review-materials/diff/{from_version}/{to_version}",
+ web::get().to(diff_archive_review_materials),
+ )
+ .route(
+ "/archive-drafts/{draft_id}/review-materials/{version}/restore",
+ web::post().to(restore_archive_review_material),
),
);
}
@@ -106,3 +118,54 @@ async fn review_archive_draft(
.await?,
))
}
+
+async fn list_archive_review_materials(
+ auth: AuthenticatedUser,
+ state: web::Data,
+ draft_id: web::Path,
+) -> Result {
+ Ok(HttpResponse::Ok().json(
+ crate::services::case_collaboration_service::list_archive_review_materials(
+ &state.db, &auth, &draft_id,
+ )
+ .await?,
+ ))
+}
+
+async fn diff_archive_review_materials(
+ auth: AuthenticatedUser,
+ state: web::Data,
+ path: web::Path<(String, i32, i32)>,
+) -> Result {
+ let (draft_id, from_version, to_version) = path.into_inner();
+ Ok(HttpResponse::Ok().json(
+ crate::services::case_collaboration_service::diff_archive_review_materials(
+ &state.db,
+ &auth,
+ &draft_id,
+ from_version,
+ to_version,
+ )
+ .await?,
+ ))
+}
+
+async fn restore_archive_review_material(
+ auth: AuthenticatedUser,
+ state: web::Data,
+ path: web::Path<(String, i32)>,
+ request: web::Json,
+) -> Result {
+ let (draft_id, version) = path.into_inner();
+ Ok(HttpResponse::Ok().json(
+ crate::services::case_collaboration_service::restore_archive_review_material(
+ &state.db,
+ &auth,
+ &draft_id,
+ version,
+ request.into_inner(),
+ &state.ai_gateway,
+ )
+ .await?,
+ ))
+}
diff --git a/src/application/services/case_collaboration_service.rs b/src/application/services/case_collaboration_service.rs
index 7fb252c..c8ca1df 100644
--- a/src/application/services/case_collaboration_service.rs
+++ b/src/application/services/case_collaboration_service.rs
@@ -15,13 +15,14 @@ use crate::{
},
error::ApiError,
models::{
- ArchiveDraftResponse, AuthenticatedUser, CasePoiItem, CasePoiQuery, CasePoiResponse,
- CasePublicProgressItem, CasePublicProgressResponse, CaseSourceRecordResponse,
- ClueDraftCandidate, ClueDraftFieldDecision, ClueDraftResponse,
- CreateCaseSourceRecordRequest, CreateClueDraftRequest, CreateClueRequest,
- CreateSummaryDraftRequest, DeidentifyArchiveDraftRequest, ReviewArchiveDraftRequest,
- ReviewClueDraftRequest, ReviewSummaryDraftRequest, SummaryDraftDiffResponse,
- SummaryDraftResponse, SummaryDraftVersionResponse,
+ ArchiveDraftResponse, ArchiveReviewMaterialDiffResponse, ArchiveReviewMaterialResponse,
+ AuthenticatedUser, CasePoiItem, CasePoiQuery, CasePoiResponse, CasePublicProgressItem,
+ CasePublicProgressResponse, CaseSourceRecordResponse, ClueDraftCandidate,
+ ClueDraftFieldDecision, ClueDraftResponse, CreateCaseSourceRecordRequest,
+ CreateClueDraftRequest, CreateClueRequest, CreateSummaryDraftRequest,
+ DeidentifyArchiveDraftRequest, RestoreArchiveReviewMaterialRequest,
+ ReviewArchiveDraftRequest, ReviewClueDraftRequest, ReviewSummaryDraftRequest,
+ SummaryDraftDiffResponse, SummaryDraftResponse, SummaryDraftVersionResponse,
},
roles::{CaseRole, GlobalCapability},
services::{case_service, case_summary_service, task_service},
@@ -261,6 +262,12 @@ pub async fn deidentify_archive_draft(
));
}
let reason = required_text("reason", request.reason, 1_000)?;
+ let deidentified_material = if outcome == "confirm" {
+ Some(request.deidentified_material.unwrap_or_default())
+ } else {
+ None
+ };
+ let timestamp = now();
let transaction = db.begin().await?;
let existing = archive_drafts::Entity::find_by_id(draft_id)
.one(&transaction)
@@ -271,17 +278,6 @@ pub async fn deidentify_archive_draft(
"archive draft is not awaiting de-identification review".to_owned(),
));
}
- let timestamp = now();
- let next_deidentification_status = if outcome == "confirm" {
- "deidentified"
- } else {
- "rejected"
- };
- let next_status = if outcome == "confirm" {
- "pending_review"
- } else {
- "rejected"
- };
let material_id = existing.review_material_id.clone().ok_or_else(|| {
ApiError::Conflict("archive draft is missing its review material".to_owned())
})?;
@@ -289,83 +285,88 @@ pub async fn deidentify_archive_draft(
.one(&transaction)
.await?
.ok_or(ApiError::Internal)?;
- let (
- content,
- provider_model,
- template_version,
- _material_status,
- next_material_id,
- execution_audit,
- ) = if outcome == "confirm" {
- let material_text = required_text(
- "deidentified_material",
- request.deidentified_material.unwrap_or_default(),
- 12_000,
- )?;
- let material = archive_review_materials::ActiveModel {
- id: Set(case_service::new_id()),
- case_id: Set(original_material.case_id.clone()),
- version: Set(original_material.version + 1),
- parent_material_id: Set(Some(original_material.id)),
- content: Set(material_text.clone()),
- source_scope_json: Set(original_material.source_scope_json.clone()),
- status: Set("deidentified".to_owned()),
- created_by_user_id: Set(auth.id.clone()),
- reviewed_by_user_id: Set(Some(auth.id.clone())),
- reviewed_at: Set(Some(timestamp.clone())),
- review_reason: Set(Some(reason.clone())),
- created_at: Set(timestamp.clone()),
- }
- .insert(&transaction)
- .await?;
- let ai_request = AiRequest { capability: AiCapability::CaseOrganization, data_level: DataLevel::Internal, purpose: AiPurpose::CaseArchiveDraft, data_region: "CN".to_owned(), system_instruction: Some("Return JSON only: {timeline:string[],lessons:string[],uncertainty:string}. Use only this administrator-approved de-identified material. Do not infer identities, exact locations, health details, causes, or operational outcomes.".to_owned()), output_schema: Some(archive_candidate_schema()), output_schema_name: Some("case_archive_candidate".to_owned()), input: serde_json::to_string(&json!({"deidentified_material": material_text})).map_err(|_| ApiError::Internal)?, requested_output_tokens: 500, template_version: "case-archive-ai-v2".to_owned(), input_scope_reference: format!("approved-deidentified-material:{}:v{}", material.id, material.version), redaction_policy_version: "archive-approved-material-v1".to_owned() };
- let execution = gateway.execute(&ai_request).await;
- let execution_audits = crate::ai_gateway::execution_attempt_audits(&ai_request, &execution);
- let (content, provider_model, _audit_status) = match execution {
- AiExecutionResult::Completed { route, output, .. } => {
- match gateway.decode_json::(&output) {
- Ok(candidate) if valid_archive_candidate(&candidate) => (
- archive_candidate_content(candidate),
- Some(route.model),
- AiTaskStatus::Completed,
- ),
- _ => (
- deterministic_archive_material_content(&material_text),
- None,
- AiTaskStatus::Failed,
- ),
- }
+ let deidentified_material = deidentified_material
+ .map(|material| required_text("deidentified_material", material, 12_000))
+ .transpose()?;
+ let approved_material = if let Some(material_text) = deidentified_material.as_ref() {
+ Some(
+ archive_review_materials::ActiveModel {
+ id: Set(case_service::new_id()),
+ case_id: Set(original_material.case_id.clone()),
+ version: Set(original_material.version + 1),
+ parent_material_id: Set(Some(original_material.id.clone())),
+ content: Set(material_text.clone()),
+ source_scope_json: Set(original_material.source_scope_json.clone()),
+ status: Set("deidentified".to_owned()),
+ created_by_user_id: Set(auth.id.clone()),
+ reviewed_by_user_id: Set(Some(auth.id.clone())),
+ reviewed_at: Set(Some(timestamp.clone())),
+ review_reason: Set(Some(reason.clone())),
+ created_at: Set(timestamp.clone()),
}
- AiExecutionResult::Degraded { .. } => (
- deterministic_archive_material_content(&material_text),
+ .insert(&transaction)
+ .await?,
+ )
+ } else {
+ None
+ };
+ transaction.commit().await?;
+
+ let (content, provider_model, template_version, next_material_id, execution_audits) =
+ if let Some(material) = approved_material.as_ref() {
+ let ai_request = AiRequest { capability: AiCapability::CaseOrganization, data_level: DataLevel::Internal, purpose: AiPurpose::CaseArchiveDraft, data_region: "CN".to_owned(), system_instruction: Some("Return JSON only: {timeline:string[],lessons:string[],uncertainty:string}. Use only this administrator-approved de-identified material. Do not infer identities, exact locations, health details, causes, or operational outcomes.".to_owned()), output_schema: Some(archive_candidate_schema()), output_schema_name: Some("case_archive_candidate".to_owned()), input: serde_json::to_string(&json!({"deidentified_material": material.content})).map_err(|_| ApiError::Internal)?, requested_output_tokens: 500, template_version: "case-archive-ai-v2".to_owned(), input_scope_reference: format!("approved-deidentified-material:{}:v{}", material.id, material.version), redaction_policy_version: "archive-approved-material-v1".to_owned() };
+ let execution = gateway.execute(&ai_request).await;
+ let execution_audits =
+ crate::ai_gateway::execution_attempt_audits(&ai_request, &execution);
+ let (content, provider_model) = match execution {
+ AiExecutionResult::Completed { route, output, .. } => {
+ match gateway.decode_json::(&output) {
+ Ok(candidate) if valid_archive_candidate(&candidate) => {
+ (archive_candidate_content(candidate), Some(route.model))
+ }
+ _ => (
+ deterministic_archive_material_content(&material.content),
+ None,
+ ),
+ }
+ }
+ AiExecutionResult::Degraded { .. } => (
+ deterministic_archive_material_content(&material.content),
+ None,
+ ),
+ AiExecutionResult::Failed { .. } => (
+ deterministic_archive_material_content(&material.content),
+ None,
+ ),
+ };
+ (
+ content,
+ provider_model,
+ "case-archive-ai-v2".to_owned(),
+ Some(material.id.clone()),
+ Some(execution_audits),
+ )
+ } else {
+ (
+ existing.content.clone(),
None,
- AiTaskStatus::Degraded,
- ),
- AiExecutionResult::Failed { .. } => (
- deterministic_archive_material_content(&material_text),
+ existing.template_version.clone(),
+ Some(original_material.id.clone()),
None,
- AiTaskStatus::Failed,
- ),
+ )
};
- (
- content,
- provider_model,
- "case-archive-ai-v2".to_owned(),
- "deidentified",
- Some(material.id),
- Some(execution_audits),
- )
+ let next_deidentification_status = if outcome == "confirm" {
+ "deidentified"
} else {
- (
- existing.content.clone(),
- None,
- existing.template_version.clone(),
- "rejected",
- Some(original_material.id),
- None,
- )
+ "rejected"
+ };
+ let next_status = if outcome == "confirm" {
+ "pending_review"
+ } else {
+ "rejected"
};
let next_version = existing.version.checked_add(1).ok_or(ApiError::Internal)?;
+ let transaction = db.begin().await?;
let update = archive_drafts::Entity::update_many()
.col_expr(archive_drafts::Column::Status, Expr::value(next_status))
.col_expr(
@@ -416,7 +417,7 @@ pub async fn deidentify_archive_draft(
.one(&transaction)
.await?
.ok_or_else(|| ApiError::Internal)?;
- if let Some(execution_audits) = execution_audit {
+ if let Some(execution_audits) = execution_audits {
crate::ai_gateway::persist_execution_audits(
&transaction,
&execution_audits,
@@ -575,6 +576,304 @@ pub async fn list_archive_drafts_for_admin(
.collect()
}
+/// Lists the immutable review-material chain for one archive draft, including
+/// the administrator-approved material currently selected as AI input.
+pub async fn list_archive_review_materials(
+ db: &DatabaseConnection,
+ auth: &AuthenticatedUser,
+ draft_id: &str,
+) -> Result, ApiError> {
+ require_admin(auth)?;
+ let draft = archive_drafts::Entity::find_by_id(draft_id)
+ .one(db)
+ .await?
+ .ok_or_else(|| ApiError::NotFound("archive draft was not found".to_owned()))?;
+ archive_review_materials::Entity::find()
+ .filter(archive_review_materials::Column::CaseId.eq(&draft.case_id))
+ .order_by_desc(archive_review_materials::Column::Version)
+ .all(db)
+ .await?
+ .into_iter()
+ .map(|material| {
+ archive_review_material_response(material, draft.review_material_id.as_deref())
+ })
+ .collect()
+}
+
+/// Compares two immutable material versions without collapsing duplicate lines
+/// or modifying either version.
+pub async fn diff_archive_review_materials(
+ db: &DatabaseConnection,
+ auth: &AuthenticatedUser,
+ draft_id: &str,
+ from_version: i32,
+ to_version: i32,
+) -> Result {
+ require_admin(auth)?;
+ if from_version < 1 || to_version < 1 {
+ return Err(ApiError::Validation(
+ "material versions must be positive".to_owned(),
+ ));
+ }
+ let draft = archive_drafts::Entity::find_by_id(draft_id)
+ .one(db)
+ .await?
+ .ok_or_else(|| ApiError::NotFound("archive draft was not found".to_owned()))?;
+ let materials = archive_review_materials::Entity::find()
+ .filter(archive_review_materials::Column::CaseId.eq(&draft.case_id))
+ .filter(archive_review_materials::Column::Version.is_in([from_version, to_version]))
+ .all(db)
+ .await?;
+ let from = materials.iter().find(|item| item.version == from_version);
+ let to = materials.iter().find(|item| item.version == to_version);
+ let (Some(from), Some(to)) = (from, to) else {
+ return Err(ApiError::NotFound(
+ "archive review material version was not found".to_owned(),
+ ));
+ };
+ let (added, removed) = ordered_line_diff(&from.content, &to.content);
+ Ok(ArchiveReviewMaterialDiffResponse {
+ from_version,
+ to_version,
+ added,
+ removed,
+ })
+}
+
+/// Copies a historical approved material into a new version and regenerates an
+/// archive draft only while that draft is awaiting final administrator review.
+pub async fn restore_archive_review_material(
+ db: &DatabaseConnection,
+ auth: &AuthenticatedUser,
+ draft_id: &str,
+ version: i32,
+ request: RestoreArchiveReviewMaterialRequest,
+ gateway: &crate::ai_gateway::AiGateway,
+) -> Result {
+ require_admin(auth)?;
+ if version < 1 {
+ return Err(ApiError::Validation(
+ "material version must be positive".to_owned(),
+ ));
+ }
+ let reason = required_text("reason", request.reason, 1_000)?;
+ let transaction = db.begin().await?;
+ let existing = archive_drafts::Entity::find_by_id(draft_id)
+ .one(&transaction)
+ .await?
+ .ok_or_else(|| ApiError::NotFound("archive draft was not found".to_owned()))?;
+ if existing.status != "pending_review" {
+ return Err(ApiError::Conflict(
+ "archive draft is not awaiting final review and cannot restore material".to_owned(),
+ ));
+ }
+ let source = archive_review_materials::Entity::find()
+ .filter(archive_review_materials::Column::CaseId.eq(&existing.case_id))
+ .filter(archive_review_materials::Column::Version.eq(version))
+ .one(&transaction)
+ .await?
+ .ok_or_else(|| {
+ ApiError::NotFound("archive review material version was not found".to_owned())
+ })?;
+ if source.status != "deidentified" {
+ return Err(ApiError::Conflict(
+ "only an administrator-approved deidentified material can be restored".to_owned(),
+ ));
+ }
+ let current_material_id = existing.review_material_id.clone().ok_or_else(|| {
+ ApiError::Conflict("archive draft is missing its selected review material".to_owned())
+ })?;
+ let mut cursor = Some(current_material_id);
+ let mut visited = std::collections::HashSet::new();
+ let mut source_is_in_chain = false;
+ while let Some(material_id) = cursor {
+ if !visited.insert(material_id.clone()) {
+ break;
+ }
+ if material_id == source.id {
+ source_is_in_chain = true;
+ break;
+ }
+ cursor = archive_review_materials::Entity::find_by_id(material_id)
+ .one(&transaction)
+ .await?
+ .and_then(|material| material.parent_material_id);
+ }
+ if !source_is_in_chain {
+ return Err(ApiError::Conflict(
+ "material version is not part of this archive draft's immutable review chain"
+ .to_owned(),
+ ));
+ }
+ let next_material_version = archive_review_materials::Entity::find()
+ .filter(archive_review_materials::Column::CaseId.eq(&existing.case_id))
+ .order_by_desc(archive_review_materials::Column::Version)
+ .one(&transaction)
+ .await?
+ .map_or(Ok(1), |item| {
+ item.version.checked_add(1).ok_or(ApiError::Internal)
+ })?;
+ let timestamp = now();
+ let restored = archive_review_materials::ActiveModel {
+ id: Set(case_service::new_id()),
+ case_id: Set(existing.case_id.clone()),
+ version: Set(next_material_version),
+ parent_material_id: Set(Some(source.id.clone())),
+ content: Set(source.content.clone()),
+ source_scope_json: Set(source.source_scope_json.clone()),
+ status: Set("deidentified".to_owned()),
+ created_by_user_id: Set(auth.id.clone()),
+ reviewed_by_user_id: Set(Some(auth.id.clone())),
+ reviewed_at: Set(Some(timestamp.clone())),
+ review_reason: Set(Some(reason.clone())),
+ created_at: Set(timestamp.clone()),
+ }
+ .insert(&transaction)
+ .await?;
+ transaction.commit().await?;
+
+ let ai_request = AiRequest {
+ capability: AiCapability::CaseOrganization,
+ data_level: DataLevel::Internal,
+ purpose: AiPurpose::CaseArchiveDraft,
+ data_region: "CN".to_owned(),
+ system_instruction: Some("Return JSON only: {timeline:string[],lessons:string[],uncertainty:string}. Use only this administrator-approved de-identified material. Do not infer identities, exact locations, health details, causes, or operational outcomes.".to_owned()),
+ output_schema: Some(archive_candidate_schema()),
+ output_schema_name: Some("case_archive_candidate".to_owned()),
+ input: serde_json::to_string(&json!({"deidentified_material": restored.content}))
+ .map_err(|_| ApiError::Internal)?,
+ requested_output_tokens: 500,
+ template_version: "case-archive-ai-v2".to_owned(),
+ input_scope_reference: format!("approved-deidentified-material:{}:v{}", restored.id, restored.version),
+ redaction_policy_version: "archive-approved-material-v1".to_owned(),
+ };
+ let execution = gateway.execute(&ai_request).await;
+ let execution_audits = crate::ai_gateway::execution_attempt_audits(&ai_request, &execution);
+ let (content, provider_model) = match execution {
+ AiExecutionResult::Completed { route, output, .. } => {
+ match gateway.decode_json::(&output) {
+ Ok(candidate) if valid_archive_candidate(&candidate) => {
+ (archive_candidate_content(candidate), Some(route.model))
+ }
+ _ => (
+ deterministic_archive_material_content(&restored.content),
+ None,
+ ),
+ }
+ }
+ _ => (
+ deterministic_archive_material_content(&restored.content),
+ None,
+ ),
+ };
+ let next_draft_version = existing.version.checked_add(1).ok_or(ApiError::Internal)?;
+ let transaction = db.begin().await?;
+ let updated = archive_drafts::Entity::update_many()
+ .col_expr(
+ archive_drafts::Column::Status,
+ Expr::value("pending_review"),
+ )
+ .col_expr(
+ archive_drafts::Column::DeidentificationStatus,
+ Expr::value("deidentified"),
+ )
+ .col_expr(
+ archive_drafts::Column::DeidentifiedByUserId,
+ Expr::value(Some(auth.id.clone())),
+ )
+ .col_expr(
+ archive_drafts::Column::DeidentifiedAt,
+ Expr::value(Some(timestamp.clone())),
+ )
+ .col_expr(
+ archive_drafts::Column::DeidentificationReason,
+ Expr::value(Some(reason.clone())),
+ )
+ .col_expr(archive_drafts::Column::Content, Expr::value(content))
+ .col_expr(
+ archive_drafts::Column::ProviderModel,
+ Expr::value(provider_model),
+ )
+ .col_expr(
+ archive_drafts::Column::TemplateVersion,
+ Expr::value("case-archive-ai-v2"),
+ )
+ .col_expr(
+ archive_drafts::Column::ReviewMaterialId,
+ Expr::value(Some(restored.id.clone())),
+ )
+ .col_expr(
+ archive_drafts::Column::Version,
+ Expr::value(next_draft_version),
+ )
+ .col_expr(
+ archive_drafts::Column::UsageScope,
+ Expr::value("internal_archive"),
+ )
+ .col_expr(
+ archive_drafts::Column::RetentionStatus,
+ Expr::value("retained"),
+ )
+ .col_expr(
+ archive_drafts::Column::ReviewedByUserId,
+ Expr::value(None::),
+ )
+ .col_expr(
+ archive_drafts::Column::ReviewedAt,
+ Expr::value(None::),
+ )
+ .col_expr(
+ archive_drafts::Column::ReviewReason,
+ Expr::value(None::),
+ )
+ .col_expr(
+ archive_drafts::Column::UpdatedAt,
+ Expr::value(timestamp.clone()),
+ )
+ .filter(archive_drafts::Column::Id.eq(draft_id))
+ .filter(archive_drafts::Column::Version.eq(existing.version))
+ .filter(archive_drafts::Column::Status.eq(&existing.status))
+ .filter(
+ archive_drafts::Column::DeidentificationStatus.eq(&existing.deidentification_status),
+ )
+ .exec(&transaction)
+ .await?;
+ if updated.rows_affected != 1 {
+ return Err(ApiError::Conflict(
+ "archive draft changed before material restore could be recorded".to_owned(),
+ ));
+ }
+ let model = archive_drafts::Entity::find_by_id(draft_id)
+ .one(&transaction)
+ .await?
+ .ok_or(ApiError::Internal)?;
+ crate::ai_gateway::persist_execution_audits(
+ &transaction,
+ &execution_audits,
+ &auth.id,
+ Some(&model.case_id),
+ )
+ .await?;
+ case_service::write_audit(
+ &transaction,
+ Some(model.case_id.clone()),
+ auth,
+ "archive_review_material.restored",
+ "archive_review_material",
+ restored.id,
+ Some(json!({
+ "draft_id": draft_id,
+ "source_version": version,
+ "restored_version": next_material_version,
+ "draft_version": model.version,
+ "reason_length": reason.chars().count(),
+ })),
+ )
+ .await?;
+ transaction.commit().await?;
+ archive_draft_response(model)
+}
+
pub async fn get_public_progress(
db: &DatabaseConnection,
auth: &AuthenticatedUser,
@@ -1288,6 +1587,38 @@ fn deterministic_archive_material_content(material: &str) -> String {
}
}
+// Retain every occurrence and ordering signal while avoiding an unbounded
+// quadratic diff for administrator-supplied material up to the input limit.
+fn ordered_line_diff(from: &str, to: &str) -> (Vec, Vec) {
+ let from_lines = from.lines().collect::>();
+ let to_lines = to.lines().collect::>();
+ let mut prefix = 0;
+ while prefix < from_lines.len()
+ && prefix < to_lines.len()
+ && from_lines[prefix] == to_lines[prefix]
+ {
+ prefix += 1;
+ }
+
+ let mut from_end = from_lines.len();
+ let mut to_end = to_lines.len();
+ while from_end > prefix && to_end > prefix && from_lines[from_end - 1] == to_lines[to_end - 1] {
+ from_end -= 1;
+ to_end -= 1;
+ }
+
+ (
+ to_lines[prefix..to_end]
+ .iter()
+ .map(|line| (*line).to_owned())
+ .collect(),
+ from_lines[prefix..from_end]
+ .iter()
+ .map(|line| (*line).to_owned())
+ .collect(),
+ )
+}
+
fn valid_archive_candidate(candidate: &ArchiveOrganizationCandidate) -> bool {
candidate.timeline.len() <= 12
&& candidate.lessons.len() <= 12
@@ -1379,6 +1710,29 @@ fn archive_draft_response(model: archive_drafts::Model) -> Result,
+) -> Result {
+ let selected_for_ai = selected_id == Some(model.id.as_str());
+ Ok(ArchiveReviewMaterialResponse {
+ id: model.id,
+ case_id: model.case_id,
+ version: model.version,
+ parent_material_id: model.parent_material_id,
+ content: model.content,
+ source_scope: serde_json::from_str(&model.source_scope_json)
+ .map_err(|_| ApiError::Internal)?,
+ status: model.status,
+ created_by_user_id: model.created_by_user_id,
+ reviewed_by_user_id: model.reviewed_by_user_id,
+ reviewed_at: model.reviewed_at,
+ review_reason: model.review_reason,
+ created_at: model.created_at,
+ selected_for_ai,
+ })
+}
+
fn require_admin(auth: &AuthenticatedUser) -> Result<(), ApiError> {
auth.global_capabilities
.contains(&GlobalCapability::Admin)
diff --git a/src/config.rs b/src/config.rs
index 881b7c8..5efb168 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -4,7 +4,9 @@ use std::{
path::{Component, PathBuf},
};
-use crate::integrations::ai_gateway::{ProviderConfig, validate_provider_configurations};
+use crate::integrations::ai_gateway::{
+ AiGateway, ProviderConfig, validate_provider_configurations,
+};
/// Load a local development `.env` file without overriding process-level
/// configuration. A missing file is normal in production and CI.
@@ -37,25 +39,25 @@ pub struct Settings {
impl Settings {
pub fn from_env() -> Result {
load_local_env_file()?;
- Self::from_values(|name| env::var(name).ok())
+ Self::from_values(environment_value)
}
/// Parses settings from a supplied lookup so validation can be tested without
/// mutating process-global environment variables.
fn from_values(mut value: F) -> Result
where
- F: FnMut(&str) -> Option,
+ F: FnMut(&str) -> Result