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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user