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:
+19
-5
@@ -1,6 +1,19 @@
|
||||
# 地质知识离线检索前端
|
||||
# 地质知识 RAG 工作台前端
|
||||
|
||||
React + TypeScript 的 synthetic/offline RAG 演示工作台。前端只访问同源 `/api`,不读取、保存或显示模型 Key、百炼工作区地址和数据库连接信息。
|
||||
React + TypeScript 的地质知识 RAG 工作台,覆盖离线演示、正式检索、证据问答和受控文档入库。前端只访问同源 `/api`,不读取、保存或显示模型 Key、百炼工作区地址和数据库连接信息。
|
||||
|
||||
## 文档入库工作台
|
||||
|
||||
访问 `/documents` 可验证完整的治理流程:
|
||||
|
||||
1. 浏览器计算文件 SHA-256,以 UUID `Idempotency-Key` 创建上传声明;
|
||||
2. 以 `application/octet-stream` 写入隔离存储,完成后轮询本地解析作业;
|
||||
3. 加载全部分页复核包,核对页、块、Chunk、profile 与出域 manifest;
|
||||
4. 人工确认后以 `expected_revision` 和 manifest 提交批准,或选择明确原因拒绝;
|
||||
5. 批准后轮询向量索引作业,直到完整性校验与激活完成。
|
||||
|
||||
支持 TXT、Markdown、DOCX 和 PDF,浏览器侧限制 100 MiB。PDF 进入 `OCR_REQUIRED`
|
||||
并不代表系统已经理解地图空间关系;页面不会把上传成功或解析成功显示为可检索。
|
||||
|
||||
## 运行环境与固定版本
|
||||
|
||||
@@ -28,19 +41,20 @@ Vite 将同源 `/api` 代理到本机后端。生产构建输出到 `dist/`。
|
||||
|
||||
## OpenAPI 类型
|
||||
|
||||
`src/api/schema.generated.ts` 来自 FastAPI 的真实 OpenAPI 文档。后端 API 契约变化后,启动后端并运行:
|
||||
`src/api/schema.generated.ts` 来自 FastAPI 应用工厂的真实 OpenAPI 文档。默认离线导出,不需要启动 API、连接数据库或读取模型 Secret。后端契约变化后运行:
|
||||
|
||||
```bash
|
||||
npm run generate:api
|
||||
```
|
||||
|
||||
如后端使用其他本机端口,可设置非敏感的 `OPENAPI_SCHEMA_URL`:
|
||||
如需对照正在运行的其他本机端口,可显式设置非敏感的 `OPENAPI_SCHEMA_URL`:
|
||||
|
||||
```bash
|
||||
OPENAPI_SCHEMA_URL=http://127.0.0.1:8010/openapi.json npm run generate:api
|
||||
```
|
||||
|
||||
生成脚本会确认 `/api/v1/demo/status` 和 `/api/v1/demo/search` 均存在后才覆盖类型文件。
|
||||
生成脚本会确认 Demo、正式 Retrieval 与 Grounded Chat 契约均存在后才覆盖类型文件。
|
||||
`npm run check:api` 会离线重新生成到内存并与提交文件逐字比较,CI 用它阻止后端和前端类型漂移。
|
||||
|
||||
## 质量门禁
|
||||
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ http {
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
client_max_body_size 1m;
|
||||
client_max_body_size 100m;
|
||||
|
||||
add_header Content-Security-Policy $content_security_policy always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
|
||||
@@ -12,13 +12,14 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"generate:api": "node scripts/generate-openapi.mjs",
|
||||
"check:api": "node scripts/check-openapi.mjs",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"verify": "npm run format:check && npm run lint && npm run typecheck && npm run test && npm run build"
|
||||
"verify": "npm run format:check && npm run check:api && npm run lint && npm run typecheck && npm run test && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { loadOpenApiSchema, renderOpenApiTypes } from "./openapi-contract.mjs";
|
||||
|
||||
const outputPath = resolve("src/api/schema.generated.ts");
|
||||
const [expected, actual] = await Promise.all([
|
||||
loadOpenApiSchema().then(renderOpenApiTypes),
|
||||
readFile(outputPath, "utf8"),
|
||||
]);
|
||||
|
||||
if (actual !== expected) {
|
||||
throw new Error("Generated OpenAPI types are stale; run npm run generate:api");
|
||||
}
|
||||
|
||||
console.log("Generated OpenAPI types match the offline FastAPI contract");
|
||||
@@ -1,41 +1,10 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import openapiTS, { astToString, COMMENT_HEADER } from "openapi-typescript";
|
||||
import { loadOpenApiSchema, renderOpenApiTypes } from "./openapi-contract.mjs";
|
||||
|
||||
const schemaUrl = process.env.OPENAPI_SCHEMA_URL ?? "http://127.0.0.1:8000/openapi.json";
|
||||
const outputPath = resolve("src/api/schema.generated.ts");
|
||||
const requiredPaths = ["/api/v1/demo/status", "/api/v1/demo/search"];
|
||||
const schema = await loadOpenApiSchema();
|
||||
await writeFile(outputPath, await renderOpenApiTypes(schema), "utf8");
|
||||
|
||||
const response = await fetch(schemaUrl, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAPI schema request failed with HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const schema = await response.json();
|
||||
if (typeof schema !== "object" || schema === null || !("paths" in schema)) {
|
||||
throw new Error("OpenAPI response does not contain a paths object");
|
||||
}
|
||||
|
||||
const paths = schema.paths;
|
||||
if (typeof paths !== "object" || paths === null) {
|
||||
throw new Error("OpenAPI paths value is invalid");
|
||||
}
|
||||
|
||||
for (const path of requiredPaths) {
|
||||
if (!(path in paths)) {
|
||||
throw new Error(`Required API path is missing: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
const ast = await openapiTS(schema, {
|
||||
alphabetize: true,
|
||||
immutable: true,
|
||||
});
|
||||
await writeFile(outputPath, `${COMMENT_HEADER}${astToString(ast)}`, "utf8");
|
||||
|
||||
console.log(`Generated ${outputPath} from ${schemaUrl}`);
|
||||
console.log(`Generated ${outputPath} from the offline FastAPI application contract`);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import openapiTS, { astToString, COMMENT_HEADER } from "openapi-typescript";
|
||||
|
||||
const requiredPaths = [
|
||||
"/api/v1/demo/status",
|
||||
"/api/v1/demo/search",
|
||||
"/api/v1/retrieval/search",
|
||||
"/api/v1/chat/completions",
|
||||
"/api/v1/document-uploads",
|
||||
"/api/v1/document-uploads/{upload_id}/content",
|
||||
"/api/v1/document-uploads/{upload_id}/complete",
|
||||
"/api/v1/documents",
|
||||
"/api/v1/documents/{document_id}",
|
||||
"/api/v1/documents/{document_id}/review-bundle",
|
||||
"/api/v1/documents/{document_id}/review-decisions",
|
||||
"/api/v1/document-jobs/{job_id}",
|
||||
];
|
||||
|
||||
async function schemaFromUrl(schemaUrl) {
|
||||
const response = await fetch(schemaUrl, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`OpenAPI schema request failed with HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function offlineSchema() {
|
||||
const backendDirectory = resolve("..", "backend");
|
||||
const python = process.env.BACKEND_PYTHON ?? resolve(backendDirectory, ".venv/bin/python");
|
||||
const result = spawnSync(python, ["-m", "app.tools.export_openapi"], {
|
||||
cwd: backendDirectory,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.status !== 0 || result.error || !result.stdout) {
|
||||
throw new Error("Offline OpenAPI export failed; run make backend-sync first");
|
||||
}
|
||||
try {
|
||||
return JSON.parse(result.stdout);
|
||||
} catch {
|
||||
throw new Error("Offline OpenAPI export returned invalid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadOpenApiSchema() {
|
||||
const schema = process.env.OPENAPI_SCHEMA_URL
|
||||
? await schemaFromUrl(process.env.OPENAPI_SCHEMA_URL)
|
||||
: offlineSchema();
|
||||
if (typeof schema !== "object" || schema === null || !("paths" in schema)) {
|
||||
throw new Error("OpenAPI response does not contain a paths object");
|
||||
}
|
||||
const paths = schema.paths;
|
||||
if (typeof paths !== "object" || paths === null) {
|
||||
throw new Error("OpenAPI paths value is invalid");
|
||||
}
|
||||
for (const path of requiredPaths) {
|
||||
if (!(path in paths)) throw new Error(`Required API path is missing: ${path}`);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
export async function renderOpenApiTypes(schema) {
|
||||
const ast = await openapiTS(schema, { alphabetize: true, immutable: true });
|
||||
return `${COMMENT_HEADER}${astToString(ast)}`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,10 @@
|
||||
import { createBrowserRouter } from "react-router-dom";
|
||||
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { ChatPage } from "../pages/ChatPage";
|
||||
import { DocumentsPage } from "../pages/DocumentsPage";
|
||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||
import { RetrievalPage } from "../pages/RetrievalPage";
|
||||
import { SystemPage } from "../pages/SystemPage";
|
||||
import { WorkbenchPage } from "../pages/WorkbenchPage";
|
||||
|
||||
@@ -11,6 +14,9 @@ export const router = createBrowserRouter([
|
||||
element: <AppShell />,
|
||||
children: [
|
||||
{ index: true, element: <WorkbenchPage /> },
|
||||
{ path: "retrieval", element: <RetrievalPage /> },
|
||||
{ path: "chat", element: <ChatPage /> },
|
||||
{ path: "documents", element: <DocumentsPage /> },
|
||||
{ path: "system", element: <SystemPage /> },
|
||||
{ path: "*", element: <NotFoundPage /> },
|
||||
],
|
||||
|
||||
@@ -3,7 +3,10 @@ import { NavLink, Outlet } from "react-router-dom";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", label: "检索工作台", icon: "search" as const, end: true },
|
||||
{ to: "/", label: "离线演示", icon: "search" as const, end: true },
|
||||
{ to: "/retrieval", label: "正式检索", icon: "vector" as const, end: false },
|
||||
{ to: "/chat", label: "证据问答", icon: "layers" as const, end: false },
|
||||
{ to: "/documents", label: "文档入库", icon: "document" as const, end: false },
|
||||
{ to: "/system", label: "系统说明", icon: "settings" as const, end: false },
|
||||
] as const;
|
||||
|
||||
@@ -78,7 +81,7 @@ export function AppShell() {
|
||||
</main>
|
||||
<footer className="footer">
|
||||
<span>毕业设计 · 地质知识问答系统</span>
|
||||
<span>当前仅使用公开合成验证数据</span>
|
||||
<span>默认使用公开合成数据 · 真实资料受授权范围约束</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ export type IconName =
|
||||
| "compass"
|
||||
| "copy"
|
||||
| "database"
|
||||
| "document"
|
||||
| "layers"
|
||||
| "search"
|
||||
| "settings"
|
||||
@@ -59,6 +60,14 @@ function iconPath(name: IconName): ReactNode {
|
||||
<path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" />
|
||||
</>
|
||||
);
|
||||
case "document":
|
||||
return (
|
||||
<>
|
||||
<path d="M6 2h8l4 4v16H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z" />
|
||||
<path d="M14 2v5h5" />
|
||||
<path d="M8 12h8M8 16h8" />
|
||||
</>
|
||||
);
|
||||
case "layers":
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ChatCompletionRequest, ChatStreamEvent } from "./types";
|
||||
import { ChatStreamError, consumeChatSse } from "./sse";
|
||||
|
||||
const CHAT_COMPLETION_PATH = "/api/v1/chat/completions";
|
||||
|
||||
function safeHttpError(status: number): ChatStreamError {
|
||||
if (status === 403) {
|
||||
return new ChatStreamError(
|
||||
"forbidden",
|
||||
"当前身份无权访问该知识库。请恢复合成知识库或联系管理员授权。",
|
||||
status,
|
||||
);
|
||||
}
|
||||
if (status === 409) {
|
||||
return new ChatStreamError(
|
||||
"not_ready",
|
||||
"知识库尚未完成审批、索引和 Active Profile 激活。",
|
||||
status,
|
||||
);
|
||||
}
|
||||
if (status === 422) {
|
||||
return new ChatStreamError("validation", "问题或运行参数未通过服务端校验。", status);
|
||||
}
|
||||
if (status === 503) {
|
||||
return new ChatStreamError(
|
||||
"unavailable",
|
||||
"数据库、模型网关或生成服务暂不可用。请稍后原样重试。",
|
||||
status,
|
||||
);
|
||||
}
|
||||
return new ChatStreamError("http", `问答请求未成功(HTTP ${status})。`, status);
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === "AbortError";
|
||||
}
|
||||
|
||||
export async function streamGroundedChat(
|
||||
request: ChatCompletionRequest,
|
||||
options: {
|
||||
signal: AbortSignal;
|
||||
onEvent: (event: ChatStreamEvent) => void;
|
||||
},
|
||||
): Promise<void> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(CHAT_COMPLETION_PATH, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal: options.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAbortError(error) || options.signal.aborted) {
|
||||
throw new ChatStreamError("aborted", "回答已停止。");
|
||||
}
|
||||
throw new ChatStreamError("network", "无法连接 Grounded Chat API。请检查 Docker 服务。");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Do not surface untrusted Problem detail/provider bodies. Status is enough
|
||||
// to select a stable, user-actionable message.
|
||||
try {
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
// A failed cancellation must not replace the sanitized HTTP error.
|
||||
}
|
||||
throw safeHttpError(response.status);
|
||||
}
|
||||
|
||||
try {
|
||||
await consumeChatSse(response, request.knowledge_base_id, options.onEvent);
|
||||
} catch (error) {
|
||||
if (isAbortError(error) || options.signal.aborted) {
|
||||
throw new ChatStreamError("aborted", "回答已停止。");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useId, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Icon } from "../../../components/Icon";
|
||||
import {
|
||||
CHAT_MAX_TOKENS_LIMIT,
|
||||
CHAT_QUESTION_MAX_LENGTH,
|
||||
CHAT_REQUEST_LIMIT_MAX,
|
||||
DEFAULT_CHAT_INPUT,
|
||||
type ChatCompletionRequest,
|
||||
type ChatFormInput,
|
||||
validateChatInput,
|
||||
} from "../types";
|
||||
|
||||
interface ChatComposerProps {
|
||||
isRunning: boolean;
|
||||
onStart: (request: ChatCompletionRequest) => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export function ChatComposer({ isRunning, onStart, onStop }: ChatComposerProps) {
|
||||
const [input, setInput] = useState<ChatFormInput>(DEFAULT_CHAT_INPUT);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const errorId = useId();
|
||||
const validation = useMemo(() => validateChatInput(input), [input]);
|
||||
const showValidation = submitted && validation.request === null;
|
||||
|
||||
function updateInput<K extends keyof ChatFormInput>(key: K, value: ChatFormInput[K]) {
|
||||
setInput((current) => ({ ...current, [key]: value }));
|
||||
if (submitted) setSubmitted(false);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
setSubmitted(true);
|
||||
if (isRunning || validation.request === null) return;
|
||||
onStart(validation.request);
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="chat-composer-heading" className="chat-composer-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="eyebrow">GROUNDED QUESTION</span>
|
||||
<h2 id="chat-composer-heading">提交证据约束问题</h2>
|
||||
</div>
|
||||
<span className="section-heading__meta">POST · SSE</span>
|
||||
</div>
|
||||
|
||||
<form
|
||||
aria-busy={isRunning}
|
||||
className="chat-composer"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
ref={formRef}
|
||||
>
|
||||
<label className="field-label" htmlFor="chat-knowledge-base">
|
||||
知识库 ID
|
||||
</label>
|
||||
<input
|
||||
className="text-input text-input--mono"
|
||||
disabled={isRunning}
|
||||
id="chat-knowledge-base"
|
||||
onChange={(event) => updateInput("knowledgeBaseId", event.target.value)}
|
||||
spellCheck={false}
|
||||
type="text"
|
||||
value={input.knowledgeBaseId}
|
||||
/>
|
||||
<p className="field-hint">默认使用公开合成知识库,访问 scope 始终由后端身份决定。</p>
|
||||
|
||||
<label className="field-label chat-composer__question-label" htmlFor="chat-question">
|
||||
地质问题
|
||||
</label>
|
||||
<div className={`query-box${showValidation ? " query-box--error" : ""}`}>
|
||||
<textarea
|
||||
aria-describedby={showValidation ? errorId : undefined}
|
||||
aria-invalid={showValidation}
|
||||
disabled={isRunning}
|
||||
id="chat-question"
|
||||
maxLength={CHAT_QUESTION_MAX_LENGTH}
|
||||
onChange={(event) => updateInput("question", event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
formRef.current?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
rows={5}
|
||||
value={input.question}
|
||||
/>
|
||||
<span
|
||||
className={`character-count${input.question.length >= 450 ? " character-count--warning" : ""}`}
|
||||
>
|
||||
{input.question.length}/{CHAT_QUESTION_MAX_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
<p className="field-hint">Enter 发送,Shift + Enter 换行。回答只允许使用本次检索证据。</p>
|
||||
|
||||
<details className="chat-advanced-settings">
|
||||
<summary>运行参数</summary>
|
||||
<div className="chat-parameter-grid">
|
||||
<label htmlFor="chat-vector-top-k">
|
||||
<span>Vector Top K</span>
|
||||
<input
|
||||
disabled={isRunning}
|
||||
id="chat-vector-top-k"
|
||||
max={CHAT_REQUEST_LIMIT_MAX}
|
||||
min={1}
|
||||
onChange={(event) => updateInput("vectorTopK", event.target.value)}
|
||||
type="number"
|
||||
value={input.vectorTopK}
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor="chat-rerank-top-n">
|
||||
<span>Rerank Top N</span>
|
||||
<input
|
||||
disabled={isRunning}
|
||||
id="chat-rerank-top-n"
|
||||
max={CHAT_REQUEST_LIMIT_MAX}
|
||||
min={1}
|
||||
onChange={(event) => updateInput("rerankTopN", event.target.value)}
|
||||
type="number"
|
||||
value={input.rerankTopN}
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor="chat-max-tokens">
|
||||
<span>回答 Token 上限</span>
|
||||
<input
|
||||
disabled={isRunning}
|
||||
id="chat-max-tokens"
|
||||
max={CHAT_MAX_TOKENS_LIMIT}
|
||||
min={1}
|
||||
onChange={(event) => updateInput("maxTokens", event.target.value)}
|
||||
type="number"
|
||||
value={input.maxTokens}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{showValidation ? (
|
||||
<p className="field-error chat-composer__error" id={errorId} role="alert">
|
||||
{validation.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="chat-composer__actions">
|
||||
<button
|
||||
className="tertiary-button"
|
||||
disabled={isRunning}
|
||||
onClick={() => {
|
||||
setInput(DEFAULT_CHAT_INPUT);
|
||||
setSubmitted(false);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
恢复合成示例
|
||||
</button>
|
||||
{isRunning ? (
|
||||
<button className="stop-button" onClick={onStop} type="button">
|
||||
<span aria-hidden="true" />
|
||||
停止回答
|
||||
</button>
|
||||
) : (
|
||||
<button className="primary-button" type="submit">
|
||||
<Icon name="layers" size={19} />
|
||||
开始证据问答
|
||||
<Icon name="arrow" size={17} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { Icon } from "../../../components/Icon";
|
||||
import type { ChatEvidence, ChatPhase, ChatRunState } from "../types";
|
||||
|
||||
interface ChatConversationProps {
|
||||
isRunning: boolean;
|
||||
onRetry: () => void;
|
||||
state: ChatRunState;
|
||||
}
|
||||
|
||||
const PHASE_LABELS: Record<ChatPhase, string> = {
|
||||
idle: "等待问题",
|
||||
retrieving: "正在检索",
|
||||
generating: "正在生成",
|
||||
complete: "回答完成",
|
||||
refused: "证据不足 · 已拒答",
|
||||
retrieval_only: "仅检索证据模式",
|
||||
error: "问答未完成",
|
||||
stopped: "已停止",
|
||||
};
|
||||
|
||||
function formatScore(value: number | null): string {
|
||||
return value === null ? "—" : value.toFixed(4);
|
||||
}
|
||||
|
||||
function SourceCard({ evidence }: { evidence: ChatEvidence }) {
|
||||
return (
|
||||
<article aria-labelledby={`chat-source-${evidence.citation_id}`} className="chat-source-card">
|
||||
<div className="chat-source-card__label">[{evidence.label}]</div>
|
||||
<div className="chat-source-card__body">
|
||||
<div className="chat-source-card__header">
|
||||
<div>
|
||||
<span>GROUNDED SOURCE</span>
|
||||
<h4 id={`chat-source-${evidence.citation_id}`}>{evidence.source_name}</h4>
|
||||
</div>
|
||||
<div className="chat-source-card__score">
|
||||
<span>Vector #{evidence.vector_rank}</span>
|
||||
<strong>{formatScore(evidence.rerank_score)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p>{evidence.snippet}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>章节</dt>
|
||||
<dd>
|
||||
{evidence.section_path.length > 0 ? evidence.section_path.join(" / ") : "章节未知"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>页码</dt>
|
||||
<dd>{evidence.page_label}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Citation ID</dt>
|
||||
<dd>
|
||||
<code>{evidence.citation_id}</code>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function RunMetadata({ state }: { state: ChatRunState }) {
|
||||
if (state.meta === null) return null;
|
||||
const timings = state.retrieval?.timings;
|
||||
return (
|
||||
<div className="chat-run-metadata">
|
||||
<div>
|
||||
<span>Generation</span>
|
||||
<strong>
|
||||
{state.meta.generation_mode === "synthetic_extractive" ? "合成抽取式" : "百炼 Grounded"}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Profile</span>
|
||||
<strong>{state.meta.profile.synthetic ? "Synthetic" : "Live"}</strong>
|
||||
<small>{state.meta.profile.model}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Retrieval</span>
|
||||
<strong>{timings === undefined ? "—" : `${timings.total_ms.toFixed(1)} ms`}</strong>
|
||||
<small>{state.retrieval?.rerank_status ?? "等待中"}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Trace ID</span>
|
||||
<code>{state.meta.trace_id}</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function phaseDescription(state: ChatRunState): string {
|
||||
if (state.phase === "retrieving")
|
||||
return "正在生成 Query Vector 并检索当前授权范围内的已批准证据。";
|
||||
if (state.phase === "generating") return "检索已完成,正在生成并校验带来源标签的回答。";
|
||||
if (state.phase === "refused") return "未找到足以支持回答的证据,系统没有生成地质结论。";
|
||||
if (state.phase === "retrieval_only")
|
||||
return "模型答案未通过引用约束,当前展示后端抽取式证据回答。";
|
||||
if (state.phase === "stopped") return "请求已由你主动取消,尚未完成的流内容不会继续展示。";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function ChatConversation({ isRunning, onRetry, state }: ChatConversationProps) {
|
||||
const sources = state.citations.length > 0 ? state.citations : [];
|
||||
const degraded = state.retrieval?.rerank_status === "degraded";
|
||||
const request = state.request;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-busy={isRunning}
|
||||
aria-labelledby="chat-conversation-heading"
|
||||
className="chat-conversation"
|
||||
>
|
||||
<div className="section-heading section-heading--results">
|
||||
<div>
|
||||
<span className="eyebrow">ANSWER STREAM</span>
|
||||
<h2 id="chat-conversation-heading">回答与引用证据</h2>
|
||||
</div>
|
||||
<span className={`chat-phase chat-phase--${state.phase}`}>{PHASE_LABELS[state.phase]}</span>
|
||||
</div>
|
||||
|
||||
<span aria-atomic="true" aria-live="polite" className="visually-hidden">
|
||||
{PHASE_LABELS[state.phase]}
|
||||
</span>
|
||||
|
||||
{request === null ? (
|
||||
<div className="chat-empty-state">
|
||||
<span className="chat-empty-state__icon">
|
||||
<Icon name="layers" size={28} />
|
||||
</span>
|
||||
<h3>从一条有证据边界的问题开始</h3>
|
||||
<p>系统会先完成授权检索,再输出经过 Citation Label 校验的纯文本回答和来源卡片。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chat-thread">
|
||||
<article className="chat-message chat-message--user">
|
||||
<span className="chat-message__role">你的问题</span>
|
||||
<p>{request.question}</p>
|
||||
</article>
|
||||
|
||||
<article className="chat-message chat-message--assistant">
|
||||
<div className="chat-message__heading">
|
||||
<span className="chat-message__role">证据助手</span>
|
||||
<span>{PHASE_LABELS[state.phase]}</span>
|
||||
</div>
|
||||
|
||||
{state.phase === "retrieving" ||
|
||||
(state.phase === "generating" && state.answer.length === 0) ? (
|
||||
<div className="chat-progress" role="status">
|
||||
<span className="button-spinner" />
|
||||
<p>{phaseDescription(state)}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.answer.length > 0 ? (
|
||||
<p className="chat-answer" data-testid="chat-answer">
|
||||
{state.answer}
|
||||
{state.phase === "generating" ? (
|
||||
<span aria-hidden="true" className="chat-answer__cursor" />
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{state.phase === "refused" ||
|
||||
state.phase === "retrieval_only" ||
|
||||
state.phase === "stopped" ? (
|
||||
<p className={`chat-mode-note chat-mode-note--${state.phase}`}>
|
||||
{phaseDescription(state)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{state.phase === "error" ? (
|
||||
<div className="chat-error" role="alert">
|
||||
<Icon name="alert" size={21} />
|
||||
<div>
|
||||
<strong>
|
||||
{state.streamError === null ? "回答流未能安全完成" : "生成模型未能完成回答"}
|
||||
</strong>
|
||||
<p>{state.errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
|
||||
{degraded ? (
|
||||
<div className="chat-degraded" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<div>
|
||||
<strong>检索已降级</strong>
|
||||
<span>
|
||||
Rerank 不可用,本次答案基于 Vector Rank 证据;页面不会伪装为已完成重排。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<RunMetadata state={state} />
|
||||
|
||||
{state.usage ? (
|
||||
<div className="chat-usage" aria-label="模型用量">
|
||||
<span>
|
||||
Model <strong>{state.usage.model}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Input <strong>{state.usage.input_tokens ?? "—"}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Output <strong>{state.usage.output_tokens ?? "—"}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Total <strong>{state.usage.total_tokens ?? "—"}</strong>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sources.length > 0 ? (
|
||||
<section aria-labelledby="chat-sources-heading" className="chat-sources">
|
||||
<div className="chat-sources__heading">
|
||||
<div>
|
||||
<span className="eyebrow">CITATIONS</span>
|
||||
<h3 id="chat-sources-heading">
|
||||
{state.streamError ? "已保留的检索证据" : "回答引用来源"}
|
||||
</h3>
|
||||
</div>
|
||||
<span>{sources.length} 条</span>
|
||||
</div>
|
||||
<div className="chat-source-list">
|
||||
{sources.map((evidence) => (
|
||||
<SourceCard evidence={evidence} key={evidence.citation_id} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{(state.phase === "error" || state.phase === "stopped") && state.request ? (
|
||||
<div className="chat-retry-row">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={isRunning}
|
||||
onClick={onRetry}
|
||||
type="button"
|
||||
>
|
||||
原样重试问题
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { streamGroundedChat } from "../api";
|
||||
import { ChatStreamError } from "../sse";
|
||||
import {
|
||||
INITIAL_CHAT_STATE,
|
||||
type ChatCompletionRequest,
|
||||
type ChatRunState,
|
||||
type ChatStreamEvent,
|
||||
} from "../types";
|
||||
|
||||
function stateForEvent(current: ChatRunState, event: ChatStreamEvent): ChatRunState {
|
||||
switch (event.name) {
|
||||
case "meta":
|
||||
return { ...current, meta: event };
|
||||
case "retrieval":
|
||||
return { ...current, phase: "generating", retrieval: event };
|
||||
case "delta":
|
||||
return { ...current, phase: "generating", answer: current.answer + event.text };
|
||||
case "citations":
|
||||
return { ...current, citations: event.citations };
|
||||
case "usage":
|
||||
return { ...current, usage: event };
|
||||
case "done":
|
||||
return {
|
||||
...current,
|
||||
phase:
|
||||
event.answer_mode === "grounded"
|
||||
? "complete"
|
||||
: event.answer_mode === "refused"
|
||||
? "refused"
|
||||
: "retrieval_only",
|
||||
done: event,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
...current,
|
||||
phase: "error",
|
||||
citations: current.retrieval?.evidence ?? [],
|
||||
streamError: event,
|
||||
errorMessage: event.retryable
|
||||
? "生成模型暂不可用,已保留本次检索证据,可以稍后重试。"
|
||||
: "生成过程未能安全完成,已切换为仅检索证据模式。",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function useGroundedChat() {
|
||||
const [state, setState] = useState<ChatRunState>(INITIAL_CHAT_STATE);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const runIdRef = useRef(0);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
controllerRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async (request: ChatCompletionRequest) => {
|
||||
controllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
const runId = ++runIdRef.current;
|
||||
setState({ ...INITIAL_CHAT_STATE, phase: "retrieving", request });
|
||||
|
||||
try {
|
||||
await streamGroundedChat(request, {
|
||||
signal: controller.signal,
|
||||
onEvent: (event) => {
|
||||
if (runId === runIdRef.current) setState((current) => stateForEvent(current, event));
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (runId !== runIdRef.current) return;
|
||||
if (error instanceof ChatStreamError && error.kind === "aborted") {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
phase: "stopped",
|
||||
errorMessage: null,
|
||||
}));
|
||||
} else {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
answer:
|
||||
error instanceof ChatStreamError && error.kind === "invalid_stream"
|
||||
? ""
|
||||
: current.answer,
|
||||
citations:
|
||||
error instanceof ChatStreamError && error.kind === "invalid_stream"
|
||||
? []
|
||||
: current.citations.length > 0
|
||||
? current.citations
|
||||
: (current.retrieval?.evidence ?? []),
|
||||
errorMessage:
|
||||
error instanceof ChatStreamError
|
||||
? error.message
|
||||
: "回答流发生未预期错误,请重新发起问题。",
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
if (runId === runIdRef.current) controllerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => controllerRef.current?.abort(), []);
|
||||
|
||||
return {
|
||||
state,
|
||||
isRunning: state.phase === "retrieving" || state.phase === "generating",
|
||||
start,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
import type {
|
||||
ChatCitationsEvent,
|
||||
ChatDeltaEvent,
|
||||
ChatDoneEvent,
|
||||
ChatErrorEvent,
|
||||
ChatEvidence,
|
||||
ChatMetaEvent,
|
||||
ChatRetrievalEvent,
|
||||
ChatStreamEvent,
|
||||
ChatTimings,
|
||||
ChatUsageEvent,
|
||||
} from "./types";
|
||||
|
||||
export type ChatStreamErrorKind =
|
||||
| "aborted"
|
||||
| "forbidden"
|
||||
| "not_ready"
|
||||
| "unavailable"
|
||||
| "validation"
|
||||
| "network"
|
||||
| "invalid_stream"
|
||||
| "http";
|
||||
|
||||
export class ChatStreamError extends Error {
|
||||
readonly kind: ChatStreamErrorKind;
|
||||
readonly status: number | null;
|
||||
|
||||
constructor(kind: ChatStreamErrorKind, message: string, status: number | null = null) {
|
||||
super(message);
|
||||
this.name = "ChatStreamError";
|
||||
this.kind = kind;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const PROFILE_HASH_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const LABEL_PATTERN = /^S[1-9]\d*$/;
|
||||
const EVENT_NAMES = new Set(["meta", "retrieval", "delta", "citations", "usage", "done", "error"]);
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
type StreamStage =
|
||||
| "expect_meta"
|
||||
| "expect_retrieval"
|
||||
| "expect_delta_or_error"
|
||||
| "accepting_delta"
|
||||
| "expect_usage"
|
||||
| "expect_done"
|
||||
| "terminal";
|
||||
|
||||
function invalidStream(): never {
|
||||
throw new ChatStreamError(
|
||||
"invalid_stream",
|
||||
"回答流不完整或格式无效。已停止展示后续内容,请重新发起问题。",
|
||||
);
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasExactKeys(value: JsonObject, keys: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
||||
}
|
||||
|
||||
function isFiniteNumber(
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum = Number.POSITIVE_INFINITY,
|
||||
): value is number {
|
||||
return (
|
||||
typeof value === "number" && Number.isFinite(value) && value >= minimum && value <= maximum
|
||||
);
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && typeof value === "number" && value >= 1;
|
||||
}
|
||||
|
||||
function isNullableNonnegativeInteger(value: unknown): value is number | null {
|
||||
return value === null || (Number.isInteger(value) && typeof value === "number" && value >= 0);
|
||||
}
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || typeof value === "string";
|
||||
}
|
||||
|
||||
function parseProfile(value: unknown) {
|
||||
if (
|
||||
!isObject(value) ||
|
||||
!hasExactKeys(value, ["profile_hash", "model", "dimension", "synthetic"]) ||
|
||||
typeof value.profile_hash !== "string" ||
|
||||
!PROFILE_HASH_PATTERN.test(value.profile_hash) ||
|
||||
typeof value.model !== "string" ||
|
||||
value.model.length === 0 ||
|
||||
value.dimension !== 1024 ||
|
||||
typeof value.synthetic !== "boolean"
|
||||
) {
|
||||
invalidStream();
|
||||
}
|
||||
return {
|
||||
profile_hash: value.profile_hash,
|
||||
model: value.model,
|
||||
dimension: 1024 as const,
|
||||
synthetic: value.synthetic,
|
||||
};
|
||||
}
|
||||
|
||||
function parseEvidence(value: unknown): ChatEvidence {
|
||||
const keys = [
|
||||
"label",
|
||||
"rank",
|
||||
"vector_rank",
|
||||
"citation_id",
|
||||
"document_id",
|
||||
"source_name",
|
||||
"snippet",
|
||||
"section_path",
|
||||
"page_start",
|
||||
"page_end",
|
||||
"page_label",
|
||||
"vector_score",
|
||||
"rerank_score",
|
||||
] as const;
|
||||
if (!isObject(value) || !hasExactKeys(value, keys)) invalidStream();
|
||||
if (
|
||||
typeof value.label !== "string" ||
|
||||
!LABEL_PATTERN.test(value.label) ||
|
||||
!isPositiveInteger(value.rank) ||
|
||||
value.label !== `S${value.rank}` ||
|
||||
!isPositiveInteger(value.vector_rank) ||
|
||||
typeof value.citation_id !== "string" ||
|
||||
!UUID_PATTERN.test(value.citation_id) ||
|
||||
typeof value.document_id !== "string" ||
|
||||
!UUID_PATTERN.test(value.document_id) ||
|
||||
typeof value.source_name !== "string" ||
|
||||
value.source_name.length < 1 ||
|
||||
value.source_name.length > 240 ||
|
||||
typeof value.snippet !== "string" ||
|
||||
value.snippet.length < 1 ||
|
||||
value.snippet.length > 1_200 ||
|
||||
!Array.isArray(value.section_path) ||
|
||||
value.section_path.some((part) => typeof part !== "string") ||
|
||||
(value.page_start !== null && !isPositiveInteger(value.page_start)) ||
|
||||
(value.page_end !== null && !isPositiveInteger(value.page_end)) ||
|
||||
(value.page_start === null) !== (value.page_end === null) ||
|
||||
(typeof value.page_start === "number" &&
|
||||
typeof value.page_end === "number" &&
|
||||
value.page_end < value.page_start) ||
|
||||
typeof value.page_label !== "string" ||
|
||||
value.page_label.length === 0 ||
|
||||
!isFiniteNumber(value.vector_score, -1, 1) ||
|
||||
(value.rerank_score !== null && !isFiniteNumber(value.rerank_score, 0, 1))
|
||||
) {
|
||||
invalidStream();
|
||||
}
|
||||
return {
|
||||
label: value.label,
|
||||
rank: value.rank,
|
||||
vector_rank: value.vector_rank,
|
||||
citation_id: value.citation_id,
|
||||
document_id: value.document_id,
|
||||
source_name: value.source_name,
|
||||
snippet: value.snippet,
|
||||
section_path: value.section_path,
|
||||
page_start: value.page_start,
|
||||
page_end: value.page_end,
|
||||
page_label: value.page_label,
|
||||
vector_score: value.vector_score,
|
||||
rerank_score: value.rerank_score,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTimings(value: unknown): ChatTimings {
|
||||
if (
|
||||
!isObject(value) ||
|
||||
!hasExactKeys(value, ["embedding_ms", "database_ms", "rerank_ms", "total_ms"]) ||
|
||||
!isFiniteNumber(value.embedding_ms, 0) ||
|
||||
!isFiniteNumber(value.database_ms, 0) ||
|
||||
!isFiniteNumber(value.rerank_ms, 0) ||
|
||||
!isFiniteNumber(value.total_ms, 0)
|
||||
) {
|
||||
invalidStream();
|
||||
}
|
||||
return {
|
||||
embedding_ms: value.embedding_ms,
|
||||
database_ms: value.database_ms,
|
||||
rerank_ms: value.rerank_ms,
|
||||
total_ms: value.total_ms,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMeta(value: JsonObject): ChatMetaEvent {
|
||||
if (
|
||||
!hasExactKeys(value, ["seq", "trace_id", "knowledge_base_id", "profile", "generation_mode"]) ||
|
||||
!isPositiveInteger(value.seq) ||
|
||||
typeof value.trace_id !== "string" ||
|
||||
value.trace_id.length === 0 ||
|
||||
typeof value.knowledge_base_id !== "string" ||
|
||||
!UUID_PATTERN.test(value.knowledge_base_id) ||
|
||||
(value.generation_mode !== "synthetic_extractive" && value.generation_mode !== "cloud_grounded")
|
||||
) {
|
||||
invalidStream();
|
||||
}
|
||||
const profile = parseProfile(value.profile);
|
||||
if (
|
||||
(profile.synthetic && value.generation_mode !== "synthetic_extractive") ||
|
||||
(!profile.synthetic && value.generation_mode !== "cloud_grounded")
|
||||
) {
|
||||
invalidStream();
|
||||
}
|
||||
return {
|
||||
name: "meta",
|
||||
seq: value.seq,
|
||||
trace_id: value.trace_id,
|
||||
knowledge_base_id: value.knowledge_base_id,
|
||||
profile,
|
||||
generation_mode: value.generation_mode,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRetrieval(value: JsonObject): ChatRetrievalEvent {
|
||||
if (
|
||||
!hasExactKeys(value, [
|
||||
"seq",
|
||||
"status",
|
||||
"rerank_status",
|
||||
"degradation_reason",
|
||||
"evidence",
|
||||
"timings",
|
||||
]) ||
|
||||
!isPositiveInteger(value.seq) ||
|
||||
(value.status !== "ok" && value.status !== "empty") ||
|
||||
!["applied", "degraded", "skipped_empty"].includes(String(value.rerank_status)) ||
|
||||
(value.degradation_reason !== null && value.degradation_reason !== "rerank_unavailable") ||
|
||||
!Array.isArray(value.evidence)
|
||||
) {
|
||||
invalidStream();
|
||||
}
|
||||
const evidence = value.evidence.map(parseEvidence);
|
||||
if (new Set(evidence.map((item) => item.citation_id)).size !== evidence.length) invalidStream();
|
||||
if (evidence.some((item, index) => item.rank !== index + 1)) invalidStream();
|
||||
if ((value.status === "empty") !== (evidence.length === 0)) invalidStream();
|
||||
if ((value.status === "empty") !== (value.rerank_status === "skipped_empty")) invalidStream();
|
||||
if ((value.rerank_status === "degraded") !== (value.degradation_reason !== null)) invalidStream();
|
||||
return {
|
||||
name: "retrieval",
|
||||
seq: value.seq,
|
||||
status: value.status,
|
||||
rerank_status: value.rerank_status as "applied" | "degraded" | "skipped_empty",
|
||||
degradation_reason: value.degradation_reason,
|
||||
evidence,
|
||||
timings: parseTimings(value.timings),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDelta(value: JsonObject): ChatDeltaEvent {
|
||||
if (
|
||||
!hasExactKeys(value, ["seq", "text"]) ||
|
||||
!isPositiveInteger(value.seq) ||
|
||||
typeof value.text !== "string"
|
||||
) {
|
||||
invalidStream();
|
||||
}
|
||||
return { name: "delta", seq: value.seq, text: value.text };
|
||||
}
|
||||
|
||||
function parseCitations(value: JsonObject): ChatCitationsEvent {
|
||||
if (
|
||||
!hasExactKeys(value, ["seq", "citations"]) ||
|
||||
!isPositiveInteger(value.seq) ||
|
||||
!Array.isArray(value.citations)
|
||||
) {
|
||||
invalidStream();
|
||||
}
|
||||
return { name: "citations", seq: value.seq, citations: value.citations.map(parseEvidence) };
|
||||
}
|
||||
|
||||
function parseUsage(value: JsonObject): ChatUsageEvent {
|
||||
if (
|
||||
!hasExactKeys(value, [
|
||||
"seq",
|
||||
"model",
|
||||
"request_id",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
]) ||
|
||||
!isPositiveInteger(value.seq) ||
|
||||
typeof value.model !== "string" ||
|
||||
value.model.length === 0 ||
|
||||
!isNullableString(value.request_id) ||
|
||||
!isNullableNonnegativeInteger(value.input_tokens) ||
|
||||
!isNullableNonnegativeInteger(value.output_tokens) ||
|
||||
!isNullableNonnegativeInteger(value.total_tokens)
|
||||
) {
|
||||
invalidStream();
|
||||
}
|
||||
return {
|
||||
name: "usage",
|
||||
seq: value.seq,
|
||||
model: value.model,
|
||||
request_id: value.request_id,
|
||||
input_tokens: value.input_tokens,
|
||||
output_tokens: value.output_tokens,
|
||||
total_tokens: value.total_tokens,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDone(value: JsonObject): ChatDoneEvent {
|
||||
if (
|
||||
!hasExactKeys(value, ["seq", "status", "answer_mode", "finish_reason"]) ||
|
||||
!isPositiveInteger(value.seq) ||
|
||||
value.status !== "complete" ||
|
||||
!["grounded", "refused", "retrieval_only"].includes(String(value.answer_mode)) ||
|
||||
!isNullableString(value.finish_reason)
|
||||
) {
|
||||
invalidStream();
|
||||
}
|
||||
return {
|
||||
name: "done",
|
||||
seq: value.seq,
|
||||
status: "complete",
|
||||
answer_mode: value.answer_mode as "grounded" | "refused" | "retrieval_only",
|
||||
finish_reason: value.finish_reason,
|
||||
};
|
||||
}
|
||||
|
||||
function parseError(value: JsonObject): ChatErrorEvent {
|
||||
if (
|
||||
!hasExactKeys(value, ["seq", "status", "code", "title", "retryable", "answer_mode"]) ||
|
||||
!isPositiveInteger(value.seq) ||
|
||||
value.status !== "error" ||
|
||||
(value.code !== "CHAT_PROVIDER_UNAVAILABLE" && value.code !== "CHAT_GENERATION_FAILED") ||
|
||||
typeof value.title !== "string" ||
|
||||
value.title.length === 0 ||
|
||||
typeof value.retryable !== "boolean" ||
|
||||
value.answer_mode !== "retrieval_only"
|
||||
) {
|
||||
invalidStream();
|
||||
}
|
||||
return {
|
||||
name: "error",
|
||||
seq: value.seq,
|
||||
status: "error",
|
||||
code: value.code,
|
||||
title: value.title,
|
||||
retryable: value.retryable,
|
||||
answer_mode: "retrieval_only",
|
||||
};
|
||||
}
|
||||
|
||||
function parseEvent(name: string, data: string): ChatStreamEvent {
|
||||
if (!EVENT_NAMES.has(name)) invalidStream();
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(data);
|
||||
} catch {
|
||||
invalidStream();
|
||||
}
|
||||
if (!isObject(value)) invalidStream();
|
||||
switch (name) {
|
||||
case "meta":
|
||||
return parseMeta(value);
|
||||
case "retrieval":
|
||||
return parseRetrieval(value);
|
||||
case "delta":
|
||||
return parseDelta(value);
|
||||
case "citations":
|
||||
return parseCitations(value);
|
||||
case "usage":
|
||||
return parseUsage(value);
|
||||
case "done":
|
||||
return parseDone(value);
|
||||
case "error":
|
||||
return parseError(value);
|
||||
default:
|
||||
return invalidStream();
|
||||
}
|
||||
}
|
||||
|
||||
function evidenceMatches(left: ChatEvidence, right: ChatEvidence): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
export class ChatEventSequence {
|
||||
private stage: StreamStage = "expect_meta";
|
||||
private expectedSeq = 1;
|
||||
private expectedKnowledgeBaseId: string;
|
||||
private retrievalEvidence: readonly ChatEvidence[] = [];
|
||||
private citations: readonly ChatEvidence[] = [];
|
||||
private generatedCharacters = 0;
|
||||
private generatedText = "";
|
||||
|
||||
constructor(expectedKnowledgeBaseId: string) {
|
||||
this.expectedKnowledgeBaseId = expectedKnowledgeBaseId;
|
||||
}
|
||||
|
||||
accept(event: ChatStreamEvent): void {
|
||||
if (this.stage === "terminal" || event.seq !== this.expectedSeq) invalidStream();
|
||||
this.expectedSeq += 1;
|
||||
|
||||
if (event.name === "meta") {
|
||||
if (
|
||||
this.stage !== "expect_meta" ||
|
||||
event.knowledge_base_id !== this.expectedKnowledgeBaseId
|
||||
) {
|
||||
invalidStream();
|
||||
}
|
||||
this.stage = "expect_retrieval";
|
||||
return;
|
||||
}
|
||||
if (event.name === "retrieval") {
|
||||
if (this.stage !== "expect_retrieval") invalidStream();
|
||||
this.retrievalEvidence = event.evidence;
|
||||
this.stage = "expect_delta_or_error";
|
||||
return;
|
||||
}
|
||||
if (event.name === "delta") {
|
||||
if (this.stage !== "expect_delta_or_error" && this.stage !== "accepting_delta")
|
||||
invalidStream();
|
||||
this.generatedCharacters += event.text.length;
|
||||
if (this.generatedCharacters > 64_000) invalidStream();
|
||||
this.generatedText += event.text;
|
||||
this.stage = "accepting_delta";
|
||||
return;
|
||||
}
|
||||
if (event.name === "citations") {
|
||||
if (this.stage !== "accepting_delta") invalidStream();
|
||||
const evidenceByLabel = new Map(this.retrievalEvidence.map((item) => [item.label, item]));
|
||||
const labels = new Set<string>();
|
||||
for (const citation of event.citations) {
|
||||
const retrieved = evidenceByLabel.get(citation.label);
|
||||
if (
|
||||
retrieved === undefined ||
|
||||
labels.has(citation.label) ||
|
||||
!evidenceMatches(citation, retrieved)
|
||||
) {
|
||||
invalidStream();
|
||||
}
|
||||
labels.add(citation.label);
|
||||
}
|
||||
const referencedLabels = [
|
||||
...new Set(
|
||||
[...this.generatedText.matchAll(/\[S([1-9]\d*)\]/g)].map((match) => `S${match[1]}`),
|
||||
),
|
||||
];
|
||||
if (
|
||||
referencedLabels.length !== event.citations.length ||
|
||||
referencedLabels.some((label, index) => event.citations[index]?.label !== label)
|
||||
) {
|
||||
invalidStream();
|
||||
}
|
||||
this.citations = event.citations;
|
||||
this.stage = "expect_usage";
|
||||
return;
|
||||
}
|
||||
if (event.name === "usage") {
|
||||
if (this.stage !== "expect_usage") invalidStream();
|
||||
this.stage = "expect_done";
|
||||
return;
|
||||
}
|
||||
if (event.name === "done") {
|
||||
if (this.stage !== "expect_done") invalidStream();
|
||||
if (event.answer_mode === "grounded" && this.citations.length === 0) invalidStream();
|
||||
if (event.answer_mode === "refused" && this.citations.length !== 0) invalidStream();
|
||||
this.stage = "terminal";
|
||||
return;
|
||||
}
|
||||
if (event.name === "error") {
|
||||
if (this.stage !== "expect_delta_or_error") invalidStream();
|
||||
this.stage = "terminal";
|
||||
}
|
||||
}
|
||||
|
||||
finish(): void {
|
||||
if (this.stage !== "terminal") invalidStream();
|
||||
}
|
||||
}
|
||||
|
||||
function parseSseBlock(block: string): ChatStreamEvent {
|
||||
let eventName: string | null = null;
|
||||
const dataLines: string[] = [];
|
||||
for (const rawLine of block.split("\n")) {
|
||||
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
||||
if (line.length === 0 || line.startsWith(":")) continue;
|
||||
const separator = line.indexOf(":");
|
||||
const field = separator === -1 ? line : line.slice(0, separator);
|
||||
const rawValue = separator === -1 ? "" : line.slice(separator + 1);
|
||||
const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue;
|
||||
if (field === "event") {
|
||||
if (eventName !== null || value.length === 0) invalidStream();
|
||||
eventName = value;
|
||||
} else if (field === "data") {
|
||||
dataLines.push(value);
|
||||
} else {
|
||||
invalidStream();
|
||||
}
|
||||
}
|
||||
if (eventName === null || dataLines.length === 0) invalidStream();
|
||||
return parseEvent(eventName, dataLines.join("\n"));
|
||||
}
|
||||
|
||||
function nextBlock(buffer: string): { block: string; rest: string } | null {
|
||||
const match = /\r?\n\r?\n/.exec(buffer);
|
||||
if (match?.index === undefined) return null;
|
||||
return {
|
||||
block: buffer.slice(0, match.index),
|
||||
rest: buffer.slice(match.index + match[0].length),
|
||||
};
|
||||
}
|
||||
|
||||
export async function consumeChatSse(
|
||||
response: Response,
|
||||
expectedKnowledgeBaseId: string,
|
||||
onEvent: (event: ChatStreamEvent) => void,
|
||||
): Promise<void> {
|
||||
if (!response.headers.get("content-type")?.toLowerCase().startsWith("text/event-stream")) {
|
||||
invalidStream();
|
||||
}
|
||||
if (response.body === null) invalidStream();
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const sequence = new ChatEventSequence(expectedKnowledgeBaseId);
|
||||
let buffer = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let extracted = nextBlock(buffer);
|
||||
while (extracted !== null) {
|
||||
buffer = extracted.rest;
|
||||
if (extracted.block.trim().length > 0) {
|
||||
const event = parseSseBlock(extracted.block);
|
||||
sequence.accept(event);
|
||||
onEvent(event);
|
||||
}
|
||||
extracted = nextBlock(buffer);
|
||||
}
|
||||
}
|
||||
buffer += decoder.decode();
|
||||
if (buffer.trim().length > 0) {
|
||||
const event = parseSseBlock(buffer);
|
||||
sequence.accept(event);
|
||||
onEvent(event);
|
||||
}
|
||||
sequence.finish();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { components } from "../../api/schema.generated";
|
||||
import { DEFAULT_RETRIEVAL_QUERY, SYNTHETIC_KNOWLEDGE_BASE_ID } from "../retrieval/types";
|
||||
|
||||
export type ChatCompletionRequest = components["schemas"]["ChatCompletionRequest"];
|
||||
|
||||
export const DEFAULT_CHAT_QUESTION = DEFAULT_RETRIEVAL_QUERY;
|
||||
export const DEFAULT_CHAT_KNOWLEDGE_BASE_ID = SYNTHETIC_KNOWLEDGE_BASE_ID;
|
||||
export const CHAT_QUESTION_MAX_LENGTH = 500;
|
||||
export const CHAT_MAX_TOKENS_LIMIT = 2_048;
|
||||
export const CHAT_REQUEST_LIMIT_MAX = 10_000;
|
||||
|
||||
export interface ChatProfile {
|
||||
profile_hash: string;
|
||||
model: string;
|
||||
dimension: 1024;
|
||||
synthetic: boolean;
|
||||
}
|
||||
|
||||
export interface ChatEvidence {
|
||||
label: string;
|
||||
rank: number;
|
||||
vector_rank: number;
|
||||
citation_id: string;
|
||||
document_id: string;
|
||||
source_name: string;
|
||||
snippet: string;
|
||||
section_path: readonly string[];
|
||||
page_start: number | null;
|
||||
page_end: number | null;
|
||||
page_label: string;
|
||||
vector_score: number;
|
||||
rerank_score: number | null;
|
||||
}
|
||||
|
||||
export interface ChatTimings {
|
||||
embedding_ms: number;
|
||||
database_ms: number;
|
||||
rerank_ms: number;
|
||||
total_ms: number;
|
||||
}
|
||||
|
||||
export interface ChatMetaEvent {
|
||||
name: "meta";
|
||||
seq: number;
|
||||
trace_id: string;
|
||||
knowledge_base_id: string;
|
||||
profile: ChatProfile;
|
||||
generation_mode: "synthetic_extractive" | "cloud_grounded";
|
||||
}
|
||||
|
||||
export interface ChatRetrievalEvent {
|
||||
name: "retrieval";
|
||||
seq: number;
|
||||
status: "ok" | "empty";
|
||||
rerank_status: "applied" | "degraded" | "skipped_empty";
|
||||
degradation_reason: "rerank_unavailable" | null;
|
||||
evidence: readonly ChatEvidence[];
|
||||
timings: ChatTimings;
|
||||
}
|
||||
|
||||
export interface ChatDeltaEvent {
|
||||
name: "delta";
|
||||
seq: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ChatCitationsEvent {
|
||||
name: "citations";
|
||||
seq: number;
|
||||
citations: readonly ChatEvidence[];
|
||||
}
|
||||
|
||||
export interface ChatUsageEvent {
|
||||
name: "usage";
|
||||
seq: number;
|
||||
model: string;
|
||||
request_id: string | null;
|
||||
input_tokens: number | null;
|
||||
output_tokens: number | null;
|
||||
total_tokens: number | null;
|
||||
}
|
||||
|
||||
export interface ChatDoneEvent {
|
||||
name: "done";
|
||||
seq: number;
|
||||
status: "complete";
|
||||
answer_mode: "grounded" | "refused" | "retrieval_only";
|
||||
finish_reason: string | null;
|
||||
}
|
||||
|
||||
export interface ChatErrorEvent {
|
||||
name: "error";
|
||||
seq: number;
|
||||
status: "error";
|
||||
code: "CHAT_PROVIDER_UNAVAILABLE" | "CHAT_GENERATION_FAILED";
|
||||
title: string;
|
||||
retryable: boolean;
|
||||
answer_mode: "retrieval_only";
|
||||
}
|
||||
|
||||
export type ChatStreamEvent =
|
||||
| ChatMetaEvent
|
||||
| ChatRetrievalEvent
|
||||
| ChatDeltaEvent
|
||||
| ChatCitationsEvent
|
||||
| ChatUsageEvent
|
||||
| ChatDoneEvent
|
||||
| ChatErrorEvent;
|
||||
|
||||
export type ChatPhase =
|
||||
| "idle"
|
||||
| "retrieving"
|
||||
| "generating"
|
||||
| "complete"
|
||||
| "refused"
|
||||
| "retrieval_only"
|
||||
| "error"
|
||||
| "stopped";
|
||||
|
||||
export interface ChatRunState {
|
||||
phase: ChatPhase;
|
||||
request: ChatCompletionRequest | null;
|
||||
answer: string;
|
||||
meta: ChatMetaEvent | null;
|
||||
retrieval: ChatRetrievalEvent | null;
|
||||
citations: readonly ChatEvidence[];
|
||||
usage: ChatUsageEvent | null;
|
||||
done: ChatDoneEvent | null;
|
||||
streamError: ChatErrorEvent | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export const INITIAL_CHAT_STATE: ChatRunState = {
|
||||
phase: "idle",
|
||||
request: null,
|
||||
answer: "",
|
||||
meta: null,
|
||||
retrieval: null,
|
||||
citations: [],
|
||||
usage: null,
|
||||
done: null,
|
||||
streamError: null,
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
export interface ChatFormInput {
|
||||
knowledgeBaseId: string;
|
||||
question: string;
|
||||
vectorTopK: string;
|
||||
rerankTopN: string;
|
||||
maxTokens: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_CHAT_INPUT: ChatFormInput = {
|
||||
knowledgeBaseId: DEFAULT_CHAT_KNOWLEDGE_BASE_ID,
|
||||
question: DEFAULT_CHAT_QUESTION,
|
||||
vectorTopK: "50",
|
||||
rerankTopN: "10",
|
||||
maxTokens: "1024",
|
||||
};
|
||||
|
||||
export interface ChatFormValidation {
|
||||
request: ChatCompletionRequest | null;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function parseInteger(value: string, maximum: number): number | null {
|
||||
if (!/^\d+$/.test(value)) return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed >= 1 && parsed <= maximum ? parsed : null;
|
||||
}
|
||||
|
||||
export function validateChatInput(input: ChatFormInput): ChatFormValidation {
|
||||
const knowledgeBaseId = input.knowledgeBaseId.trim();
|
||||
const question = input.question.replace(/\s+/g, " ").trim();
|
||||
const vectorTopK = parseInteger(input.vectorTopK, CHAT_REQUEST_LIMIT_MAX);
|
||||
const rerankTopN = parseInteger(input.rerankTopN, CHAT_REQUEST_LIMIT_MAX);
|
||||
const maxTokens = parseInteger(input.maxTokens, CHAT_MAX_TOKENS_LIMIT);
|
||||
|
||||
if (!UUID_PATTERN.test(knowledgeBaseId)) {
|
||||
return { request: null, message: "请输入有效的知识库 UUID" };
|
||||
}
|
||||
if (question.length === 0) {
|
||||
return { request: null, message: "请输入要提问的地质问题" };
|
||||
}
|
||||
if (input.question.length > CHAT_QUESTION_MAX_LENGTH) {
|
||||
return { request: null, message: `问题不能超过 ${CHAT_QUESTION_MAX_LENGTH} 个字符` };
|
||||
}
|
||||
if (vectorTopK === null || rerankTopN === null) {
|
||||
return { request: null, message: "检索参数必须是 1–10000 的整数" };
|
||||
}
|
||||
if (maxTokens === null) {
|
||||
return { request: null, message: `回答 Token 上限必须是 1–${CHAT_MAX_TOKENS_LIMIT}` };
|
||||
}
|
||||
return {
|
||||
request: {
|
||||
knowledge_base_id: knowledgeBaseId,
|
||||
question,
|
||||
vector_top_k: vectorTopK,
|
||||
rerank_top_n: rerankTopN,
|
||||
max_tokens: maxTokens,
|
||||
},
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -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: "复核内容在加载期间发生变化,请重新加载" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
"文件名包含不安全内容,请重命名后再上传",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { requestJson } from "../../api/client";
|
||||
import type { RetrievalSearchRequest, RetrievalSearchResponse } from "./types";
|
||||
|
||||
const RETRIEVAL_SEARCH_PATH = "/api/v1/retrieval/search";
|
||||
|
||||
export function searchRetrieval(request: RetrievalSearchRequest): Promise<RetrievalSearchResponse> {
|
||||
return requestJson<RetrievalSearchResponse>(RETRIEVAL_SEARCH_PATH, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useId, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Icon } from "../../../components/Icon";
|
||||
import {
|
||||
DEFAULT_RETRIEVAL_QUERY,
|
||||
QUERY_MAX_LENGTH,
|
||||
REQUESTED_LIMIT_MAX,
|
||||
SYNTHETIC_KNOWLEDGE_BASE_ID,
|
||||
type RetrievalFormInput,
|
||||
type RetrievalSearchRequest,
|
||||
validateRetrievalInput,
|
||||
} from "../types";
|
||||
|
||||
interface RetrievalFormProps {
|
||||
isSearching: boolean;
|
||||
onSearch: (request: RetrievalSearchRequest) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_INPUT: RetrievalFormInput = {
|
||||
knowledgeBaseId: SYNTHETIC_KNOWLEDGE_BASE_ID,
|
||||
query: DEFAULT_RETRIEVAL_QUERY,
|
||||
vectorTopK: "50",
|
||||
rerankTopN: "10",
|
||||
};
|
||||
|
||||
export function RetrievalForm({ isSearching, onSearch }: RetrievalFormProps) {
|
||||
const [input, setInput] = useState<RetrievalFormInput>(DEFAULT_INPUT);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const hintId = useId();
|
||||
const errorId = useId();
|
||||
const validation = useMemo(() => validateRetrievalInput(input), [input]);
|
||||
const showValidation = submitted && !validation.valid;
|
||||
|
||||
function updateInput<K extends keyof RetrievalFormInput>(key: K, value: RetrievalFormInput[K]) {
|
||||
setInput((current) => ({ ...current, [key]: value }));
|
||||
if (submitted) setSubmitted(false);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
setSubmitted(true);
|
||||
if (isSearching || validation.request === null) return;
|
||||
onSearch(validation.request);
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="formal-retrieval-form-heading" className="retrieval-form-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="eyebrow">AUTHORIZED RETRIEVAL</span>
|
||||
<h2 id="formal-retrieval-form-heading">配置正式检索</h2>
|
||||
</div>
|
||||
<span className="section-heading__meta">Dense → Rerank</span>
|
||||
</div>
|
||||
|
||||
<form
|
||||
aria-busy={isSearching}
|
||||
className="retrieval-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
ref={formRef}
|
||||
>
|
||||
<label className="field-label" htmlFor="retrieval-knowledge-base">
|
||||
知识库 ID
|
||||
</label>
|
||||
<input
|
||||
aria-describedby={hintId}
|
||||
className="text-input text-input--mono"
|
||||
disabled={isSearching}
|
||||
id="retrieval-knowledge-base"
|
||||
onChange={(event) => updateInput("knowledgeBaseId", event.target.value)}
|
||||
spellCheck={false}
|
||||
type="text"
|
||||
value={input.knowledgeBaseId}
|
||||
/>
|
||||
<p className="field-hint" id={hintId}>
|
||||
默认是公开合成知识库。访问范围由服务端身份派生,页面不能提交 scope。
|
||||
</p>
|
||||
|
||||
<label className="field-label retrieval-form__query-label" htmlFor="retrieval-query">
|
||||
地质问题
|
||||
</label>
|
||||
<div className={`query-box${showValidation ? " query-box--error" : ""}`}>
|
||||
<textarea
|
||||
aria-describedby={showValidation ? errorId : undefined}
|
||||
aria-invalid={showValidation}
|
||||
disabled={isSearching}
|
||||
id="retrieval-query"
|
||||
maxLength={QUERY_MAX_LENGTH}
|
||||
onChange={(event) => updateInput("query", event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
formRef.current?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
rows={5}
|
||||
value={input.query}
|
||||
/>
|
||||
<span
|
||||
className={`character-count${input.query.length >= 450 ? " character-count--warning" : ""}`}
|
||||
>
|
||||
{input.query.length}/{QUERY_MAX_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<fieldset className="retrieval-parameter-fieldset">
|
||||
<legend>请求参数</legend>
|
||||
<div className="retrieval-parameter-grid">
|
||||
<label htmlFor="retrieval-vector-top-k">
|
||||
<span>向量候选</span>
|
||||
<small>Vector Top K · 生效上限 50</small>
|
||||
<input
|
||||
disabled={isSearching}
|
||||
id="retrieval-vector-top-k"
|
||||
inputMode="numeric"
|
||||
max={REQUESTED_LIMIT_MAX}
|
||||
min={1}
|
||||
onChange={(event) => updateInput("vectorTopK", event.target.value)}
|
||||
type="number"
|
||||
value={input.vectorTopK}
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor="retrieval-rerank-top-n">
|
||||
<span>重排返回</span>
|
||||
<small>Rerank Top N · 生效上限 10</small>
|
||||
<input
|
||||
disabled={isSearching}
|
||||
id="retrieval-rerank-top-n"
|
||||
inputMode="numeric"
|
||||
max={REQUESTED_LIMIT_MAX}
|
||||
min={1}
|
||||
onChange={(event) => updateInput("rerankTopN", event.target.value)}
|
||||
type="number"
|
||||
value={input.rerankTopN}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{showValidation ? (
|
||||
<p className="field-error retrieval-form__error" id={errorId} role="alert">
|
||||
{validation.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="retrieval-form__actions">
|
||||
<button
|
||||
className="tertiary-button"
|
||||
disabled={isSearching}
|
||||
onClick={() => {
|
||||
setInput(DEFAULT_INPUT);
|
||||
setSubmitted(false);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
恢复合成示例
|
||||
</button>
|
||||
<button className="primary-button" disabled={isSearching} type="submit">
|
||||
{isSearching ? <span className="button-spinner" /> : <Icon name="vector" size={19} />}
|
||||
{isSearching ? "正在检索" : "运行正式检索"}
|
||||
{!isSearching ? <Icon name="arrow" size={17} /> : null}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { ApiError, getApiErrorMessage } from "../../../api/client";
|
||||
import { Icon } from "../../../components/Icon";
|
||||
import type { RetrievalHit, RetrievalSearchRequest, RetrievalSearchResponse } from "../types";
|
||||
|
||||
interface RetrievalResultsProps {
|
||||
data: RetrievalSearchResponse | undefined;
|
||||
error: unknown;
|
||||
isIdle: boolean;
|
||||
isPending: boolean;
|
||||
onRetry: () => void;
|
||||
request: RetrievalSearchRequest | undefined;
|
||||
}
|
||||
|
||||
function errorContent(error: unknown): { heading: string; detail: string } {
|
||||
if (error instanceof ApiError && error.status === 403) {
|
||||
return {
|
||||
heading: "当前身份无权检索该知识库",
|
||||
detail: "知识库和访问范围由服务端授权。请恢复合成知识库,或联系管理员配置访问权限。",
|
||||
};
|
||||
}
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
return {
|
||||
heading: "知识库尚未具备可检索 Profile",
|
||||
detail: "请先完成文档审批、向量写入和 Active Embedding Profile 激活。",
|
||||
};
|
||||
}
|
||||
if (error instanceof ApiError && error.status === 503) {
|
||||
return {
|
||||
heading: "检索服务暂不可用",
|
||||
detail: "数据库、模型网关或 Embedding 服务暂不可用。已保留本次请求,可稍后原样重试。",
|
||||
};
|
||||
}
|
||||
if (error instanceof ApiError && error.kind === "validation") {
|
||||
return {
|
||||
heading: "检索参数未通过服务端校验",
|
||||
detail: "请检查知识库 ID、问题长度和 Top K 参数后重新提交。",
|
||||
};
|
||||
}
|
||||
if (error instanceof ApiError && error.kind === "network") {
|
||||
return {
|
||||
heading: "无法连接正式检索 API",
|
||||
detail: "请确认 Docker 服务已启动,并检查同源 /api 代理是否健康。",
|
||||
};
|
||||
}
|
||||
return { heading: "本次正式检索未完成", detail: getApiErrorMessage(error) };
|
||||
}
|
||||
|
||||
function formatMilliseconds(value: number): string {
|
||||
if (value < 1) return `${value.toFixed(2)} ms`;
|
||||
return `${value.toFixed(1)} ms`;
|
||||
}
|
||||
|
||||
function formatScore(value: number | null | undefined): string {
|
||||
return value === null || value === undefined ? "—" : value.toFixed(4);
|
||||
}
|
||||
|
||||
function ResultCard({ hit }: { hit: RetrievalHit }) {
|
||||
return (
|
||||
<article aria-labelledby={`retrieval-source-${hit.citation_id}`} className="retrieval-hit-card">
|
||||
<div className="retrieval-hit-card__rank">
|
||||
<span>最终排序</span>
|
||||
<strong>#{hit.rank}</strong>
|
||||
</div>
|
||||
<div className="retrieval-hit-card__content">
|
||||
<div className="retrieval-hit-card__header">
|
||||
<div>
|
||||
<span className="retrieval-hit-card__type">AUTHORIZED SOURCE</span>
|
||||
<h3 id={`retrieval-source-${hit.citation_id}`}>{hit.source_name}</h3>
|
||||
</div>
|
||||
<div className="retrieval-score-pair">
|
||||
<span aria-label={`向量初召回第 ${hit.vector_rank} 名`}>
|
||||
Vector #{hit.vector_rank} <strong>{formatScore(hit.vector_score)}</strong>
|
||||
</span>
|
||||
<span aria-label={`重排分数 ${formatScore(hit.rerank_score)}`}>
|
||||
Rerank <strong>{formatScore(hit.rerank_score)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="retrieval-hit-card__snippet">{hit.snippet}</p>
|
||||
|
||||
<dl className="retrieval-source-metadata">
|
||||
<div>
|
||||
<dt>章节</dt>
|
||||
<dd>{hit.section_path.length > 0 ? hit.section_path.join(" / ") : "章节未知"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>页码</dt>
|
||||
<dd>{hit.page_label}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>引用编号</dt>
|
||||
<dd>
|
||||
<code>{hit.citation_id}</code>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<details className="retrieval-hit-card__details">
|
||||
<summary>查看文档技术标识</summary>
|
||||
<code>{hit.document_id}</code>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function RunSummary({
|
||||
data,
|
||||
request,
|
||||
}: {
|
||||
data: RetrievalSearchResponse;
|
||||
request: RetrievalSearchRequest;
|
||||
}) {
|
||||
const timingItems = [
|
||||
["Embedding", data.timings.embedding_ms],
|
||||
["SQL / pgvector", data.timings.database_ms],
|
||||
["Rerank", data.timings.rerank_ms],
|
||||
["Total", data.timings.total_ms],
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="retrieval-run-summary">
|
||||
<div className="retrieval-config-grid">
|
||||
<div className="retrieval-config-card">
|
||||
<span>向量候选</span>
|
||||
<strong>{data.parameters.vector_top_k}</strong>
|
||||
<small>
|
||||
请求 {request.vector_top_k} → 生效 {data.parameters.vector_top_k}
|
||||
</small>
|
||||
</div>
|
||||
<div className="retrieval-config-card">
|
||||
<span>重排返回</span>
|
||||
<strong>{data.parameters.rerank_top_n}</strong>
|
||||
<small>
|
||||
请求 {request.rerank_top_n} → 生效 {data.parameters.rerank_top_n}
|
||||
</small>
|
||||
</div>
|
||||
<div className="retrieval-config-card retrieval-config-card--profile">
|
||||
<span>Active Profile</span>
|
||||
<strong>{data.profile.synthetic ? "合成 / 本地" : "真实 / 百炼"}</strong>
|
||||
<small>
|
||||
{data.profile.model} · {data.profile.dimension}D
|
||||
</small>
|
||||
</div>
|
||||
<div className="retrieval-config-card">
|
||||
<span>授权范围</span>
|
||||
<strong>{data.access_scope_count}</strong>
|
||||
<small>由服务端身份派生</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="retrieval-timing-grid" aria-label="检索耗时">
|
||||
{timingItems.map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<span>{label}</span>
|
||||
<strong>{formatMilliseconds(value)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<dl className="retrieval-trace">
|
||||
<div>
|
||||
<dt>Trace ID</dt>
|
||||
<dd>
|
||||
<code>{data.trace_id}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Profile Hash</dt>
|
||||
<dd>
|
||||
<code>{data.profile.profile_hash}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Embedding Request</dt>
|
||||
<dd>
|
||||
<code>{data.embedding_request_id ?? "local / unavailable"}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Rerank Request</dt>
|
||||
<dd>
|
||||
<code>{data.rerank_request_id ?? "degraded / local"}</code>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RetrievalResults({
|
||||
data,
|
||||
error,
|
||||
isIdle,
|
||||
isPending,
|
||||
onRetry,
|
||||
request,
|
||||
}: RetrievalResultsProps) {
|
||||
const failure = error === null || error === undefined ? null : errorContent(error);
|
||||
const isEmpty = data?.status === "empty" || (data?.status === "ok" && data.results.length === 0);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-busy={isPending}
|
||||
aria-labelledby="formal-results-heading"
|
||||
className="retrieval-results"
|
||||
>
|
||||
<div className="section-heading section-heading--results">
|
||||
<div>
|
||||
<span className="eyebrow">RUN EVIDENCE</span>
|
||||
<h2 id="formal-results-heading">生效配置与检索证据</h2>
|
||||
</div>
|
||||
{data?.status === "ok" ? (
|
||||
<span className={`run-status run-status--${data.rerank_status}`}>
|
||||
{data.rerank_status === "applied"
|
||||
? "重排完成"
|
||||
: data.rerank_status === "degraded"
|
||||
? "向量降级"
|
||||
: "无候选"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<span className="visually-hidden" aria-live="polite">
|
||||
{isPending
|
||||
? "正在执行正式检索"
|
||||
: failure
|
||||
? failure.heading
|
||||
: isEmpty
|
||||
? "检索完成,当前授权范围内没有命中证据"
|
||||
: data?.status === "ok"
|
||||
? `检索完成,返回 ${data.results.length} 条证据`
|
||||
: ""}
|
||||
</span>
|
||||
|
||||
{isIdle ? (
|
||||
<div className="retrieval-state retrieval-state--idle">
|
||||
<span className="retrieval-state__icon">
|
||||
<Icon name="vector" size={28} />
|
||||
</span>
|
||||
<h3>运行一条可审计的检索</h3>
|
||||
<p>
|
||||
后端将使用 Active Embedding Profile 生成 Query Vector,在 SQL
|
||||
候选阶段完成权限过滤,再执行有界重排。
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPending ? (
|
||||
<div className="retrieval-loading" role="status">
|
||||
<div className="retrieval-loading__steps" aria-hidden="true">
|
||||
<span>Query Embedding</span>
|
||||
<span>Filtered pgvector</span>
|
||||
<span>Rerank</span>
|
||||
</div>
|
||||
{[0, 1, 2].map((item) => (
|
||||
<div className="skeleton-card" key={item}>
|
||||
<span className="skeleton-line skeleton-line--short" />
|
||||
<span className="skeleton-line skeleton-line--title" />
|
||||
<span className="skeleton-line" />
|
||||
<span className="skeleton-line skeleton-line--medium" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{failure ? (
|
||||
<div className="retrieval-state retrieval-state--error" role="alert">
|
||||
<span className="retrieval-state__icon">
|
||||
<Icon name="alert" size={25} />
|
||||
</span>
|
||||
<h3>{failure.heading}</h3>
|
||||
<p>{failure.detail}</p>
|
||||
<button className="secondary-button" onClick={onRetry} type="button">
|
||||
原样重试
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data && request && isEmpty ? (
|
||||
<div className="retrieval-state retrieval-state--empty">
|
||||
<span className="retrieval-state__icon">
|
||||
<Icon name="search" size={25} />
|
||||
</span>
|
||||
<h3>当前授权范围内没有命中证据</h3>
|
||||
<p>
|
||||
这不代表资料库中不存在相关信息。请调整问题表达,或检查知识库是否已完成审批和索引激活。
|
||||
</p>
|
||||
<RunSummary data={data} request={request} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data?.status === "ok" && data.results.length > 0 && request ? (
|
||||
<div className="retrieval-result-stack">
|
||||
{data.rerank_status === "degraded" ? (
|
||||
<div className="retrieval-degraded-notice" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<div>
|
||||
<strong>重排服务不可用,已保留向量召回结果</strong>
|
||||
<span>以下结果按 Vector Rank 展示,Rerank 分数为空,不能视为已完成重排。</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="retrieval-query-summary">
|
||||
<span>本次问题</span>
|
||||
<p>{request.query}</p>
|
||||
</div>
|
||||
<RunSummary data={data} request={request} />
|
||||
|
||||
<div className="retrieval-hit-list" aria-label="检索证据列表">
|
||||
{data.results.map((hit) => (
|
||||
<ResultCard hit={hit} key={hit.citation_id} />
|
||||
))}
|
||||
</div>
|
||||
<p className="score-disclaimer">
|
||||
Vector 与 Rerank 分数仅用于本次查询内排序,不是矿产存在概率、地质结论置信度或投资依据。
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { searchRetrieval } from "../api";
|
||||
|
||||
export function useRetrievalSearch() {
|
||||
return useMutation({ mutationFn: searchRetrieval });
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { components } from "../../api/schema.generated";
|
||||
|
||||
export type RetrievalHit = components["schemas"]["RetrievalHitResponse"];
|
||||
export type RetrievalSearchRequest = components["schemas"]["RetrievalSearchRequest"];
|
||||
export type RetrievalSearchResponse = components["schemas"]["RetrievalSearchResponse"];
|
||||
|
||||
export const SYNTHETIC_KNOWLEDGE_BASE_ID = "3acd3785-970b-55f7-a669-9eb4695e27eb";
|
||||
export const DEFAULT_RETRIEVAL_QUERY = "花岗斑岩铜矿化有哪些典型蚀变特征?";
|
||||
export const QUERY_MAX_LENGTH = 500;
|
||||
export const REQUESTED_LIMIT_MAX = 10_000;
|
||||
|
||||
export interface RetrievalFormInput {
|
||||
knowledgeBaseId: string;
|
||||
query: string;
|
||||
vectorTopK: string;
|
||||
rerankTopN: string;
|
||||
}
|
||||
|
||||
export interface RetrievalValidation {
|
||||
valid: boolean;
|
||||
message: string | null;
|
||||
request: RetrievalSearchRequest | null;
|
||||
}
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function parseLimit(value: string): number | null {
|
||||
if (!/^\d+$/.test(value)) return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > REQUESTED_LIMIT_MAX) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function validateRetrievalInput(input: RetrievalFormInput): RetrievalValidation {
|
||||
const knowledgeBaseId = input.knowledgeBaseId.trim();
|
||||
const query = input.query.replace(/\s+/g, " ").trim();
|
||||
const vectorTopK = parseLimit(input.vectorTopK);
|
||||
const rerankTopN = parseLimit(input.rerankTopN);
|
||||
|
||||
if (!UUID_PATTERN.test(knowledgeBaseId)) {
|
||||
return { valid: false, message: "请输入有效的知识库 UUID", request: null };
|
||||
}
|
||||
if (query.length === 0) {
|
||||
return { valid: false, message: "请输入要检索的地质问题", request: null };
|
||||
}
|
||||
if (input.query.length > QUERY_MAX_LENGTH) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `问题不能超过 ${QUERY_MAX_LENGTH} 个字符`,
|
||||
request: null,
|
||||
};
|
||||
}
|
||||
if (vectorTopK === null || rerankTopN === null) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `请求参数必须是 1–${REQUESTED_LIMIT_MAX} 的整数,后端仍会按安全上限限幅`,
|
||||
request: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
message: null,
|
||||
request: {
|
||||
knowledge_base_id: knowledgeBaseId,
|
||||
query,
|
||||
vector_top_k: vectorTopK,
|
||||
rerank_top_n: rerankTopN,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createAppQueryClient } from "../app/queryClient";
|
||||
import {
|
||||
DEFAULT_CHAT_KNOWLEDGE_BASE_ID,
|
||||
DEFAULT_CHAT_QUESTION,
|
||||
type ChatEvidence,
|
||||
} from "../features/chat/types";
|
||||
import { ChatPage } from "./ChatPage";
|
||||
|
||||
const TRACE_ID = "50000000-0000-0000-0000-000000000001";
|
||||
const evidence: ChatEvidence = {
|
||||
label: "S1",
|
||||
rank: 1,
|
||||
vector_rank: 2,
|
||||
citation_id: "60000000-0000-0000-0000-000000000001",
|
||||
document_id: "70000000-0000-0000-0000-000000000001",
|
||||
source_name: "<img src=x onerror=alert(1)>青岚铜矿报告.pdf",
|
||||
snippet: "<script>alert('evidence')</script> 已批准的斑岩铜矿蚀变证据。",
|
||||
section_path: ["区域地质", "蚀变分带"],
|
||||
page_start: 8,
|
||||
page_end: 9,
|
||||
page_label: "第 8-9 页",
|
||||
vector_score: 0.81,
|
||||
rerank_score: 0.94,
|
||||
};
|
||||
|
||||
const meta = {
|
||||
seq: 1,
|
||||
trace_id: TRACE_ID,
|
||||
knowledge_base_id: DEFAULT_CHAT_KNOWLEDGE_BASE_ID,
|
||||
profile: {
|
||||
profile_hash: "b".repeat(64),
|
||||
model: "fake-feature-hash-v1",
|
||||
dimension: 1024,
|
||||
synthetic: true,
|
||||
},
|
||||
generation_mode: "synthetic_extractive",
|
||||
} as const;
|
||||
|
||||
const timings = {
|
||||
embedding_ms: 1,
|
||||
database_ms: 2,
|
||||
rerank_ms: 3,
|
||||
total_ms: 6,
|
||||
};
|
||||
|
||||
function block(name: string, payload: object): string {
|
||||
return `event: ${name}\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
|
||||
function successStream({ degraded = false }: { degraded?: boolean } = {}): string {
|
||||
return [
|
||||
block("meta", meta),
|
||||
block("retrieval", {
|
||||
seq: 2,
|
||||
status: "ok",
|
||||
rerank_status: degraded ? "degraded" : "applied",
|
||||
degradation_reason: degraded ? "rerank_unavailable" : null,
|
||||
evidence: [evidence],
|
||||
timings,
|
||||
}),
|
||||
block("delta", { seq: 3, text: "<script>alert('answer')</script> " }),
|
||||
block("delta", { seq: 4, text: "斑岩铜矿蚀变证据 [S1]。" }),
|
||||
block("citations", { seq: 5, citations: [evidence] }),
|
||||
block("usage", {
|
||||
seq: 6,
|
||||
model: "synthetic-grounded-extractive-v1",
|
||||
request_id: null,
|
||||
input_tokens: null,
|
||||
output_tokens: null,
|
||||
total_tokens: null,
|
||||
}),
|
||||
block("done", {
|
||||
seq: 7,
|
||||
status: "complete",
|
||||
answer_mode: "grounded",
|
||||
finish_reason: "synthetic_extractive",
|
||||
}),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function refusedStream(): string {
|
||||
return [
|
||||
block("meta", meta),
|
||||
block("retrieval", {
|
||||
seq: 2,
|
||||
status: "empty",
|
||||
rerank_status: "skipped_empty",
|
||||
degradation_reason: null,
|
||||
evidence: [],
|
||||
timings,
|
||||
}),
|
||||
block("delta", { seq: 3, text: "未检索到足以支持回答的已批准证据,本次拒绝生成结论。" }),
|
||||
block("citations", { seq: 4, citations: [] }),
|
||||
block("usage", {
|
||||
seq: 5,
|
||||
model: "none",
|
||||
request_id: null,
|
||||
input_tokens: null,
|
||||
output_tokens: null,
|
||||
total_tokens: null,
|
||||
}),
|
||||
block("done", {
|
||||
seq: 6,
|
||||
status: "complete",
|
||||
answer_mode: "refused",
|
||||
finish_reason: "insufficient_evidence",
|
||||
}),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function providerErrorStream(): string {
|
||||
return [
|
||||
block("meta", {
|
||||
...meta,
|
||||
profile: {
|
||||
...meta.profile,
|
||||
model: "text-embedding-v4",
|
||||
synthetic: false,
|
||||
},
|
||||
generation_mode: "cloud_grounded",
|
||||
}),
|
||||
block("retrieval", {
|
||||
seq: 2,
|
||||
status: "ok",
|
||||
rerank_status: "applied",
|
||||
degradation_reason: null,
|
||||
evidence: [evidence],
|
||||
timings,
|
||||
}),
|
||||
block("error", {
|
||||
seq: 3,
|
||||
status: "error",
|
||||
code: "CHAT_PROVIDER_UNAVAILABLE",
|
||||
title: "Grounded answer provider unavailable",
|
||||
retryable: true,
|
||||
answer_mode: "retrieval_only",
|
||||
}),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function sseResponse(body: string): Response {
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status: number): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/problem+json" },
|
||||
});
|
||||
}
|
||||
|
||||
function renderChat() {
|
||||
return render(
|
||||
<QueryClientProvider client={createAppQueryClient()}>
|
||||
<ChatPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function submittedBody(): unknown {
|
||||
const call = vi.mocked(fetch).mock.calls[0];
|
||||
if (call === undefined || typeof call[1]?.body !== "string") {
|
||||
throw new Error("missing grounded chat request body");
|
||||
}
|
||||
return JSON.parse(call[1].body);
|
||||
}
|
||||
|
||||
describe("ChatPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
it("streams a grounded answer, validates citations, and renders malicious markup as text", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(sseResponse(successStream()));
|
||||
const user = userEvent.setup();
|
||||
renderChat();
|
||||
|
||||
expect(screen.getByLabelText("知识库 ID")).toHaveValue(DEFAULT_CHAT_KNOWLEDGE_BASE_ID);
|
||||
expect(screen.getByLabelText("地质问题")).toHaveValue(DEFAULT_CHAT_QUESTION);
|
||||
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
|
||||
|
||||
expect(await screen.findAllByText("回答完成")).toHaveLength(3);
|
||||
expect(screen.getByTestId("chat-answer")).toHaveTextContent(
|
||||
"<script>alert('answer')</script> 斑岩铜矿蚀变证据 [S1]。",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "<img src=x onerror=alert(1)>青岚铜矿报告.pdf" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("区域地质 / 蚀变分带")).toBeInTheDocument();
|
||||
expect(screen.getByText("第 8-9 页")).toBeInTheDocument();
|
||||
expect(screen.getByText(evidence.citation_id)).toBeInTheDocument();
|
||||
expect(screen.getByText(TRACE_ID)).toBeInTheDocument();
|
||||
expect(screen.getByText("Synthetic")).toBeInTheDocument();
|
||||
expect(document.querySelector("script")).toBeNull();
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
expect(submittedBody()).toEqual({
|
||||
knowledge_base_id: DEFAULT_CHAT_KNOWLEDGE_BASE_ID,
|
||||
question: DEFAULT_CHAT_QUESTION,
|
||||
vector_top_k: 50,
|
||||
rerank_top_n: 10,
|
||||
max_tokens: 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders validated delta events incrementally before the terminal event", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
streamController = controller;
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
block("meta", meta) +
|
||||
block("retrieval", {
|
||||
seq: 2,
|
||||
status: "ok",
|
||||
rerank_status: "applied",
|
||||
degradation_reason: null,
|
||||
evidence: [evidence],
|
||||
timings,
|
||||
}) +
|
||||
block("delta", { seq: 3, text: "第一段证据 [S1]" }),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response(body, { headers: { "Content-Type": "text/event-stream" } }),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderChat();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
|
||||
|
||||
expect(await screen.findByTestId("chat-answer")).toHaveTextContent("第一段证据 [S1]");
|
||||
expect(screen.getAllByText("正在生成")).not.toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
streamController?.enqueue(
|
||||
encoder.encode(
|
||||
block("delta", { seq: 4, text: ",第二段。" }) +
|
||||
block("citations", { seq: 5, citations: [evidence] }) +
|
||||
block("usage", {
|
||||
seq: 6,
|
||||
model: "synthetic-grounded-extractive-v1",
|
||||
request_id: null,
|
||||
input_tokens: null,
|
||||
output_tokens: null,
|
||||
total_tokens: null,
|
||||
}) +
|
||||
block("done", {
|
||||
seq: 7,
|
||||
status: "complete",
|
||||
answer_mode: "grounded",
|
||||
finish_reason: "synthetic_extractive",
|
||||
}),
|
||||
),
|
||||
);
|
||||
streamController?.close();
|
||||
});
|
||||
|
||||
expect(await screen.findAllByText("回答完成")).toHaveLength(3);
|
||||
expect(screen.getByTestId("chat-answer")).toHaveTextContent("第一段证据 [S1],第二段。");
|
||||
});
|
||||
|
||||
it("shows refused when retrieval has no approved evidence", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(sseResponse(refusedStream()));
|
||||
const user = userEvent.setup();
|
||||
renderChat();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
|
||||
|
||||
expect(await screen.findAllByText("证据不足 · 已拒答")).toHaveLength(3);
|
||||
expect(screen.getByTestId("chat-answer")).toHaveTextContent("本次拒绝生成结论");
|
||||
expect(screen.getByText(/未找到足以支持回答的证据/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: /回答引用来源/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows retrieval_only when the server safely falls back from an uncited model answer", async () => {
|
||||
const stream = successStream().replace(
|
||||
'"answer_mode":"grounded"',
|
||||
'"answer_mode":"retrieval_only"',
|
||||
);
|
||||
vi.mocked(fetch).mockResolvedValue(sseResponse(stream));
|
||||
const user = userEvent.setup();
|
||||
renderChat();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
|
||||
|
||||
expect(await screen.findAllByText("仅检索证据模式")).not.toHaveLength(0);
|
||||
expect(screen.getByText(/模型答案未通过引用约束/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chat-answer")).toHaveTextContent("[S1]");
|
||||
expect(screen.getByRole("heading", { name: "回答引用来源" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps retrieval evidence when the provider emits a sanitized terminal error", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(sseResponse(providerErrorStream()));
|
||||
const user = userEvent.setup();
|
||||
renderChat();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
|
||||
|
||||
expect(await screen.findByText("生成模型未能完成回答")).toBeInTheDocument();
|
||||
expect(screen.getByText(/已保留本次检索证据/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "已保留的检索证据" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "<img src=x onerror=alert(1)>青岚铜矿报告.pdf" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "原样重试问题" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("aborts the active POST stream and reports a stopped state", async () => {
|
||||
let observedSignal: AbortSignal | undefined;
|
||||
vi.mocked(fetch).mockImplementation(
|
||||
(_input, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
observedSignal = init?.signal ?? undefined;
|
||||
observedSignal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("aborted", "AbortError"));
|
||||
});
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderChat();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
|
||||
expect(await screen.findByText(/正在生成 Query Vector/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "停止回答" }));
|
||||
|
||||
expect(observedSignal?.aborted).toBe(true);
|
||||
expect(await screen.findByText(/请求已由你主动取消/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "原样重试问题" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("makes rerank degradation explicit while still completing the grounded answer", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(sseResponse(successStream({ degraded: true })));
|
||||
const user = userEvent.setup();
|
||||
renderChat();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
|
||||
|
||||
expect(await screen.findByText("检索已降级")).toBeInTheDocument();
|
||||
expect(screen.getByText(/不会伪装为已完成重排/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText("回答完成")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"跳号",
|
||||
block("meta", meta) +
|
||||
block("retrieval", {
|
||||
seq: 4,
|
||||
status: "empty",
|
||||
rerank_status: "skipped_empty",
|
||||
degradation_reason: null,
|
||||
evidence: [],
|
||||
timings,
|
||||
}),
|
||||
],
|
||||
[
|
||||
"断流",
|
||||
block("meta", meta) +
|
||||
block("retrieval", {
|
||||
seq: 2,
|
||||
status: "ok",
|
||||
rerank_status: "applied",
|
||||
degradation_reason: null,
|
||||
evidence: [evidence],
|
||||
timings,
|
||||
}) +
|
||||
block("delta", { seq: 3, text: "不完整回答 [S1]" }),
|
||||
],
|
||||
])("fails closed for an invalid SSE %s", async (_caseName, body) => {
|
||||
vi.mocked(fetch).mockResolvedValue(sseResponse(body));
|
||||
const user = userEvent.setup();
|
||||
renderChat();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
|
||||
|
||||
expect(await screen.findByText("回答流未能安全完成")).toBeInTheDocument();
|
||||
expect(screen.getByText(/回答流不完整或格式无效/)).toBeInTheDocument();
|
||||
expect(screen.queryByText("不完整回答 [S1]")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(evidence.citation_id)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles pre-stream Problem JSON with a sanitized friendly message", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ detail: "password=do-not-leak sk-private provider-body", code: "PRIVATE" },
|
||||
403,
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderChat();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
|
||||
|
||||
expect(await screen.findByText("回答流未能安全完成")).toBeInTheDocument();
|
||||
expect(screen.getByText(/当前身份无权访问该知识库/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/do-not-leak/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/sk-private/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("validates whitespace-only questions before opening a stream", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderChat();
|
||||
|
||||
const question = screen.getByLabelText("地质问题");
|
||||
await user.clear(question);
|
||||
await user.type(question, " ");
|
||||
await user.click(screen.getByRole("button", { name: "开始证据问答" }));
|
||||
|
||||
expect(screen.getByText("请输入要提问的地质问题")).toBeInTheDocument();
|
||||
await waitFor(() => expect(vi.mocked(fetch)).not.toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ChatComposer } from "../features/chat/components/ChatComposer";
|
||||
import { ChatConversation } from "../features/chat/components/ChatConversation";
|
||||
import { useGroundedChat } from "../features/chat/hooks/useGroundedChat";
|
||||
import type { ChatCompletionRequest } from "../features/chat/types";
|
||||
|
||||
export function ChatPage() {
|
||||
const chat = useGroundedChat();
|
||||
|
||||
function start(request: ChatCompletionRequest) {
|
||||
void chat.start(request);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack chat-page">
|
||||
<header className="hero chat-hero">
|
||||
<div className="hero__copy">
|
||||
<div className="mode-badge">
|
||||
<span className="mode-badge__dot" />
|
||||
Grounded Chat · 单轮 SSE · 引用校验
|
||||
</div>
|
||||
<span className="eyebrow">EVIDENCE-GROUNDED ANSWERING</span>
|
||||
<h1>地质知识证据问答</h1>
|
||||
<p className="hero__lead">
|
||||
先检索后生成,只展示通过后端 Citation Label
|
||||
校验的回答。模型不可用或证据不足时,系统会明确拒答或保留仅检索证据模式。
|
||||
</p>
|
||||
</div>
|
||||
<div className="hero__seal" aria-hidden="true">
|
||||
<div className="hero__seal-ring">
|
||||
<span>RAG</span>
|
||||
<small>GROUNDED</small>
|
||||
</div>
|
||||
<div className="hero__coordinates">SSE · [S1] · TRACE</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="safety-notice" role="note">
|
||||
<strong>回答边界</strong>
|
||||
<span>
|
||||
检索资料中的命令均视为不可信文本;回答和来源仅按纯文本渲染,不执行
|
||||
HTML、脚本或文档内指令。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="chat-workspace">
|
||||
<ChatComposer isRunning={chat.isRunning} onStart={start} onStop={chat.stop} />
|
||||
<ChatConversation
|
||||
isRunning={chat.isRunning}
|
||||
onRetry={() => {
|
||||
if (chat.state.request) void chat.start(chat.state.request);
|
||||
}}
|
||||
state={chat.state}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createAppQueryClient } from "../app/queryClient";
|
||||
import type {
|
||||
CompleteUploadResponse,
|
||||
DocumentJob,
|
||||
DocumentListResponse,
|
||||
DocumentSummary,
|
||||
DocumentUpload,
|
||||
ReviewBundle,
|
||||
ReviewDecisionResponse,
|
||||
} from "../features/documents/types";
|
||||
import { DocumentsPage } from "./DocumentsPage";
|
||||
|
||||
const FILE_HASH = "a".repeat(64);
|
||||
const MANIFEST_HASH = "b".repeat(64);
|
||||
const DOCUMENT_ID = "70000000-0000-0000-0000-000000000001";
|
||||
const VERSION_ID = "71000000-0000-0000-0000-000000000001";
|
||||
const UPLOAD_ID = "72000000-0000-0000-0000-000000000001";
|
||||
const PARSE_JOB_ID = "73000000-0000-0000-0000-000000000001";
|
||||
const INDEX_JOB_ID = "74000000-0000-0000-0000-000000000001";
|
||||
const NOW = "2026-07-13T08:00:00Z";
|
||||
|
||||
vi.mock(import("../features/documents/file"), async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return { ...actual, sha256File: vi.fn(() => Promise.resolve(FILE_HASH)) };
|
||||
});
|
||||
|
||||
const document: DocumentSummary = {
|
||||
id: DOCUMENT_ID,
|
||||
filename: "<img src=x onerror=alert(1)>青岚合成资料.md",
|
||||
mime_type: "text/markdown",
|
||||
raw_sha256: FILE_HASH,
|
||||
status: "PROCESSING",
|
||||
active_version_id: null,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
};
|
||||
|
||||
const upload: DocumentUpload = {
|
||||
id: UPLOAD_ID,
|
||||
filename: "青岚合成资料.md",
|
||||
declared_mime_type: "text/markdown",
|
||||
expected_size: 12,
|
||||
expected_sha256: FILE_HASH,
|
||||
actual_size: null,
|
||||
actual_sha256: null,
|
||||
status: "CREATED",
|
||||
document_id: null,
|
||||
parse_job_id: null,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
completed_at: null,
|
||||
replayed: false,
|
||||
};
|
||||
|
||||
function job(id: string, jobType: string, status: DocumentJob["status"]): DocumentJob {
|
||||
return {
|
||||
id,
|
||||
job_type: jobType,
|
||||
stage:
|
||||
status === "SUCCEEDED" && jobType === "PARSE_DOCUMENT"
|
||||
? "LOCAL_PARSED_PENDING_CLOUD_REVIEW"
|
||||
: status === "SUCCEEDED"
|
||||
? "READY"
|
||||
: "PENDING",
|
||||
status,
|
||||
progress: status === "SUCCEEDED" ? 100 : 0,
|
||||
attempt: 1,
|
||||
max_attempts: 3,
|
||||
last_error_code: null,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
finished_at: status === "SUCCEEDED" ? NOW : null,
|
||||
};
|
||||
}
|
||||
|
||||
const reviewBundle: ReviewBundle = {
|
||||
document,
|
||||
version: {
|
||||
id: VERSION_ID,
|
||||
review_state: "LOCAL_PARSED_PENDING_CLOUD_REVIEW",
|
||||
review_revision: 4,
|
||||
status: "PROCESSING",
|
||||
parser_profile_hash: "c".repeat(64),
|
||||
chunk_profile_hash: "d".repeat(64),
|
||||
cloud_policy_id: "synthetic-public-only-v1",
|
||||
outbound_manifest_sha256: MANIFEST_HASH,
|
||||
expected_chunk_count: 1,
|
||||
error_code: null,
|
||||
created_at: NOW,
|
||||
completed_at: null,
|
||||
},
|
||||
pages: [
|
||||
{
|
||||
id: "75000000-0000-0000-0000-000000000001",
|
||||
ordinal: 0,
|
||||
page_number: 1,
|
||||
text: "<script>alert('page')</script> 本地解析证据",
|
||||
text_sha256: "e".repeat(64),
|
||||
line_start: 1,
|
||||
line_end: 3,
|
||||
},
|
||||
],
|
||||
blocks: [
|
||||
{
|
||||
id: "76000000-0000-0000-0000-000000000001",
|
||||
ordinal: 0,
|
||||
kind: "paragraph",
|
||||
text: "<iframe>block</iframe> 结构化矿化块",
|
||||
text_sha256: "8".repeat(64),
|
||||
section_path: ["矿化特征"],
|
||||
anchor_id: "line:1-3",
|
||||
char_start: 0,
|
||||
char_end: 18,
|
||||
line_start: 1,
|
||||
line_end: 3,
|
||||
page_start: 1,
|
||||
page_end: 1,
|
||||
},
|
||||
],
|
||||
chunks: [
|
||||
{
|
||||
ordinal: 0,
|
||||
display_text: "展示文本",
|
||||
cloud_text: "<img src=x onerror=alert(1)> 拟出域蚀变证据",
|
||||
cloud_text_sha256: "f".repeat(64),
|
||||
embedding_text_sha256: "f".repeat(64),
|
||||
token_count: 18,
|
||||
page_start: 1,
|
||||
page_end: 1,
|
||||
section_path: ["区域地质", "蚀变分带"],
|
||||
approval_status: "PENDING",
|
||||
index_status: "PENDING",
|
||||
},
|
||||
],
|
||||
next_ordinal: null,
|
||||
};
|
||||
|
||||
function response(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function pathOf(input: RequestInfo | URL): string {
|
||||
if (typeof input === "string") return input;
|
||||
return input instanceof URL ? input.href : input.url;
|
||||
}
|
||||
|
||||
function jsonBody(init: RequestInit | undefined): unknown {
|
||||
if (typeof init?.body !== "string") throw new Error("expected a JSON request body");
|
||||
return JSON.parse(init.body);
|
||||
}
|
||||
|
||||
function renderDocuments() {
|
||||
return render(
|
||||
<QueryClientProvider client={createAppQueryClient()}>
|
||||
<DocumentsPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function installReviewFetch(decision?: ReviewDecisionResponse | { status: number }) {
|
||||
vi.mocked(fetch).mockImplementation((input, init) => {
|
||||
const path = pathOf(input);
|
||||
if (path.startsWith("/api/v1/documents?")) {
|
||||
return Promise.resolve(response({ items: [document], next_cursor: null }));
|
||||
}
|
||||
if (path.includes("/review-bundle")) return Promise.resolve(response(reviewBundle));
|
||||
if (path.includes("/review-decisions")) {
|
||||
if (decision !== undefined && "status" in decision) {
|
||||
return Promise.resolve(response({ detail: "private provider error" }, decision.status));
|
||||
}
|
||||
return Promise.resolve(
|
||||
response(
|
||||
decision ?? {
|
||||
document_id: DOCUMENT_ID,
|
||||
document_version_id: VERSION_ID,
|
||||
decision: "APPROVE",
|
||||
review_state: "CLOUD_APPROVED",
|
||||
review_revision: 5,
|
||||
outbound_manifest_sha256: MANIFEST_HASH,
|
||||
embedding_profile_hash: "9".repeat(64),
|
||||
job: job(INDEX_JOB_ID, "EMBED_DOCUMENT", "SUCCEEDED"),
|
||||
},
|
||||
202,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (path.includes("/document-jobs/")) {
|
||||
return Promise.resolve(response(job(INDEX_JOB_ID, "EMBED_DOCUMENT", "SUCCEEDED")));
|
||||
}
|
||||
throw new Error(`unexpected request ${path} ${String(init?.method)}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("DocumentsPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
it("renders an accessible empty library and explicit governance boundary", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
response({ items: [], next_cursor: null } satisfies DocumentListResponse),
|
||||
);
|
||||
renderDocuments();
|
||||
|
||||
expect(screen.getByRole("heading", { name: "地质资料入库与复核" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("选择待上传文档")).toHaveAttribute(
|
||||
"accept",
|
||||
".txt,.md,.markdown,.pdf,.docx",
|
||||
);
|
||||
expect(await screen.findByText(/暂无文档/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/上传成功不等于可检索/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/PDF 当前可能进入 OCR_REQUIRED/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hashes, declares, streams, completes, and observes the parse job", async () => {
|
||||
const completed: CompleteUploadResponse = {
|
||||
upload: {
|
||||
...upload,
|
||||
status: "COMPLETED",
|
||||
document_id: DOCUMENT_ID,
|
||||
parse_job_id: PARSE_JOB_ID,
|
||||
completed_at: NOW,
|
||||
},
|
||||
document,
|
||||
job: job(PARSE_JOB_ID, "PARSE_DOCUMENT", "QUEUED"),
|
||||
};
|
||||
vi.mocked(fetch).mockImplementation((input, init) => {
|
||||
const path = pathOf(input);
|
||||
if (path.startsWith("/api/v1/documents?")) {
|
||||
return Promise.resolve(response({ items: [], next_cursor: null }));
|
||||
}
|
||||
if (path === "/api/v1/document-uploads") return Promise.resolve(response(upload, 201));
|
||||
if (path.endsWith(`/document-uploads/${UPLOAD_ID}/content`)) {
|
||||
return Promise.resolve(
|
||||
response({ ...upload, status: "STORED", actual_size: 12, actual_sha256: FILE_HASH }),
|
||||
);
|
||||
}
|
||||
if (path.endsWith(`/document-uploads/${UPLOAD_ID}/complete`)) {
|
||||
return Promise.resolve(response(completed, 202));
|
||||
}
|
||||
if (path.endsWith(`/document-jobs/${PARSE_JOB_ID}`)) {
|
||||
return Promise.resolve(response(job(PARSE_JOB_ID, "PARSE_DOCUMENT", "SUCCEEDED")));
|
||||
}
|
||||
if (path.includes("/review-bundle")) return Promise.resolve(response(reviewBundle));
|
||||
throw new Error(`unexpected request ${path} ${String(init?.method)}`);
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderDocuments();
|
||||
const file = new File(["hello geology"], "青岚合成资料.md", { type: "text/markdown" });
|
||||
|
||||
await user.upload(screen.getByLabelText("选择待上传文档"), file);
|
||||
await user.click(screen.getByRole("button", { name: "校验并上传" }));
|
||||
|
||||
expect(await screen.findByText(/本地解析完成,必须人工复核/)).toBeInTheDocument();
|
||||
const calls = vi.mocked(fetch).mock.calls;
|
||||
const declaration = calls.find(([input]) => pathOf(input) === "/api/v1/document-uploads");
|
||||
expect(declaration?.[1]?.headers).toMatchObject({
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": expect.stringMatching(/^[0-9a-f-]{36}$/),
|
||||
});
|
||||
expect(jsonBody(declaration?.[1])).toEqual({
|
||||
filename: "青岚合成资料.md",
|
||||
declared_mime_type: "text/markdown",
|
||||
expected_size: 13,
|
||||
expected_sha256: FILE_HASH,
|
||||
});
|
||||
const content = calls.find(([input]) => pathOf(input).endsWith(`/${UPLOAD_ID}/content`));
|
||||
expect(content?.[1]?.headers).toMatchObject({ "Content-Type": "application/octet-stream" });
|
||||
expect(content?.[1]?.body).toBe(file);
|
||||
});
|
||||
|
||||
it("shows a deterministic parse rejection as an error instead of a successful review", async () => {
|
||||
const rejected = {
|
||||
...job(PARSE_JOB_ID, "PARSE_DOCUMENT", "SUCCEEDED"),
|
||||
stage: "PARSE_REJECTED",
|
||||
last_error_code: "DOCX_PATH_TRAVERSAL",
|
||||
};
|
||||
vi.mocked(fetch).mockImplementation((input) => {
|
||||
const path = pathOf(input);
|
||||
if (path.startsWith("/api/v1/documents?")) {
|
||||
return Promise.resolve(response({ items: [], next_cursor: null }));
|
||||
}
|
||||
if (path === "/api/v1/document-uploads") return Promise.resolve(response(upload, 201));
|
||||
if (path.endsWith(`/document-uploads/${UPLOAD_ID}/content`)) {
|
||||
return Promise.resolve(response({ ...upload, status: "STORED" }));
|
||||
}
|
||||
if (path.endsWith(`/document-uploads/${UPLOAD_ID}/complete`)) {
|
||||
return Promise.resolve(
|
||||
response(
|
||||
{
|
||||
upload: { ...upload, status: "COMPLETED" },
|
||||
document,
|
||||
job: job(PARSE_JOB_ID, "PARSE_DOCUMENT", "QUEUED"),
|
||||
},
|
||||
202,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (path.endsWith(`/document-jobs/${PARSE_JOB_ID}`)) {
|
||||
return Promise.resolve(response(rejected));
|
||||
}
|
||||
throw new Error(`unexpected request ${path}`);
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderDocuments();
|
||||
|
||||
await user.upload(
|
||||
screen.getByLabelText("选择待上传文档"),
|
||||
new File(["hello geology"], "青岚合成资料.md", { type: "text/markdown" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "校验并上传" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/本地解析被安全策略拒绝:DOCX_PATH_TRAVERSAL/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/本地解析完成,必须人工复核/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("requires human confirmation and binds approval to revision and manifest", async () => {
|
||||
installReviewFetch();
|
||||
const user = userEvent.setup();
|
||||
renderDocuments();
|
||||
|
||||
expect(await screen.findByRole("heading", { name: document.filename })).toBeInTheDocument();
|
||||
expect(screen.getByText("<script>alert('page')</script> 本地解析证据")).toBeInTheDocument();
|
||||
expect(screen.getByText("<iframe>block</iframe> 结构化矿化块")).toBeInTheDocument();
|
||||
expect(screen.getByText("<img src=x onerror=alert(1)> 拟出域蚀变证据")).toBeInTheDocument();
|
||||
expect(window.document.querySelector("script")).toBeNull();
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
|
||||
const approve = screen.getByRole("button", { name: "批准并开始向量化" });
|
||||
expect(approve).toBeDisabled();
|
||||
await user.click(screen.getByRole("checkbox", { name: /我已逐项核对/ }));
|
||||
expect(approve).toBeEnabled();
|
||||
await user.click(approve);
|
||||
|
||||
expect(await screen.findByText("向量索引完成,文档已通过完整性校验")).toBeInTheDocument();
|
||||
const call = vi
|
||||
.mocked(fetch)
|
||||
.mock.calls.find(([input]) => pathOf(input).includes("/review-decisions"));
|
||||
expect(jsonBody(call?.[1])).toEqual({
|
||||
decision: "APPROVE",
|
||||
reason_code: "SYNTHETIC_REVIEW_APPROVED",
|
||||
expected_revision: 4,
|
||||
outbound_manifest_sha256: MANIFEST_HASH,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects without exporting a manifest and preserves the selected reason", async () => {
|
||||
installReviewFetch({
|
||||
document_id: DOCUMENT_ID,
|
||||
document_version_id: VERSION_ID,
|
||||
decision: "REJECT",
|
||||
review_state: "REJECTED",
|
||||
review_revision: 5,
|
||||
outbound_manifest_sha256: null,
|
||||
embedding_profile_hash: null,
|
||||
job: null,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderDocuments();
|
||||
|
||||
await screen.findByRole("heading", { name: document.filename });
|
||||
await user.selectOptions(screen.getByLabelText("拒绝原因"), "CLOUD_PROCESSING_REJECTED");
|
||||
await user.click(screen.getByRole("button", { name: "拒绝出域" }));
|
||||
|
||||
expect(await screen.findByText(/文档已拒绝出域/)).toBeInTheDocument();
|
||||
const call = vi
|
||||
.mocked(fetch)
|
||||
.mock.calls.find(([input]) => pathOf(input).includes("/review-decisions"));
|
||||
expect(jsonBody(call?.[1])).toEqual({
|
||||
decision: "REJECT",
|
||||
reason_code: "CLOUD_PROCESSING_REJECTED",
|
||||
expected_revision: 4,
|
||||
outbound_manifest_sha256: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces stale-review conflicts without exposing the provider body", async () => {
|
||||
installReviewFetch({ status: 412 });
|
||||
const user = userEvent.setup();
|
||||
renderDocuments();
|
||||
|
||||
await screen.findByRole("heading", { name: document.filename });
|
||||
await user.click(screen.getByRole("checkbox", { name: /我已逐项核对/ }));
|
||||
await user.click(screen.getByRole("button", { name: "批准并开始向量化" }));
|
||||
|
||||
expect(await screen.findByText("审核内容已有新版本,请重新加载并复核")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/private provider error/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blocks approval when the expected chunk count is incomplete", async () => {
|
||||
vi.mocked(fetch).mockImplementation((input) => {
|
||||
const path = pathOf(input);
|
||||
if (path.startsWith("/api/v1/documents?")) {
|
||||
return Promise.resolve(response({ items: [document], next_cursor: null }));
|
||||
}
|
||||
if (path.includes("/review-bundle")) {
|
||||
return Promise.resolve(
|
||||
response({
|
||||
...reviewBundle,
|
||||
version: { ...reviewBundle.version!, expected_chunk_count: 2 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected request ${path}`);
|
||||
});
|
||||
renderDocuments();
|
||||
|
||||
expect(await screen.findByText(/期望 2 个 Chunk/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("checkbox", { name: /我已逐项核对/ })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "批准并开始向量化" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("rejects unsupported file extensions before any upload request", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(response({ items: [], next_cursor: null }));
|
||||
const user = userEvent.setup({ applyAccept: false });
|
||||
renderDocuments();
|
||||
|
||||
await user.upload(
|
||||
screen.getByLabelText("选择待上传文档"),
|
||||
new File(["secret"], "archive.exe", { type: "application/octet-stream" }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("仅支持 TXT、Markdown、DOCX 和 PDF 文件");
|
||||
expect(screen.getByRole("button", { name: "校验并上传" })).toBeDisabled();
|
||||
await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("aborts an in-flight upload request and explains the server-side boundary", async () => {
|
||||
vi.mocked(fetch).mockImplementation((input, init) => {
|
||||
const path = pathOf(input);
|
||||
if (path.startsWith("/api/v1/documents?")) {
|
||||
return Promise.resolve(response({ items: [], next_cursor: null }));
|
||||
}
|
||||
if (path === "/api/v1/document-uploads") {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("cancelled", "AbortError"));
|
||||
});
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected request ${path}`);
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderDocuments();
|
||||
|
||||
await user.upload(
|
||||
screen.getByLabelText("选择待上传文档"),
|
||||
new File(["safe"], "safe.md", { type: "text/markdown" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "校验并上传" }));
|
||||
await screen.findByText("正在创建幂等上传声明");
|
||||
await user.click(screen.getByRole("button", { name: "停止本页流程" }));
|
||||
|
||||
expect(await screen.findByText("已停止当前页面的上传流程")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { DocumentLibrary } from "../features/documents/components/DocumentLibrary";
|
||||
import { DocumentReviewPanel } from "../features/documents/components/DocumentReviewPanel";
|
||||
import { DocumentUploadPanel } from "../features/documents/components/DocumentUploadPanel";
|
||||
import { useDocumentsWorkspace } from "../features/documents/hooks/useDocumentsWorkspace";
|
||||
|
||||
export function DocumentsPage() {
|
||||
const workspace = useDocumentsWorkspace();
|
||||
|
||||
return (
|
||||
<div className="page-stack documents-page">
|
||||
<header className="hero documents-hero">
|
||||
<div className="hero__copy">
|
||||
<div className="mode-badge">
|
||||
<span className="mode-badge__dot" />
|
||||
Local-first · Human Review · Manifest Bound
|
||||
</div>
|
||||
<span className="eyebrow">GOVERNED KNOWLEDGE INGESTION</span>
|
||||
<h1>地质资料入库与复核</h1>
|
||||
<p className="hero__lead">
|
||||
从文件摘要、隔离上传、本地解析,到人工确认出域清单和异步向量索引,每个阶段都保留明确状态与不可绕过的审核边界。
|
||||
</p>
|
||||
</div>
|
||||
<div className="hero__seal" aria-hidden="true">
|
||||
<div className="hero__seal-ring">
|
||||
<span>DOC</span>
|
||||
<small>REVIEW</small>
|
||||
</div>
|
||||
<div className="hero__coordinates">SHA-256 · LEASE · ACL</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="safety-notice" role="note">
|
||||
<strong>治理边界</strong>
|
||||
<span>
|
||||
上传成功不等于可检索。只有本地解析、人工绑定 manifest
|
||||
审批、向量完整性校验和原子激活全部完成后,文档才进入正式检索范围。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="documents-top-grid">
|
||||
<DocumentUploadPanel
|
||||
job={workspace.jobQuery.data}
|
||||
onCancel={workspace.cancel}
|
||||
onUpload={(file) => void workspace.startUpload(file)}
|
||||
workflow={workspace.workflow}
|
||||
/>
|
||||
<DocumentLibrary
|
||||
documents={workspace.documentsQuery.data?.items}
|
||||
error={workspace.documentsQuery.error}
|
||||
isLoading={workspace.documentsQuery.isLoading}
|
||||
isRefreshing={workspace.documentsQuery.isFetching}
|
||||
onRefresh={workspace.refresh}
|
||||
onSelect={workspace.selectDocument}
|
||||
selectedId={workspace.selectedDocumentId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DocumentReviewPanel
|
||||
bundle={workspace.reviewQuery.data}
|
||||
decisionError={workspace.decisionMutation.error}
|
||||
error={workspace.reviewQuery.error}
|
||||
isDecisionPending={workspace.decisionMutation.isPending}
|
||||
isLoading={workspace.reviewQuery.isLoading && workspace.selectedDocumentId !== null}
|
||||
key={`${workspace.reviewQuery.data?.version?.id ?? "none"}:${workspace.reviewQuery.data?.version?.review_revision ?? 0}`}
|
||||
onApprove={workspace.approve}
|
||||
onReject={workspace.reject}
|
||||
onRetry={() => void workspace.reviewQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createAppQueryClient } from "../app/queryClient";
|
||||
import {
|
||||
DEFAULT_RETRIEVAL_QUERY,
|
||||
SYNTHETIC_KNOWLEDGE_BASE_ID,
|
||||
type RetrievalHit,
|
||||
type RetrievalSearchResponse,
|
||||
} from "../features/retrieval/types";
|
||||
import { RetrievalPage } from "./RetrievalPage";
|
||||
|
||||
const successfulHit: RetrievalHit = {
|
||||
rank: 1,
|
||||
vector_rank: 3,
|
||||
citation_id: "60000000-0000-0000-0000-000000000001",
|
||||
document_id: "70000000-0000-0000-0000-000000000001",
|
||||
source_name: "青岚示范区斑岩铜矿报告.pdf",
|
||||
snippet: "<img src=x onerror=alert(1)> 已批准的合成蚀变分带证据。",
|
||||
section_path: ["区域地质", "蚀变分带"],
|
||||
page_start: 3,
|
||||
page_end: 4,
|
||||
page_label: "第 3-4 页",
|
||||
vector_score: 0.812345,
|
||||
rerank_score: 0.934567,
|
||||
};
|
||||
|
||||
const successfulResponse: RetrievalSearchResponse = {
|
||||
status: "ok",
|
||||
trace_id: "50000000-0000-0000-0000-000000000001",
|
||||
knowledge_base_id: SYNTHETIC_KNOWLEDGE_BASE_ID,
|
||||
access_scope_count: 1,
|
||||
profile: {
|
||||
profile_hash: "a".repeat(64),
|
||||
model: "fake-feature-hash-v1",
|
||||
dimension: 1024,
|
||||
synthetic: true,
|
||||
},
|
||||
parameters: { vector_top_k: 50, rerank_top_n: 10 },
|
||||
rerank_status: "applied",
|
||||
degradation_reason: null,
|
||||
embedding_request_id: null,
|
||||
rerank_request_id: "rerank-safe-request",
|
||||
embedding_model: "fake-feature-hash-v1",
|
||||
rerank_model: "fake-lexical-rerank-v1",
|
||||
timings: {
|
||||
embedding_ms: 2.25,
|
||||
database_ms: 5.5,
|
||||
rerank_ms: 3.75,
|
||||
total_ms: 11.5,
|
||||
},
|
||||
results: [successfulHit],
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function renderRetrieval() {
|
||||
return render(
|
||||
<QueryClientProvider client={createAppQueryClient()}>
|
||||
<RetrievalPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function requestBody(init: RequestInit | undefined): unknown {
|
||||
const body = init?.body;
|
||||
if (typeof body !== "string") throw new Error("retrieval request body must be JSON text");
|
||||
return JSON.parse(body);
|
||||
}
|
||||
|
||||
describe("RetrievalPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
it("starts with the synthetic knowledge base, example question, and bounded defaults", () => {
|
||||
renderRetrieval();
|
||||
|
||||
expect(screen.getByLabelText("知识库 ID")).toHaveValue(SYNTHETIC_KNOWLEDGE_BASE_ID);
|
||||
expect(screen.getByLabelText("地质问题")).toHaveValue(DEFAULT_RETRIEVAL_QUERY);
|
||||
expect(screen.getByLabelText(/向量候选/)).toHaveValue(50);
|
||||
expect(screen.getByLabelText(/重排返回/)).toHaveValue(10);
|
||||
expect(screen.getByText(/页面不能提交 scope/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "运行正式检索" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("shows effective parameters, profile, ranks, timings, trace, and plain-text evidence", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(jsonResponse(successfulResponse));
|
||||
const user = userEvent.setup();
|
||||
renderRetrieval();
|
||||
|
||||
const vectorInput = screen.getByLabelText(/向量候选/);
|
||||
const rerankInput = screen.getByLabelText(/重排返回/);
|
||||
await user.clear(vectorInput);
|
||||
await user.type(vectorInput, "999");
|
||||
await user.clear(rerankInput);
|
||||
await user.type(rerankInput, "999");
|
||||
await user.click(screen.getByRole("button", { name: "运行正式检索" }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "青岚示范区斑岩铜矿报告.pdf" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("请求 999 → 生效 50")).toBeInTheDocument();
|
||||
expect(screen.getByText("请求 999 → 生效 10")).toBeInTheDocument();
|
||||
expect(screen.getByText("合成 / 本地")).toBeInTheDocument();
|
||||
expect(screen.getByText("fake-feature-hash-v1 · 1024D")).toBeInTheDocument();
|
||||
expect(screen.getByText("Vector #3")).toBeInTheDocument();
|
||||
expect(screen.getByText("0.8123")).toBeInTheDocument();
|
||||
expect(screen.getByText("0.9346")).toBeInTheDocument();
|
||||
expect(screen.getByText("区域地质 / 蚀变分带")).toBeInTheDocument();
|
||||
expect(screen.getByText("第 3-4 页")).toBeInTheDocument();
|
||||
expect(screen.getByText("60000000-0000-0000-0000-000000000001")).toBeInTheDocument();
|
||||
expect(screen.getByText("50000000-0000-0000-0000-000000000001")).toBeInTheDocument();
|
||||
expect(screen.getByText("11.5 ms")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("<img src=x onerror=alert(1)> 已批准的合成蚀变分带证据。"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(fetch).mock.calls[0];
|
||||
if (call === undefined) throw new Error("missing retrieval request");
|
||||
expect(requestBody(call[1])).toEqual({
|
||||
knowledge_base_id: SYNTHETIC_KNOWLEDGE_BASE_ID,
|
||||
query: DEFAULT_RETRIEVAL_QUERY,
|
||||
vector_top_k: 999,
|
||||
rerank_top_n: 999,
|
||||
});
|
||||
});
|
||||
|
||||
it("announces loading while the three-stage request is pending", async () => {
|
||||
let resolveResponse: ((response: Response) => void) | undefined;
|
||||
vi.mocked(fetch).mockImplementation(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveResponse = resolve;
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderRetrieval();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "运行正式检索" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "正在检索" })).toBeDisabled();
|
||||
expect(screen.getByText("Query Embedding")).toBeInTheDocument();
|
||||
expect(screen.getByText("Filtered pgvector")).toBeInTheDocument();
|
||||
expect(screen.getByText("Rerank")).toBeInTheDocument();
|
||||
|
||||
resolveResponse?.(jsonResponse(successfulResponse));
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "青岚示范区斑岩铜矿报告.pdf" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an explicit empty state with the executed run metadata", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
jsonResponse({
|
||||
...successfulResponse,
|
||||
status: "empty",
|
||||
rerank_status: "skipped_empty",
|
||||
rerank_request_id: null,
|
||||
rerank_model: null,
|
||||
results: [],
|
||||
} satisfies RetrievalSearchResponse),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderRetrieval();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "运行正式检索" }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "当前授权范围内没有命中证据" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("合成 / 本地")).toBeInTheDocument();
|
||||
expect(screen.getByText("50000000-0000-0000-0000-000000000001")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("makes rerank degradation visible without hiding vector evidence", async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
jsonResponse({
|
||||
...successfulResponse,
|
||||
rerank_status: "degraded",
|
||||
degradation_reason: "rerank_unavailable",
|
||||
rerank_request_id: null,
|
||||
rerank_model: null,
|
||||
timings: { ...successfulResponse.timings, rerank_ms: 0 },
|
||||
results: [{ ...successfulHit, vector_rank: 1, rerank_score: null }],
|
||||
} satisfies RetrievalSearchResponse),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderRetrieval();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "运行正式检索" }));
|
||||
|
||||
expect(await screen.findByText("重排服务不可用,已保留向量召回结果")).toBeInTheDocument();
|
||||
expect(screen.getByText(/不能视为已完成重排/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "青岚示范区斑岩铜矿报告.pdf" })).toBeInTheDocument();
|
||||
expect(screen.getByText("向量降级")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[403, "当前身份无权检索该知识库", /服务端授权/],
|
||||
[503, "检索服务暂不可用", /数据库、模型网关或 Embedding 服务/],
|
||||
] as const)(
|
||||
"explains HTTP %s without leaking provider details",
|
||||
async (status, heading, detail) => {
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
jsonResponse(
|
||||
{
|
||||
detail: "password=do-not-leak sk-secret provider-private-body",
|
||||
trace_id: "private-trace",
|
||||
},
|
||||
status,
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderRetrieval();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "运行正式检索" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: heading })).toBeInTheDocument();
|
||||
expect(screen.getByText(detail)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "原样重试" })).toBeEnabled();
|
||||
expect(screen.queryByText(/do-not-leak/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/sk-secret/)).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps invalid whitespace input on the client and sends no request", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRetrieval();
|
||||
|
||||
const query = screen.getByLabelText("地质问题");
|
||||
await user.clear(query);
|
||||
await user.type(query, " ");
|
||||
await user.click(screen.getByRole("button", { name: "运行正式检索" }));
|
||||
|
||||
expect(screen.getByText("请输入要检索的地质问题")).toBeInTheDocument();
|
||||
await waitFor(() => expect(vi.mocked(fetch)).not.toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { RetrievalForm } from "../features/retrieval/components/RetrievalForm";
|
||||
import { RetrievalResults } from "../features/retrieval/components/RetrievalResults";
|
||||
import { useRetrievalSearch } from "../features/retrieval/hooks/useRetrievalSearch";
|
||||
import type { RetrievalSearchRequest } from "../features/retrieval/types";
|
||||
|
||||
export function RetrievalPage() {
|
||||
const searchMutation = useRetrievalSearch();
|
||||
|
||||
function handleSearch(request: RetrievalSearchRequest) {
|
||||
searchMutation.mutate(request);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack retrieval-page">
|
||||
<header className="hero retrieval-hero">
|
||||
<div className="hero__copy">
|
||||
<div className="mode-badge">
|
||||
<span className="mode-badge__dot" />
|
||||
正式 API · Active Profile · SQL 权限过滤
|
||||
</div>
|
||||
<span className="eyebrow">PROFILE-AWARE RETRIEVAL LAB</span>
|
||||
<h1>地质证据检索实验室</h1>
|
||||
<p className="hero__lead">
|
||||
对比向量初召回与 qwen3-rerank 重排结果,并保留生效参数、模型
|
||||
Profile、请求标识、耗时和稳定引用锚点。
|
||||
</p>
|
||||
</div>
|
||||
<div className="hero__seal" aria-hidden="true">
|
||||
<div className="hero__seal-ring">
|
||||
<span>ANN</span>
|
||||
<small>RERANK</small>
|
||||
</div>
|
||||
<div className="hero__coordinates">ACL · 1024D · TRACE</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="safety-notice" role="note">
|
||||
<strong>证据边界</strong>
|
||||
<span>
|
||||
页面只展示当前服务端身份获准访问的 CLOUD_APPROVED、READY、Active Version
|
||||
切片;排序分数不构成地质结论。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="retrieval-workspace">
|
||||
<RetrievalForm isSearching={searchMutation.isPending} onSearch={handleSearch} />
|
||||
<RetrievalResults
|
||||
data={searchMutation.data}
|
||||
error={searchMutation.error}
|
||||
isIdle={searchMutation.isIdle}
|
||||
isPending={searchMutation.isPending}
|
||||
onRetry={() => {
|
||||
if (searchMutation.variables) searchMutation.mutate(searchMutation.variables);
|
||||
}}
|
||||
request={searchMutation.variables}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2157
-2
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user