diff --git a/frontend/scripts/phase14-chat-report-legal-refs-smoke.mjs b/frontend/scripts/phase14-chat-report-legal-refs-smoke.mjs new file mode 100644 index 00000000..5c604f5f --- /dev/null +++ b/frontend/scripts/phase14-chat-report-legal-refs-smoke.mjs @@ -0,0 +1,336 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, "..", ".."); +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + +const apiBase = process.env.PHASE14_API_URL || "http://192.168.3.90:8001/api/v1"; +const webBase = process.env.PHASE14_WEB_URL || "http://192.168.3.90:3003"; +const adminAccount = process.env.AI_CONTRACT_ADMIN_ACCOUNT || "admin@example.com"; +const adminPassword = process.env.AI_CONTRACT_ADMIN_PASSWORD; +const artifactDir = + process.env.PHASE14_ARTIFACT_DIR || + path.resolve(repoRoot, "project-docs", "qa-artifacts", "phase14-chat-report-legal-refs-smoke", stamp); + +fs.mkdirSync(artifactDir, { recursive: true }); + +const checks = []; +const details = { + api_base: apiBase, + web_base: webBase, + artifact_dir: artifactDir, + review_id: null, + legal_article_ref_count: 0, + chat_legal_source_count: 0, +}; + +function apiUrl(route) { + return `${apiBase.replace(/\/$/, "")}${route}`; +} + +function addCheck(key, label, status, detail, meta = {}) { + checks.push({ key, label, status, required: true, detail, meta }); +} + +function assert(condition, message, meta = {}) { + if (!condition) { + const error = new Error(message); + error.meta = meta; + throw error; + } +} + +function unwrap(payload, label) { + assert(payload && typeof payload === "object", `${label} response is not an object`, { payload }); + if ("code" in payload) { + assert(payload.code === 0, `${label} API code ${payload.code}`, { payload }); + return payload.data; + } + return payload; +} + +async function readJsonResponse(response, label) { + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + assert(response.status >= 200 && response.status < 300, `${label} HTTP ${response.status}`, { body }); + return unwrap(body, label); +} + +async function apiJson(method, route, { token, body } = {}) { + const response = await fetch(apiUrl(route), { + method, + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(body ? { "Content-Type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + return readJsonResponse(response, `${method} ${route}`); +} + +async function createReview(token) { + const form = new FormData(); + form.set( + "text", + [ + `phase14-chat-report-${Date.now()} production legal refs smoke.`, + "\u5408\u540c\u7ea6\u5b9a\u81ea\u52a8\u7eed\u7ea6\uff0c\u5e76\u5305\u542b\u514d\u8d23\u3001\u4ed8\u6b3e\u6761\u4ef6\u53e6\u884c\u534f\u5546\u548c50%\u8fdd\u7ea6\u91d1\u3002", + "\u8bf7\u91cd\u70b9\u5173\u6ce8\u98ce\u9669\u4f9d\u636e\u3001\u6cd5\u6761\u5f15\u7528\u548c\u62a5\u544a\u4e0b\u8f7d\u5143\u6570\u636e\u3002", + ].join("\n"), + ); + form.set("review_as", "party_a"); + form.set("contract_type", "procurement"); + + const response = await fetch(apiUrl("/reviews"), { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: form, + }); + return readJsonResponse(response, "POST /reviews"); +} + +async function downloadReport(token, reviewId) { + const response = await fetch(apiUrl(`/reviews/${reviewId}/report/download`), { + headers: { Authorization: `Bearer ${token}` }, + }); + const bytes = new Uint8Array(await response.arrayBuffer()); + assert(response.status >= 200 && response.status < 300, `report download HTTP ${response.status}`, { + byte_length: bytes.length, + }); + return { + byte_length: bytes.length, + content_type: response.headers.get("content-type") || "", + }; +} + +function parseSseEvents(text) { + const events = []; + for (const line of text.split(/\r?\n/)) { + if (!line.startsWith("data:")) { + continue; + } + const raw = line.slice("data:".length).trim(); + if (!raw) { + continue; + } + try { + events.push(JSON.parse(raw)); + } catch { + events.push({ type: "raw", raw }); + } + } + return events; +} + +async function streamChat(token, reviewId) { + const response = await fetch(apiUrl("/chat/stream"), { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + message: "\u8bf7\u7ed3\u5408\u5f53\u524d\u5ba1\u67e5\u7ed3\u679c\u8bf4\u660e\u81ea\u52a8\u7eed\u7ea6\u548c\u8fdd\u7ea6\u91d1\u98ce\u9669\uff0c\u5e76\u5217\u51fa\u6cd5\u5f8b\u4f9d\u636e\u3002", + review_context: { + reviewId, + metadata: { + reviewTitle: "Phase14 legal refs smoke", + focusLabel: "\u81ea\u52a8\u7eed\u7ea6\u4e0e\u8fdd\u7ea6\u91d1", + focusType: "risk_item", + }, + }, + }), + }); + + const text = await response.text(); + assert(response.status >= 200 && response.status < 300, `chat stream HTTP ${response.status}`, { text }); + const events = parseSseEvents(text); + const sources = events.flatMap((event) => (Array.isArray(event.sources) ? event.sources : [])); + return { events, sources, raw_length: text.length }; +} + +async function runStep(key, label, fn) { + try { + const meta = await fn(); + addCheck(key, label, "pass", `${label} passed`, meta); + return meta; + } catch (error) { + addCheck(key, label, "fail", error.message, error.meta || {}); + throw error; + } +} + +async function main() { + await runStep("gstack", "gstack preflight", async () => { + const gstackPath = path.join(os.homedir(), ".Codex", "skills", "gstack", "bin"); + assert(fs.existsSync(gstackPath), "GSTACK_MISSING", { gstack_path: gstackPath }); + return { detail: "GSTACK_OK" }; + }); + + await runStep("password_source", "admin password source", async () => { + assert(Boolean(adminPassword), "AI_CONTRACT_ADMIN_PASSWORD is required"); + return { account: adminAccount }; + }); + + let token = null; + await runStep("login", "admin login", async () => { + const login = await apiJson("POST", "/auth/login", { + body: { account: adminAccount, password: adminPassword }, + }); + assert(login?.access_token, "login response missing access token"); + token = login.access_token; + return { user: login.user?.username || adminAccount, role: login.user?.role || null, access_token_present: true }; + }); + + const review = await runStep("create_review", "create legal refs review", async () => { + const created = await createReview(token); + assert(created?.id, "created review missing id", { created }); + details.review_id = created.id; + return { review_id: created.id, status: created.status }; + }); + + const reviewId = review.review_id; + + await runStep("review_result_legal_refs", "review result legal article refs", async () => { + const result = await apiJson("GET", `/reviews/${reviewId}/result`, { token }); + const refs = (result.risk_items || []).flatMap((item) => item.legal_article_refs || []); + const withDetailLinks = refs.filter((ref) => ref.detail_path && String(ref.detail_path).includes("/legal-articles")); + details.legal_article_ref_count = refs.length; + assert(refs.length > 0, "review result has no legal article refs", { review_id: reviewId }); + assert(withDetailLinks.length > 0, "legal refs missing legal article detail links", { refs }); + return { + review_id: reviewId, + legal_article_ref_count: refs.length, + sample_refs: refs.slice(0, 3).map((ref) => ({ + id: ref.id, + source_id: ref.source_id, + article_no: ref.article_no, + detail_path: ref.detail_path, + })), + }; + }); + + await runStep("report_metadata_legal_refs", "report metadata legal article refs", async () => { + const report = await apiJson("GET", `/reviews/${reviewId}/report`, { token }); + const metadataRefs = report.report_metadata?.legal_article_refs || {}; + assert(report.legal_article_ref_count > 0, "report legal_article_ref_count is empty", { report }); + assert((metadataRefs.legal_article_ref_count || 0) > 0, "report metadata legal refs are empty", { metadataRefs }); + return { + review_id: reviewId, + legal_article_ref_count: report.legal_article_ref_count, + metadata_count: metadataRefs.legal_article_ref_count, + ids: (metadataRefs.legal_article_ref_ids || []).slice(0, 5), + }; + }); + + await runStep("report_download", "report download with legal refs metadata", async () => { + const download = await downloadReport(token, reviewId); + assert(download.byte_length > 1000, "downloaded report is unexpectedly small", download); + return download; + }); + + await runStep("chat_sources_legal_refs", "chat stream legal article sources", async () => { + const stream = await streamChat(token, reviewId); + const legalSources = stream.sources.filter( + (item) => + item?.source_type === "legal_article" || + String(item?.detail_path || "").includes("/legal-articles") || + String(item?.source_id || "").startsWith("LAW-"), + ); + details.chat_legal_source_count = legalSources.length; + assert(legalSources.length > 0, "chat stream did not return legal article sources", { + event_count: stream.events.length, + source_count: stream.sources.length, + }); + return { + event_count: stream.events.length, + source_count: stream.sources.length, + legal_source_count: legalSources.length, + sample_sources: legalSources.slice(0, 3).map((source) => ({ + title: source.title, + source_type: source.source_type, + source_id: source.source_id, + detail_path: source.detail_path, + article_no: source.article_no, + })), + raw_length: stream.raw_length, + }; + }); + + await runStep("frontend_chat_link_budget", "frontend chat URL budget", async () => { + const payload = { + reviewId, + prompt: "\u8bf7\u7ed3\u5408\u5f53\u524d\u5ba1\u67e5\u7ed3\u679c\u7ed9\u51fa\u4f18\u5148\u4fee\u6539\u6e05\u5355\u3002", + metadata: { + reviewTitle: "Phase14 legal refs smoke", + overallRisk: "high", + }, + }; + const href = `${webBase.replace(/\/$/, "")}/chat?review=${encodeURIComponent(JSON.stringify(payload))}`; + assert(href.length < 2000, "chat restore URL is too long", { href_length: href.length }); + return { href_length: href.length }; + }); +} + +try { + await main(); +} catch { + // Individual step already recorded the failure. +} + +const failed = checks.filter((check) => check.required && check.status === "fail"); +const summary = { + name: "phase14-chat-report-legal-refs-smoke", + status: failed.length ? "FAIL" : "PASS", + generated_at: new Date().toISOString(), + details, + checks, + totals: { + total: checks.length, + failed_required: failed.length, + }, +}; + +fs.writeFileSync(path.join(artifactDir, "phase14-chat-report-legal-refs-smoke-summary.json"), JSON.stringify(summary, null, 2)); +fs.writeFileSync( + path.join(artifactDir, "phase14-chat-report-legal-refs-smoke-summary.md"), + [ + "# Phase14 Chat / Report Legal Article Refs Smoke", + "", + `- Status: ${summary.status}`, + `- Generated at: ${summary.generated_at}`, + `- API: ${apiBase}`, + `- Web: ${webBase}`, + `- Review ID: ${details.review_id || "-"}`, + `- Legal article refs: ${details.legal_article_ref_count}`, + `- Chat legal sources: ${details.chat_legal_source_count}`, + "", + "| Check | Required | Status | Detail |", + "|---|---:|---|---|", + ...checks.map((check) => `| ${check.label} | ${check.required} | ${check.status.toUpperCase()} | ${check.detail} |`), + "", + "Artifacts:", + "", + "- `phase14-chat-report-legal-refs-smoke-summary.json`", + "- `phase14-chat-report-legal-refs-smoke-summary.md`", + "", + ].join("\n"), +); + +if (summary.status === "PASS") { + console.log(`PHASE14_CHAT_REPORT_LEGAL_REFS_SMOKE_PASS ${artifactDir}`); +} else { + console.error(`PHASE14_CHAT_REPORT_LEGAL_REFS_SMOKE_FAIL ${artifactDir}`); + process.exitCode = 1; +} diff --git a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md index 5cd83bb1..43181909 100644 --- a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md +++ b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md @@ -2017,6 +2017,66 @@ - 本轮无 required 阻塞项。 - 仍保留旧的非 required 项:外部告警 Webhook 未配置。 +## 2026-05-16 Phase14 生产巡检与 Chat/报告法条引用闭环 +### 本轮完成项 +- 已完成 Chat/报告法条引用闭环代码补齐并推送远程 `main`: + - `4fd9d995`:后端 Chat SSE 来源、报告元数据、报告下载审计、前端 Chat 来源卡片接入 `legal_article_refs`。 + - `f4272a0f`:修复审查详情跳转 Chat 时 URL 过长问题,URL 只保留轻量审查上下文,法条引用由后端按 `reviewId` 从审查结果重新补齐。 +- 已修复生产机 Docker 运行时阻塞: + - 现象:`/usr/bin/runc` 触发 `ld.so` 断言,导致容器无法启动、前端镜像构建中断。 + - 根因:`containerd.io` 包内 `/usr/bin/runc` 校验不一致。 + - 处理:在 `192.168.3.90` 仅启用 Docker CE 仓库执行 `dnf reinstall -y containerd.io`,`runc --version` 恢复为 `1.1.12`,随后重启 `containerd/docker` 并恢复 compose 服务。 +- 已重新部署生产前端: + - `project-docs/qa-artifacts/phase7-frontend-docker-deploy/2026-05-16T21-14-09/frontend-docker-deploy-summary.md` + - 状态:`PHASE7_FRONTEND_DOCKER_DEPLOY_PASS` + - 内置 post-deploy release gate:PASS +- 已补充可复跑在线冒烟脚本: + - `frontend/scripts/phase14-chat-report-legal-refs-smoke.mjs` + - 覆盖:真实创建审查任务、审查结果法条引用、报告元数据、报告下载、Chat SSE 法条来源、Chat URL 长度预算。 + +### 本轮验证 +- 本地代码验证: + - `npm --prefix frontend run lint`:通过 + - `npm --prefix frontend run build`:通过 + - `git diff --check`:通过,仅有 Git CRLF 工作区提示 +- 生产发布门禁: + - `project-docs/qa-artifacts/phase7-frontend-docker-deploy/2026-05-16T21-14-09/release-gate/2026-05-16T21-18-15/release-gate-summary.md` + - 结果:PASS + - 覆盖:dependency governance、production health、security baseline、production regression。 +- 生产部署后巡检: + - `project-docs/qa-artifacts/phase5-production-health/2026-05-16T21-23-40/production-health-summary.md` + - 结果:`PHASE5_PRODUCTION_HEALTH_PASS` + - `project-docs/qa-artifacts/phase7-production-ops-check/2026-05-16T21-23-40-545/production-ops-check-summary.md` + - 结果:`PHASE7_PRODUCTION_OPS_CHECK_PASS` +- Chat/报告法条引用在线冒烟: + - `project-docs/qa-artifacts/phase14-chat-report-legal-refs-smoke/2026-05-16T13-27-47-446Z/phase14-chat-report-legal-refs-smoke-summary.md` + - 结果:`PHASE14_CHAT_REPORT_LEGAL_REFS_SMOKE_PASS` + - 验证样本:`review-af75d6436d33` + - 审查结果法条引用:8 条 + - 报告元数据法条引用:6 条 + - Chat 法条来源:6 条 +- 远端容器状态: + - `ai-contract-platform-postgres-1`:healthy + - `ai-contract-platform-backend-1`:running + - `ai-contract-platform-frontend-1`:running + - `ai-contract-platform-redis-1`:running + +### 当前状态 +- “风险项 -> 法条全文 -> 报告元数据/下载 -> Chat 法条来源 -> 前端查看法条全文”已经形成线上闭环。 +- 生产巡检和运维检查均通过,当前 required 上线门禁无失败项。 +- 本轮曾出现的 Docker `runc` 阻塞已排除,并有明确处理记录。 + +### 未完成项 +- 法条全文库仍是 Phase14 首批合同高频条文,不是全国全部法律法规全文。 +- 后续增强项: + - 扩展更多法律法规、司法解释和行业监管规则全文。 + - 将规则配置中的风险触发条件更精确地绑定具体法条。 + - 把 Phase14 在线冒烟脚本纳入更高层的一键生产巡检编排。 + +### 阻塞项 +- 本轮无 required 阻塞项。 +- 仍保留旧的非 required 项:外部告警 Webhook 未配置。 + ## 2026-05-16 Phase14 法条全文级知识库第一刀闭环 ### 本轮完成项 - 已新增 Phase14 中文实施方案: