Make the governed RAG evidence path executable end to end
Some checks failed
verify / verify (push) Has been cancelled
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:
@@ -1 +0,0 @@
|
||||
|
||||
115
frontend/src/features/documents/api.test.ts
Normal file
115
frontend/src/features/documents/api.test.ts
Normal 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: "复核内容在加载期间发生变化,请重新加载" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
176
frontend/src/features/documents/api.ts
Normal file
176
frontend/src/features/documents/api.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
39
frontend/src/features/documents/file.test.ts
Normal file
39
frontend/src/features/documents/file.test.ts
Normal 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(
|
||||
"文件名包含不安全内容,请重命名后再上传",
|
||||
);
|
||||
});
|
||||
});
|
||||
47
frontend/src/features/documents/file.ts
Normal file
47
frontend/src/features/documents/file.ts
Normal 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`;
|
||||
}
|
||||
291
frontend/src/features/documents/hooks/useDocumentsWorkspace.ts
Normal file
291
frontend/src/features/documents/hooks/useDocumentsWorkspace.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
160
frontend/src/features/documents/types.ts
Normal file
160
frontend/src/features/documents/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user