Make the governed RAG evidence path executable end to end
Some checks failed
verify / verify (push) Has been cancelled

Separate local parsing from model indexing, bind review decisions to immutable manifests, persist vectors behind active profiles, and expose retrieval, chat, evaluation, and document workflows through the React workbench.

Constraint: Live Bailian authentication currently fails for all three configured capabilities

Rejected: Direct upload-to-embedding flow | bypasses local review and manifest binding

Confidence: high

Scope-risk: broad

Directive: Keep private-data deployment blocked until authentication, RBAC, and separate database roles land

Tested: make verify; fresh and replay Docker document smoke; worker recovery smoke; frozen synthetic evaluation; migration 0003-0004 roundtrip

Not-tested: Successful live Bailian calls, OCR, real multi-user authorization
This commit is contained in:
2026-07-13 05:58:11 +08:00
parent 75592af33a
commit ecdb10c37a
111 changed files with 25457 additions and 152 deletions

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1,115 @@
import { describe, expect, it, vi } from "vitest";
import { getCompleteReviewBundle, listDocuments } from "./api";
import type { ReviewBundle } from "./types";
const bundle: ReviewBundle = {
document: {
id: "70000000-0000-0000-0000-000000000001",
filename: "sample.md",
mime_type: "text/markdown",
raw_sha256: "a".repeat(64),
status: "PROCESSING",
active_version_id: null,
created_at: "2026-07-13T00:00:00Z",
updated_at: "2026-07-13T00:00:00Z",
},
version: {
id: "71000000-0000-0000-0000-000000000001",
review_state: "LOCAL_PARSED_PENDING_CLOUD_REVIEW",
review_revision: 2,
status: "PROCESSING",
parser_profile_hash: "b".repeat(64),
chunk_profile_hash: "c".repeat(64),
cloud_policy_id: "policy",
outbound_manifest_sha256: "d".repeat(64),
expected_chunk_count: 2,
error_code: null,
created_at: "2026-07-13T00:00:00Z",
completed_at: null,
},
pages: [],
blocks: [],
chunks: [
{
ordinal: 0,
display_text: "one",
cloud_text: "one",
cloud_text_sha256: "e".repeat(64),
embedding_text_sha256: "e".repeat(64),
token_count: 1,
page_start: null,
page_end: null,
section_path: [],
approval_status: "PENDING",
index_status: "PENDING",
},
],
next_ordinal: 0,
};
function response(value: unknown): Response {
return new Response(JSON.stringify(value), {
headers: { "Content-Type": "application/json" },
});
}
describe("document review API", () => {
it("loads the complete governed document directory", async () => {
const first = bundle.document;
const second = { ...first, id: "70000000-0000-0000-0000-000000000002" };
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(response({ items: [first], next_cursor: first.id }))
.mockResolvedValueOnce(response({ items: [second], next_cursor: null })),
);
const result = await listDocuments();
expect(result.items.map((item) => item.id)).toEqual([first.id, second.id]);
expect(vi.mocked(fetch).mock.calls[1]?.[0]).toContain(`cursor=${first.id}`);
});
it("loads every page while preserving one reviewed version", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(response(bundle))
.mockResolvedValueOnce(
response({
...bundle,
chunks: [{ ...bundle.chunks[0]!, ordinal: 1, cloud_text: "two" }],
next_ordinal: null,
}),
),
);
const result = await getCompleteReviewBundle(bundle.document.id);
expect(result.chunks.map((chunk) => chunk.cloud_text)).toEqual(["one", "two"]);
expect(vi.mocked(fetch).mock.calls[1]?.[0]).toContain("after_ordinal=0");
});
it("fails closed if revision changes between pages", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(response(bundle))
.mockResolvedValueOnce(
response({
...bundle,
version: { ...bundle.version!, review_revision: 3 },
next_ordinal: null,
}),
),
);
await expect(getCompleteReviewBundle(bundle.document.id)).rejects.toEqual(
expect.objectContaining({ message: "复核内容在加载期间发生变化,请重新加载" }),
);
});
});

View File

