feat(v2): 纠偏第四批——会议结果录入与改进发布接真实知识发布入口

- api/prescreen.py:transition_improvement 注入 _knowledge_publisher,
  release 委托 knowledge_cards.publish → component_registry.release;
  按 proposed_revision_ref 以「组件id@版本」精确解析草稿(不取"最新草稿",
  否则会出现"审核的是 A、发布的是 B");客户端自报 released_ref 一律 422;
  非 knowledge 目标 409 诚实拒绝;发布与候选状态同一事务,失败保留旧版。
- TrajectoryPanel:新增会议结果录入(追加式更正走 supersedes_id;
  未匹配单列且不计入分母)。
- ImprovementPanel(新增):偏差→候选→两道评测门→独立审核→发布;
  请求体不含版本号,无自动上线,不自批。
- 界面并入「轨迹复盘」,不新增第四个导航入口(AC-53)。
- 守卫:新增 scripts/test_improvement_panel.cjs(verify.py 13→14 项);
  test_module_shell_ui.cjs 的「只读」断言改为分别锁死「回放只读」与「结果只可追加」。
- 测试:新增 backend/test_improvement_release.py(AC-57 端到端 + 三条反例);
  test_improvements.py 补「自报版本号被拒」。
- 文档:17 第四批交付记录、15 §7 交接、03 §9.4 发布事务边界。

验证:scripts/verify.py 14/14 PASS(backend-unit 572 项),证据
test-evidence/20261001-013517-3e15bda/;scripts/xingbang_batch4_smoke.py
对真实服务器实跑通过(发布指针实测移到新版本)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-01 02:21:11 +08:00
co-authored by Claude Code
parent 3e15bda8cd
commit f775c6cbe4
13 changed files with 1269 additions and 91 deletions
+14 -6
View File
@@ -2,6 +2,7 @@
import {useEffect, useState} from "react";
import AgentNav from "@/components/xingbang/AgentNav";
import TrajectoryPanel from "@/components/xingbang/TrajectoryPanel";
import ImprovementPanel from "@/components/xingbang/ImprovementPanel";
import {messageOf} from "@/lib/prescreen";
import "../data-access/workspace.css";
@@ -9,6 +10,9 @@ import "../data-access/workspace.css";
*
* 原 `/trajectory` 是合并后的重定向桩,本页是**新路由**,不复用也不复活那个旧页
* (AC-55 的"防重复页面")。只负责项目上下文与请求助手,回放逻辑在 TrajectoryPanel。
*
* 纠偏第四批把「会议记录」与「反向改进」并入本模块(14 §7 本就把三者写在一节),
* 因此**不新增第四个导航入口**(AC-53 要求恰好三个)。
*/
export default function TrajectoryCenterPage() {
const [identity, setIdentity] = useState<any>(null);
@@ -26,9 +30,11 @@ export default function TrajectoryCenterPage() {
...(role ? {"X-Xingbang-Demo-Role": role} : {})};
}
async function request(path: string, body?: any) {
// 必须支持 PATCH:改进候选的"自然语言修改适用范围"走的是既有候选端点的 PATCH。
// 形参与 /experience-center 一致,两个模块不各写一份请求助手。
async function send(method: string, path: string, body?: any) {
const response = await fetch(`/api/risk-agent/project-assistant${path}`,
{method: body === undefined ? "GET" : "POST", headers: headers(),
{method, headers: headers(),
body: body === undefined ? undefined : JSON.stringify(body)});
const data = await response.json();
if (!response.ok) throw Error(messageOf(data));
@@ -40,7 +46,7 @@ export default function TrajectoryCenterPage() {
try {
const [context, list] = await Promise.all([
fetch("/api/risk-agent/access/context", {headers: headers(false)}).then((r) => r.ok ? r.json() : null).catch(() => null),
request("/projects"),
send("GET", "/projects"),
]);
setIdentity(context);
const items = list.items || list || [];
@@ -48,7 +54,7 @@ export default function TrajectoryCenterPage() {
const wanted = new URLSearchParams(location.search).get("project") || items[0]?.id || "";
if (wanted) {
setProjectId(wanted);
setProject(await request(`/projects/${encodeURIComponent(wanted)}`));
setProject(await send("GET", `/projects/${encodeURIComponent(wanted)}`));
}
} catch (e: any) {setError(e.message);} finally {setLoaded(true);}
})();
@@ -57,7 +63,7 @@ export default function TrajectoryCenterPage() {
async function open(id: string) {
setProjectId(id); setError("");
try {
setProject(await request(`/projects/${encodeURIComponent(id)}`));
setProject(await send("GET", `/projects/${encodeURIComponent(id)}`));
history.replaceState({}, "", `?project=${encodeURIComponent(id)}`);
} catch (e: any) {setError(e.message);}
}
@@ -86,7 +92,9 @@ export default function TrajectoryCenterPage() {
{projects.map((item) => <option key={item.id} value={item.id}>{item.id} · {item.customer || ""}</option>)}
</select>
</label>
{project && <TrajectoryPanel key={project.id} project={project} request={request}/>}
{project && <TrajectoryPanel key={project.id} project={project} send={send}/>}
{project && <ImprovementPanel key={`imp-${project.id}`} project={project}
identity={identity} send={send}/>}
</>}
</main>
</div>;