docs: record phase14 production legal refs closure
This commit is contained in:
@@ -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 中文实施方案:
|
||||
|
||||
Reference in New Issue
Block a user