@@ -0,0 +1,176 @@
import { ApiError } from "../../api/client";
import type {
CompleteUploadResponse,
DocumentJob,
DocumentListResponse,
DocumentUpload,
ReviewBundle,
ReviewDecisionRequest,
ReviewDecisionResponse,
} from "./types";
interface UploadDeclaration {
filename: string;
declared_mime_type: string;
expected_size: number;
expected_sha256: string;
}
const JSON_HEADERS = { Accept: "application/json", "Content-Type": "application/json" } as const;
function optionalSignal(signal: AbortSignal | undefined): { signal?: AbortSignal } {
return signal === undefined ? {} : { signal };
}
function safeDocumentError(status: number): string {
if (status === 409) return "当前状态已变化,请刷新后重试";
if (status === 412) return "审核内容已有新版本,请重新加载并复核";
if (status === 413) return "文件超过服务端允许的大小";
if (status === 415) return "文件类型不受支持";
if (status === 422) return "文件声明、内容或审核参数不符合约束";
if (status === 503) return "文档服务暂不可用,请稍后重试";
return `文档请求未成功HTTP ${status}`;
}
async function documentFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
let response: Response;
try {
response = await fetch(path, init);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new ApiError("network", "无法连接本地文档服务");
}
if (!response.ok) {
throw new ApiError(
response.status === 422 ? "validation" : response.status === 503 ? "unavailable" : "http",
safeDocumentError(response.status),
response.status,
);
}
try {
return (await response.json()) as T;
} catch {
throw new ApiError("http", "文档服务返回了无法解析的数据", response.status);
}
}
export function createDocumentUpload(
declaration: UploadDeclaration,
idempotencyKey: string,
signal: AbortSignal,
): Promise<DocumentUpload> {
return documentFetch("/api/v1/document-uploads", {
method: "POST",
headers: { ...JSON_HEADERS, "Idempotency-Key": idempotencyKey },
body: JSON.stringify(declaration),
signal,
});
}
export function putDocumentContent(
uploadId: string,
file: File,
signal: AbortSignal,
): Promise<DocumentUpload> {
return documentFetch(`/api/v1/document-uploads/${encodeURIComponent(uploadId)}/content`, {
method: "PUT",
headers: { Accept: "application/json", "Content-Type": "application/octet-stream" },
body: file,
signal,
});
}
export function completeDocumentUpload(
uploadId: string,
signal: AbortSignal,
): Promise<CompleteUploadResponse> {
return documentFetch(`/api/v1/document-uploads/${encodeURIComponent(uploadId)}/complete`, {
method: "POST",
headers: { Accept: "application/json" },
signal,
});
}
export function getDocumentJob(jobId: string, signal?: AbortSignal): Promise<DocumentJob> {
return documentFetch(`/api/v1/document-jobs/${encodeURIComponent(jobId)}`, {
headers: { Accept: "application/json" },
...optionalSignal(signal),
});
}
export async function listDocuments(signal?: AbortSignal): Promise<DocumentListResponse> {
let page = await documentFetch<DocumentListResponse>("/api/v1/documents?limit=100", {
headers: { Accept: "application/json" },
...optionalSignal(signal),
});
const items = [...page.items];
const seenCursors = new Set<string>();
let requests = 1;
while (page.next_cursor !== null) {
if (requests >= 100 || seenCursors.has(page.next_cursor)) {
throw new ApiError("http", "文档目录分页超过安全上限");
}
seenCursors.add(page.next_cursor);
page = await documentFetch<DocumentListResponse>(
`/api/v1/documents?limit=100&cursor=${encodeURIComponent(page.next_cursor)}`,
{ headers: { Accept: "application/json" }, ...optionalSignal(signal) },
);
items.push(...page.items);
requests += 1;
}
return { items, next_cursor: null };
}
function assertSameReviewVersion(previous: ReviewBundle, next: ReviewBundle): void {
if (
previous.document.id !== next.document.id ||
previous.version?.id !== next.version?.id ||
previous.version?.review_revision !== next.version?.review_revision
) {
throw new ApiError("http", "复核内容在加载期间发生变化,请重新加载");
}
}
export async function getCompleteReviewBundle(
documentId: string,
signal?: AbortSignal,
): Promise<ReviewBundle> {
const basePath = `/api/v1/documents/${encodeURIComponent(documentId)}/review-bundle`;
let bundle = await documentFetch<ReviewBundle>(`${basePath}?after_ordinal=-1&limit=100`, {
headers: { Accept: "application/json" },
...optionalSignal(signal),
});
let cursor = bundle.next_ordinal;
let requests = 1;
while (cursor !== null) {
if (requests >= 100) throw new ApiError("http", "复核内容分页超过安全上限");
const page = await documentFetch<ReviewBundle>(
`${basePath}?after_ordinal=${encodeURIComponent(String(cursor))}&limit=100`,
{ headers: { Accept: "application/json" }, ...optionalSignal(signal) },
);
assertSameReviewVersion(bundle, page);
bundle = {
...bundle,
pages: [...bundle.pages, ...page.pages],
blocks: [...bundle.blocks, ...page.blocks],
chunks: [...bundle.chunks, ...page.chunks],
next_ordinal: page.next_ordinal,
};
cursor = page.next_ordinal;
requests += 1;
}
return bundle;
}
export function createReviewDecision(
documentId: string,
request: ReviewDecisionRequest,
signal?: AbortSignal,
): Promise<ReviewDecisionResponse> {
return documentFetch(`/api/v1/documents/${encodeURIComponent(documentId)}/review-decisions`, {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify(request),
...optionalSignal(signal),
});
}

View File

