docs: record phase14 production legal refs closure

This commit is contained in:
2026-05-16 21:33:18 +08:00
parent f4272a0f02
commit 8fb7d6feb3
2 changed files with 396 additions and 0 deletions
@@ -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;
}
@@ -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 中文实施方案: