feat: 增加人机共管场景决策树 (#8)

Co-authored-by: yige <yige@yigedeMacBook-Neo.local>
This commit is contained in:
2026-08-30 14:01:19 -04:00
committed by GitHub
parent af338dc13b
commit 3261135305
10 changed files with 1044 additions and 26 deletions

View File

@@ -2,7 +2,7 @@
"name": "DesireCore",
"$schema": "http://desirecore/schemas/agent-seed.json",
"id": "7da73b7f-bb08-4e7b-a3cf-5d4af6e22c7f",
"version": "1.11.0",
"version": "1.12.0",
"requiredClientVersion": "10.0.100",
"description": "系统中枢调度器负责任务分发、Agent 编排与全局状态监控",
"author": "DesireCore Team",

View File

@@ -17,6 +17,18 @@ The screenshot viewer supports:
Each newly opened image starts in fit mode. Closing and reopening the viewer does not retain stale zoom or pan state.
## Human + Agent scenario decision trees
Every scenario derives a structured decision tree from its Scenario Pack, accepted Run, and independent validation receipts. The tree shows:
- the business facts, hard rules, and objective priorities confirmed by a human;
- Agent clarification, modeling, and compatible-engine solve or diagnosis steps;
- independent recomputation of variable domains, constraints, objectives, and evidence;
- fail-closed branches for missing facts, incomplete rule mapping, or failed verification;
- the observed delivery, conflict diagnosis, rejection, or recovery outcome.
Tree data follows [`decision-tree.schema.json`](./decision-tree.schema.json) and binds the Scenario Pack, messages, session, governed tool receipts, result payload, OptimizationSpec, and validation-report hashes. The observed path comes only from settled summaries and receipts in the accepted Run; oracle data is used only to detect expectation drift. Optimization, validation-only, and recovery scenarios use different topologies, so the report never invents modeling or solve steps that did not occur. The page can switch between all branches and the observed path, and can download an SVG snapshot of the current scenario. UI code remains a generic renderer and contains no scenario-specific business tree.
## Build
The build consumes an evidence directory with `scenario-packs/`, `evidence/`, and `report-web/dist/` output. Rendering dependencies are resolved from a DesireCore application checkout.

View File

@@ -17,6 +17,18 @@
每次打开新图片都从“适应窗口”开始;关闭后重新打开不会保留旧的缩放或平移状态。
## 人 + Agent 场景决策树
每个场景都会从 Scenario Pack、真实 Run 和独立验证回执派生一棵结构化决策树,展示:
- 人类确认业务事实、硬规则和目标优先级;
- Agent 补问、建模以及调用兼容引擎求解或诊断;
- 独立验证者重新检查变量域、约束、目标和证据;
- 信息不足、规则未映射或校核失败时的 fail-closed 阻断分支;
- 实际运行最终到达的交付、冲突诊断、拒绝或恢复结果。
树数据遵循 [`decision-tree.schema.json`](./decision-tree.schema.json),并绑定 Scenario Pack、消息、session、工具回执、结果 payload、OptimizationSpec 和验证报告哈希。实际路径只由已接受 Run 中的 settled summary 与受治理回执决定oracle 仅用于发现预期不一致;求解、仅验证和恢复场景使用不同拓扑,不为未发生的建模或求解步骤造假。页面可切换“完整分支/实际路径”,也可下载当前场景的 SVG 快照;视图代码只负责通用渲染,不包含具体场景业务树。
## 构建
构建输入目录需要包含 `scenario-packs/``evidence/`,产物默认写到 `report-web/dist/`。Markdown、公式和图片处理依赖从 DesireCore 应用 checkout 解析。

View File

@@ -0,0 +1,97 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://desirecore.com/schemas/solver-report-decision-tree.schema.json",
"title": "Solver report human-Agent decision tree",
"description": "A scenario-specific, evidence-bound decision tree that explains which facts humans confirm, which reasoning Agents perform, which gates can block delivery, and which path was actually taken.",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "rootNodeId", "nodes", "edges", "observed", "evidenceBindings"],
"properties": {
"schemaVersion": {
"const": "solver.human-agent-decision-tree/v1",
"description": "Version of the stable decision-tree interchange contract."
},
"rootNodeId": {
"type": "string",
"minLength": 1,
"description": "ID of the first node on the scenario decision path."
},
"nodes": {
"type": "array",
"minItems": 1,
"description": "Decision, gate, action, and outcome nodes. Business wording is derived from scenario evidence rather than UI code.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "kind", "owner", "title", "detail", "status", "evidenceRef"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", "description": "Stable node identifier within one tree." },
"kind": {
"enum": ["context", "question", "action", "check", "outcome"],
"description": "Visual and semantic role of the node."
},
"owner": {
"enum": ["human", "agent", "shared", "validator", "platform"],
"description": "Party responsible for confirming or performing this step."
},
"title": { "type": "string", "minLength": 1, "description": "Plain-language step or question shown to non-algorithm users." },
"detail": { "type": "string", "minLength": 1, "description": "Scenario-specific explanation of the step, rule, or outcome." },
"status": {
"enum": ["pass", "guard", "blocked", "neutral"],
"description": "Observed state in this evidence run; guard and blocked nodes explain non-delivery paths."
},
"evidenceRef": {
"type": "string",
"pattern": "^#[a-z0-9-]+$",
"description": "In-page anchor where a reviewer can inspect the supporting evidence."
}
}
}
},
"edges": {
"type": "array",
"minItems": 1,
"description": "Directed branches between nodes. Exactly one selected path records what happened in the accepted run.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["from", "to", "label", "tone", "selected"],
"properties": {
"from": { "type": "string", "description": "Source node ID." },
"to": { "type": "string", "description": "Destination node ID." },
"label": { "type": "string", "minLength": 1, "description": "Human-readable branch condition or observed answer." },
"tone": { "enum": ["continue", "guard", "success"], "description": "Branch appearance and governance meaning." },
"selected": { "type": "boolean", "description": "Whether this branch belongs to the observed evidence path." }
}
}
},
"observed": {
"type": "object",
"additionalProperties": false,
"required": ["workflowKind", "runStatus", "solveStatus", "validationVerdict"],
"description": "Observed workflow and verdicts parsed from the accepted Run, settled tool summaries, and receipts; oracle expectations never substitute for these values.",
"properties": {
"workflowKind": { "enum": ["optimization", "validation", "recovery"], "description": "Actual tool path used by this run." },
"runStatus": { "type": "string", "minLength": 1, "description": "Observed case-run acceptance status." },
"solveStatus": { "type": ["string", "null"], "description": "Observed settled solver status, or null when no solve was performed." },
"validationVerdict": { "type": ["string", "null"], "enum": ["pass", "fail", null], "description": "Observed independent validation verdict, or null when the workflow has no validation step." }
}
},
"evidenceBindings": {
"type": "object",
"additionalProperties": false,
"required": ["scenarioPackSha256", "runId", "sourceMessagesSha256", "sourceSessionSha256", "sourceToolInvocationsSha256", "optimizationSpecSha256", "resultPayloadSha256", "validationReportSha256"],
"description": "Immutable bindings that prevent a generic or stale tree from being presented as scenario evidence.",
"properties": {
"scenarioPackSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "SHA-256 of the source Scenario Pack." },
"runId": { "type": "string", "minLength": 1, "description": "Accepted run that supplied observed outcomes." },
"sourceMessagesSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "SHA-256 of the source conversation log." },
"sourceSessionSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "SHA-256 of the session log containing settled solve, validation, or recovery-ledger summaries." },
"sourceToolInvocationsSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "SHA-256 of the governed tool receipt log that proves which workflow actually ran." },
"optimizationSpecSha256": { "type": ["string", "null"], "pattern": "^[a-f0-9]{64}$", "description": "OptimizationSpec hash when a solver path exists." },
"resultPayloadSha256": { "type": ["string", "null"], "pattern": "^[a-f0-9]{64}$", "description": "Settled solver-result hash or recovery EvidenceLedger payload hash; null only for validation-only candidate workflows." },
"validationReportSha256": { "type": ["string", "null"], "pattern": "^[a-f0-9]{64}$", "description": "Independent validation report hash when recorded." }
}
}
}
}

View File

@@ -28,6 +28,9 @@ addFormats(ajv)
const validatePublicReleaseAttestationSchema = ajv.compile(
JSON.parse(readFileSync(join(codeRoot, 'public-release-attestation.schema.json'), 'utf8'))
)
const validateDecisionTreeSchema = ajv.compile(
JSON.parse(readFileSync(join(codeRoot, 'decision-tree.schema.json'), 'utf8'))
)
const outputRoot = resolveSafeOutputRoot({
inputRoot,
@@ -263,6 +266,201 @@ const parseSummaryJson = (event) => {
}
}
const compactList = (items, limit = 3) => {
const visible = items.slice(0, limit)
return `${visible.join('')}${items.length > limit ? `;另有 ${items.length - limit}` : ''}`
}
const observedStatusLabel = (status) =>
({ optimal: '最优解', infeasible: '不可行', pass: '通过', fail: '拒绝' })[status] ?? String(status)
const buildDecisionTree = ({ pack, packSha256, evidence, workflowKind, observed, problemFamily, engineId }) => {
const hardChecks = pack.oracle?.hardChecks ?? []
const intent = {
id: 'intent',
kind: 'context',
owner: 'shared',
title: pack.businessContract.decision,
detail: pack.businessContract.plainLanguageStory,
status: 'neutral',
evidenceRef: '#scenario-and-decision',
}
let nodes
let edges
if (workflowKind === 'optimization') {
const isInfeasible = observed.solveStatus === 'infeasible'
const isRejected = observed.validationVerdict === 'fail'
nodes = [
intent,
{
id: 'facts', kind: 'question', owner: 'human', title: '业务事实、硬规则与优先级是否已确认?',
detail: `${compactList(pack.businessContract.objectives)}${compactList(pack.businessContract.constraints)}`,
status: 'pass', evidenceRef: '#input-and-provenance',
},
{
id: 'clarify', kind: 'action', owner: 'agent', title: '信息不足:向人类补问',
detail: '缺少决定性事实时保持阻断,不猜测参数、不静默补值。', status: 'guard',
evidenceRef: '#input-and-provenance',
},
{
id: 'model', kind: 'check', owner: 'agent',
title: `规则是否完整映射为 ${String(problemFamily).toUpperCase()} 决策模型?`,
detail: hardChecks.length ? `交付必须覆盖 ${compactList(hardChecks, 4)}` : pack.businessContract.professionalSummary,
status: 'pass', evidenceRef: '#model-definition',
},
{
id: 'mapping-block', kind: 'outcome', owner: 'shared', title: '模型映射不完整:停止求解',
detail: '由人类补充或修正规则Agent 不得以近似模型冒充原问题。', status: 'blocked',
evidenceRef: '#model-definition',
},
{
id: 'candidate', kind: 'action', owner: 'agent',
title: isInfeasible ? `使用 ${engineId} 做可行性诊断` : `使用 ${engineId} 生成候选方案`,
detail: `真实求解回执:${observedStatusLabel(observed.solveStatus)}${observed.solveStatus}${pack.businessContract.baseline}`,
status: isInfeasible ? 'guard' : 'pass', evidenceRef: '#solve-process',
},
{
id: 'verify', kind: 'question', owner: 'validator',
title: isInfeasible ? '不可行结论与冲突集合是否可追溯?' : '独立校核是否接受当前候选?',
detail: hardChecks.length ? `重新检查 ${compactList(hardChecks, 4)}` : '重新计算变量域、硬约束、目标与证据哈希。',
status: isRejected ? 'guard' : 'pass', evidenceRef: '#independent-validation',
},
{
id: 'verification-guard', kind: 'outcome', owner: 'validator', title: '校核证据不足:拒绝交付',
detail: '不采信求解器自报;保留候选、失败检查和哈希后返回 Agent 修正。', status: 'blocked',
evidenceRef: '#independent-validation',
},
{
id: 'outcome', kind: 'outcome', owner: 'human',
title: isRejected ? '阻断违规方案,不进入执行' : isInfeasible ? '交付冲突诊断,等待人类调整规则' : '建议方案交由业务负责人确认',
detail: isRejected ? '独立校核拒绝该候选;保留失败证据供人复核。' : isInfeasible ? '不偷偷删除任务、放宽容量或伪造可行解。' : `${pack.businessContract.objectives.join('')};执行前仍由人类确认适用边界。`,
status: isRejected ? 'blocked' : isInfeasible ? 'guard' : 'pass', evidenceRef: '#reproduction-and-conclusion',
},
]
edges = [
{ from: 'intent', to: 'facts', label: '进入事实确认', tone: 'continue', selected: true },
{ from: 'facts', to: 'clarify', label: '否:缺少决定性信息', tone: 'guard', selected: false },
{ from: 'facts', to: 'model', label: '是:事实门关闭', tone: 'continue', selected: true },
{ from: 'model', to: 'mapping-block', label: '否:存在未映射规则', tone: 'guard', selected: false },
{ from: 'model', to: 'candidate', label: '是:模型可执行', tone: 'continue', selected: true },
{ from: 'candidate', to: 'verify', label: `求解回执:${observedStatusLabel(observed.solveStatus)}`, tone: isInfeasible ? 'guard' : 'continue', selected: true },
{ from: 'verify', to: 'verification-guard', label: '证据不足或对账失败', tone: 'guard', selected: false },
{ from: 'verify', to: 'outcome', label: isRejected ? '校核回执:拒绝' : isInfeasible ? '校核回执:通过,冲突可追溯' : '校核回执:通过', tone: isRejected ? 'guard' : 'success', selected: true },
]
} else if (workflowKind === 'validation') {
const isRejected = observed.validationVerdict === 'fail'
nodes = [
intent,
{
id: 'candidate', kind: 'question', owner: 'human', title: '候选变量、目标声明与适用边界是否齐全?',
detail: `${compactList(pack.businessContract.constraints)}${pack.businessContract.baseline}`,
status: 'pass', evidenceRef: '#input-and-provenance',
},
{
id: 'clarify', kind: 'action', owner: 'agent', title: '候选信息不全:请求补充',
detail: '缺少变量、约束、目标口径或来源时不启动校核。', status: 'guard', evidenceRef: '#input-and-provenance',
},
{
id: 'verify', kind: 'check', owner: 'validator', title: '不重新求解,独立复算候选',
detail: hardChecks.length ? `核对 ${compactList(hardChecks, 4)}` : '核对变量域、硬约束、目标与基线。',
status: isRejected ? 'guard' : 'pass', evidenceRef: '#independent-validation',
},
{
id: 'verification-guard', kind: 'outcome', owner: 'validator', title: '校核证据不足:拒绝下结论',
detail: '验证报告或工具回执缺失时保持阻断。', status: 'blocked', evidenceRef: '#independent-validation',
},
{
id: 'outcome', kind: 'outcome', owner: 'human',
title: isRejected ? '阻断违规方案,不进入执行' : '候选通过校核,交由人类决定是否采用',
detail: isRejected ? '高目标值不能覆盖硬约束违规本次独立校核回执为拒绝fail。' : '本次独立校核回执为通过pass验证通过不等于自动批准执行。',
status: isRejected ? 'blocked' : 'pass', evidenceRef: '#reproduction-and-conclusion',
},
]
edges = [
{ from: 'intent', to: 'candidate', label: '进入候选校核', tone: 'continue', selected: true },
{ from: 'candidate', to: 'clarify', label: '否:候选信息不完整', tone: 'guard', selected: false },
{ from: 'candidate', to: 'verify', label: '是:只校核、不重算方案', tone: 'continue', selected: true },
{ from: 'verify', to: 'verification-guard', label: '验证证据缺失', tone: 'guard', selected: false },
{ from: 'verify', to: 'outcome', label: `校核回执:${observedStatusLabel(observed.validationVerdict)}`, tone: isRejected ? 'guard' : 'success', selected: true },
]
} else {
nodes = [
intent,
{
id: 'recovery-source', kind: 'question', owner: 'platform', title: '正常关闭标记与恢复前基线是否可读取?',
detail: pack.businessContract.baseline, status: 'pass', evidenceRef: '#input-and-provenance',
},
{
id: 'missing-state', kind: 'outcome', owner: 'platform', title: '恢复来源不完整:停止声称恢复成功',
detail: '缺少关闭标记、团队快照或账本时,必须进入人工排查。', status: 'blocked', evidenceRef: '#input-and-provenance',
},
{
id: 'restore', kind: 'action', owner: 'platform', title: '恢复团队、Skill、工具与既有证据',
detail: '该路径不建立、不编译、不求解优化模型。', status: 'pass', evidenceRef: '#solve-process',
},
{
id: 'ledger-check', kind: 'check', owner: 'validator', title: '恢复前后 EvidenceLedger 是否保持幂等?',
detail: compactList(pack.businessContract.constraints, 4), status: 'pass', evidenceRef: '#independent-validation',
},
{
id: 'ledger-guard', kind: 'outcome', owner: 'validator', title: '账本计数或证据变化:拒绝恢复结论',
detail: '发现重复已结算调用或身份漂移时保持阻断。', status: 'blocked', evidenceRef: '#independent-validation',
},
{
id: 'outcome', kind: 'outcome', owner: 'human', title: '恢复完成,不重复已结算求解',
detail: '本次 Run 已通过,且 EvidenceLedger 回执已绑定到这棵树。', status: 'pass', evidenceRef: '#reproduction-and-conclusion',
},
]
edges = [
{ from: 'intent', to: 'recovery-source', label: '进入恢复核对', tone: 'continue', selected: true },
{ from: 'recovery-source', to: 'missing-state', label: '否:恢复来源缺失', tone: 'guard', selected: false },
{ from: 'recovery-source', to: 'restore', label: '是:加载既有状态', tone: 'continue', selected: true },
{ from: 'restore', to: 'ledger-check', label: '读取 EvidenceLedger', tone: 'continue', selected: true },
{ from: 'ledger-check', to: 'ledger-guard', label: '否:计数或身份漂移', tone: 'guard', selected: false },
{ from: 'ledger-check', to: 'outcome', label: '是:无重复求解', tone: 'success', selected: true },
]
}
const tree = {
schemaVersion: 'solver.human-agent-decision-tree/v1', rootNodeId: 'intent', nodes, edges, observed,
evidenceBindings: {
scenarioPackSha256: packSha256,
runId: evidence.runId,
sourceMessagesSha256: evidence.sourceMessagesSha256,
sourceSessionSha256: evidence.sourceSessionSha256,
sourceToolInvocationsSha256: evidence.sourceToolInvocationsSha256,
optimizationSpecSha256: evidence.optimizationSpecSha256,
resultPayloadSha256: evidence.resultPayloadSha256,
validationReportSha256: evidence.validationReportSha256,
},
}
if (!validateDecisionTreeSchema(tree)) {
throw new Error(`decision tree schema validation failed: ${ajv.errorsText(validateDecisionTreeSchema.errors)}`)
}
const nodeIds = new Set(tree.nodes.map((node) => node.id))
if (nodeIds.size !== tree.nodes.length || !nodeIds.has(tree.rootNodeId)) throw new Error('decision tree node IDs invalid')
if (tree.edges.some((edge) => !nodeIds.has(edge.from) || !nodeIds.has(edge.to))) {
throw new Error('decision tree edge refers to an unknown node')
}
const selectedEdges = tree.edges.filter((edge) => edge.selected)
const selectedByFrom = new Map()
for (const edge of selectedEdges) selectedByFrom.set(edge.from, [...(selectedByFrom.get(edge.from) ?? []), edge])
if ([...selectedByFrom.values()].some((outgoing) => outgoing.length !== 1)) {
throw new Error('decision tree path must have exactly one selected outgoing edge per path node')
}
if (tree.nodes.some((node) => node.id !== tree.rootNodeId && !tree.edges.some((edge) => edge.to === node.id))) {
throw new Error('decision tree contains an orphan node')
}
let cursor = tree.rootNodeId
const visited = new Set()
while (selectedByFrom.has(cursor) && !visited.has(cursor)) {
visited.add(cursor)
cursor = selectedByFrom.get(cursor)[0].to
}
if (cursor !== 'outcome' || visited.size !== selectedEdges.length || selectedByFrom.has('outcome')) {
throw new Error('decision tree selected path must be acyclic, complete, and terminate at outcome')
}
return tree
}
const buildChapter = async (planItem, order) => {
const source = caseRunById.get(planItem.runId)
if (!source) throw new Error(`${planItem.scenarioId}: accepted Run evidence missing: ${planItem.runId}`)
@@ -305,14 +503,70 @@ const buildChapter = async (planItem, order) => {
const timeline = [...renderedMessages, ...toolEvents].sort(
(left, right) => Number(left.timestamp ?? 0) - Number(right.timestamp ?? 0) || left.kind.localeCompare(right.kind)
)
const validationSummaries = session
.filter((event) => event.type === 'tool_use_summary' && event.data?.metadata?.validation_subject !== undefined)
.map(parseSummaryJson)
.filter(Boolean)
const validation = validationSummaries.at(-1) ?? null
const parsedToolSummaries = session
.filter((event) => event.type === 'tool_use_summary')
.map((event) => ({ event, summary: parseSummaryJson(event) }))
.filter((entry) => entry.summary && typeof entry.summary === 'object' && !Array.isArray(entry.summary))
const finished = invocations.filter((item) => item.phase === 'finished' && item.status === 'success')
const solve = finished.find((item) => item.tool_name === 'OptimizationSolve')
const validate = [...finished].reverse().find((item) => item.tool_name === 'OptimizationValidate')
const ledger = [...finished].reverse().find((item) => item.tool_name === 'OptimizationEvidenceLedger')
const compile = finished.find((item) => item.tool_name === 'OptimizationCompile')
const solveSummary = solve
? typeof solve.result_provenance?.result_payload_hash === 'string'
? parsedToolSummaries.find(
({ summary }) =>
typeof summary.status === 'string' &&
summary.result_payload_hash === solve.result_provenance?.result_payload_hash
)?.summary ?? null
: null
: null
const validationEntry = validate
? typeof validate.result_provenance?.validation_report_hash === 'string'
? [...parsedToolSummaries]
.reverse()
.find(
({ event, summary }) =>
['pass', 'fail'].includes(summary.verdict) &&
event.data?.metadata?.validation_report_hash === validate.result_provenance?.validation_report_hash
) ?? null
: null
: null
const validation = validationEntry?.summary ?? null
const ledgerSummary = ledger
? [...parsedToolSummaries]
.reverse()
.find(({ summary }) => summary.schema_version === 'solver.optimization-evidence-ledger/v1')?.summary ?? null
: null
if (source.data.status !== 'passed') throw new Error(`${planItem.scenarioId}: decision tree requires an accepted Run`)
const workflowKind = solve ? 'optimization' : validate ? 'validation' : ledger ? 'recovery' : null
if (!workflowKind) throw new Error(`${planItem.scenarioId}: no observed solve, validation, or recovery workflow`)
if (workflowKind !== 'optimization' && compile) {
throw new Error(`${planItem.scenarioId}: ${workflowKind} workflow cannot hide an observed OptimizationCompile step`)
}
if (workflowKind === 'optimization' && (!solveSummary || !validation)) {
throw new Error(`${planItem.scenarioId}: settled solve or validation summary missing`)
}
if (
workflowKind === 'optimization' &&
(!solve.result_provenance?.optimization_spec_hash ||
!solve.result_provenance?.result_payload_hash ||
!validate.result_provenance?.validation_report_hash)
) {
throw new Error(`${planItem.scenarioId}: optimization workflow provenance hashes missing`)
}
if (workflowKind === 'validation' && !validation) {
throw new Error(`${planItem.scenarioId}: observed validation summary missing`)
}
if (workflowKind === 'validation' && !validate.result_provenance?.validation_report_hash) {
throw new Error(`${planItem.scenarioId}: validation workflow report hash missing`)
}
if (workflowKind === 'recovery' && !ledgerSummary) {
throw new Error(`${planItem.scenarioId}: recovery EvidenceLedger summary missing`)
}
if (workflowKind === 'recovery' && !ledger.result_provenance?.result_payload_hash) {
throw new Error(`${planItem.scenarioId}: recovery EvidenceLedger result hash missing`)
}
const problemFamily =
solve?.result_provenance?.problem_family ??
validate?.result_provenance?.problem_family ??
@@ -320,6 +574,49 @@ const buildChapter = async (planItem, order) => {
pack.manifest.tags.find((tag) => ['lp', 'milp', 'qp', 'qcp', 'cp'].includes(tag)) ??
'validation'
const stability = stabilityMatrix.groups.find((group) => group.scenarioId === planItem.scenarioId) ?? null
const resultStatus =
workflowKind === 'optimization'
? solveSummary.status
: workflowKind === 'validation'
? validation.verdict
: 'recovered'
const validationVerdict = validation?.verdict ?? null
if (pack.oracle.expectedStatuses?.length && !pack.oracle.expectedStatuses.includes(resultStatus)) {
throw new Error(`${planItem.scenarioId}: observed result ${resultStatus} differs from Scenario Pack oracle`)
}
if (
source.data.diagnostics?.observedValidationVerdict != null &&
source.data.diagnostics.observedValidationVerdict !== validationVerdict
) {
throw new Error(`${planItem.scenarioId}: validation summary differs from case-run diagnostics`)
}
const sourceMessagesSha256 = sha256(readFileSync(messagesFile))
const evidence = {
runId: planItem.runId,
conversationId: planItem.conversationId,
sourceMessagesSha256,
sourceSessionSha256: sha256(readFileSync(sessionFile)),
sourceToolInvocationsSha256: sha256(readFileSync(invocationsFile)),
optimizationSpecSha256: solve?.result_provenance?.optimization_spec_hash ?? null,
compiledRequestSha256: solve?.result_provenance?.compiled_request_hash ?? null,
resultPayloadSha256:
solve?.result_provenance?.result_payload_hash ?? ledger?.result_provenance?.result_payload_hash ?? null,
validationReportSha256: validate?.result_provenance?.validation_report_hash ?? null,
}
const decisionTree = buildDecisionTree({
pack,
packSha256: sha256(readFileSync(packEntry.file)),
evidence,
workflowKind,
observed: {
workflowKind,
runStatus: source.data.status,
solveStatus: solveSummary?.status ?? null,
validationVerdict,
},
problemFamily,
engineId: solve?.result_provenance?.engine_id ?? null,
})
const media = []
for (const screenshot of planItem.screenshots) {
@@ -379,8 +676,8 @@ const buildChapter = async (planItem, order) => {
baseline: pack.businessContract.baseline,
outOfScope: pack.businessContract.outOfScope,
problemFamily,
resultStatus: pack.oracle.expectedStatuses?.[0] ?? source.data.status,
validationVerdict: source.data.diagnostics?.observedValidationVerdict ?? null,
resultStatus,
validationVerdict,
engineId: solve?.result_provenance?.engine_id ?? null,
engineRequirement: solve?.result_provenance
? {
@@ -390,17 +687,8 @@ const buildChapter = async (planItem, order) => {
capabilitySha256: solve.result_provenance.capability_hash,
}
: null,
evidence: {
runId: planItem.runId,
conversationId: planItem.conversationId,
sourceMessagesSha256: sha256(readFileSync(messagesFile)),
sourceSessionSha256: sha256(readFileSync(sessionFile)),
sourceToolInvocationsSha256: sha256(readFileSync(invocationsFile)),
optimizationSpecSha256: solve?.result_provenance?.optimization_spec_hash ?? null,
compiledRequestSha256: solve?.result_provenance?.compiled_request_hash ?? null,
resultPayloadSha256: solve?.result_provenance?.result_payload_hash ?? null,
validationReportSha256: validate?.result_provenance?.validation_report_hash ?? null,
},
evidence,
decisionTree,
counts: {
rawConversationMessages: conversationMessages.length,
visibleConversationMessages: renderedMessages.filter((item) => !item.empty).length,
@@ -461,6 +749,7 @@ cpSync(join(appRoot, 'node_modules', 'katex', 'dist', 'katex.min.css'), join(out
cpSync(join(appRoot, 'node_modules', 'katex', 'dist', 'fonts'), join(outputRoot, 'assets', 'fonts'), {
recursive: true,
})
cpSync(join(codeRoot, 'decision-tree.schema.json'), join(outputRoot, 'data', 'decision-tree.schema.json'))
const chapters = []
for (const [index, scenarioId] of scenarioOrder.entries()) {
@@ -502,7 +791,7 @@ const manifest = {
title: '通用求解器智能体团队 · 真实场景验证书',
generatedAt: new Date().toISOString(),
contentRootSha256,
reportVersion: '3.3.0',
reportVersion: '3.4.0',
platformCommit: latestPlatformRegression.platform.commit,
agentSkillVersion: latestPlatformRegression.platform.agentSkillVersion,
evidencePlatformCommit: historicalPlatformCommit,
@@ -513,6 +802,7 @@ const manifest = {
latestPlatformRegression,
counts: {
chapters: chapters.length,
decisionTrees: chapters.length,
screenshots: screenshotPlan.scenarios.reduce((total, item) => total + item.screenshots.length, 0),
representativeStabilityPasses,
},
@@ -552,6 +842,7 @@ writeFileSync(
integrityEntries: expectedIntegrityEntries,
gates: {
scenarioPacks: 'pass',
decisionTrees: 'pass',
stability: 'pass',
screenshots: 'pass',
publicReleasePrivacy: 'pass',

View File

@@ -1,5 +1,7 @@
import { existsSync, globSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
assertPublicText,
canonical,
@@ -8,6 +10,13 @@ import {
sha256,
} from './public-release-policy.mjs'
const codeRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const require = createRequire(import.meta.url)
const Ajv = require('ajv').default
const ajv = new Ajv({ allErrors: true, strict: true })
const validateDecisionTreeSchema = ajv.compile(
JSON.parse(readFileSync(join(codeRoot, 'decision-tree.schema.json'), 'utf8'))
)
const inputRoot = resolve(process.env.SOLVER_REPORT_INPUT_ROOT ?? process.env.BUILDER_ROOT ?? process.cwd())
const dist = process.env.SOLVER_REPORT_OUTPUT_ROOT ?? join(inputRoot, 'report-web', 'dist')
const validationFile =
@@ -29,6 +38,12 @@ const caseRunById = new Map(
return [data.runId, { file, data, runDir: join(dirname(file), 'run') }]
})
)
const packById = new Map(
globSync(join(inputRoot, 'scenario-packs', '*', '2.0.0', 'scenario-pack.json')).map((file) => {
const pack = JSON.parse(readFileSync(file, 'utf8'))
return [pack.manifest.scenarioId, { file, pack }]
})
)
const screenshotPlan = JSON.parse(
readFileSync(
process.env.SOLVER_REPORT_PUBLIC_SCREENSHOT_PLAN_FILE ??
@@ -60,6 +75,8 @@ const { attestation: publicReleaseAttestation, sha256: publicReleaseAttestationS
loadPublicReleaseAttestation(publicAttestationFile, publicEvidenceRoots)
if (manifest.chapters.length !== 24) failures.push({ issue: 'chapter-count', actual: manifest.chapters.length })
if (manifest.counts.decisionTrees !== 24)
failures.push({ issue: 'decision-tree-count', actual: manifest.counts.decisionTrees })
if (manifest.counts.screenshots !== 174)
failures.push({ issue: 'screenshot-count', actual: manifest.counts.screenshots })
const historicalEvidenceContentRootSha256 = sha256(
@@ -105,6 +122,7 @@ if (
canonical(build.publicEvidenceRoots) !== canonical(manifest.publicEvidenceRoots) ||
canonical(build.counts) !== canonical(manifest.counts) ||
build.integrityEntries !== integrity.entries.length ||
build.gates?.decisionTrees !== 'pass' ||
build.gates?.publicReleasePrivacy !== 'pass' ||
!integrity.entries.some((entry) => entry.path === 'build.json')
)
@@ -117,6 +135,7 @@ try {
let formulas = 0
let screenshotCount = 0
let decisionTreeCount = 0
for (const index of manifest.chapters) {
const file = join(dist, index.json)
const script = join(dist, index.script)
@@ -155,6 +174,135 @@ for (const index of manifest.chapters) {
}
formulas += chapter.formulaStats.rendered
screenshotCount += chapter.media.length
const source = caseRunById.get(chapter.evidence.runId)
const sourceMessagesFile = source ? join(source.runDir, 'messages.jsonl') : null
const sourceSessionFile = source ? join(source.runDir, 'sessions', 'session.jsonl') : null
const sourceInvocationsFile = source ? join(source.runDir, 'receipts', 'tool-invocations.jsonl') : null
const sourceInvocations = source ? readJsonLines(sourceInvocationsFile) : []
const sourceSession = source ? readJsonLines(sourceSessionFile) : []
const finishedReceipts = sourceInvocations.filter((item) => item.phase === 'finished' && item.status === 'success')
const finishedToolNames = finishedReceipts.map((item) => item.tool_name)
const parsedSummaries = sourceSession
.filter((event) => event.type === 'tool_use_summary')
.map((event) => {
try {
return { event, summary: JSON.parse(event.data?.summary ?? '') }
} catch {
return null
}
})
.filter((entry) => entry?.summary && typeof entry.summary === 'object' && !Array.isArray(entry.summary))
const solveReceipt = finishedReceipts.find((item) => item.tool_name === 'OptimizationSolve')
const validationReceipt = [...finishedReceipts].reverse().find((item) => item.tool_name === 'OptimizationValidate')
const ledgerReceipt = [...finishedReceipts].reverse().find((item) => item.tool_name === 'OptimizationEvidenceLedger')
const observedSolveSummary = solveReceipt
? typeof solveReceipt.result_provenance?.result_payload_hash === 'string'
? parsedSummaries.find(
({ summary }) => summary.result_payload_hash === solveReceipt.result_provenance?.result_payload_hash
)?.summary ?? null
: null
: null
const observedValidationSummary = validationReceipt
? typeof validationReceipt.result_provenance?.validation_report_hash === 'string'
? [...parsedSummaries]
.reverse()
.find(
({ event, summary }) =>
['pass', 'fail'].includes(summary.verdict) &&
event.data?.metadata?.validation_report_hash === validationReceipt.result_provenance?.validation_report_hash
)?.summary ?? null
: null
: null
const observedLedgerSummary = ledgerReceipt
? [...parsedSummaries]
.reverse()
.find(({ summary }) => summary.schema_version === 'solver.optimization-evidence-ledger/v1')?.summary ?? null
: null
const tree = chapter.decisionTree
const treeNodes = new Map(tree?.nodes?.map((node) => [node.id, node]) ?? [])
const packEntry = packById.get(index.scenarioId)
if (
!validateDecisionTreeSchema(tree) ||
tree?.schemaVersion !== 'solver.human-agent-decision-tree/v1' ||
treeNodes.size !== tree?.nodes?.length ||
!treeNodes.has(tree?.rootNodeId) ||
tree?.edges?.some((edge) => !treeNodes.has(edge.from) || !treeNodes.has(edge.to)) ||
!packEntry ||
tree?.evidenceBindings?.scenarioPackSha256 !== sha256(readFileSync(packEntry.file)) ||
tree?.evidenceBindings?.runId !== chapter.evidence.runId ||
tree?.evidenceBindings?.sourceMessagesSha256 !== chapter.evidence.sourceMessagesSha256 ||
tree?.evidenceBindings?.sourceSessionSha256 !== chapter.evidence.sourceSessionSha256 ||
tree?.evidenceBindings?.sourceToolInvocationsSha256 !== chapter.evidence.sourceToolInvocationsSha256 ||
tree?.evidenceBindings?.optimizationSpecSha256 !== chapter.evidence.optimizationSpecSha256 ||
tree?.evidenceBindings?.resultPayloadSha256 !== chapter.evidence.resultPayloadSha256 ||
tree?.evidenceBindings?.validationReportSha256 !== chapter.evidence.validationReportSha256 ||
!source ||
tree?.evidenceBindings?.sourceMessagesSha256 !== sha256(readFileSync(sourceMessagesFile)) ||
tree?.evidenceBindings?.sourceSessionSha256 !== sha256(readFileSync(sourceSessionFile)) ||
tree?.evidenceBindings?.sourceToolInvocationsSha256 !== sha256(readFileSync(sourceInvocationsFile)) ||
source.data.status !== 'passed' ||
tree?.observed?.runStatus !== source.data.status ||
!['optimization', 'validation', 'recovery'].includes(tree?.observed?.workflowKind) ||
(tree?.observed?.workflowKind === 'optimization' &&
(!finishedToolNames.includes('OptimizationSolve') ||
!finishedToolNames.includes('OptimizationValidate') ||
!observedSolveSummary ||
!observedValidationSummary ||
tree.observed.solveStatus !== observedSolveSummary.status ||
tree.observed.validationVerdict !== observedValidationSummary.verdict ||
!solveReceipt?.result_provenance?.optimization_spec_hash ||
tree.evidenceBindings.optimizationSpecSha256 !== solveReceipt.result_provenance.optimization_spec_hash ||
tree.evidenceBindings.resultPayloadSha256 !== solveReceipt?.result_provenance?.result_payload_hash ||
!validationReceipt?.result_provenance?.validation_report_hash ||
tree.evidenceBindings.validationReportSha256 !== validationReceipt?.result_provenance?.validation_report_hash ||
!tree.evidenceBindings.resultPayloadSha256 ||
chapter.resultStatus !== tree.observed.solveStatus ||
chapter.validationVerdict !== tree.observed.validationVerdict)) ||
(tree?.observed?.workflowKind === 'validation' &&
(finishedToolNames.includes('OptimizationCompile') ||
finishedToolNames.includes('OptimizationSolve') ||
tree.observed.solveStatus !== null ||
!finishedToolNames.includes('OptimizationValidate') ||
!observedValidationSummary ||
tree.observed.validationVerdict !== observedValidationSummary.verdict ||
!validationReceipt?.result_provenance?.validation_report_hash ||
tree.evidenceBindings.validationReportSha256 !== validationReceipt?.result_provenance?.validation_report_hash ||
treeNodes.has('model') ||
chapter.resultStatus !== tree.observed.validationVerdict)) ||
(tree?.observed?.workflowKind === 'recovery' &&
(finishedToolNames.includes('OptimizationCompile') ||
finishedToolNames.includes('OptimizationSolve') ||
finishedToolNames.includes('OptimizationValidate') ||
tree.observed.solveStatus !== null ||
tree.observed.validationVerdict !== null ||
!finishedToolNames.includes('OptimizationEvidenceLedger') ||
!observedLedgerSummary ||
!ledgerReceipt?.result_provenance?.result_payload_hash ||
tree.evidenceBindings.resultPayloadSha256 !== ledgerReceipt?.result_provenance?.result_payload_hash ||
treeNodes.has('model') ||
!tree.evidenceBindings.resultPayloadSha256 ||
chapter.resultStatus !== 'recovered'))
) {
failures.push({ scenarioId: index.scenarioId, issue: 'decision-tree-contract-or-evidence-binding' })
} else {
const selectedEdges = tree.edges.filter((edge) => edge.selected)
const selectedByFrom = new Map()
for (const edge of selectedEdges) selectedByFrom.set(edge.from, [...(selectedByFrom.get(edge.from) ?? []), edge])
let cursor = tree.rootNodeId
const visited = new Set()
while (selectedByFrom.get(cursor)?.length === 1 && !visited.has(cursor)) {
visited.add(cursor)
cursor = selectedByFrom.get(cursor)[0].to
}
if (
cursor !== 'outcome' ||
visited.size !== selectedEdges.length ||
[...selectedByFrom.values()].some((outgoing) => outgoing.length !== 1) ||
tree.nodes.some((node) => node.id !== tree.rootNodeId && !tree.edges.some((edge) => edge.to === node.id))
)
failures.push({ scenarioId: index.scenarioId, issue: 'decision-tree-selected-path' })
else decisionTreeCount += 1
}
for (const image of chapter.media) {
const original = join(dist, image.original)
if (!existsSync(original) || sha256(readFileSync(original)) !== image.sha256) {
@@ -165,10 +313,9 @@ for (const index of manifest.chapters) {
failures.push({ scenarioId: index.scenarioId, issue: 'thumbnail-missing', path: thumb })
}
}
const source = caseRunById.get(chapter.evidence.runId)
if (!source) failures.push({ scenarioId: index.scenarioId, issue: 'source-run-missing' })
else {
const raw = readJsonLines(join(source.runDir, 'messages.jsonl')).filter((item) =>
const raw = readJsonLines(sourceMessagesFile).filter((item) =>
['user', 'assistant'].includes(item.role)
)
const projected = chapter.timeline.filter((item) => item.kind === 'message')
@@ -194,6 +341,7 @@ for (const index of manifest.chapters) {
}
if (screenshotCount !== 174) failures.push({ issue: 'chapter-screenshot-sum', actual: screenshotCount })
if (decisionTreeCount !== 24) failures.push({ issue: 'chapter-decision-tree-sum', actual: decisionTreeCount })
for (const entry of integrity.entries) {
const file = join(dist, entry.path)
if (!existsSync(file)) failures.push({ issue: 'integrity-file-missing', path: entry.path })
@@ -209,6 +357,7 @@ for (const required of [
'assets/report.js',
'assets/katex.min.css',
'data/manifest.js',
'data/decision-tree.schema.json',
'data/latest-platform-regression.json',
]) {
if (!existsSync(join(dist, required))) failures.push({ issue: 'required-asset', path: required })
@@ -220,6 +369,7 @@ const report = {
buildId: manifest.buildId,
chapters: manifest.chapters.length,
screenshots: screenshotCount,
decisionTrees: decisionTreeCount,
formulas,
integrityEntries: integrity.entries.length,
bytes: globSync(join(dist, '**', '*'))

View File

@@ -40,6 +40,8 @@ manifest = json.loads((root / "data" / "manifest.json").read_text(encoding="utf-
build = json.loads((root / "build.json").read_text(encoding="utf-8"))
if build.get("gates", {}).get("publicReleasePrivacy") != "pass":
failures.append({"path": "build.json", "issue": "public-release-privacy-gate"})
if build.get("gates", {}).get("decisionTrees") != "pass":
failures.append({"path": "build.json", "issue": "decision-tree-gate"})
if build.get("publicReleaseAttestationSha256") != manifest.get("publicReleaseAttestationSha256"):
failures.append({"path": "build.json", "issue": "public-release-attestation-mismatch"})
if (

View File

@@ -88,7 +88,7 @@ body {
}
.rail-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
gap: 6px;
margin-bottom: 14px;
}
@@ -355,7 +355,7 @@ body {
}
.kpis {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(6, 1fr);
gap: 10px;
margin: 20px 0;
}
@@ -563,6 +563,248 @@ body {
margin-top: 7px;
overflow-wrap: anywhere;
}
.decision-tree-section {
overflow: hidden;
scroll-margin-top: 82px;
background:
radial-gradient(circle at 10% 0, rgba(35, 184, 178, 0.1), transparent 30%),
#fff;
}
.tree-toolbar,
.tree-proof {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.tree-toolbar {
margin-bottom: 16px;
}
.tree-legend,
.tree-actions {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.tree-legend span {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border: 1px solid var(--line);
border-radius: 999px;
background: #fff;
color: var(--muted);
font-size: 9px;
}
.tree-legend span::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--cyan);
}
.tree-legend .human::before {
background: var(--violet);
}
.tree-legend .agent::before {
background: var(--cyan);
}
.tree-legend .validator::before {
background: var(--green);
}
.tree-legend .blocked::before {
background: var(--red);
}
.tree-actions button {
min-height: 36px;
border: 1px solid var(--line);
border-radius: 10px;
padding: 8px 11px;
background: #fff;
color: var(--ink);
font: inherit;
font-size: 10px;
cursor: pointer;
}
.tree-actions button:hover,
.tree-actions button:focus-visible {
border-color: #7ecac1;
outline: none;
box-shadow: 0 0 0 3px rgba(35, 184, 178, 0.12);
}
.tree-scroll {
overflow-x: auto;
overscroll-behavior-inline: contain;
padding: 8px 4px 18px;
scrollbar-color: #aab8ca #edf1f6;
outline: none;
}
.tree-scroll:focus-visible {
border-radius: 12px;
box-shadow: inset 0 0 0 3px rgba(35, 184, 178, 0.24);
}
.tree-flow {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 200px;
align-items: start;
gap: 52px;
width: max-content;
min-width: 100%;
}
.tree-stage {
position: relative;
min-width: 0;
}
.tree-node {
position: relative;
min-height: 224px;
padding: 15px;
border: 1px solid var(--line);
border-top: 5px solid var(--cyan);
border-radius: 15px;
background: #fff;
box-shadow: 0 10px 28px rgba(24, 42, 80, 0.08);
}
.tree-node.neutral {
border-top-color: var(--violet);
}
.tree-node.guard {
border-top-color: var(--amber);
}
.tree-node.blocked {
border-top-color: var(--red);
background: #fff9f8;
}
.tree-node-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 7px;
}
.tree-owner,
.tree-kind {
border-radius: 999px;
padding: 4px 7px;
font-size: 8px;
line-height: 1;
white-space: nowrap;
}
.tree-owner {
background: #e9f8f5;
color: #177369;
font-weight: 800;
}
.tree-owner.human {
background: #f0edff;
color: #6251bd;
}
.tree-owner.validator {
background: #eaf8f0;
color: #247b53;
}
.tree-owner.platform {
background: #eef2f8;
color: #4d627e;
}
.tree-kind {
background: var(--soft);
color: var(--muted);
text-transform: uppercase;
}
.tree-node h3 {
margin: 14px 0 9px;
font-size: 14px;
line-height: 1.42;
}
.tree-node p {
margin: 0 0 14px;
color: var(--muted);
font-size: 10px;
line-height: 1.65;
}
.tree-node a {
position: absolute;
left: 15px;
bottom: 14px;
color: #167f75;
font-size: 9px;
text-decoration: none;
}
.tree-connector {
position: absolute;
z-index: 2;
left: calc(100% + 5px);
top: 74px;
width: 42px;
color: #2a9289;
text-align: center;
}
.tree-connector span {
display: block;
width: 88px;
margin-left: -23px;
margin-bottom: 7px;
color: var(--muted);
font-size: 8px;
line-height: 1.25;
}
.tree-connector i {
display: block;
font-size: 25px;
font-style: normal;
line-height: 1;
}
.tree-connector.guard {
color: var(--amber);
}
.tree-connector.success {
color: var(--green);
}
.tree-alternatives {
position: relative;
margin-top: 46px;
}
.tree-alternatives::before {
content: '';
position: absolute;
left: 24px;
bottom: 100%;
width: 1px;
height: 32px;
background: repeating-linear-gradient(to bottom, var(--red) 0 4px, transparent 4px 8px);
}
.tree-alternative > span {
display: inline-block;
margin: 0 0 7px 10px;
border-radius: 999px;
padding: 4px 7px;
background: #fff1ec;
color: #a34f43;
font-size: 8px;
}
.tree-node.branch {
min-height: 190px;
border-style: dashed;
box-shadow: none;
}
.decision-tree.path-only .tree-alternatives {
display: none;
}
.tree-proof {
align-items: flex-start;
border-top: 1px solid var(--line);
padding-top: 12px;
color: var(--muted);
font-size: 9px;
line-height: 1.5;
}
.tree-proof code {
color: #477070;
overflow-wrap: anywhere;
text-align: right;
}
.gallery {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -989,6 +1231,62 @@ body {
.validation-grid {
grid-template-columns: 1fr;
}
.decision-tree-section {
padding: 22px 16px;
}
.tree-toolbar,
.tree-proof {
align-items: stretch;
flex-direction: column;
}
.tree-actions {
display: grid;
grid-template-columns: 1fr 1fr;
}
.tree-actions button {
min-height: 44px;
}
.tree-scroll {
overflow-x: hidden;
padding-inline: 1px;
}
.tree-flow {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
gap: 0;
}
.tree-stage {
width: 100%;
}
.tree-node {
min-height: 190px;
}
.tree-connector {
position: static;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding: 9px 0;
}
.tree-connector span {
width: auto;
margin: 0 0 2px;
}
.tree-connector i {
transform: rotate(90deg);
}
.tree-alternatives {
margin: 30px 0 4px 24px;
}
.tree-node.branch {
min-height: 176px;
}
.tree-proof code {
text-align: left;
}
.kpis {
grid-template-columns: repeat(2, 1fr);
}

View File

@@ -86,7 +86,7 @@
function renderShell() {
$('#rail-summary').innerHTML =
`<span><b>${manifest.counts.chapters}</b>场景</span><span><b>${manifest.counts.screenshots}</b>真机图</span><span><b>${manifest.counts.representativeStabilityPasses}</b>稳定运行</span>`
`<span><b>${manifest.counts.chapters}</b>场景</span><span><b>${manifest.counts.decisionTrees}</b>决策树</span><span><b>${manifest.counts.screenshots}</b>真机图</span><span><b>${manifest.counts.representativeStabilityPasses}</b>稳定运行</span>`
$('#build-id').textContent = manifest.buildId
const families = ['ALL', ...new Set(manifest.chapters.map((item) => familyLabel(item.problemFamily)))]
$('#filters').innerHTML = families
@@ -173,6 +173,102 @@
.join('')
}
const ownerLabel = (owner) =>
({ human: '人类确认', agent: 'Agent 推导', shared: '人 + Agent', validator: '独立校核', platform: '平台执行' })[
owner
] || owner
const kindLabel = (kind) =>
({ context: '决策背景', question: '判断门', action: '执行', check: '校核门', outcome: '结果' })[kind] || kind
function decisionTreePath(tree) {
const nodes = new Map(tree.nodes.map((node) => [node.id, node]))
const selected = new Map(tree.edges.filter((edge) => edge.selected).map((edge) => [edge.from, edge]))
const path = []
const visited = new Set()
let current = tree.rootNodeId
while (nodes.has(current) && !visited.has(current)) {
visited.add(current)
path.push(nodes.get(current))
current = selected.get(current)?.to
}
return path
}
function renderTreeNode(node, branch = false) {
return `<article class="tree-node ${esc(node.status)}${branch ? ' branch' : ''}" data-tree-node="${esc(node.id)}">
<div class="tree-node-top"><span class="tree-owner ${esc(node.owner)}">${esc(ownerLabel(node.owner))}</span><span class="tree-kind">${esc(kindLabel(node.kind))}</span></div>
<h3>${esc(node.title)}</h3><p>${esc(node.detail)}</p>
<a href="${esc(node.evidenceRef)}">查看关联证据 →</a>
</article>`
}
function renderDecisionTree(tree) {
const path = decisionTreePath(tree)
const nodes = new Map(tree.nodes.map((node) => [node.id, node]))
const selectedEdges = new Map(tree.edges.filter((edge) => edge.selected).map((edge) => [edge.from, edge]))
const stages = path
.map((node, index) => {
const nextEdge = selectedEdges.get(node.id)
const alternatives = tree.edges
.filter((edge) => edge.from === node.id && !edge.selected)
.map(
(edge) =>
`<div class="tree-alternative"><span>${esc(edge.label)}</span>${renderTreeNode(nodes.get(edge.to), true)}</div>`
)
.join('')
return `<div class="tree-stage" role="listitem">${renderTreeNode(node)}${alternatives ? `<div class="tree-alternatives">${alternatives}</div>` : ''}${index < path.length - 1 ? `<div class="tree-connector ${esc(nextEdge.tone)}"><span>${esc(nextEdge.label)}</span><i aria-hidden="true">→</i></div>` : ''}</div>`
})
.join('')
return `<div class="decision-tree" data-decision-tree>
<div class="tree-toolbar"><div class="tree-legend" aria-label="决策树职责图例"><span class="human">人类确认</span><span class="agent">Agent 推导</span><span class="validator">独立校核</span><span class="blocked">阻断分支</span></div><div class="tree-actions"><button type="button" data-tree-mode aria-pressed="false">只看实际路径</button><button type="button" data-tree-export>下载 SVG</button></div></div>
<div class="tree-scroll" tabindex="0" aria-label="${esc(`场景决策树:${state.chapter.title}`)}"><div class="tree-flow" role="list">${stages}</div></div>
<div class="tree-proof"><span>当前实际路径以实线连接;旁路展示系统何时必须补问或阻断。</span><code>PACK ${esc(shortHash(tree.evidenceBindings.scenarioPackSha256))} · RUN ${esc(shortHash(tree.evidenceBindings.runId))}</code></div>
</div>`
}
const svgTextLines = (value, max = 18, lines = 3) => {
const chars = [...String(value)]
const result = []
while (chars.length && result.length < lines) result.push(chars.splice(0, max).join(''))
if (chars.length) result[result.length - 1] = `${result[result.length - 1].slice(0, -1)}`
return result
}
function downloadDecisionTreeSvg(c) {
const path = decisionTreePath(c.decisionTree)
const width = Math.max(1200, path.length * 230 + 100)
const height = 420
const cards = path
.map((node, index) => {
const x = 50 + index * 230
const title = svgTextLines(node.title)
.map(
(line, lineIndex) =>
`<text x="${x + 16}" y="${112 + lineIndex * 22}" font-size="15" font-weight="700" fill="#172238">${esc(line)}</text>`
)
.join('')
const detail = svgTextLines(node.detail, 22, 3)
.map(
(line, lineIndex) =>
`<text x="${x + 16}" y="${194 + lineIndex * 18}" font-size="11" fill="#637089">${esc(line)}</text>`
)
.join('')
const connector =
index < path.length - 1
? `<path d="M ${x + 190} 174 H ${x + 222}" stroke="#23b8b2" stroke-width="3"/><path d="M ${x + 216} 168 L ${x + 224} 174 L ${x + 216} 180" fill="none" stroke="#23b8b2" stroke-width="3"/>`
: ''
return `<g><rect x="${x}" y="64" width="190" height="210" rx="16" fill="#fff" stroke="#dce3ed"/><rect x="${x}" y="64" width="190" height="8" rx="4" fill="${node.status === 'blocked' ? '#d95c63' : '#23b8b2'}"/><text x="${x + 16}" y="94" font-size="11" font-weight="700" fill="#477070">${esc(ownerLabel(node.owner))}</text>${title}${detail}${connector}</g>`
})
.join('')
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="100%" height="100%" fill="#eef2f7"/><text x="50" y="35" font-size="22" font-weight="800" fill="#172238">${esc(c.title)} · 人 + Agent 决策树</text>${cards}<text x="50" y="316" font-size="13" font-weight="700" fill="#172238">实际证据路径</text><text x="50" y="342" font-size="12" fill="#637089">场景 ${esc(c.scenarioId)} · ${esc(c.decisionTree.evidenceBindings.scenarioPackSha256)}</text><text x="50" y="370" font-size="12" fill="#637089">此 SVG 由验证书中的结构化决策树即时导出;完整旁路与证据链接请在 Web 页面审阅。</text></svg>`
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }))
const link = document.createElement('a')
link.href = url
link.download = `${c.scenarioId.replace(/[^a-z0-9._-]/gi, '-')}-decision-tree.svg`
link.click()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
function renderChapter() {
const c = state.chapter
const tone = statusTone(c.resultStatus)
@@ -188,13 +284,15 @@
<div class="kpi"><small>问题族</small><strong>${familyLabel(c.problemFamily)}</strong></div>
<div class="kpi"><small>实际引擎</small><strong>${esc(c.engineId || '独立校核')}</strong></div>
<div class="kpi"><small>结果 / 校核</small><strong class="status ${tone}">${statusLabel(c.resultStatus)} / ${esc(c.validationVerdict || '不适用')}</strong></div>
<div class="kpi"><small>人机决策树</small><strong>${c.decisionTree.nodes.length} 节点 · 证据绑定</strong></div>
<div class="kpi"><small>真实消息 / 工具</small><strong>${c.counts.rawConversationMessages} / ${c.counts.toolEvents}</strong></div>
<div class="kpi"><small>真机截图</small><strong>${c.counts.screenshots} 张 · 2x</strong></div>
<div class="kpi"><small>原生真机截图</small><strong>${c.counts.screenshots} 张 · 2x</strong></div>
</section>
<section class="section-shell"><div class="section-head"><div><h2>场景故事与意义</h2><p>为什么这个问题具有代表性,以及哪些业务规则决定可行性。</p></div><span class="evidence-tag">PACK ${esc(c.scenarioVersion)} · ${shortHash(c.contentSha256)}</span></div>
<div class="hero-grid"><div class="validation-card"><small>目标</small><strong>${c.objectives.map(esc).join('')}</strong></div><div class="validation-card"><small>基线</small><strong>${esc(c.baseline)}</strong></div></div>
<div class="validation-card" style="margin-top:12px"><small>硬规则</small><strong>${c.constraints.map(esc).join('')}</strong></div>
</section>
<section class="section-shell decision-tree-section" id="decision-tree"><div class="section-head"><div><h2>人 + Agent 场景决策树</h2><p>从业务确认到交付门禁,展示本次实际路径以及必须补问、拒绝或阻断的旁路。</p></div><span class="evidence-tag">EVIDENCE-BOUND · ${c.decisionTree.nodes.length} NODES</span></div>${renderDecisionTree(c.decisionTree)}</section>
<section class="section-shell"><div class="section-head"><div><h2>真实多轮对话</h2><p>普通消息逐条来自 messages.jsonl工具事件来自持久回执未用离线脚本改写对话。</p></div><span class="evidence-tag">RUN ${shortHash(c.evidence.runId)}</span></div><div class="timeline">${renderTimeline(c)}</div></section>
<section class="section-shell"><div class="section-head"><div><h2>智能体最终十部分报告</h2><p>十个 section ID、数量和顺序固定正文由最终消息 Markdown 原样构建。</p></div><span class="evidence-tag">${c.formulaStats.rendered} FORMULAS · HTML+MATHML</span></div><div class="report-grid">${c.sections.map((section) => `<section class="report-part" id="${section.id}"><div class="part-number">${section.number}</div><div class="part-body"><h3>${esc(section.title)}</h3><div class="markdown">${section.html}</div></div></section>`).join('')}</div></section>
<section class="section-shell"><div class="section-head"><div><h2>专业校核与稳定性</h2><p>求解结果、独立验证、等价 formulation 和重复运行分别留证。</p></div><span class="evidence-tag">${esc(c.stability.policy)}</span></div>
@@ -214,6 +312,13 @@
})
})
$('#chapter [data-open-regression]')?.addEventListener('click', openLatestRegression)
$('#chapter [data-tree-mode]')?.addEventListener('click', (event) => {
const tree = event.currentTarget.closest('[data-decision-tree]')
const pathOnly = tree.classList.toggle('path-only')
event.currentTarget.setAttribute('aria-pressed', String(pathOnly))
event.currentTarget.textContent = pathOnly ? '显示完整分支' : '只看实际路径'
})
$('#chapter [data-tree-export]')?.addEventListener('click', () => downloadDecisionTreeSvg(c))
document.title = `${String(c.scenarioOrder).padStart(2, '0')} · ${c.title}${manifest.title}`
$('#app').setAttribute('aria-busy', 'false')
}

View File

@@ -9,11 +9,13 @@ const html = readFileSync(join(root, 'source', 'index.html'), 'utf8')
const css = readFileSync(join(root, 'source', 'report.css'), 'utf8')
const javascript = readFileSync(join(root, 'source', 'report.js'), 'utf8')
const builder = readFileSync(join(root, 'scripts', 'build-report-web.mjs'), 'utf8')
const validator = readFileSync(join(root, 'scripts', 'validate-report-web.mjs'), 'utf8')
const ocrValidator = readFileSync(join(root, 'scripts', 'validate-screenshot-privacy-ocr.mjs'), 'utf8')
const deploymentValidator = readFileSync(join(root, 'scripts', 'validate-deployed-report.mjs'), 'utf8')
const attestationSchema = JSON.parse(
readFileSync(join(root, 'public-release-attestation.schema.json'), 'utf8')
)
const decisionTreeSchema = JSON.parse(readFileSync(join(root, 'decision-tree.schema.json'), 'utf8'))
test('图片查看器暴露完整的可访问缩放控制', () => {
for (const id of [
@@ -54,6 +56,55 @@ test('响应式样式约束全屏查看器且允许平移', () => {
assert.match(css, /height:\s*100dvh/)
})
test('场景决策树由证据数据驱动并支持完整分支、实际路径和 SVG 导出', () => {
assert.match(builder, /buildDecisionTree/)
assert.match(builder, /solver\.human-agent-decision-tree\/v1/)
assert.match(builder, /scenarioPackSha256/)
assert.match(javascript, /renderDecisionTree/)
assert.match(javascript, /decisionTreePath/)
assert.match(javascript, /data-tree-mode/)
assert.match(javascript, /data-tree-export/)
assert.match(javascript, /downloadDecisionTreeSvg/)
assert.match(css, /\.tree-flow/)
assert.match(css, /\.tree-alternatives/)
assert.match(css, /\.decision-tree\.path-only/)
assert.match(builder, /workflowKind === 'optimization'/)
assert.match(builder, /workflowKind === 'validation'/)
assert.match(builder, /OptimizationEvidenceLedger/)
assert.match(builder, /cannot hide an observed OptimizationCompile step/)
assert.match(builder, /workflowKind !== 'optimization' && compile/)
assert.match(builder, /solveSummary\.status/)
assert.match(builder, /validation\.verdict/)
assert.doesNotMatch(builder, /const resultStatus = pack\.oracle/)
assert.match(builder, /exactly one selected outgoing edge/)
assert.match(validator, /validateDecisionTreeSchema\(tree\)/)
assert.match(validator, /\['optimization', 'validation', 'recovery'\]\.includes/)
assert.match(validator, /source\.data\.status !== 'passed'/)
})
test('决策树 Schema 自描述人类、Agent、校核和证据绑定边界', () => {
assert.equal(decisionTreeSchema.$schema, 'http://json-schema.org/draft-07/schema#')
assert.match(decisionTreeSchema.description, /humans confirm/)
assert.deepEqual(decisionTreeSchema.properties.nodes.items.properties.owner.enum, [
'human',
'agent',
'shared',
'validator',
'platform',
])
assert.match(decisionTreeSchema.properties.evidenceBindings.description, /Immutable bindings/)
for (const field of [
'sourceSessionSha256',
'sourceToolInvocationsSha256',
'resultPayloadSha256',
'validationReportSha256',
]) {
assert.ok(decisionTreeSchema.properties.evidenceBindings.required.includes(field))
assert.ok(decisionTreeSchema.properties.evidenceBindings.properties[field].description)
}
assert.match(decisionTreeSchema.properties.observed.description, /oracle expectations never substitute/i)
})
test('构建器从 Agent 仓库读取代码、从显式目录读取证据', () => {
assert.match(builder, /SOLVER_REPORT_INPUT_ROOT/)
assert.match(builder, /SOLVER_REPORT_OUTPUT_ROOT/)