@@ -0,0 +1,93 @@
import { Icon } from "../../../components/Icon";
import { getApiErrorMessage } from "../../../api/client";
import type { DocumentSummary } from "../types";
interface DocumentLibraryProps {
documents: DocumentSummary[] | undefined;
error: unknown;
isLoading: boolean;
isRefreshing: boolean;
selectedId: string | null;
onRefresh: () => void;
onSelect: (id: string) => void;
}
function documentState(document: DocumentSummary): string {
if (document.active_version_id !== null) return "已激活检索";
if (document.status === "REJECTED") return "已拒绝出域";
return document.status;
}
export function DocumentLibrary({
documents,
error,
isLoading,
isRefreshing,
selectedId,
onRefresh,
onSelect,
}: DocumentLibraryProps) {
return (
<section className="document-panel document-library" aria-labelledby="document-library-title">
<div className="section-heading">
<div>
<span className="eyebrow">GOVERNED LIBRARY</span>
<h2 id="document-library-title"></h2>
</div>
<button
className="tertiary-button"
disabled={isRefreshing}
onClick={onRefresh}
type="button"
>
{isRefreshing ? "刷新中" : "刷新"}
</button>
</div>
{isLoading && (
<div className="document-library-state" role="status">
<span className="button-spinner button-spinner--forest" aria-hidden="true" />
</div>
)}
{error !== null && (
<div className="document-library-state document-library-state--error" role="alert">
<Icon name="alert" size={20} />
<span>{getApiErrorMessage(error)}</span>
</div>
)}
{!isLoading && error === null && documents?.length === 0 && (
<div className="document-library-state">
<Icon name="database" size={22} />
<span></span>
</div>
)}
{documents !== undefined && documents.length > 0 && (
<div className="document-list" role="list">
{documents.map((document) => (
<div key={document.id} role="listitem">
<button
aria-pressed={selectedId === document.id}
className={`document-list__item${
selectedId === document.id ? " document-list__item--active" : ""
}`}
onClick={() => onSelect(document.id)}
type="button"
>
<span className="document-list__icon">
<Icon name="document" size={18} />
</span>
<span className="document-list__copy">
<strong>{document.filename}</strong>
<small>{document.mime_type}</small>
<code>{document.raw_sha256.slice(0, 16)}</code>
</span>
<span className="document-list__state">{documentState(document)}</span>
</button>
</div>
))}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,300 @@
import { useState } from "react";
import { getApiErrorMessage } from "../../../api/client";
import { Icon } from "../../../components/Icon";
import type { RejectionReason, ReviewBundle } from "../types";
interface DocumentReviewPanelProps {
bundle: ReviewBundle | undefined;
error: unknown;
isLoading: boolean;
isDecisionPending: boolean;
decisionError: unknown;
onApprove: () => void;
onReject: (reason: RejectionReason) => void;
onRetry: () => void;
}
const REJECTION_REASONS: { value: RejectionReason; label: string }[] = [
{ value: "RIGHTS_NOT_VERIFIED", label: "资料权利或授权未核实" },
{ value: "CONTENT_QUALITY_REJECTED", label: "内容质量不满足入库要求" },
{ value: "CLOUD_PROCESSING_REJECTED", label: "不允许提交云端处理" },
];
function reviewStateLabel(state: string): string {
const labels: Record<string, string> = {
LOCAL_PARSED_PENDING_CLOUD_REVIEW: "等待人工复核",
CLOUD_APPROVED: "已批准出域",
REJECTED: "已拒绝出域",
OCR_REQUIRED: "需要可靠 OCR",
};
return labels[state] ?? state;
}
export function DocumentReviewPanel({
bundle,
error,
isLoading,
isDecisionPending,
decisionError,
onApprove,
onReject,
onRetry,
}: DocumentReviewPanelProps) {
const [confirmed, setConfirmed] = useState(false);
const [rejectionReason, setRejectionReason] = useState<RejectionReason>("RIGHTS_NOT_VERIFIED");
if (isLoading) {
return (
<section className="document-panel document-review-panel" aria-live="polite">
<div className="document-review-state">
<span className="button-spinner button-spinner--forest" aria-hidden="true" />
<h2></h2>
<p> revision </p>
</div>
</section>
);
}
if (error !== null) {
return (
<section className="document-panel document-review-panel">
<div className="document-review-state document-review-state--error" role="alert">
<Icon name="alert" size={24} />
<h2></h2>
<p>{getApiErrorMessage(error)}</p>
<button className="secondary-button" onClick={onRetry} type="button">
</button>
</div>
</section>
);
}
if (bundle === undefined) {
return (
<section className="document-panel document-review-panel">
<div className="document-review-state">
<Icon name="document" size={28} />
<h2></h2>
<p></p>
</div>
</section>
);
}
const version = bundle.version;
if (version === null) {
return (
<section className="document-panel document-review-panel">
<div className="document-review-state">
<Icon name="layers" size={28} />
<h2>{bundle.document.filename}</h2>
<p> Worker </p>
</div>
</section>
);
}
const expectedChunksMatch = version.expected_chunk_count === bundle.chunks.length;
const reviewable =
version.review_state === "LOCAL_PARSED_PENDING_CLOUD_REVIEW" &&
version.status === "PROCESSING" &&
version.outbound_manifest_sha256 !== null &&
bundle.chunks.length > 0 &&
expectedChunksMatch;
return (
<section
className="document-panel document-review-panel"
aria-labelledby="document-review-title"
>
<div className="document-review-header">
<div>
<span className="eyebrow">MANIFEST-BOUND REVIEW</span>
<h2 id="document-review-title">{bundle.document.filename}</h2>
<span className="document-review-state-badge">
{reviewStateLabel(version.review_state)}
</span>
</div>
<dl>
<div>
<dt>Revision</dt>
<dd>{version.review_revision}</dd>
</div>
<div>
<dt></dt>
<dd>{version.status}</dd>
</div>
<div>
<dt> / / Chunk</dt>
<dd>
{bundle.pages.length} / {bundle.blocks.length} / {bundle.chunks.length}
</dd>
</div>
</dl>
</div>
<div className="document-manifest">
<div>
<span> SHA-256</span>
<code>{version.outbound_manifest_sha256 ?? "尚未生成"}</code>
</div>
<div>
<span>Parser / Chunk Profile</span>
<code>{version.parser_profile_hash}</code>
<code>{version.chunk_profile_hash}</code>
</div>
<div>
<span>Cloud Policy</span>
<code>{version.cloud_policy_id}</code>
</div>
</div>
{!expectedChunksMatch && (
<div className="document-review-warning" role="alert">
<Icon name="alert" size={18} />
<span>
{version.expected_chunk_count} Chunk {bundle.chunks.length}
</span>
</div>
)}
<div className="document-review-content">
<section aria-labelledby="review-pages-title">
<div className="document-subheading">
<h3 id="review-pages-title"></h3>
<span>{bundle.pages.length}</span>
</div>
{bundle.pages.length === 0 ? (
<p className="document-empty-copy">PDF OCR</p>
) : (
<div className="document-evidence-list">
{bundle.pages.map((page) => (
<details key={page.id}>
<summary>
{page.page_number ?? page.ordinal + 1} · {page.line_start}-
{page.line_end}
</summary>
<p>{page.text}</p>
<code>{page.text_sha256}</code>
</details>
))}
</div>
)}
<div className="document-subheading document-subheading--blocks">
<h3></h3>
<span>{bundle.blocks.length}</span>
</div>
{bundle.blocks.length === 0 ? (
<p className="document-empty-copy"></p>
) : (
<div className="document-evidence-list">
{bundle.blocks.map((block) => (
<details key={block.id}>
<summary>
{block.kind} · {block.section_path.join(" / ") || "未标注章节"} ·
{block.line_start}-{block.line_end}
</summary>
<p>{block.text}</p>
<code>{block.anchor_id}</code>
</details>
))}
</div>
)}
</section>
<section aria-labelledby="review-chunks-title">
<div className="document-subheading">
<h3 id="review-chunks-title"> Chunk</h3>
<span>{bundle.chunks.length}</span>
</div>
{bundle.chunks.length === 0 ? (
<p className="document-empty-copy"> Chunk</p>
) : (
<div className="document-chunk-list">
{bundle.chunks.map((chunk) => (
<article key={`${chunk.ordinal}:${chunk.cloud_text_sha256}`}>
<header>
<strong>Chunk {chunk.ordinal + 1}</strong>
<span>{chunk.token_count} tokens</span>
</header>
<p>{chunk.cloud_text}</p>
<footer>
<span>
{chunk.section_path.length === 0
? "未标注章节"
: chunk.section_path.join(" / ")}
</span>
<code>{chunk.cloud_text_sha256.slice(0, 18)}</code>
</footer>
</article>
))}
</div>
)}
</section>
</div>
<div className="document-human-gate">
<div className="document-human-gate__notice">
<Icon name="shield" size={20} />
<div>
<strong></strong>
<span>
revision
manifest
</span>
</div>
</div>
<label className="document-confirmation">
<input
checked={confirmed}
disabled={!reviewable || isDecisionPending}
onChange={(event) => setConfirmed(event.target.checked)}
type="checkbox"
/>
</label>
<div className="document-decision-actions">
<button
className="primary-button"
disabled={!reviewable || !confirmed || isDecisionPending}
onClick={onApprove}
type="button"
>
{isDecisionPending && <span className="button-spinner" aria-hidden="true" />}
</button>
<div className="document-reject-control">
<label htmlFor="rejection-reason"></label>
<select
disabled={!reviewable || isDecisionPending}
id="rejection-reason"
onChange={(event) => setRejectionReason(event.target.value as RejectionReason)}
value={rejectionReason}
>
{REJECTION_REASONS.map((reason) => (
<option key={reason.value} value={reason.value}>
{reason.label}
</option>
))}
</select>
<button
className="tertiary-button document-reject-button"
disabled={!reviewable || isDecisionPending}
onClick={() => onReject(rejectionReason)}
type="button"
>
</button>
</div>
</div>
{decisionError !== null && (
<p className="field-error document-decision-error" role="alert">
{getApiErrorMessage(decisionError)}
</p>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,162 @@
import { useRef, useState } from "react";
import { Icon } from "../../../components/Icon";
import { DOCUMENT_ACCEPT, formatBytes, validateDocumentFile } from "../file";
import type { DocumentJob, UploadPhase, UploadWorkflowState } from "../types";
interface DocumentUploadPanelProps {
workflow: UploadWorkflowState;
job: DocumentJob | undefined;
onUpload: (file: File) => void;
onCancel: () => void;
}
const ACTIVE_PHASES: UploadPhase[] = [
"hashing",
"declaring",
"uploading",
"completing",
"parsing",
"indexing",
];
const STEPS = [
{ phases: ["hashing", "declaring", "uploading", "completing"], label: "校验并隔离上传" },
{ phases: ["parsing"], label: "本地安全解析" },
{ phases: ["review"], label: "人工复核出域清单" },
{ phases: ["indexing", "indexed"], label: "向量化与激活" },
] as const;
function stepState(phase: UploadPhase, index: number): "pending" | "active" | "done" {
if (phase === "indexed") return "done";
const activeIndex = STEPS.findIndex((step) => (step.phases as readonly string[]).includes(phase));
if (activeIndex === -1) return "pending";
if (index < activeIndex) return "done";
return index === activeIndex ? "active" : "pending";
}
export function DocumentUploadPanel({
workflow,
job,
onUpload,
onCancel,
}: DocumentUploadPanelProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [selectionError, setSelectionError] = useState<string | null>(null);
const busy = ACTIVE_PHASES.includes(workflow.phase);
function chooseFile(next: File | undefined) {
if (next === undefined) return;
const error = validateDocumentFile(next);
setSelectionError(error);
setFile(error === null ? next : null);
}
return (
<section
className="document-panel document-upload-panel"
aria-labelledby="document-upload-title"
>
<div className="section-heading">
<div>
<span className="eyebrow">INGESTION GATE</span>
<h2 id="document-upload-title"></h2>
</div>
<span className="section-heading__meta"> 100 MiB</span>
</div>
<div
className={`document-dropzone${selectionError === null ? "" : " document-dropzone--error"}`}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
if (!busy) chooseFile(event.dataTransfer.files[0]);
}}
>
<Icon name="document" size={27} />
<strong>{file === null ? "选择或拖入地质资料" : file.name}</strong>
<span>
{file === null
? "支持 TXT、Markdown、DOCX、PDF"
: `${formatBytes(file.size)} · 等待本地校验`}
</span>
<button
className="secondary-button document-file-button"
disabled={busy}
onClick={() => inputRef.current?.click()}
type="button"
>
</button>
<input
ref={inputRef}
accept={DOCUMENT_ACCEPT}
aria-label="选择待上传文档"
className="visually-hidden"
disabled={busy}
onChange={(event) => chooseFile(event.target.files?.[0])}
type="file"
/>
</div>
{selectionError !== null && (
<p className="field-error" role="alert">
{selectionError}
</p>
)}
<div className="document-workflow" aria-label="文档处理阶段">
{STEPS.map((step, index) => {
const state = stepState(workflow.phase, index);
return (
<div
className={`document-workflow__step document-workflow__step--${state}`}
key={step.label}
>
<span aria-hidden="true">{state === "done" ? "✓" : index + 1}</span>
<small>{step.label}</small>
</div>
);
})}
</div>
<div className="document-live-status" aria-live="polite">
<div>
<strong>{workflow.filename ?? "尚未启动上传"}</strong>
<span>{workflow.message ?? "文件只在浏览器计算摘要,模型密钥不会进入页面。"}</span>
</div>
{job !== undefined && (
<div className="document-job-progress">
<span>{job.stage}</span>
<strong>{job.progress}%</strong>
</div>
)}
</div>
<div className="document-upload-actions">
<button
className="primary-button"
disabled={file === null || busy}
onClick={() => {
if (file !== null) onUpload(file);
}}
type="button"
>
{busy && <span className="button-spinner" aria-hidden="true" />}
{busy ? "处理中" : "校验并上传"}
</button>
{busy && (
<button className="tertiary-button" onClick={onCancel} type="button">
</button>
)}
</div>
<p className="disabled-note">
<Icon name="shield" size={15} />
PDF OCR_REQUIRED
</p>
</section>
);
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { declaredMimeType, sha256File, validateDocumentFile } from "./file";
describe("document file validation", () => {
it("computes the browser SHA-256 over exact file bytes", async () => {
const file = new File(["abc"], "sample.md", { type: "text/markdown" });
expect(await sha256File(file)).toBe(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
);
});
it.each([
["report.txt", "text/plain"],
["report.md", "text/markdown"],
["report.markdown", "text/markdown"],
["report.pdf", "application/pdf"],
["report.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
])("maps %s to the server declaration %s", (filename, expected) => {
expect(declaredMimeType(filename)).toBe(expected);
});
it("rejects empty and unsupported files before hashing", () => {
expect(validateDocumentFile(new File([], "empty.md"))).toBe("不能上传空文件");
expect(validateDocumentFile(new File(["x"], "archive.zip"))).toBe(
"仅支持 TXT、Markdown、DOCX 和 PDF 文件",
);
});
it("rejects unsafe or secret-looking filenames", () => {
expect(validateDocumentFile(new File(["x"], " report.md"))).toBe(
"文件名包含不安全内容,请重命名后再上传",
);
expect(validateDocumentFile(new File(["x"], "sk-1234567890123456.md"))).toBe(
"文件名包含不安全内容,请重命名后再上传",
);
});
});

View File

@@ -0,0 +1,47 @@
const ACCEPTED_FILE_TYPES = {
".txt": "text/plain",
".md": "text/markdown",
".markdown": "text/markdown",
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
} as const;
export const DOCUMENT_ACCEPT = Object.keys(ACCEPTED_FILE_TYPES).join(",");
export const MAX_DOCUMENT_BYTES = 100 * 1024 * 1024;
export function declaredMimeType(filename: string): string | null {
const normalized = filename.toLowerCase();
const extension = Object.keys(ACCEPTED_FILE_TYPES).find((candidate) =>
normalized.endsWith(candidate),
) as keyof typeof ACCEPTED_FILE_TYPES | undefined;
return extension === undefined ? null : ACCEPTED_FILE_TYPES[extension];
}
export function validateDocumentFile(file: File): string | null {
if (
file.name !== file.name.trim() ||
file.name.includes("\0") ||
file.name.includes("/") ||
file.name.includes("\\") ||
/(?:sk-[A-Za-z0-9_-]{16,}|Bearer\s+[A-Za-z0-9._~+/-]{16,})/i.test(file.name)
) {
return "文件名包含不安全内容,请重命名后再上传";
}
if (declaredMimeType(file.name) === null) {
return "仅支持 TXT、Markdown、DOCX 和 PDF 文件";
}
if (file.size === 0) return "不能上传空文件";
if (file.size > MAX_DOCUMENT_BYTES) return "文件不能超过 100 MiB";
return null;
}
export async function sha256File(file: File): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
}

View File

@@ -0,0 +1,291 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { getApiErrorMessage } from "../../../api/client";
import {
completeDocumentUpload,
createDocumentUpload,
createReviewDecision,
getCompleteReviewBundle,
getDocumentJob,
listDocuments,
putDocumentContent,
} from "../api";
import { declaredMimeType, sha256File, validateDocumentFile } from "../file";
import type { RejectionReason, ReviewDecisionRequest, UploadWorkflowState } from "../types";
const INITIAL_WORKFLOW: UploadWorkflowState = {
phase: "idle",
filename: null,
message: null,
};
type JobKind = "parse" | "index";
interface TrackedJob {
id: string;
kind: JobKind;
}
function isAbort(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
export function useDocumentsWorkspace() {
const queryClient = useQueryClient();
const [workflow, setWorkflow] = useState<UploadWorkflowState>(INITIAL_WORKFLOW);
const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(null);
const [trackedJob, setTrackedJob] = useState<TrackedJob | null>(null);
const uploadController = useRef<AbortController | null>(null);
const handledTerminal = useRef<string | null>(null);
const documentsQuery = useQuery({
queryKey: ["documents"],
queryFn: ({ signal }) => listDocuments(signal),
});
const effectiveSelectedDocumentId =
selectedDocumentId ?? documentsQuery.data?.items[0]?.id ?? null;
const reviewQuery = useQuery({
queryKey: ["document-review", effectiveSelectedDocumentId],
queryFn: ({ signal }) => {
if (effectiveSelectedDocumentId === null) throw new Error("document id is required");
return getCompleteReviewBundle(effectiveSelectedDocumentId, signal);
},
enabled: effectiveSelectedDocumentId !== null,
});
const jobQuery = useQuery({
queryKey: ["document-job", trackedJob?.id],
queryFn: ({ signal }) => {
if (trackedJob === null) throw new Error("job id is required");
return getDocumentJob(trackedJob.id, signal);
},
enabled: trackedJob !== null,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "QUEUED" || status === "RUNNING" || status === undefined ? 1_000 : false;
},
});
useEffect(() => {
const job = jobQuery.data;
if (job === undefined || trackedJob === null) return;
const terminalKey = `${job.id}:${job.status}`;
if (job.status !== "SUCCEEDED" && job.status !== "FAILED" && job.status !== "CANCELLED") return;
if (handledTerminal.current === terminalKey) return;
handledTerminal.current = terminalKey;
const timeout = window.setTimeout(() => {
if (job.status === "SUCCEEDED") {
if (trackedJob.kind === "parse" && job.stage === "PARSE_REJECTED") {
setWorkflow((current) => ({
...current,
phase: "error",
message: `本地解析被安全策略拒绝${
job.last_error_code === null ? "" : `${job.last_error_code}`
}`,
}));
void queryClient.invalidateQueries({ queryKey: ["documents"] });
return;
}
setWorkflow((current) => ({
...current,
phase: trackedJob.kind === "parse" ? "review" : "indexed",
message:
trackedJob.kind === "parse"
? job.stage === "OCR_REQUIRED"
? "本地检查完成,但该 PDF 需要可靠 OCR 后才能复核"
: "本地解析完成,必须人工复核后才能出域向量化"
: "向量索引完成,文档已通过完整性校验",
}));
void queryClient.invalidateQueries({ queryKey: ["documents"] });
void queryClient.invalidateQueries({
queryKey: ["document-review", effectiveSelectedDocumentId],
});
return;
}
setWorkflow((current) => ({
...current,
phase: "error",
message:
job.status === "CANCELLED"
? "后台作业已取消"
: `后台作业失败${job.last_error_code === null ? "" : `${job.last_error_code}`}`,
}));
}, 0);
return () => window.clearTimeout(timeout);
}, [effectiveSelectedDocumentId, jobQuery.data, queryClient, trackedJob]);
useEffect(() => {
if (jobQuery.error === null || trackedJob === null) return;
const errorKey = `${trackedJob.id}:request-error`;
if (handledTerminal.current === errorKey) return;
handledTerminal.current = errorKey;
const timeout = window.setTimeout(() => {
setWorkflow((current) => ({
...current,
phase: "error",
message: getApiErrorMessage(jobQuery.error),
}));
}, 0);
return () => window.clearTimeout(timeout);
}, [jobQuery.error, trackedJob]);
const startUpload = useCallback(
async (file: File) => {
const validationError = validateDocumentFile(file);
if (validationError !== null) {
setWorkflow({ phase: "error", filename: file.name, message: validationError });
return;
}
const mimeType = declaredMimeType(file.name);
if (mimeType === null) return;
uploadController.current?.abort();
const controller = new AbortController();
uploadController.current = controller;
setTrackedJob(null);
handledTerminal.current = null;
try {
setWorkflow({ phase: "hashing", filename: file.name, message: "正在本地计算 SHA-256" });
const sha256 = await sha256File(file);
if (controller.signal.aborted) throw new DOMException("cancelled", "AbortError");
setWorkflow({ phase: "declaring", filename: file.name, message: "正在创建幂等上传声明" });
const upload = await createDocumentUpload(
{
filename: file.name,
declared_mime_type: mimeType,
expected_size: file.size,
expected_sha256: sha256,
},
crypto.randomUUID(),
controller.signal,
);
setWorkflow({ phase: "uploading", filename: file.name, message: "正在写入隔离存储" });
await putDocumentContent(upload.id, file, controller.signal);
setWorkflow({ phase: "completing", filename: file.name, message: "正在提交本地解析作业" });
const completed = await completeDocumentUpload(upload.id, controller.signal);
setSelectedDocumentId(completed.document.id);
setTrackedJob({ id: completed.job.id, kind: "parse" });
handledTerminal.current = null;
setWorkflow({ phase: "parsing", filename: file.name, message: "本地 Worker 正在安全解析" });
void queryClient.invalidateQueries({ queryKey: ["documents"] });
} catch (error) {
if (isAbort(error)) {
setWorkflow({
phase: "cancelled",
filename: file.name,
message: "已停止当前页面的上传流程",
});
} else {
setWorkflow({ phase: "error", filename: file.name, message: getApiErrorMessage(error) });
}
} finally {
if (uploadController.current === controller) uploadController.current = null;
}
},
[queryClient],
);
const decisionMutation = useMutation({
mutationFn: ({ documentId, request }: { documentId: string; request: ReviewDecisionRequest }) =>
createReviewDecision(documentId, request),
onSuccess: (result) => {
handledTerminal.current = null;
if (result.job !== null) {
setTrackedJob({ id: result.job.id, kind: "index" });
setWorkflow((current) => ({
...current,
phase: "indexing",
message: "审核已锁定,模型 Worker 正在生成并校验向量索引",
}));
} else {
setTrackedJob(null);
setWorkflow((current) => ({
...current,
phase: "review",
message: "文档已拒绝出域,本地原始资料仍保持隔离",
}));
}
void queryClient.invalidateQueries({ queryKey: ["documents"] });
void queryClient.invalidateQueries({ queryKey: ["document-review", result.document_id] });
},
});
const approve = useCallback(() => {
const bundle = reviewQuery.data;
const version = bundle?.version;
if (bundle === undefined || !version?.outbound_manifest_sha256) {
return;
}
decisionMutation.mutate({
documentId: bundle.document.id,
request: {
decision: "APPROVE",
reason_code: "SYNTHETIC_REVIEW_APPROVED",
expected_revision: version.review_revision,
outbound_manifest_sha256: version.outbound_manifest_sha256,
},
});
}, [decisionMutation, reviewQuery.data]);
const reject = useCallback(
(reason: RejectionReason) => {
const bundle = reviewQuery.data;
if (bundle?.version === null || bundle?.version === undefined) return;
decisionMutation.mutate({
documentId: bundle.document.id,
request: {
decision: "REJECT",
reason_code: reason,
expected_revision: bundle.version.review_revision,
outbound_manifest_sha256: null,
},
});
},
[decisionMutation, reviewQuery.data],
);
const cancel = useCallback(() => {
uploadController.current?.abort();
if (trackedJob !== null) {
setTrackedJob(null);
setWorkflow((current) => ({
...current,
phase: "cancelled",
message: "已停止本页轮询;已提交的后台作业不会被页面强制终止",
}));
}
}, [trackedJob]);
const refresh = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: ["documents"] });
if (effectiveSelectedDocumentId !== null) {
void queryClient.invalidateQueries({
queryKey: ["document-review", effectiveSelectedDocumentId],
});
}
}, [effectiveSelectedDocumentId, queryClient]);
return {
workflow,
documentsQuery,
reviewQuery,
jobQuery,
selectedDocumentId: effectiveSelectedDocumentId,
selectDocument: setSelectedDocumentId,
startUpload,
cancel,
refresh,
approve,
reject,
decisionMutation,
};
}

View File

@@ -0,0 +1,160 @@
export type UploadStatus = "CREATED" | "STORED" | "COMPLETED";
export type JobStatus = "QUEUED" | "RUNNING" | "SUCCEEDED" | "FAILED" | "CANCELLED";
export type ReviewDecision = "APPROVE" | "REJECT";
export interface DocumentUpload {
id: string;
filename: string;
declared_mime_type: string;
expected_size: number;
expected_sha256: string;
actual_size: number | null;
actual_sha256: string | null;
status: UploadStatus;
document_id: string | null;
parse_job_id: string | null;
created_at: string;
updated_at: string;
completed_at: string | null;
replayed: boolean;
}
export interface DocumentJob {
id: string;
job_type: string;
stage: string;
status: JobStatus;
progress: number;
attempt: number;
max_attempts: number;
last_error_code: string | null;
created_at: string;
updated_at: string;
finished_at: string | null;
}
export interface DocumentSummary {
id: string;
filename: string;
mime_type: string;
raw_sha256: string;
status: string;
active_version_id: string | null;
created_at: string;
updated_at: string;
}
export interface DocumentListResponse {
items: DocumentSummary[];
next_cursor: string | null;
}
export interface CompleteUploadResponse {
upload: DocumentUpload;
document: DocumentSummary;
job: DocumentJob;
}
export interface ReviewVersion {
id: string;
review_state: string;
review_revision: number;
status: string;
parser_profile_hash: string;
chunk_profile_hash: string;
cloud_policy_id: string;
outbound_manifest_sha256: string | null;
expected_chunk_count: number | null;
error_code: string | null;
created_at: string;
completed_at: string | null;
}
export interface ReviewPage {
id: string;
ordinal: number;
page_number: number | null;
text: string;
text_sha256: string;
line_start: number;
line_end: number;
}
export interface ReviewBlock {
id: string;
ordinal: number;
kind: string;
text: string;
text_sha256: string;
section_path: string[];
anchor_id: string;
char_start: number;
char_end: number;
line_start: number;
line_end: number;
page_start: number | null;
page_end: number | null;
}
export interface ReviewChunk {
ordinal: number;
display_text: string;
cloud_text: string;
cloud_text_sha256: string;
embedding_text_sha256: string;
token_count: number;
page_start: number | null;
page_end: number | null;
section_path: string[];
approval_status: string;
index_status: string;
}
export interface ReviewBundle {
document: DocumentSummary;
version: ReviewVersion | null;
pages: ReviewPage[];
blocks: ReviewBlock[];
chunks: ReviewChunk[];
next_ordinal: number | null;
}
export type RejectionReason =
"RIGHTS_NOT_VERIFIED" | "CONTENT_QUALITY_REJECTED" | "CLOUD_PROCESSING_REJECTED";
export interface ReviewDecisionRequest {
decision: ReviewDecision;
reason_code: "SYNTHETIC_REVIEW_APPROVED" | RejectionReason;
expected_revision: number;
outbound_manifest_sha256: string | null;
}
export interface ReviewDecisionResponse {
document_id: string;
document_version_id: string;
decision: ReviewDecision;
review_state: "CLOUD_APPROVED" | "REJECTED";
review_revision: number;
outbound_manifest_sha256: string | null;
embedding_profile_hash: string | null;
job: DocumentJob | null;
}
export type UploadPhase =
| "idle"
| "hashing"
| "declaring"
| "uploading"
| "completing"
| "parsing"
| "review"
| "indexing"
| "indexed"
| "cancelled"
| "error";
export interface UploadWorkflowState {
phase: UploadPhase;
filename: string | null;
message: string | null;
}