From af338dc13bae54f925826bddde550864b01a0680 Mon Sep 17 00:00:00 2001 From: Yige Date: Sun, 30 Aug 2026 11:02:51 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=B1=82=E8=A7=A3?= =?UTF-8?q?=E5=99=A8=E9=AA=8C=E8=AF=81=E4=B9=A6=20Web=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: yige --- agent.json | 2 +- web/solver-report/.gitignore | 3 + web/solver-report/README.md | 60 + web/solver-report/README.zh-CN.md | 60 + web/solver-report/package.json | 15 + .../public-release-attestation.schema.json | 56 + .../scripts/audit-screenshot-privacy.swift | 99 ++ .../scripts/build-report-web.mjs | 591 +++++++++ .../scripts/create-privacy-contact-sheets.mjs | 75 ++ .../scripts/create-public-screenshots.mjs | 143 +++ .../scripts/public-release-policy.mjs | 257 ++++ .../scripts/validate-deployed-report.mjs | 100 ++ .../scripts/validate-report-web.mjs | 238 ++++ .../validate-screenshot-privacy-ocr.mjs | 80 ++ .../scripts/verify-static-release.py | 64 + web/solver-report/source/index.html | 107 ++ web/solver-report/source/report.css | 1132 +++++++++++++++++ web/solver-report/source/report.js | 478 +++++++ .../tests/public-release-policy.test.mjs | 132 ++ .../tests/source-contract.test.mjs | 86 ++ 20 files changed, 3777 insertions(+), 1 deletion(-) create mode 100644 web/solver-report/.gitignore create mode 100644 web/solver-report/README.md create mode 100644 web/solver-report/README.zh-CN.md create mode 100644 web/solver-report/package.json create mode 100644 web/solver-report/public-release-attestation.schema.json create mode 100644 web/solver-report/scripts/audit-screenshot-privacy.swift create mode 100644 web/solver-report/scripts/build-report-web.mjs create mode 100644 web/solver-report/scripts/create-privacy-contact-sheets.mjs create mode 100644 web/solver-report/scripts/create-public-screenshots.mjs create mode 100644 web/solver-report/scripts/public-release-policy.mjs create mode 100644 web/solver-report/scripts/validate-deployed-report.mjs create mode 100644 web/solver-report/scripts/validate-report-web.mjs create mode 100644 web/solver-report/scripts/validate-screenshot-privacy-ocr.mjs create mode 100644 web/solver-report/scripts/verify-static-release.py create mode 100644 web/solver-report/source/index.html create mode 100644 web/solver-report/source/report.css create mode 100644 web/solver-report/source/report.js create mode 100644 web/solver-report/tests/public-release-policy.test.mjs create mode 100644 web/solver-report/tests/source-contract.test.mjs diff --git a/agent.json b/agent.json index d2f5e68..5153b6e 100644 --- a/agent.json +++ b/agent.json @@ -2,7 +2,7 @@ "name": "DesireCore", "$schema": "http://desirecore/schemas/agent-seed.json", "id": "7da73b7f-bb08-4e7b-a3cf-5d4af6e22c7f", - "version": "1.10.0", + "version": "1.11.0", "requiredClientVersion": "10.0.100", "description": "系统中枢调度器,负责任务分发、Agent 编排与全局状态监控", "author": "DesireCore Team", diff --git a/web/solver-report/.gitignore b/web/solver-report/.gitignore new file mode 100644 index 0000000..7b85b0e --- /dev/null +++ b/web/solver-report/.gitignore @@ -0,0 +1,3 @@ +deployment-validation.json +validation.json +dist/ diff --git a/web/solver-report/README.md b/web/solver-report/README.md new file mode 100644 index 0000000..4f8c44a --- /dev/null +++ b/web/solver-report/README.md @@ -0,0 +1,60 @@ +# Solver Report Web + +Version-controlled source and build tooling for the public Solver Agent Team evidence report. + +This directory owns the Web application, interaction behavior, build contract, validators, and deployment verification scripts. Scenario packs, runtime receipts, and original screenshots remain external build inputs so the core DesireCore Agent bootstrap does not grow by hundreds of megabytes. + +## Image viewer + +The screenshot viewer supports: + +- fit-to-window and 1:1 pixel modes; +- bounded zoom buttons and `+` / `-` / `0` / `1` keyboard shortcuts, including 1:1 source pixels on mobile; +- `Ctrl`/`Command` + wheel or trackpad pinch zoom around the pointer; +- double-click zoom and mouse/touch drag panning; +- two-pointer touch pinch zoom; +- an original-file download link and accessible live zoom status. + +Each newly opened image starts in fit mode. Closing and reopening the viewer does not retain stale zoom or pan state. + +## 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. + +Public builds also require `evidence/public-release-attestation.json`. The [Draft-07 Schema](./public-release-attestation.schema.json) binds the review to the content roots of every public message and all 174 screenshots, together with the review methods, evidence ownership, and public-release verdict. Any message or screenshot change invalidates the old attestation and fails the build. + +The site consumes `evidence/screenshots/public-screenshot-plan.json`, a derivation plan whose `sourceSha256` values bind every public image to the immutable raw evidence. Raw screenshots containing local paths must never be used as public originals. + +```bash +export SOLVER_REPORT_INPUT_ROOT=/absolute/path/to/scenario-book +export DESIRECORE_APP_ROOT=/absolute/path/to/desirecore +node scripts/build-report-web.mjs +node scripts/validate-report-web.mjs +``` + +Optional environment variables: + +- `SOLVER_REPORT_OUTPUT_ROOT`: override the generated directory. To make recursive cleanup safe, it must remain below `/report-web/` and must not overlap the Agent source or DesireCore application checkout. +- `LATEST_PLATFORM_REGRESSION_FILE`: override the latest regression summary. +- `SOLVER_REPORT_PUBLIC_RELEASE_ATTESTATION_FILE`: override the public-release attestation path. +- `SOLVER_REPORT_VALIDATION_FILE`: override the validation report path. +- `REPORT_BASE_URL`: public URL used by `validate-deployed-report.mjs`. +- `SOLVER_REPORT_DEPLOYMENT_VALIDATION_FILE`: override deployment validation output. + +Run the source contract tests with `node --test tests/*.test.mjs`. + +Before signing a public-release attestation, review every original screenshot at readable resolution. Contact sheets are only a hash-labelled coverage index; they are not sufficient for reading small credentials. On macOS, run the original-resolution Apple Vision OCR gate as a second, independent check: + +```bash +export SOLVER_REPORT_SCREENSHOT_OCR_FILE=/private/tmp/solver-report-ocr.json +export SOLVER_REPORT_SCREENSHOT_OCR_VALIDATION_FILE=/private/tmp/solver-report-ocr-validation.json +npm run privacy:ocr +``` + +The validator requires all 174 planned files in order, recomputes every source hash, and applies the same fail-closed sensitive-text policy to recognized text. OCR and full-resolution visual review are both required before updating the attestation. Review-only artifacts never enter the site output or Agent bootstrap. + +## Deployment boundary + +The builder projects only allowlisted public provenance fields and rejects local paths, email addresses, private URLs, common key formats, and named credentials. Deployment validation also requires `build.json#gates.publicReleasePrivacy=pass` and an attestation digest matching the manifest. + +Generate a new immutable release, verify every `integrity.json` entry before switching, and atomically update the server's `current` symlink. Never overwrite an existing release or relabel historical evidence as output from a newer platform commit. diff --git a/web/solver-report/README.zh-CN.md b/web/solver-report/README.zh-CN.md new file mode 100644 index 0000000..0611c00 --- /dev/null +++ b/web/solver-report/README.zh-CN.md @@ -0,0 +1,60 @@ +# 求解器验证书 Web + +这里版本化管理“求解器智能体团队验证书”的 Web 源码与构建工具。 + +本目录负责 Web 应用、交互行为、构建契约、静态验证器和部署验证脚本。场景包、运行回执和原始截图继续作为外部构建输入,避免让 DesireCore 核心 Agent 的 bootstrap 体积增加数百 MB。 + +## 图片查看器 + +截图预览支持: + +- 适应窗口与 1:1 原始像素模式; +- 有上下限的缩放按钮,以及 `+` / `-` / `0` / `1` 键盘快捷键(移动端也能达到截图的 1:1 原始像素); +- `Ctrl`/`Command` + 滚轮或触控板捏合,以指针位置为中心缩放; +- 双击缩放、鼠标/单指拖动平移; +- 双指触控捏合缩放; +- 原图下载入口和可访问的实时缩放状态。 + +每次打开新图片都从“适应窗口”开始;关闭后重新打开不会保留旧的缩放或平移状态。 + +## 构建 + +构建输入目录需要包含 `scenario-packs/`、`evidence/`,产物默认写到 `report-web/dist/`。Markdown、公式和图片处理依赖从 DesireCore 应用 checkout 解析。 + +公网构建还必须提供 `evidence/public-release-attestation.json`。它按 [Draft-07 Schema](./public-release-attestation.schema.json) 绑定全部公开消息与 174 张截图的内容根哈希,并明确记录审查方法、证据归属和公开发布结论。消息或截图发生任何变化,旧证明都会失效,构建直接失败。 + +站点只消费 `evidence/screenshots/public-screenshot-plan.json`。该派生计划通过每张图的 `sourceSha256` 将公开脱敏图绑定到不可变原始证据;含本地路径的原始截图绝不能直接作为公网原图。 + +```bash +export SOLVER_REPORT_INPUT_ROOT=/absolute/path/to/scenario-book +export DESIRECORE_APP_ROOT=/absolute/path/to/desirecore +node scripts/build-report-web.mjs +node scripts/validate-report-web.mjs +``` + +可选环境变量: + +- `SOLVER_REPORT_OUTPUT_ROOT`:覆盖生成路径;为避免递归清理误伤,它必须位于 `/report-web/` 的子目录内,且不得与 Agent 源码或 DesireCore 应用目录重叠。 +- `LATEST_PLATFORM_REGRESSION_FILE`:覆盖最新回归摘要路径。 +- `SOLVER_REPORT_PUBLIC_RELEASE_ATTESTATION_FILE`:覆盖公开发布证明路径。 +- `SOLVER_REPORT_VALIDATION_FILE`:覆盖静态验证结果路径。 +- `REPORT_BASE_URL`:`validate-deployed-report.mjs` 使用的公网地址。 +- `SOLVER_REPORT_DEPLOYMENT_VALIDATION_FILE`:覆盖部署验证结果路径。 + +源码契约测试:`node --test tests/*.test.mjs`。 + +签署公开发布证明前,必须以可读分辨率逐张检查全部原始截图。联系表只用于核对场景 ID、截图哈希和覆盖范围,不能替代小字凭据审查。在 macOS 上还要运行原图级 Apple Vision OCR 门禁,作为第二条独立检查路径: + +```bash +export SOLVER_REPORT_SCREENSHOT_OCR_FILE=/private/tmp/solver-report-ocr.json +export SOLVER_REPORT_SCREENSHOT_OCR_VALIDATION_FILE=/private/tmp/solver-report-ocr-validation.json +npm run privacy:ocr +``` + +验证器要求 174 张计划内原图按顺序全部出现,重新计算每张源图哈希,并对识别文字复用 fail-closed 敏感信息策略。OCR 与全分辨率人工视觉审查必须同时通过后,才能更新公开发布证明;审查产物不会进入站点或 Agent bootstrap。 + +## 部署边界 + +构建器只允许公开 provenance 白名单字段,并会拒绝本机路径、邮箱、私有 URL、常见密钥和具名凭据。部署验证同时要求 `build.json#gates.publicReleasePrivacy=pass` 且证明哈希与 manifest 一致。 + +每次生成新的不可变 release,切换前逐项验证 `integrity.json`,然后原子更新服务端 `current` 软链接。禁止覆盖既有 release,也禁止把历史证据重新标记为新版平台产物。 diff --git a/web/solver-report/package.json b/web/solver-report/package.json new file mode 100644 index 0000000..d347dba --- /dev/null +++ b/web/solver-report/package.json @@ -0,0 +1,15 @@ +{ + "name": "@desirecore/solver-report-web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test tests/*.test.mjs", + "build": "node scripts/build-report-web.mjs", + "privacy:contact-sheets": "node scripts/create-privacy-contact-sheets.mjs", + "privacy:ocr": "swift scripts/audit-screenshot-privacy.swift \"$SOLVER_REPORT_INPUT_ROOT\" \"${SOLVER_REPORT_SCREENSHOT_PLAN_FILE:-$SOLVER_REPORT_INPUT_ROOT/evidence/screenshots/screenshot-plan.json}\" \"$SOLVER_REPORT_SCREENSHOT_OCR_FILE\" && node scripts/validate-screenshot-privacy-ocr.mjs", + "privacy:redact": "node scripts/create-public-screenshots.mjs", + "validate": "node scripts/validate-report-web.mjs", + "validate:deployed": "node scripts/validate-deployed-report.mjs" + } +} diff --git a/web/solver-report/public-release-attestation.schema.json b/web/solver-report/public-release-attestation.schema.json new file mode 100644 index 0000000..7aca3ce --- /dev/null +++ b/web/solver-report/public-release-attestation.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://desirecore.dev/schemas/solver-public-release-attestation-v1.json", + "title": "Solver report public release attestation", + "description": "Binds a public-release privacy review to the exact transcript and screenshot evidence roots consumed by the report builder.", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "audience", "review", "evidence", "metadata"], + "properties": { + "schemaVersion": { "const": "solver.public-release-attestation/v1", "description": "Attestation contract version." }, + "audience": { "const": "public", "description": "Confirms that the reviewed output is approved for a public audience." }, + "review": { + "type": "object", + "additionalProperties": false, + "required": ["status", "reviewedAt", "reviewer", "methods", "assertions"], + "properties": { + "status": { "const": "passed", "description": "Fail-closed public privacy review result." }, + "reviewedAt": { "type": "string", "format": "date-time", "description": "Time at which the bound evidence was reviewed." }, + "reviewer": { "type": "string", "minLength": 1, "description": "Accountable reviewer or release process identity." }, + "methods": { "type": "array", "minItems": 1, "items": { "type": "string" }, "description": "Review methods used for transcripts and screenshots." }, + "assertions": { + "type": "object", + "additionalProperties": false, + "required": ["transcriptsContainNoSensitiveData", "screenshotsContainNoSensitiveData", "customerIdentifiersExcluded"], + "properties": { + "transcriptsContainNoSensitiveData": { "const": true, "description": "All published transcript text was reviewed for secrets and personal data." }, + "screenshotsContainNoSensitiveData": { "const": true, "description": "Every screenshot hash in the bound root was visually reviewed for secrets and personal data." }, + "customerIdentifiersExcluded": { "const": true, "description": "Customer names, codenames, and private identifiers are excluded." } + } + } + } + }, + "evidence": { + "type": "object", + "additionalProperties": false, + "required": ["publicTranscriptContentRootSha256", "publicScreenshotContentRootSha256", "screenshotPlanSha256", "screenshotValidationSha256", "screenshotPrivacyValidationSha256"], + "properties": { + "publicTranscriptContentRootSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "Canonical root hash of every public user and Agent message." }, + "publicScreenshotContentRootSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "Canonical root hash binding every reviewed screenshot path, digest, and dimension." }, + "screenshotPlanSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "Canonical screenshot plan digest." }, + "screenshotValidationSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "Canonical screenshot validation digest." }, + "screenshotPrivacyValidationSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "Canonical digest of the full-resolution OCR privacy validation bound to every public screenshot." } + } + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "required": ["platformCommit", "agentSkillVersion", "representativeStabilityPasses"], + "properties": { + "platformCommit": { "type": "string", "minLength": 7, "description": "Platform commit that owns the historical evidence." }, + "agentSkillVersion": { "type": "string", "minLength": 1, "description": "Solver Agent Skill version that produced the historical evidence." }, + "representativeStabilityPasses": { "type": "integer", "minimum": 0, "description": "Number of representative stability passes supported by the evidence." } + } + } + } +} diff --git a/web/solver-report/scripts/audit-screenshot-privacy.swift b/web/solver-report/scripts/audit-screenshot-privacy.swift new file mode 100644 index 0000000..1d864aa --- /dev/null +++ b/web/solver-report/scripts/audit-screenshot-privacy.swift @@ -0,0 +1,99 @@ +#!/usr/bin/env swift +import AppKit +import Foundation +import Vision + +enum AuditError: Error, CustomStringConvertible { + case usage + case malformedPlan(String) + case unsafePath(String) + case unreadableImage(String) + + var description: String { + switch self { + case .usage: + return "usage: audit-screenshot-privacy.swift " + case .malformedPlan(let detail): + return "malformed screenshot plan: \(detail)" + case .unsafePath(let path): + return "screenshot path escapes input root: \(path)" + case .unreadableImage(let path): + return "cannot decode screenshot: \(path)" + } + } +} + +func requiredString(_ object: [String: Any], _ key: String) throws -> String { + guard let value = object[key] as? String, !value.isEmpty else { + throw AuditError.malformedPlan("missing \(key)") + } + return value +} + +do { + guard CommandLine.arguments.count == 4 else { throw AuditError.usage } + let inputRoot = URL(fileURLWithPath: CommandLine.arguments[1]).standardizedFileURL + let planURL = URL(fileURLWithPath: CommandLine.arguments[2]).standardizedFileURL + let outputURL = URL(fileURLWithPath: CommandLine.arguments[3]).standardizedFileURL + let planData = try Data(contentsOf: planURL) + guard + let plan = try JSONSerialization.jsonObject(with: planData) as? [String: Any], + let scenarios = plan["scenarios"] as? [[String: Any]] + else { + throw AuditError.malformedPlan("scenarios must be an array") + } + + var entries: [[String: Any]] = [] + for scenario in scenarios { + let scenarioId = try requiredString(scenario, "scenarioId") + guard let screenshots = scenario["screenshots"] as? [[String: Any]] else { + throw AuditError.malformedPlan("\(scenarioId).screenshots must be an array") + } + for screenshot in screenshots { + let relativePath = try requiredString(screenshot, "file") + let imageURL = inputRoot.appendingPathComponent(relativePath).standardizedFileURL + let rootPrefix = inputRoot.path.hasSuffix("/") ? inputRoot.path : inputRoot.path + "/" + guard imageURL.path.hasPrefix(rootPrefix) else { throw AuditError.unsafePath(relativePath) } + + let recognizedLines: [[String: Any]] = try autoreleasepool { + guard + let image = NSImage(contentsOf: imageURL), + let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) + else { + throw AuditError.unreadableImage(relativePath) + } + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.recognitionLanguages = ["zh-Hans", "en-US"] + request.usesLanguageCorrection = true + try VNImageRequestHandler(cgImage: cgImage, options: [:]).perform([request]) + return (request.results ?? []).compactMap { observation in + guard let text = observation.topCandidates(1).first?.string else { return nil } + let box = observation.boundingBox + return [ + "text": text, + "boundingBox": [box.origin.x, box.origin.y, box.size.width, box.size.height], + ] + } + } + entries.append([ + "scenarioId": scenarioId, + "file": relativePath, + "lines": recognizedLines, + ]) + FileHandle.standardError.write(Data("OCR \(entries.count): \(relativePath)\n".utf8)) + } + } + + let output: [String: Any] = [ + "schemaVersion": "solver.screenshot-privacy-ocr/v1", + "generatedAt": ISO8601DateFormatter().string(from: Date()), + "engine": "Apple Vision VNRecognizeTextRequest accurate zh-Hans+en-US", + "entries": entries, + ] + let outputData = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys]) + try outputData.write(to: outputURL, options: .atomic) +} catch { + FileHandle.standardError.write(Data("\(error)\n".utf8)) + exit(1) +} diff --git a/web/solver-report/scripts/build-report-web.mjs b/web/solver-report/scripts/build-report-web.mjs new file mode 100644 index 0000000..f356fd6 --- /dev/null +++ b/web/solver-report/scripts/build-report-web.mjs @@ -0,0 +1,591 @@ +import { createRequire } from 'node:module' +import { cpSync, existsSync, globSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { basename, dirname, extname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + assertPublicText, + canonical, + computePublicEvidenceRoots, + loadPublicReleaseAttestation, + projectPublicProvenance, + resolveSafeOutputRoot, + sha256, +} from './public-release-policy.mjs' + +const codeRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const inputRoot = resolve(process.env.SOLVER_REPORT_INPUT_ROOT ?? process.env.BUILDER_ROOT ?? process.cwd()) +const configuredAppRoot = process.env.DESIRECORE_APP_ROOT +if (!configuredAppRoot) throw new Error('DESIRECORE_APP_ROOT is required') +const appRoot = resolve(configuredAppRoot) +const requireFromApp = createRequire(join(appRoot, 'package.json')) +const { marked } = requireFromApp('marked') +const katex = requireFromApp('katex') +const sharp = requireFromApp('sharp') +const Ajv = requireFromApp('ajv').default +const addFormats = requireFromApp('ajv-formats').default +const ajv = new Ajv({ allErrors: true, strict: true }) +addFormats(ajv) +const validatePublicReleaseAttestationSchema = ajv.compile( + JSON.parse(readFileSync(join(codeRoot, 'public-release-attestation.schema.json'), 'utf8')) +) + +const outputRoot = resolveSafeOutputRoot({ + inputRoot, + codeRoot, + appRoot, + requestedOutputRoot: process.env.SOLVER_REPORT_OUTPUT_ROOT ?? join(inputRoot, 'report-web', 'dist'), +}) +const sourceRoot = join(codeRoot, 'source') +const sourceScreenshotPlan = JSON.parse( + readFileSync(join(inputRoot, 'evidence', 'screenshots', 'screenshot-plan.json'), 'utf8') +) +const publicScreenshotPlanFile = + process.env.SOLVER_REPORT_PUBLIC_SCREENSHOT_PLAN_FILE ?? + join(inputRoot, 'evidence', 'screenshots', 'public-screenshot-plan.json') +const screenshotPlan = JSON.parse(readFileSync(publicScreenshotPlanFile, 'utf8')) +const screenshotValidation = JSON.parse( + readFileSync(join(inputRoot, 'evidence', 'screenshots', 'screenshot-validation.json'), 'utf8') +) +const screenshotPrivacyValidation = JSON.parse( + readFileSync( + process.env.SOLVER_REPORT_SCREENSHOT_PRIVACY_VALIDATION_FILE ?? + join(inputRoot, 'evidence', 'screenshots', 'public-screenshot-privacy-validation.json'), + 'utf8' + ) +) +const historicalBuildManifest = JSON.parse(readFileSync(join(inputRoot, 'build-manifest.json'), 'utf8')) +const stabilityMatrix = JSON.parse(readFileSync(join(inputRoot, 'evidence', 'stability', 'stability-matrix.json'), 'utf8')) +if (!screenshotValidation.passed || !stabilityMatrix.passed) + throw new Error('M8 evidence gates must pass before Web build') +if ( + screenshotPlan.schemaVersion !== 'solver.public-screenshot-plan/v1' || + screenshotPlan.sourcePlanSha256 !== sha256(canonical(sourceScreenshotPlan)) || + screenshotPlan.scenarios.length !== sourceScreenshotPlan.scenarios.length +) { + throw new Error('public screenshot plan is missing or differs from the source evidence plan') +} +for (const sourceScenario of sourceScreenshotPlan.scenarios) { + const publicScenario = screenshotPlan.scenarios.find((candidate) => candidate.scenarioId === sourceScenario.scenarioId) + if ( + !publicScenario || + publicScenario.runId !== sourceScenario.runId || + publicScenario.conversationId !== sourceScenario.conversationId || + publicScenario.screenshots.length !== sourceScenario.screenshots.length || + publicScenario.screenshots.some( + (screenshot, index) => screenshot.sourceSha256 !== sourceScenario.screenshots[index].sha256 + ) + ) { + throw new Error(`${sourceScenario.scenarioId}: public screenshot derivation differs from source evidence`) + } +} +const latestRegressionFile = + process.env.LATEST_PLATFORM_REGRESSION_FILE ?? + join(inputRoot, 'evidence', 'latest-platform-regression-20260830', 'summary.json') +const latestPlatformRegression = JSON.parse(readFileSync(latestRegressionFile, 'utf8')) +if ( + latestPlatformRegression.schemaVersion !== 'solver.latest-platform-regression/v1' || + latestPlatformRegression.passed !== true +) { + throw new Error('latest platform regression summary is missing, unsupported, or not passed') +} +const historicalPlatformCommit = historicalBuildManifest.platform?.commit +const historicalAgentSkillVersion = historicalBuildManifest.skill?.version +const representativeStabilityPasses = stabilityMatrix.groups?.reduce( + (total, group) => total + 1 + (group.freshAndPerturbedRuns?.length ?? 0), + 0 +) +if ( + historicalBuildManifest.schema_version !== 'solver-agent-team-build/v1' || + typeof historicalPlatformCommit !== 'string' || + typeof historicalAgentSkillVersion !== 'string' || + !Number.isInteger(representativeStabilityPasses) || + representativeStabilityPasses < 1 +) { + throw new Error('historical build manifest cannot derive public evidence ownership') +} +if ( + !historicalPlatformCommit.startsWith(latestPlatformRegression.historicalEvidence?.platformCommit ?? '') || + latestPlatformRegression.historicalEvidence?.representativeStabilityPasses !== representativeStabilityPasses +) { + throw new Error('latest regression historical attribution differs from the source evidence') +} + +const scenarioOrder = [ + 'rw.territory-assignment', + 'rw.resource-allocation', + 'rw.performance-target', + 'rw.customer-task-scheduling', + 'rw.workforce-shift', + 'rw.insufficient-capacity-iis', + 'of.api-baseline', + 'of.diet', + 'of.facility-location', + 'of.weekly-workforce', + 'of.soap-production', + 'of.ad-traffic', + 'of.min-cost-flow', + 'of.max-flow', + 'of.portfolio', + 'of.iis-diagnosis', + 'of.seven-day-shift', + 'boundary.qp-unsupported', + 'qcp.risk-budget', + 'cp.single-machine-detection', + 'exploratory.two-person-shift', + 'validation.independent-pass', + 'validation.independent-fail', + 'recovery.cold-start', +] + +const sectionIds = [ + 'scenario-and-decision', + 'input-and-provenance', + 'data-quality', + 'model-definition', + 'train-validation-test', + 'solve-process', + 'solve-result', + 'independent-validation', + 'assumptions-risks-boundary', + 'reproduction-and-conclusion', +] + +const readJsonLines = (file) => + readFileSync(file, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) +const escapeHtml = (value) => + String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + +let activeFormulaStats = null +const renderer = new marked.Renderer() +renderer.html = ({ text }) => escapeHtml(text) +marked.use({ + extensions: [ + { + name: 'blockMath', + level: 'block', + start: (source) => source.indexOf('\\['), + tokenizer(source) { + const match = /^\\\[([\s\S]*?)\\\](?:\n|$)/.exec(source) + return match ? { type: 'blockMath', raw: match[0], text: match[1].trim() } : undefined + }, + renderer(token) { + if (activeFormulaStats) { + activeFormulaStats.source += 1 + activeFormulaStats.display += 1 + } + const html = katex.renderToString(token.text, { + throwOnError: true, + strict: 'ignore', + trust: false, + output: 'htmlAndMathml', + displayMode: true, + }) + if (activeFormulaStats) activeFormulaStats.rendered += 1 + return `${html}\n` + }, + }, + { + name: 'inlineMath', + level: 'inline', + start: (source) => source.indexOf('\\('), + tokenizer(source) { + const match = /^\\\(([^\n]+?)\\\)/.exec(source) + return match ? { type: 'inlineMath', raw: match[0], text: match[1].trim() } : undefined + }, + renderer(token) { + if (activeFormulaStats) activeFormulaStats.source += 1 + const html = katex.renderToString(token.text, { + throwOnError: true, + strict: 'ignore', + trust: false, + output: 'htmlAndMathml', + displayMode: false, + }) + if (activeFormulaStats) activeFormulaStats.rendered += 1 + return html + }, + }, + ], +}) + +const renderMarkdown = (markdown) => { + const html = marked.parse(markdown, { gfm: true, breaks: true, renderer }) + if (typeof html !== 'string') throw new Error('marked returned non-string output') + return html +} + +const splitFinalReport = (markdown, scenarioId) => { + const matches = [...markdown.matchAll(/^##\s+(10|[1-9])\.\s+(.+)\s*$/gm)] + if (matches.length !== 10 || matches.some((match, index) => Number(match[1]) !== index + 1)) { + throw new Error(`${scenarioId}: final report is not the required ten-part structure`) + } + return matches.map((match, index) => { + const start = match.index + match[0].length + const end = matches[index + 1]?.index ?? markdown.length + const source = markdown.slice(start, end).trim() + return { + id: sectionIds[index], + number: index + 1, + title: match[2].trim(), + markdown: source, + markdownSha256: sha256(source), + html: renderMarkdown(source), + } + }) +} + +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 caseRunById = new Map( + globSync(join(inputRoot, 'evidence', '**', 'case-run.json')).map((file) => { + const data = JSON.parse(readFileSync(file, 'utf8')) + return [data.runId, { file, data, runDir: join(dirname(file), 'run') }] + }) +) + +const parseSummaryJson = (event) => { + try { + return JSON.parse(event.data?.summary ?? '') + } catch { + return null + } +} + +const buildChapter = async (planItem, order) => { + const source = caseRunById.get(planItem.runId) + if (!source) throw new Error(`${planItem.scenarioId}: accepted Run evidence missing: ${planItem.runId}`) + const packEntry = packById.get(planItem.scenarioId) + if (!packEntry) throw new Error(`${planItem.scenarioId}: Scenario Pack missing`) + const { pack } = packEntry + const messagesFile = join(source.runDir, 'messages.jsonl') + const sessionFile = join(source.runDir, 'sessions', 'session.jsonl') + const invocationsFile = join(source.runDir, 'receipts', 'tool-invocations.jsonl') + const messages = readJsonLines(messagesFile) + const session = readJsonLines(sessionFile) + const invocations = readJsonLines(invocationsFile) + const conversationMessages = messages.filter((item) => ['user', 'assistant'].includes(item.role)) + const finalAssistant = conversationMessages.filter((item) => item.role === 'assistant' && item.content?.trim()).at(-1) + if (!finalAssistant) throw new Error(`${planItem.scenarioId}: final assistant message missing`) + + activeFormulaStats = { source: 0, rendered: 0, display: 0 } + const renderedMessages = conversationMessages.map((message, index) => ({ + kind: 'message', + sequence: index + 1, + role: message.role, + timestamp: message.timestamp, + markdown: message.content ?? '', + markdownSha256: sha256(message.content ?? ''), + html: message.content?.trim() ? renderMarkdown(message.content) : '', + empty: !message.content?.trim(), + })) + const toolEvents = invocations + .filter((item) => item.phase === 'finished') + .map((item, index) => ({ + kind: 'tool', + sequence: index + 1, + timestamp: Date.parse(item.timestamp), + tool: item.tool_name, + status: item.status, + durationMs: item.duration_ms ?? null, + provenance: projectPublicProvenance(item.result_provenance), + })) + const sections = splitFinalReport(finalAssistant.content, planItem.scenarioId) + 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 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 problemFamily = + solve?.result_provenance?.problem_family ?? + validate?.result_provenance?.problem_family ?? + pack.oracle?.expectedProblemFamily ?? + 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 media = [] + for (const screenshot of planItem.screenshots) { + const sourceFile = join(inputRoot, screenshot.file) + const name = basename(sourceFile) + const originalRel = `media/original/${planItem.scenarioId}/${name}` + const thumb756Rel = `media/thumb-756/${planItem.scenarioId}/${name.replace(/\.png$/, '.webp')}` + const thumb1512Rel = `media/thumb-1512/${planItem.scenarioId}/${name.replace(/\.png$/, '.webp')}` + const originalTarget = join(outputRoot, originalRel) + const thumb756Target = join(outputRoot, thumb756Rel) + const thumb1512Target = join(outputRoot, thumb1512Rel) + mkdirSync(dirname(originalTarget), { recursive: true }) + mkdirSync(dirname(thumb756Target), { recursive: true }) + mkdirSync(dirname(thumb1512Target), { recursive: true }) + cpSync(sourceFile, originalTarget) + await sharp(sourceFile) + .resize({ width: 756, withoutEnlargement: true }) + .webp({ lossless: true, effort: 4 }) + .toFile(thumb756Target) + await sharp(sourceFile) + .resize({ width: 1512, withoutEnlargement: true }) + .webp({ lossless: true, effort: 4 }) + .toFile(thumb1512Target) + media.push({ + stage: + media.length === 0 + ? 'conversation-start' + : media.length === planItem.screenshots.length - 1 + ? 'delivery-conclusion' + : 'conversation-process', + original: originalRel, + thumb756: thumb756Rel, + thumb1512: thumb1512Rel, + width: screenshot.width, + height: screenshot.height, + sha256: screenshot.sha256, + alt: `${pack.manifest.title}真实多轮会话第 ${media.length + 1} 段,展示${media.length === 0 ? '用户请求与智能体补问' : media.length === planItem.screenshots.length - 1 ? '验证结论与复现证据' : '工具过程或报告正文'}`, + }) + } + + const formulaStats = { ...activeFormulaStats } + activeFormulaStats = null + const chapter = { + schemaVersion: 'solver.web-report-chapter/v1', + scenarioId: planItem.scenarioId, + scenarioVersion: pack.manifest.version, + scenarioOrder: order, + title: pack.manifest.title, + scenarioFamily: pack.manifest.scenarioFamily, + claimBoundary: pack.manifest.claimBoundary, + tags: pack.manifest.tags, + story: pack.businessContract.plainLanguageStory, + decision: pack.businessContract.decision, + professionalSummary: pack.businessContract.professionalSummary, + objectives: pack.businessContract.objectives, + constraints: pack.businessContract.constraints, + baseline: pack.businessContract.baseline, + outOfScope: pack.businessContract.outOfScope, + problemFamily, + resultStatus: pack.oracle.expectedStatuses?.[0] ?? source.data.status, + validationVerdict: source.data.diagnostics?.observedValidationVerdict ?? null, + engineId: solve?.result_provenance?.engine_id ?? null, + engineRequirement: solve?.result_provenance + ? { + problemFamily: solve.result_provenance.problem_family, + irFamily: solve.result_provenance.ir_family, + irVersion: solve.result_provenance.ir_version, + 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, + }, + counts: { + rawConversationMessages: conversationMessages.length, + visibleConversationMessages: renderedMessages.filter((item) => !item.empty).length, + toolEvents: toolEvents.length, + screenshots: media.length, + }, + formulaStats, + timeline, + sections, + validation, + stability: stability + ? { + policy: '3 fresh + 1 natural-language perturbation', + effectivePasses: 4, + semanticConvergence: stability.assertions.semanticConvergence, + singleSolvePerRun: stability.assertions.singleSolvePerRun, + semanticSha256: stability.freshAndPerturbedRuns[0].optimizationSemanticHash, + engineRequirementSha256: stability.freshAndPerturbedRuns[0].engineRequirementHash, + } + : { policy: 'standard real Run accepted; representative families use 3+1 gate', effectivePasses: 1 }, + media, + } + assertPublicText(chapter, `${planItem.scenarioId} chapter`) + chapter.contentSha256 = sha256(canonical(chapter)) + return chapter +} + +const publicEvidenceRoots = computePublicEvidenceRoots({ + inputRoot, + screenshotPlan, + screenshotValidation, + screenshotPrivacyValidation, + caseRunById, +}) +const publicAttestationFile = + process.env.SOLVER_REPORT_PUBLIC_RELEASE_ATTESTATION_FILE ?? + join(inputRoot, 'evidence', 'public-release-attestation.json') +const { attestation: publicReleaseAttestation, sha256: publicReleaseAttestationSha256 } = + loadPublicReleaseAttestation(publicAttestationFile, publicEvidenceRoots) +if (!validatePublicReleaseAttestationSchema(publicReleaseAttestation)) { + throw new Error(`public release attestation schema validation failed: ${ajv.errorsText(validatePublicReleaseAttestationSchema.errors)}`) +} +if ( + !historicalPlatformCommit.startsWith(publicReleaseAttestation.metadata.platformCommit) || + publicReleaseAttestation.metadata.agentSkillVersion !== historicalAgentSkillVersion || + publicReleaseAttestation.metadata.representativeStabilityPasses !== representativeStabilityPasses +) { + throw new Error('public release attestation metadata differs from derived source evidence') +} +assertPublicText(latestPlatformRegression, 'latest platform regression') + +rmSync(outputRoot, { recursive: true, force: true }) +mkdirSync(join(outputRoot, 'assets'), { recursive: true }) +mkdirSync(join(outputRoot, 'data', 'chapters'), { recursive: true }) +for (const name of ['index.html', 'report.css', 'report.js']) + cpSync(join(sourceRoot, name), join(outputRoot, name === 'index.html' ? name : `assets/${name}`)) +cpSync(join(appRoot, 'node_modules', 'katex', 'dist', 'katex.min.css'), join(outputRoot, 'assets', 'katex.min.css')) +cpSync(join(appRoot, 'node_modules', 'katex', 'dist', 'fonts'), join(outputRoot, 'assets', 'fonts'), { + recursive: true, +}) + +const chapters = [] +for (const [index, scenarioId] of scenarioOrder.entries()) { + const item = screenshotPlan.scenarios.find((candidate) => candidate.scenarioId === scenarioId) + if (!item) throw new Error(`screenshot plan missing ${scenarioId}`) + const chapter = await buildChapter(item, index + 1) + const fileName = `${String(index + 1).padStart(2, '0')}-${scenarioId}.json` + const jsName = fileName.replace(/\.json$/, '.js') + writeFileSync(join(outputRoot, 'data', 'chapters', fileName), `${JSON.stringify(chapter)}\n`) + writeFileSync( + join(outputRoot, 'data', 'chapters', jsName), + `window.__REPORT_CHAPTERS__=window.__REPORT_CHAPTERS__||{};window.__REPORT_CHAPTERS__[${JSON.stringify(scenarioId)}]=${JSON.stringify(chapter)};\n` + ) + chapters.push({ + scenarioId, + order: index + 1, + title: chapter.title, + story: chapter.story, + problemFamily: chapter.problemFamily, + resultStatus: chapter.resultStatus, + validationVerdict: chapter.validationVerdict, + engineId: chapter.engineId, + json: `data/chapters/${fileName}`, + script: `data/chapters/${jsName}`, + sha256: chapter.contentSha256, + bytes: statSync(join(outputRoot, 'data', 'chapters', fileName)).size, + screenshots: chapter.media.length, + }) +} + +const historicalEvidenceContentRootSha256 = sha256( + canonical(chapters.map((item) => ({ scenarioId: item.scenarioId, sha256: item.sha256 }))) +) +const latestPlatformRegressionSha256 = sha256(canonical(latestPlatformRegression)) +const contentRootSha256 = sha256(canonical({ historicalEvidenceContentRootSha256, latestPlatformRegressionSha256 })) +const manifest = { + schemaVersion: 'solver.web-report-manifest/v1', + buildId: `${new Date().toISOString().replace(/[-:.]/g, '').slice(0, 15)}Z-${contentRootSha256.slice(0, 12)}`, + title: '通用求解器智能体团队 · 真实场景验证书', + generatedAt: new Date().toISOString(), + contentRootSha256, + reportVersion: '3.3.0', + platformCommit: latestPlatformRegression.platform.commit, + agentSkillVersion: latestPlatformRegression.platform.agentSkillVersion, + evidencePlatformCommit: historicalPlatformCommit, + publicReleaseAttestationSha256, + publicEvidenceRoots, + historicalEvidenceContentRootSha256, + latestPlatformRegressionSha256, + latestPlatformRegression, + counts: { + chapters: chapters.length, + screenshots: screenshotPlan.scenarios.reduce((total, item) => total + item.screenshots.length, 0), + representativeStabilityPasses, + }, + chapters, + rendering: { + markdown: { engine: 'marked', version: requireFromApp('marked/package.json').version }, + math: { engine: 'katex', version: requireFromApp('katex/package.json').version, output: 'htmlAndMathml' }, + }, +} +writeFileSync(join(outputRoot, 'data', 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`) +writeFileSync(join(outputRoot, 'data', 'manifest.js'), `window.__REPORT_MANIFEST__=${JSON.stringify(manifest)};\n`) +writeFileSync( + join(outputRoot, 'data', 'latest-platform-regression.json'), + `${JSON.stringify(latestPlatformRegression, null, 2)}\n` +) + +const mime = (file) => + ({ + '.html': 'text/html', + '.css': 'text/css', + '.js': 'text/javascript', + '.json': 'application/json', + '.png': 'image/png', + '.webp': 'image/webp', + '.woff2': 'font/woff2', + })[extname(file)] ?? 'application/octet-stream' +const integrityEntriesBeforeBuild = globSync(join(outputRoot, '**', '*')).filter((file) => statSync(file).isFile()).length +const expectedIntegrityEntries = integrityEntriesBeforeBuild + 1 +writeFileSync( + join(outputRoot, 'build.json'), + `${JSON.stringify( + { + schemaVersion: 'solver.web-report-build/v1', + buildId: manifest.buildId, + contentRootSha256, + counts: manifest.counts, + integrityEntries: expectedIntegrityEntries, + gates: { + scenarioPacks: 'pass', + stability: 'pass', + screenshots: 'pass', + publicReleasePrivacy: 'pass', + markdown: 'pass', + math: 'pass', + }, + publicReleaseAttestationSha256, + publicEvidenceRoots, + }, + null, + 2 + )}\n` +) +const integrityEntries = globSync(join(outputRoot, '**', '*')) + .filter((file) => statSync(file).isFile()) + .map((file) => { + const bytes = readFileSync(file) + return { path: relative(outputRoot, file), mime: mime(file), bytes: bytes.length, sha256: sha256(bytes) } + }) + .sort((left, right) => left.path.localeCompare(right.path)) +const integrity = { schemaVersion: 'solver.web-report-integrity/v1', contentRootSha256, entries: integrityEntries } +writeFileSync(join(outputRoot, 'integrity.json'), `${JSON.stringify(integrity, null, 2)}\n`) +if (integrityEntries.length !== expectedIntegrityEntries) throw new Error('integrity entry count changed unexpectedly') + +console.log( + JSON.stringify( + { + outputRoot, + buildId: manifest.buildId, + contentRootSha256, + counts: manifest.counts, + integrityEntries: integrityEntries.length, + }, + null, + 2 + ) +) diff --git a/web/solver-report/scripts/create-privacy-contact-sheets.mjs b/web/solver-report/scripts/create-privacy-contact-sheets.mjs new file mode 100644 index 0000000..9910922 --- /dev/null +++ b/web/solver-report/scripts/create-privacy-contact-sheets.mjs @@ -0,0 +1,75 @@ +import { createRequire } from 'node:module' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + +const inputRoot = resolve(process.env.SOLVER_REPORT_INPUT_ROOT ?? process.cwd()) +const appRoot = process.env.DESIRECORE_APP_ROOT +if (!appRoot) throw new Error('DESIRECORE_APP_ROOT is required') +const reviewRoot = process.env.SOLVER_REPORT_PRIVACY_REVIEW_ROOT +if (!reviewRoot) throw new Error('SOLVER_REPORT_PRIVACY_REVIEW_ROOT is required') +const outputRoot = resolve(reviewRoot) +const requireFromApp = createRequire(join(resolve(appRoot), 'package.json')) +const sharp = requireFromApp('sharp') +const plan = JSON.parse(readFileSync(join(inputRoot, 'evidence', 'screenshots', 'screenshot-plan.json'), 'utf8')) + +const entries = plan.scenarios.flatMap((scenario) => + scenario.screenshots.map((screenshot, index) => ({ + scenarioId: scenario.scenarioId, + index: index + 1, + file: screenshot.file, + sha256: screenshot.sha256, + })) +) +const columns = 5 +const rows = 6 +const tileWidth = 240 +const tileHeight = 175 +const imageHeight = 145 +const perSheet = columns * rows +const escapeXml = (value) => + String(value).replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]) + +mkdirSync(outputRoot, { recursive: true }) +const sheets = [] +for (let offset = 0; offset < entries.length; offset += perSheet) { + const page = entries.slice(offset, offset + perSheet) + const tiles = [] + for (const entry of page) { + const preview = await sharp(join(inputRoot, entry.file)) + .resize({ width: tileWidth, height: imageHeight, fit: 'contain', background: '#0b1120' }) + .png() + .toBuffer() + const label = `${entry.scenarioId} · ${entry.index} · ${entry.sha256.slice(0, 10)}` + const tile = await sharp({ + create: { width: tileWidth, height: tileHeight, channels: 4, background: '#111a2d' }, + }) + .composite([ + { input: preview, left: 0, top: 0 }, + { + input: Buffer.from( + `${escapeXml(label)}` + ), + left: 0, + top: imageHeight, + }, + ]) + .png() + .toBuffer() + const position = tiles.length + tiles.push({ input: tile, left: (position % columns) * tileWidth, top: Math.floor(position / columns) * tileHeight }) + } + const file = `contact-sheet-${String(sheets.length + 1).padStart(2, '0')}.png` + await sharp({ + create: { width: columns * tileWidth, height: rows * tileHeight, channels: 4, background: '#080d18' }, + }) + .composite(tiles) + .png() + .toFile(join(outputRoot, file)) + sheets.push({ file, entries: page }) +} + +writeFileSync( + join(outputRoot, 'review-index.json'), + `${JSON.stringify({ schemaVersion: 'solver.privacy-contact-sheets/v1', screenshots: entries.length, sheets }, null, 2)}\n` +) +console.log(JSON.stringify({ outputRoot, screenshots: entries.length, sheets: sheets.length }, null, 2)) diff --git a/web/solver-report/scripts/create-public-screenshots.mjs b/web/solver-report/scripts/create-public-screenshots.mjs new file mode 100644 index 0000000..be6f1ba --- /dev/null +++ b/web/solver-report/scripts/create-public-screenshots.mjs @@ -0,0 +1,143 @@ +import { createRequire } from 'node:module' +import { cpSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { basename, dirname, join, relative, resolve } from 'node:path' +import { canonical, publicTextViolations, sha256 } from './public-release-policy.mjs' + +const inputRoot = resolve(process.env.SOLVER_REPORT_INPUT_ROOT ?? process.cwd()) +const appRoot = process.env.DESIRECORE_APP_ROOT +const ocrFile = process.env.SOLVER_REPORT_SCREENSHOT_OCR_FILE +if (!appRoot) throw new Error('DESIRECORE_APP_ROOT is required') +if (!ocrFile) throw new Error('SOLVER_REPORT_SCREENSHOT_OCR_FILE is required') +const requireFromApp = createRequire(join(resolve(appRoot), 'package.json')) +const sharp = requireFromApp('sharp') +const planFile = join(inputRoot, 'evidence', 'screenshots', 'screenshot-plan.json') +const outputPlanFile = join(inputRoot, 'evidence', 'screenshots', 'public-screenshot-plan.json') +const outputRoot = join(inputRoot, 'evidence', 'screenshots', 'public-redacted') +const sourcePlan = JSON.parse(readFileSync(planFile, 'utf8')) +const ocr = JSON.parse(readFileSync(ocrFile, 'utf8')) +const sourceScreenshots = sourcePlan.scenarios.flatMap((scenario) => + scenario.screenshots.map((screenshot) => ({ scenarioId: scenario.scenarioId, ...screenshot })) +) +if (ocr.schemaVersion !== 'solver.screenshot-privacy-ocr/v1' || ocr.entries.length !== sourceScreenshots.length) { + throw new Error('OCR result does not cover the source screenshot plan') +} + +const clamp = (value, minimum, maximum) => Math.max(minimum, Math.min(maximum, value)) +const overlaps = (left, right) => + left.left <= right.left + right.width && + right.left <= left.left + left.width && + left.top <= right.top + right.height && + right.top <= left.top + left.height +const mergeRegions = (regions) => { + const merged = [] + for (const region of regions) { + const existing = merged.find((candidate) => overlaps(candidate, region)) + if (!existing) { + merged.push({ ...region }) + continue + } + const right = Math.max(existing.left + existing.width, region.left + region.width) + const bottom = Math.max(existing.top + existing.height, region.top + region.height) + existing.left = Math.min(existing.left, region.left) + existing.top = Math.min(existing.top, region.top) + existing.width = right - existing.left + existing.height = bottom - existing.top + } + return merged +} + +let redactedScreenshots = 0 +let redactionRegions = 0 +const publicByFile = new Map() +for (const [index, source] of sourceScreenshots.entries()) { + const ocrEntry = ocr.entries[index] + const sourceFile = join(inputRoot, source.file) + if ( + ocrEntry?.scenarioId !== source.scenarioId || + ocrEntry?.file !== source.file || + sha256(readFileSync(sourceFile)) !== source.sha256 + ) { + throw new Error(`${source.file}: OCR source identity differs from screenshot plan`) + } + const metadata = await sharp(sourceFile).metadata() + if (metadata.width !== source.width || metadata.height !== source.height) { + throw new Error(`${source.file}: source dimensions differ from screenshot plan`) + } + const proposedRegions = ocrEntry.lines + .filter((line) => publicTextViolations(line.text).length > 0) + .map((line) => { + const [x, y, width, height] = line.boundingBox + const left = clamp(Math.floor(x * source.width) - 12, 0, source.width - 1) + const top = clamp(Math.floor((1 - y - height) * source.height) - 8, 0, source.height - 1) + const right = clamp(Math.ceil((x + width) * source.width) + 12, left + 1, source.width) + const bottom = clamp(Math.ceil((1 - y) * source.height) + Math.ceil(height * source.height * 1.2) + 8, top + 1, source.height) + return { left, top, width: right - left, height: bottom - top, reason: 'private-path' } + }) + const regions = mergeRegions(proposedRegions) + const targetRelative = `evidence/screenshots/public-redacted/${source.scenarioId}/${basename(source.file)}` + const targetFile = join(inputRoot, targetRelative) + if (relative(outputRoot, targetFile).startsWith('..')) throw new Error(`${targetRelative}: unsafe public screenshot target`) + mkdirSync(dirname(targetFile), { recursive: true }) + if (regions.length === 0) cpSync(sourceFile, targetFile) + else { + await sharp(sourceFile) + .composite( + regions.map((region) => ({ + input: { + create: { + width: region.width, + height: region.height, + channels: 4, + background: { r: 32, g: 34, b: 39, alpha: 1 }, + }, + }, + left: region.left, + top: region.top, + })) + ) + .png() + .toFile(targetFile) + redactedScreenshots += 1 + redactionRegions += regions.length + } + publicByFile.set(source.file, { + file: targetRelative, + sha256: sha256(readFileSync(targetFile)), + width: source.width, + height: source.height, + sourceSha256: source.sha256, + redactions: regions, + }) +} + +const publicPlan = { + schemaVersion: 'solver.public-screenshot-plan/v1', + generatedAt: new Date().toISOString(), + sourcePlanSha256: sha256(canonical(sourcePlan)), + derivation: { + method: 'Apple Vision exact-line private-path masking', + redactedScreenshots, + redactionRegions, + }, + scenarios: sourcePlan.scenarios.map((scenario) => ({ + ...scenario, + screenshots: scenario.screenshots.map((screenshot) => publicByFile.get(screenshot.file)), + })), +} +writeFileSync(outputPlanFile, `${JSON.stringify(publicPlan, null, 2)}\n`) +console.log( + JSON.stringify( + { + outputPlanFile, + screenshots: sourceScreenshots.length, + redactedScreenshots, + redactionRegions, + bytes: sourceScreenshots.reduce( + (total, screenshot) => total + statSync(join(inputRoot, publicByFile.get(screenshot.file).file)).size, + 0 + ), + }, + null, + 2 + ) +) diff --git a/web/solver-report/scripts/public-release-policy.mjs b/web/solver-report/scripts/public-release-policy.mjs new file mode 100644 index 0000000..6df1d51 --- /dev/null +++ b/web/solver-report/scripts/public-release-policy.mjs @@ -0,0 +1,257 @@ +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path' + +export const sha256 = (value) => createHash('sha256').update(value).digest('hex') + +export const canonical = (value) => { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]` + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`) + .join(',')}}` + } + return JSON.stringify(value) +} + +const isDescendant = (parent, candidate) => { + const path = relative(parent, candidate) + return path !== '' && !path.startsWith('..') && !isAbsolute(path) +} + +const overlaps = (left, right) => + left === right || isDescendant(left, right) || isDescendant(right, left) + +const resolvePhysicalPath = (candidate) => { + let ancestor = resolve(candidate) + while (!existsSync(ancestor)) { + const parent = dirname(ancestor) + if (parent === ancestor) throw new Error(`cannot resolve an existing ancestor for ${candidate}`) + ancestor = parent + } + return resolve(realpathSync(ancestor), relative(ancestor, resolve(candidate))) +} + +export function resolveSafeOutputRoot({ inputRoot, codeRoot, appRoot, requestedOutputRoot }) { + const roots = { + inputRoot: resolvePhysicalPath(inputRoot), + codeRoot: resolvePhysicalPath(codeRoot), + appRoot: resolvePhysicalPath(appRoot), + outputRoot: resolvePhysicalPath(requestedOutputRoot), + } + const lexicalInputRoot = resolve(inputRoot) + const lexicalOutputRoot = resolve(requestedOutputRoot) + const lexicalManagedParent = join(lexicalInputRoot, 'report-web') + const physicalManagedParent = resolvePhysicalPath(lexicalManagedParent) + if ( + !isDescendant(lexicalManagedParent, lexicalOutputRoot) || + !isDescendant(roots.inputRoot, physicalManagedParent) || + !isDescendant(physicalManagedParent, roots.outputRoot) + ) { + throw new Error('SOLVER_REPORT_OUTPUT_ROOT must be a child of /report-web') + } + if (roots.outputRoot === roots.inputRoot || isDescendant(roots.outputRoot, roots.inputRoot)) { + throw new Error('report output must not equal or contain the evidence input root') + } + for (const [name, protectedRoot] of [ + ['Agent source', roots.codeRoot], + ['DesireCore application', roots.appRoot], + ]) { + if (overlaps(roots.outputRoot, protectedRoot)) { + throw new Error(`report output must not overlap ${name} root`) + } + } + return roots.outputRoot +} + +export function resolveSafeRemoteEntryUrl(baseUrl, entryPath) { + if ( + typeof entryPath !== 'string' || + entryPath.length === 0 || + entryPath.startsWith('/') || + entryPath.includes('\\') || + entryPath.includes('%') || + entryPath.includes('?') || + entryPath.includes('#') || + entryPath.split('/').some((segment) => segment === '' || segment === '.' || segment === '..') || + posix.normalize(entryPath) !== entryPath || + /^[A-Za-z][A-Za-z0-9+.-]*:/.test(entryPath) + ) { + throw new Error(`unsafe integrity entry path: ${String(entryPath)}`) + } + const base = new URL(baseUrl) + if (!['http:', 'https:'].includes(base.protocol)) throw new Error('report base URL must use HTTP or HTTPS') + const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/` + const resolved = new URL(entryPath, base) + if (resolved.origin !== base.origin || !resolved.pathname.startsWith(basePath)) { + throw new Error(`integrity entry escapes report base URL: ${entryPath}`) + } + return resolved +} + +const PUBLIC_PROVENANCE_KEYS = new Set([ + 'adapter_id', + 'capability_checked_at', + 'capability_hash', + 'compiled_request_hash', + 'compiler_id', + 'compiler_version', + 'connector_id', + 'decision_grade', + 'engine_id', + 'governance_mode', + 'ir_family', + 'ir_version', + 'job_id', + 'optimization_spec_hash', + 'problem_family', + 'request_id', + 'result_payload_hash', + 'validation_input_hash', + 'validation_report_hash', +]) + +export function projectPublicProvenance(provenance) { + if (!provenance || typeof provenance !== 'object' || Array.isArray(provenance)) return null + return Object.fromEntries( + Object.entries(provenance).filter( + ([key, value]) => + PUBLIC_PROVENANCE_KEYS.has(key) && + (value === null || ['string', 'number', 'boolean'].includes(typeof value)) + ) + ) +} + +const PUBLIC_TEXT_RULES = [ + ['private-path', /(?:\/Users\/|\/Volumes\/|[A-Za-z]:\\Users\\)/i], + ['customer-codename', /(?:贝壳|beike|bei-ke)/i], + ['email-address', /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i], + ['private-key', /-----BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY-----/i], + ['provider-secret', /\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,})\b/], + ['bearer-token', /\bBearer\s+[A-Za-z0-9._~+/-]{12,}={0,2}\b/i], + [ + 'named-secret', + /\b(?:password|passwd|authorization|api[_ -]?key|access[_ -]?token|refresh[_ -]?token|client[_ -]?secret)\b\s*[:=]\s*["']?[^\s"',}]{6,}/i, + ], + [ + 'private-url', + /https?:\/\/(?:localhost|127(?:\.\d{1,3}){3}|10(?:\.\d{1,3}){3}|192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}|[^/\s"']+\.internal)\b/i, + ], +] + +export function publicTextViolations(value) { + const text = (typeof value === 'string' ? value : JSON.stringify(value)).replace( + /\b\d+@2x\.(?:png|webp)\b/gi, + '[reviewed-retina-image]' + ) + return PUBLIC_TEXT_RULES.filter(([, pattern]) => pattern.test(text)).map(([rule]) => rule) +} + +export function assertPublicText(value, label) { + const violations = publicTextViolations(value) + if (violations.length > 0) { + throw new Error(`${label}: public-release text policy rejected ${violations.join(',')}`) + } +} + +const readJsonLines = (file) => + readFileSync(file, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + +export function computePublicEvidenceRoots({ + inputRoot, + screenshotPlan, + screenshotValidation, + screenshotPrivacyValidation, + caseRunById, +}) { + const transcriptEntries = screenshotPlan.scenarios.map((item) => { + const source = caseRunById.get(item.runId) + if (!source) throw new Error(`${item.scenarioId}: source Run missing for public attestation`) + const messages = readJsonLines(join(source.runDir, 'messages.jsonl')) + .filter((message) => ['user', 'assistant'].includes(message.role)) + .map((message) => ({ role: message.role, content: message.content ?? '', timestamp: message.timestamp ?? null })) + return { scenarioId: item.scenarioId, runId: item.runId, messages } + }) + const screenshotEntries = screenshotPlan.scenarios.flatMap((item) => + item.screenshots.map((screenshot) => { + const file = join(inputRoot, screenshot.file) + if (!existsSync(file)) throw new Error(`${item.scenarioId}: screenshot missing: ${screenshot.file}`) + const actualSha256 = sha256(readFileSync(file)) + if (actualSha256 !== screenshot.sha256) { + throw new Error(`${item.scenarioId}: screenshot hash differs from reviewed plan: ${screenshot.file}`) + } + return { + scenarioId: item.scenarioId, + file: screenshot.file, + sha256: screenshot.sha256, + width: screenshot.width, + height: screenshot.height, + } + }) + ) + const screenshotPlanSha256 = sha256(canonical(screenshotPlan)) + const publicScreenshotContentRootSha256 = sha256(canonical(screenshotEntries)) + if ( + screenshotPrivacyValidation?.schemaVersion !== 'solver.screenshot-privacy-validation/v1' || + screenshotPrivacyValidation.passed !== true || + screenshotPrivacyValidation.screenshots !== screenshotEntries.length || + screenshotPrivacyValidation.screenshotPlanSha256 !== screenshotPlanSha256 || + screenshotPrivacyValidation.screenshotContentRootSha256 !== publicScreenshotContentRootSha256 || + screenshotPrivacyValidation.ocr?.screenshotsWithText !== screenshotEntries.length || + screenshotPrivacyValidation.ocr?.sensitiveMatches !== 0 + ) { + throw new Error('public screenshot privacy validation is missing or differs from current public images') + } + return { + publicTranscriptContentRootSha256: sha256(canonical(transcriptEntries)), + publicScreenshotContentRootSha256, + screenshotPlanSha256, + screenshotValidationSha256: sha256(canonical(screenshotValidation)), + screenshotPrivacyValidationSha256: sha256(canonical(screenshotPrivacyValidation)), + } +} + +const HASH_PATTERN = /^[a-f0-9]{64}$/ + +export function validatePublicReleaseAttestation(attestation, evidenceRoots) { + if (attestation?.schemaVersion !== 'solver.public-release-attestation/v1') { + throw new Error('public release attestation is missing or has an unsupported schema') + } + if (attestation.audience !== 'public' || attestation.review?.status !== 'passed') { + throw new Error('public release attestation must explicitly pass for a public audience') + } + const assertions = attestation.review?.assertions ?? {} + for (const key of ['transcriptsContainNoSensitiveData', 'screenshotsContainNoSensitiveData', 'customerIdentifiersExcluded']) { + if (assertions[key] !== true) throw new Error(`public release attestation assertion is not true: ${key}`) + } + if (!attestation.review?.reviewedAt || !attestation.review?.reviewer || !Array.isArray(attestation.review?.methods)) { + throw new Error('public release attestation review identity, time, and methods are required') + } + for (const [key, expected] of Object.entries(evidenceRoots)) { + const actual = attestation.evidence?.[key] + if (!HASH_PATTERN.test(actual ?? '') || actual !== expected) { + throw new Error(`public release attestation does not match current evidence: ${key}`) + } + } + const metadata = attestation.metadata ?? {} + if ( + typeof metadata.platformCommit !== 'string' || + typeof metadata.agentSkillVersion !== 'string' || + !Number.isInteger(metadata.representativeStabilityPasses) || + metadata.representativeStabilityPasses < 0 + ) { + throw new Error('public release attestation metadata is incomplete') + } + return attestation +} + +export function loadPublicReleaseAttestation(file, evidenceRoots) { + if (!existsSync(file)) throw new Error(`public release attestation is required: ${file}`) + const bytes = readFileSync(file) + const attestation = validatePublicReleaseAttestation(JSON.parse(bytes), evidenceRoots) + return { attestation, sha256: sha256(bytes) } +} diff --git a/web/solver-report/scripts/validate-deployed-report.mjs b/web/solver-report/scripts/validate-deployed-report.mjs new file mode 100644 index 0000000..1335b35 --- /dev/null +++ b/web/solver-report/scripts/validate-deployed-report.mjs @@ -0,0 +1,100 @@ +import { createHash } from 'node:crypto' +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { canonical, resolveSafeRemoteEntryUrl } from './public-release-policy.mjs' + +const baseUrl = (process.env.REPORT_BASE_URL ?? 'https://build.desirecore.cc/solver-agent-team/').replace(/\/?$/, '/') +const inputRoot = process.env.SOLVER_REPORT_INPUT_ROOT ?? process.cwd() +const validationFile = + process.env.SOLVER_REPORT_DEPLOYMENT_VALIDATION_FILE ?? join(inputRoot, 'report-web', 'deployment-validation.json') + +async function fetchWithoutRedirect(url, init = {}) { + const response = await fetch(url, { ...init, redirect: 'manual' }) + if (response.status >= 300 && response.status < 400) { + throw new Error(`redirect-rejected:${response.status}`) + } + return response +} + +const integrityResponse = await fetchWithoutRedirect(new URL('integrity.json', baseUrl)) +if (!integrityResponse.ok) throw new Error(`integrity.json: ${integrityResponse.status}`) +const integrity = await integrityResponse.json() +const failures = [] +let checked = 0 +let checkedBytes = 0 +let cursor = 0 + +async function worker() { + while (cursor < integrity.entries.length) { + const index = cursor++ + const entry = integrity.entries[index] + try { + const entryUrl = resolveSafeRemoteEntryUrl(baseUrl, entry.path) + const response = await fetchWithoutRedirect(entryUrl) + if (!response.ok) { + failures.push({ path: entry.path, issue: `http-${response.status}` }) + continue + } + const bytes = Buffer.from(await response.arrayBuffer()) + const digest = createHash('sha256').update(bytes).digest('hex') + if (bytes.length !== entry.bytes || digest !== entry.sha256) { + failures.push({ path: entry.path, issue: 'integrity-mismatch' }) + } + checked += 1 + checkedBytes += bytes.length + } catch (error) { + failures.push({ path: entry.path, issue: error instanceof Error ? error.message : String(error) }) + } + } +} +await Promise.all(Array.from({ length: 12 }, () => worker())) + +const rootResponse = await fetchWithoutRedirect(baseUrl) +const mediaResponse = await fetchWithoutRedirect(new URL('media/original/boundary.qp-unsupported/01@2x.png', baseUrl), { + method: 'HEAD', +}) +const [buildResponse, manifestResponse] = await Promise.all([ + fetchWithoutRedirect(new URL('build.json', baseUrl)), + fetchWithoutRedirect(new URL('data/manifest.json', baseUrl)), +]) +const build = buildResponse.ok ? await buildResponse.json() : null +const manifest = manifestResponse.ok ? await manifestResponse.json() : null +if (build?.gates?.publicReleasePrivacy !== 'pass') failures.push({ path: 'build.json', issue: 'public-release-privacy-gate' }) +if ( + !build?.publicReleaseAttestationSha256 || + build.publicReleaseAttestationSha256 !== manifest?.publicReleaseAttestationSha256 +) + failures.push({ path: 'build.json', issue: 'public-release-attestation-mismatch' }) +if ( + !build || + !manifest || + build.buildId !== manifest.buildId || + build.contentRootSha256 !== manifest.contentRootSha256 || + integrity.contentRootSha256 !== manifest.contentRootSha256 || + canonical(build.publicEvidenceRoots) !== canonical(manifest.publicEvidenceRoots) || + canonical(build.counts) !== canonical(manifest.counts) || + build.integrityEntries !== integrity.entries.length || + !integrity.entries.some((entry) => entry.path === 'build.json') +) + failures.push({ path: 'build.json', issue: 'build-manifest-integrity-projection' }) +const report = { + schemaVersion: 'solver.web-deployment-validation/v1', + testedAt: new Date().toISOString(), baseUrl, + contentRootSha256: integrity.contentRootSha256, + checkedEntries: checked, checkedBytes, + rootStatus: rootResponse.status, + csp: rootResponse.headers.get('content-security-policy'), + mediaStatus: mediaResponse.status, + mediaCacheControl: mediaResponse.headers.get('cache-control'), + failures, + passed: + failures.length === 0 && + checked === integrity.entries.length && + rootResponse.ok && + mediaResponse.ok && + buildResponse.ok && + manifestResponse.ok, +} +writeFileSync(validationFile, `${JSON.stringify(report, null, 2)}\n`) +console.log(JSON.stringify(report, null, 2)) +if (!report.passed) process.exitCode = 1 diff --git a/web/solver-report/scripts/validate-report-web.mjs b/web/solver-report/scripts/validate-report-web.mjs new file mode 100644 index 0000000..a1bf7d4 --- /dev/null +++ b/web/solver-report/scripts/validate-report-web.mjs @@ -0,0 +1,238 @@ +import { existsSync, globSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { + assertPublicText, + canonical, + computePublicEvidenceRoots, + loadPublicReleaseAttestation, + sha256, +} from './public-release-policy.mjs' + +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 = + process.env.SOLVER_REPORT_VALIDATION_FILE ?? join(inputRoot, 'report-web', 'validation.json') +const manifest = JSON.parse(readFileSync(join(dist, 'data', 'manifest.json'), 'utf8')) +const integrity = JSON.parse(readFileSync(join(dist, 'integrity.json'), 'utf8')) +const build = JSON.parse(readFileSync(join(dist, 'build.json'), 'utf8')) +const latestRegression = JSON.parse(readFileSync(join(dist, 'data', 'latest-platform-regression.json'), 'utf8')) +const failures = [] +const readJsonLines = (file) => + readFileSync(file, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + +const caseRunById = new Map( + globSync(join(inputRoot, 'evidence', '**', 'case-run.json')).map((file) => { + const data = JSON.parse(readFileSync(file, 'utf8')) + return [data.runId, { file, data, runDir: join(dirname(file), 'run') }] + }) +) +const screenshotPlan = JSON.parse( + readFileSync( + process.env.SOLVER_REPORT_PUBLIC_SCREENSHOT_PLAN_FILE ?? + join(inputRoot, 'evidence', 'screenshots', 'public-screenshot-plan.json'), + 'utf8' + ) +) +const screenshotValidation = JSON.parse( + readFileSync(join(inputRoot, 'evidence', 'screenshots', 'screenshot-validation.json'), 'utf8') +) +const screenshotPrivacyValidation = JSON.parse( + readFileSync( + process.env.SOLVER_REPORT_SCREENSHOT_PRIVACY_VALIDATION_FILE ?? + join(inputRoot, 'evidence', 'screenshots', 'public-screenshot-privacy-validation.json'), + 'utf8' + ) +) +const publicEvidenceRoots = computePublicEvidenceRoots({ + inputRoot, + screenshotPlan, + screenshotValidation, + screenshotPrivacyValidation, + caseRunById, +}) +const publicAttestationFile = + process.env.SOLVER_REPORT_PUBLIC_RELEASE_ATTESTATION_FILE ?? + join(inputRoot, 'evidence', 'public-release-attestation.json') +const { attestation: publicReleaseAttestation, sha256: publicReleaseAttestationSha256 } = + loadPublicReleaseAttestation(publicAttestationFile, publicEvidenceRoots) + +if (manifest.chapters.length !== 24) failures.push({ issue: 'chapter-count', actual: manifest.chapters.length }) +if (manifest.counts.screenshots !== 174) + failures.push({ issue: 'screenshot-count', actual: manifest.counts.screenshots }) +const historicalEvidenceContentRootSha256 = sha256( + canonical(manifest.chapters.map((item) => ({ scenarioId: item.scenarioId, sha256: item.sha256 }))) +) +const latestPlatformRegressionSha256 = sha256(canonical(latestRegression)) +const expectedContentRootSha256 = sha256( + canonical({ historicalEvidenceContentRootSha256, latestPlatformRegressionSha256 }) +) +if (manifest.historicalEvidenceContentRootSha256 !== historicalEvidenceContentRootSha256) + failures.push({ issue: 'historical-evidence-root' }) +if (manifest.latestPlatformRegressionSha256 !== latestPlatformRegressionSha256) + failures.push({ issue: 'latest-regression-hash' }) +if ( + manifest.contentRootSha256 !== expectedContentRootSha256 || + integrity.contentRootSha256 !== expectedContentRootSha256 +) + failures.push({ issue: 'report-content-root' }) +if (latestRegression.schemaVersion !== 'solver.latest-platform-regression/v1' || latestRegression.passed !== true) + failures.push({ issue: 'latest-regression-status' }) +if (manifest.latestPlatformRegression?.platform?.commit !== latestRegression.platform?.commit) + failures.push({ issue: 'latest-regression-manifest-projection' }) +if ( + !manifest.evidencePlatformCommit?.startsWith(publicReleaseAttestation.metadata.platformCommit) || + !manifest.evidencePlatformCommit?.startsWith(latestRegression.historicalEvidence?.platformCommit) +) + failures.push({ issue: 'historical-platform-attribution' }) +if ( + manifest.platformCommit !== latestRegression.platform?.commit || + manifest.agentSkillVersion !== latestRegression.platform?.agentSkillVersion || + manifest.counts.representativeStabilityPasses !== publicReleaseAttestation.metadata.representativeStabilityPasses +) + failures.push({ issue: 'derived-release-metadata' }) +if ( + manifest.publicReleaseAttestationSha256 !== publicReleaseAttestationSha256 || + canonical(manifest.publicEvidenceRoots) !== canonical(publicEvidenceRoots) +) + failures.push({ issue: 'public-release-attestation-projection' }) +if ( + build.buildId !== manifest.buildId || + build.contentRootSha256 !== manifest.contentRootSha256 || + build.publicReleaseAttestationSha256 !== manifest.publicReleaseAttestationSha256 || + canonical(build.publicEvidenceRoots) !== canonical(manifest.publicEvidenceRoots) || + canonical(build.counts) !== canonical(manifest.counts) || + build.integrityEntries !== integrity.entries.length || + build.gates?.publicReleasePrivacy !== 'pass' || + !integrity.entries.some((entry) => entry.path === 'build.json') +) + failures.push({ issue: 'build-manifest-integrity-projection' }) +try { + assertPublicText(latestRegression, 'latest platform regression') +} catch (error) { + failures.push({ issue: 'latest-regression-public-text-policy', detail: error.message }) +} + +let formulas = 0 +let screenshotCount = 0 +for (const index of manifest.chapters) { + const file = join(dist, index.json) + const script = join(dist, index.script) + if (!existsSync(file) || !existsSync(script)) { + failures.push({ scenarioId: index.scenarioId, issue: 'chapter-file-missing' }) + continue + } + const chapter = JSON.parse(readFileSync(file, 'utf8')) + const withoutHash = structuredClone(chapter) + delete withoutHash.contentSha256 + if (sha256(canonical(withoutHash)) !== chapter.contentSha256 || chapter.contentSha256 !== index.sha256) { + failures.push({ scenarioId: index.scenarioId, issue: 'chapter-hash' }) + } + if (chapter.sections.length !== 10 || chapter.sections.some((section, i) => section.number !== i + 1)) { + failures.push({ scenarioId: index.scenarioId, issue: 'ten-part-report' }) + } + if (chapter.formulaStats.source !== chapter.formulaStats.rendered) { + failures.push({ scenarioId: index.scenarioId, issue: 'formula-count', stats: chapter.formulaStats }) + } + const renderedHtml = [ + ...chapter.timeline.filter((item) => item.kind === 'message').map((item) => item.html), + ...chapter.sections.map((item) => item.html), + ].join('\n') + const mathmlCount = (renderedHtml.match(//g, '').replace(//g, '') + if (/\\\(|\\\)|\\\[|\\\]/.test(renderedWithoutCode)) { + failures.push({ scenarioId: index.scenarioId, issue: 'unrendered-latex-delimiter' }) + } + formulas += chapter.formulaStats.rendered + screenshotCount += chapter.media.length + for (const image of chapter.media) { + const original = join(dist, image.original) + if (!existsSync(original) || sha256(readFileSync(original)) !== image.sha256) { + failures.push({ scenarioId: index.scenarioId, issue: 'original-image', path: image.original }) + } + for (const thumb of [image.thumb756, image.thumb1512]) { + if (!existsSync(join(dist, thumb))) + 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) => + ['user', 'assistant'].includes(item.role) + ) + const projected = chapter.timeline.filter((item) => item.kind === 'message') + if ( + raw.length !== projected.length || + raw.some((message, i) => message.role !== projected[i].role || (message.content ?? '') !== projected[i].markdown) + ) { + failures.push({ scenarioId: index.scenarioId, issue: 'transcript-not-1-to-1' }) + } + } + const serialized = JSON.stringify(chapter) + try { + assertPublicText(serialized, `${index.scenarioId} chapter`) + } catch (error) { + failures.push({ scenarioId: index.scenarioId, issue: 'public-text-policy', detail: error.message }) + } + if ( + ['qp', 'qcp', 'cp'].includes(String(chapter.problemFamily).toLowerCase()) && + /当前只支持\s*LP\/?MILP/i.test(serialized) + ) { + failures.push({ scenarioId: index.scenarioId, issue: 'stale-capability-summary' }) + } +} + +if (screenshotCount !== 174) failures.push({ issue: 'chapter-screenshot-sum', actual: screenshotCount }) +for (const entry of integrity.entries) { + const file = join(dist, entry.path) + if (!existsSync(file)) failures.push({ issue: 'integrity-file-missing', path: entry.path }) + else { + const bytes = readFileSync(file) + if (bytes.length !== entry.bytes || sha256(bytes) !== entry.sha256) + failures.push({ issue: 'integrity-mismatch', path: entry.path }) + } +} +for (const required of [ + 'index.html', + 'assets/report.css', + 'assets/report.js', + 'assets/katex.min.css', + 'data/manifest.js', + 'data/latest-platform-regression.json', +]) { + if (!existsSync(join(dist, required))) failures.push({ issue: 'required-asset', path: required }) +} + +const report = { + schemaVersion: 'solver.web-report-validation/v1', + generatedAt: new Date().toISOString(), + buildId: manifest.buildId, + chapters: manifest.chapters.length, + screenshots: screenshotCount, + formulas, + integrityEntries: integrity.entries.length, + bytes: globSync(join(dist, '**', '*')) + .filter((file) => statSync(file).isFile()) + .reduce((sum, file) => sum + statSync(file).size, 0), + latestPlatformRegression: { + commit: latestRegression.platform.commit, + scenarios: latestRegression.runtime.scenarios.length, + decisionWorkspace: latestRegression.runtime.decisionWorkspace.status, + }, + passed: failures.length === 0, + failures, +} +writeFileSync(validationFile, `${JSON.stringify(report, null, 2)}\n`) +console.log(JSON.stringify(report, null, 2)) +if (!report.passed) process.exitCode = 1 diff --git a/web/solver-report/scripts/validate-screenshot-privacy-ocr.mjs b/web/solver-report/scripts/validate-screenshot-privacy-ocr.mjs new file mode 100644 index 0000000..138cc8c --- /dev/null +++ b/web/solver-report/scripts/validate-screenshot-privacy-ocr.mjs @@ -0,0 +1,80 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { assertPublicText, canonical, sha256 } from './public-release-policy.mjs' + +const inputRoot = resolve(process.env.SOLVER_REPORT_INPUT_ROOT ?? process.cwd()) +const planFile = + process.env.SOLVER_REPORT_SCREENSHOT_PLAN_FILE ?? + join(inputRoot, 'evidence', 'screenshots', 'screenshot-plan.json') +const ocrFile = process.env.SOLVER_REPORT_SCREENSHOT_OCR_FILE +if (!ocrFile) throw new Error('SOLVER_REPORT_SCREENSHOT_OCR_FILE is required') +const validationFile = process.env.SOLVER_REPORT_SCREENSHOT_OCR_VALIDATION_FILE +if (!validationFile) throw new Error('SOLVER_REPORT_SCREENSHOT_OCR_VALIDATION_FILE is required') + +const plan = JSON.parse(readFileSync(planFile, 'utf8')) +const ocr = JSON.parse(readFileSync(ocrFile, 'utf8')) +if (ocr.schemaVersion !== 'solver.screenshot-privacy-ocr/v1' || !Array.isArray(ocr.entries)) { + throw new Error('unsupported screenshot OCR result') +} + +const planned = plan.scenarios.flatMap((scenario) => + scenario.screenshots.map((screenshot) => ({ scenarioId: scenario.scenarioId, ...screenshot })) +) +if (planned.length !== 174 || ocr.entries.length !== planned.length) { + throw new Error(`screenshot OCR coverage mismatch: expected ${planned.length}, received ${ocr.entries.length}`) +} + +const failures = [] +const screenshotRootEntries = [] +let recognizedLines = 0 +let screenshotsWithText = 0 +for (const [index, expected] of planned.entries()) { + const actual = ocr.entries[index] + const imageFile = join(inputRoot, expected.file) + if (!existsSync(imageFile)) failures.push({ file: expected.file, issue: 'missing-image' }) + const actualSha256 = existsSync(imageFile) ? sha256(readFileSync(imageFile)) : null + if ( + actual?.scenarioId !== expected.scenarioId || + actual?.file !== expected.file || + actualSha256 !== expected.sha256 || + !Array.isArray(actual?.lines) || + actual.lines.some((line) => typeof line?.text !== 'string' || !Array.isArray(line?.boundingBox)) + ) { + failures.push({ file: expected.file, issue: 'plan-or-hash-mismatch' }) + continue + } + const text = actual.lines.map((line) => line.text).join('\n') + recognizedLines += actual.lines.length + if (actual.lines.length > 0) screenshotsWithText += 1 + try { + assertPublicText(text, `${expected.scenarioId} screenshot OCR`) + } catch (error) { + failures.push({ file: expected.file, issue: 'sensitive-text-policy', detail: error.message }) + } + screenshotRootEntries.push({ + scenarioId: expected.scenarioId, + file: expected.file, + sha256: expected.sha256, + width: expected.width, + height: expected.height, + }) +} + +const report = { + schemaVersion: 'solver.screenshot-privacy-validation/v1', + auditedAt: new Date().toISOString(), + screenshotPlanSha256: sha256(canonical(plan)), + screenshotContentRootSha256: sha256(canonical(screenshotRootEntries)), + screenshots: planned.length, + ocr: { + engine: ocr.engine, + screenshotsWithText, + recognizedLines, + sensitiveMatches: failures.filter((failure) => failure.issue === 'sensitive-text-policy').length, + }, + passed: failures.length === 0 && screenshotsWithText === planned.length, + failures, +} +writeFileSync(validationFile, `${JSON.stringify(report, null, 2)}\n`) +console.log(JSON.stringify(report, null, 2)) +if (!report.passed) process.exitCode = 1 diff --git a/web/solver-report/scripts/verify-static-release.py b/web/solver-report/scripts/verify-static-release.py new file mode 100644 index 0000000..22f8c10 --- /dev/null +++ b/web/solver-report/scripts/verify-static-release.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +import hashlib +import json +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]).resolve() +integrity = json.loads((root / "integrity.json").read_text(encoding="utf-8")) +failures = [] +for entry in integrity["entries"]: + raw_path = entry.get("path") + if ( + not isinstance(raw_path, str) + or not raw_path + or "//" in raw_path + or "\\" in raw_path + or "%" in raw_path + or "?" in raw_path + or "#" in raw_path + ): + failures.append({"path": str(raw_path), "issue": "unsafe-path"}) + continue + relative_path = pathlib.PurePosixPath(raw_path) + if relative_path.is_absolute() or any(part in ("", ".", "..") for part in relative_path.parts): + failures.append({"path": raw_path, "issue": "unsafe-path"}) + continue + path = (root / pathlib.Path(*relative_path.parts)).resolve() + if path == root or root not in path.parents: + failures.append({"path": raw_path, "issue": "path-escape"}) + continue + if not path.is_file(): + failures.append({"path": raw_path, "issue": "missing"}) + continue + data = path.read_bytes() + digest = hashlib.sha256(data).hexdigest() + if len(data) != entry["bytes"] or digest != entry["sha256"]: + failures.append({"path": raw_path, "issue": "mismatch"}) + +manifest = json.loads((root / "data" / "manifest.json").read_text(encoding="utf-8")) +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("publicReleaseAttestationSha256") != manifest.get("publicReleaseAttestationSha256"): + failures.append({"path": "build.json", "issue": "public-release-attestation-mismatch"}) +if ( + build.get("buildId") != manifest.get("buildId") + or build.get("contentRootSha256") != manifest.get("contentRootSha256") + or integrity.get("contentRootSha256") != manifest.get("contentRootSha256") + or build.get("publicEvidenceRoots") != manifest.get("publicEvidenceRoots") + or build.get("counts") != manifest.get("counts") + or build.get("integrityEntries") != len(integrity.get("entries", [])) + or not any(entry.get("path") == "build.json" for entry in integrity.get("entries", [])) +): + failures.append({"path": "build.json", "issue": "build-manifest-integrity-projection"}) +result = { + "buildId": manifest["buildId"], + "contentRootSha256": manifest["contentRootSha256"], + "chapters": len(manifest["chapters"]), + "integrityEntries": len(integrity["entries"]), + "failures": failures, + "passed": not failures, +} +print(json.dumps(result, ensure_ascii=False)) +raise SystemExit(0 if result["passed"] else 1) diff --git a/web/solver-report/source/index.html b/web/solver-report/source/index.html new file mode 100644 index 0000000..2946cfa --- /dev/null +++ b/web/solver-report/source/index.html @@ -0,0 +1,107 @@ + + + + + + + + + 通用求解器智能体团队 · 真实场景验证书 + + + + + +
+ +
+
+ +
+ 载入证据… +
+
+
+ +
+
+
+
+ +
+
+
+ + + + + + + +

证据完整性

+
+
+ + +
+
+ + + + + diff --git a/web/solver-report/source/report.css b/web/solver-report/source/report.css new file mode 100644 index 0000000..becb63e --- /dev/null +++ b/web/solver-report/source/report.css @@ -0,0 +1,1132 @@ +:root { + --ink: #172238; + --muted: #637089; + --line: #dce3ed; + --surface: #fff; + --soft: #f5f7fb; + --navy: #101b35; + --navy2: #182a50; + --cyan: #23b8b2; + --green: #35b779; + --amber: #e29b38; + --red: #d95c63; + --violet: #7767d9; + --shadow: 0 18px 50px rgba(24, 42, 80, 0.1); + font-family: Inter, 'SF Pro Text', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif; + color: var(--ink); + background: #eef2f7; +} +* { + box-sizing: border-box; +} +html { + scroll-behavior: smooth; +} +body { + margin: 0; + background: radial-gradient(circle at 85% 0, #e3f4f2 0, transparent 30%), #eef2f7; +} +.skip-link { + position: fixed; + left: 16px; + top: -60px; + z-index: 99; + background: #fff; + padding: 10px 14px; + border-radius: 10px; +} +.skip-link:focus { + top: 16px; +} +.app-shell { + display: grid; + grid-template-columns: 330px minmax(0, 1fr); + min-height: 100vh; +} +.rail { + position: fixed; + inset: 0 auto 0 0; + width: 330px; + background: linear-gradient(160deg, var(--navy), #122346 55%, #163752); + color: #fff; + padding: 24px 20px; + display: flex; + flex-direction: column; + z-index: 20; + box-shadow: 12px 0 40px rgba(16, 27, 53, 0.16); +} +.brand { + display: flex; + gap: 12px; + align-items: center; + padding: 2px 4px 18px; +} +.brand-mark { + display: grid; + place-items: center; + width: 42px; + height: 42px; + border-radius: 13px; + background: linear-gradient(135deg, var(--cyan), #6ce0ca); + color: #0e2941; + font-size: 24px; + font-weight: 900; + box-shadow: 0 10px 22px rgba(35, 184, 178, 0.28); +} +.brand strong, +.brand small { + display: block; +} +.brand strong { + font-size: 17px; + letter-spacing: 0.02em; +} +.brand small { + margin-top: 3px; + color: #b7c4da; + font-size: 12px; +} +.rail-summary { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + margin-bottom: 14px; +} +.rail-summary span { + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.09); + padding: 9px 6px; + border-radius: 10px; + text-align: center; + font-size: 11px; + color: #bfcce0; +} +.rail-summary b { + display: block; + color: #fff; + font-size: 16px; + margin-bottom: 2px; +} +.search { + display: block; + font-size: 11px; + color: #b8c5d9; + margin-bottom: 10px; +} +.search span { + position: absolute; + clip: rect(0 0 0 0); +} +.search input { + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(0, 0, 0, 0.14); + color: #fff; + border-radius: 11px; + padding: 11px 12px; + outline: none; +} +.search input:focus { + border-color: var(--cyan); + box-shadow: 0 0 0 3px rgba(35, 184, 178, 0.16); +} +.filters { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; +} +.filter { + border: 1px solid rgba(255, 255, 255, 0.12); + background: transparent; + color: #c8d3e4; + border-radius: 999px; + padding: 5px 9px; + font-size: 10px; + cursor: pointer; +} +.filter.active { + background: var(--cyan); + color: #0d2940; + border-color: transparent; +} +.chapter-nav { + overflow: auto; + min-height: 0; + overscroll-behavior: contain; + padding-right: 5px; +} +.nav-item { + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 8px; + align-items: start; + width: 100%; + text-align: left; + color: #e8edf6; + background: transparent; + border: 0; + border-radius: 11px; + padding: 9px 8px; + cursor: pointer; +} +.nav-item:hover { + background: rgba(255, 255, 255, 0.07); +} +.nav-item.active { + background: linear-gradient(90deg, rgba(35, 184, 178, 0.25), rgba(119, 103, 217, 0.18)); + box-shadow: inset 3px 0 var(--cyan); +} +.nav-index { + display: grid; + place-items: center; + width: 25px; + height: 25px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.08); + font-size: 10px; +} +.nav-copy strong, +.nav-copy small { + display: block; +} +.nav-copy strong { + font-size: 12px; + line-height: 1.35; +} +.nav-copy small { + margin-top: 3px; + color: #aebdd2; + font-size: 10px; +} +.nav-status { + width: 8px; + height: 8px; + border-radius: 50%; + margin-top: 8px; + background: var(--green); +} +.nav-status.guard { + background: var(--amber); +} +.rail-foot { + display: flex; + justify-content: space-between; + color: #8698b4; + font-size: 9px; + padding: 12px 4px 0; +} +.main { + grid-column: 2; + min-width: 0; +} +.topbar { + position: sticky; + top: 0; + z-index: 12; + height: 66px; + background: rgba(245, 247, 251, 0.88); + backdrop-filter: blur(18px); + display: flex; + align-items: center; + gap: 18px; + padding: 0 34px; + border-bottom: 1px solid rgba(220, 227, 237, 0.8); +} +.icon-button { + display: none; +} +.progress { + flex: 1; + display: flex; + align-items: center; + gap: 14px; + font-size: 11px; + color: var(--muted); +} +.progress > div { + height: 5px; + flex: 1; + max-width: 280px; + border-radius: 4px; + background: #dce3ed; + overflow: hidden; +} +.progress i { + display: block; + height: 100%; + background: linear-gradient(90deg, var(--cyan), var(--violet)); + width: 0; + transition: width 0.4s; +} +.top-actions { + display: flex; + gap: 8px; +} +.top-actions button, +.pager button, +.lightbox button, +.lightbox a, +.integrity-dialog button { + border: 1px solid var(--line); + background: #fff; + color: var(--ink); + border-radius: 10px; + padding: 8px 12px; + cursor: pointer; + text-decoration: none; +} +.chapter { + max-width: 1180px; + margin: 0 auto; + padding: 36px 42px 64px; + outline: none; +} +.chapter-hero { + position: relative; + overflow: hidden; + background: linear-gradient(135deg, var(--navy), var(--navy2) 62%, #18545c); + color: #fff; + border-radius: 24px; + padding: 42px; + box-shadow: var(--shadow); +} +.chapter-hero:after { + content: ''; + position: absolute; + width: 360px; + height: 360px; + border-radius: 50%; + right: -130px; + top: -170px; + background: radial-gradient(circle, rgba(82, 226, 207, 0.28), transparent 67%); +} +.eyebrow { + display: flex; + gap: 8px; + align-items: center; + font-size: 11px; + color: #b8c9df; + text-transform: uppercase; + letter-spacing: 0.1em; +} +.badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 5px 9px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.12); + font-size: 10px; + color: #dce7f4; +} +.chapter-hero h1 { + font-size: 36px; + line-height: 1.2; + margin: 20px 0 12px; + max-width: 780px; +} +.chapter-hero .story { + font-size: 17px; + line-height: 1.75; + color: #e6edf7; + max-width: 860px; +} +.hero-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 16px; + margin-top: 26px; +} +.hero-card { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 15px; + padding: 16px; +} +.hero-card small { + display: block; + color: #a9bdd5; + margin-bottom: 6px; +} +.hero-card strong { + font-size: 14px; + line-height: 1.55; +} +.kpis { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px; + margin: 20px 0; +} +.kpi { + background: #fff; + border: 1px solid var(--line); + border-radius: 14px; + padding: 15px; + box-shadow: 0 8px 24px rgba(24, 42, 80, 0.05); +} +.kpi small { + color: var(--muted); + font-size: 10px; +} +.kpi strong { + display: block; + margin-top: 7px; + font-size: 14px; + overflow-wrap: anywhere; +} +.section-shell { + background: #fff; + border: 1px solid var(--line); + border-radius: 20px; + padding: 28px; + margin-top: 18px; + box-shadow: 0 9px 28px rgba(24, 42, 80, 0.05); +} +.section-head { + display: flex; + align-items: end; + justify-content: space-between; + gap: 20px; + margin-bottom: 20px; +} +.section-head h2 { + margin: 0; + font-size: 23px; +} +.section-head p { + margin: 5px 0 0; + color: var(--muted); + font-size: 12px; +} +.evidence-tag { + font-family: ui-monospace, monospace; + font-size: 9px; + color: #477070; + background: #edf8f6; + border-radius: 7px; + padding: 6px 8px; +} +.timeline { + display: flex; + flex-direction: column; + gap: 13px; +} +.message { + max-width: 86%; + border-radius: 16px; + padding: 16px 18px; + position: relative; +} +.message.user { + align-self: flex-end; + background: linear-gradient(135deg, #dcf8ee, #e9f7ff); + border: 1px solid #c9eae4; +} +.message.assistant { + align-self: flex-start; + background: var(--soft); + border: 1px solid #e5e9f1; +} +.message-meta { + display: flex; + justify-content: space-between; + gap: 10px; + color: var(--muted); + font-size: 9px; + margin-bottom: 9px; +} +.message-body { + font-size: 13px; + line-height: 1.7; +} +.message-body > :first-child { + margin-top: 0; +} +.message-body > :last-child { + margin-bottom: 0; +} +.tool-event { + display: flex; + gap: 12px; + align-items: center; + border-left: 3px solid var(--violet); + background: #f8f7ff; + border-radius: 0 12px 12px 0; + padding: 10px 13px; + font-size: 11px; +} +.tool-event strong { + font-family: ui-monospace, monospace; +} +.tool-event .status { + margin-left: auto; +} +.status.pass { + color: #16855a; +} +.status.guard { + color: #ae6a16; +} +.report-grid { + display: grid; + gap: 14px; +} +.report-part { + display: grid; + grid-template-columns: 58px minmax(0, 1fr); + border: 1px solid var(--line); + border-radius: 16px; + overflow: hidden; +} +.part-number { + display: grid; + place-items: start center; + padding-top: 18px; + background: linear-gradient(#f1f5fa, #eaf3f3); + font-size: 20px; + font-weight: 800; + color: #426477; +} +.part-body { + padding: 18px 22px; + min-width: 0; +} +.part-body h3 { + margin: 0 0 13px; + font-size: 18px; +} +.markdown { + font-size: 13px; + line-height: 1.72; + overflow-wrap: anywhere; +} +.markdown h3, +.markdown h4 { + margin: 1.1em 0 0.45em; +} +.markdown table { + border-collapse: collapse; + width: 100%; + display: block; + overflow: auto; +} +.markdown th, +.markdown td { + border: 1px solid var(--line); + padding: 7px 9px; + text-align: left; +} +.markdown th { + background: #f1f4f8; +} +.markdown code { + background: #edf2f5; + color: #24775b; + padding: 2px 5px; + border-radius: 5px; +} +.markdown pre { + background: #1a2130; + color: #e8edf5; + border-radius: 12px; + padding: 15px; + overflow: auto; +} +.markdown pre code { + background: transparent; + color: inherit; + padding: 0; +} +.katex-display { + overflow-x: auto; + overflow-y: hidden; + padding: 8px 0; +} +.validation-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} +.validation-card { + border: 1px solid var(--line); + background: var(--soft); + border-radius: 14px; + padding: 16px; +} +.validation-card small { + color: var(--muted); +} +.validation-card strong { + display: block; + margin-top: 7px; + overflow-wrap: anywhere; +} +.gallery { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +.shot { + position: relative; + border: 1px solid var(--line); + background: #fff; + border-radius: 15px; + padding: 0; + overflow: hidden; + cursor: zoom-in; + text-align: left; + font: inherit; + color: inherit; + transition: + transform 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease; +} +.shot:hover, +.shot:focus-visible { + transform: translateY(-3px); + border-color: #9bcfc9; + box-shadow: 0 16px 34px rgba(24, 42, 80, 0.13); + outline: none; +} +.shot picture, +.shot img { + display: block; + width: 100%; +} +.shot img { + aspect-ratio: 3024/1824; + object-fit: cover; + background: #eef2f7; +} +.shot-caption { + padding: 10px 12px; + font-size: 10px; + color: var(--muted); + display: flex; + justify-content: space-between; +} +.pager { + max-width: 1180px; + margin: 0 auto 50px; + padding: 0 42px; + display: flex; + align-items: center; + justify-content: space-between; + color: var(--muted); + font-size: 11px; +} +.lightbox { + width: min(96vw, 1680px); + height: 94vh; + max-height: 94vh; + border: 0; + border-radius: 18px; + padding: 0; + background: #0b1120; + color: #fff; + overflow: hidden; + grid-template-rows: auto minmax(0, 1fr) auto; +} +.lightbox[open] { + display: grid; +} +.lightbox::backdrop { + background: rgba(6, 11, 22, 0.86); + backdrop-filter: blur(4px); +} +.lightbox-bar, +.lightbox-controls { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; +} +.lightbox-bar { + min-width: 0; + gap: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.09); + background: rgba(11, 17, 32, 0.96); +} +.lightbox-heading { + min-width: 0; +} +.lightbox-heading strong, +.lightbox-heading small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.lightbox-bar small { + display: block; + color: #aebad0; + margin-top: 3px; +} +.lightbox-viewport { + position: relative; + min-height: 0; + overflow: hidden; +} +.lightbox-stage { + width: 100%; + height: 100%; + overflow: auto; + background: #080d18; + overscroll-behavior: contain; + scrollbar-color: #42516d #121a2a; + cursor: default; + touch-action: none; + outline: none; +} +.lightbox-stage:focus-visible { + box-shadow: inset 0 0 0 3px rgba(88, 212, 199, 0.82); +} +.lightbox-stage.is-pannable { + cursor: grab; +} +.lightbox-stage.is-dragging { + cursor: grabbing; + user-select: none; +} +.lightbox-canvas { + width: max-content; + min-width: 100%; + min-height: 100%; + display: grid; + place-items: center; + padding: 18px; +} +.lightbox-stage img { + display: block; + max-width: none; + width: 1px; + height: 1px; + object-fit: contain; + user-select: none; + -webkit-user-drag: none; + box-shadow: 0 20px 70px rgba(0, 0, 0, 0.38); + will-change: width, height; +} +.lightbox-zoom-hint { + position: absolute; + left: 16px; + bottom: 14px; + display: inline-flex; + width: max-content; + max-width: calc(100% - 32px); + z-index: 1; + padding: 7px 10px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 999px; + background: rgba(8, 13, 24, 0.76); + color: #aebad0; + font-size: 10px; + pointer-events: none; + backdrop-filter: blur(10px); +} +.lightbox-zoom-hint-mobile { + display: none; +} +.lightbox-controls { + justify-content: center; + gap: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.09); + background: rgba(11, 17, 32, 0.98); +} +.lightbox-controls > button, +.lightbox-controls > a, +.lightbox-zoom-controls button { + min-height: 36px; + border-color: rgba(255, 255, 255, 0.16); + background: #151f34; + color: #edf3ff; +} +.lightbox-controls > button:hover, +.lightbox-controls > a:hover, +.lightbox-zoom-controls button:hover { + border-color: #58d4c7; + background: #1b2d42; +} +.lightbox-zoom-controls { + display: grid; + grid-template-columns: 38px minmax(86px, auto) 38px; + align-items: center; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 10px; +} +.lightbox-zoom-controls button { + border: 0; + border-radius: 0; + padding: 7px 10px; + font-size: 17px; +} +.lightbox-zoom-controls output { + align-self: stretch; + display: grid; + place-items: center; + padding: 0 10px; + border-inline: 1px solid rgba(255, 255, 255, 0.12); + background: #0f182a; + color: #b8c8de; + font: 11px ui-monospace, monospace; + white-space: nowrap; +} +.integrity-dialog { + width: min(92vw, 680px); + box-sizing: border-box; + max-width: 680px; + border: 0; + border-radius: 18px; + padding: 28px; + box-shadow: var(--shadow); +} +.integrity-dialog::backdrop { + background: rgba(16, 27, 53, 0.55); +} +.dialog-close { + float: right; +} +.integrity-list { + display: grid; + gap: 9px; +} +.integrity-row { + display: grid; + grid-template-columns: 170px 1fr; + gap: 12px; + border-bottom: 1px solid var(--line); + padding-bottom: 8px; + font-size: 12px; +} +.integrity-row code { + overflow-wrap: anywhere; +} +.regression-banner { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + margin: 0 0 18px; + padding: 15px 17px; + border: 1px solid #a9ddd5; + border-radius: 18px; + background: linear-gradient(120deg, #e9faf6, #f3f4ff); + box-shadow: 0 8px 24px rgba(24, 42, 80, 0.05); +} +.regression-mark { + display: grid; + place-items: center; + width: 46px; + height: 46px; + border-radius: 14px; + background: linear-gradient(135deg, var(--cyan), #6ce0ca); + color: #0d2940; + font-size: 11px; + font-weight: 900; + letter-spacing: 0.08em; +} +.regression-copy { + display: grid; + gap: 3px; + min-width: 0; +} +.regression-copy small { + font-size: 10px; + color: #39706e; +} +.regression-copy strong { + font-size: 13px; +} +.regression-copy span { + font-size: 10px; + color: var(--muted); +} +.regression-banner button { + border: 0; + border-radius: 10px; + padding: 9px 11px; + background: var(--navy); + color: #fff; + font-size: 11px; + cursor: pointer; + white-space: nowrap; +} +.latest-dialog { + width: min(92vw, 860px); + max-height: 88vh; + border: 0; + border-radius: 22px; + padding: 28px; + box-shadow: var(--shadow); + overflow: auto; +} +.latest-dialog::backdrop { + background: rgba(16, 27, 53, 0.55); +} +.latest-dialog .dialog-close { + border: 1px solid var(--line); + background: #fff; + border-radius: 10px; + padding: 8px 12px; + cursor: pointer; +} +.regression-dialog-head { + padding: 2px 0 18px; + border-bottom: 1px solid var(--line); +} +.regression-dialog-head p { + margin: 10px 0 3px; + color: var(--muted); + font-size: 11px; +} +.regression-dialog-head h2 { + margin: 0 0 5px; + font-size: 24px; +} +.regression-dialog-head small { + color: var(--muted); +} +.regression-cases { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin: 18px 0; +} +.regression-case { + border: 1px solid var(--line); + border-radius: 15px; + background: var(--soft); + padding: 16px; + min-width: 0; +} +.regression-case > div { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} +.regression-case small { + font-size: 9px; + color: var(--muted); + overflow-wrap: anywhere; +} +.regression-case h3 { + font-size: 14px; + margin: 12px 0 7px; +} +.regression-case p { + font-size: 11px; + line-height: 1.6; + color: var(--muted); +} +.regression-case code { + font-size: 9px; + overflow-wrap: anywhere; +} +.regression-boundary { + border-top: 1px solid var(--line); + padding-top: 16px; +} +.regression-boundary h3 { + margin: 0 0 8px; +} +.regression-boundary ul { + padding-left: 20px; + color: var(--muted); + font-size: 11px; + line-height: 1.7; +} +.regression-boundary p { + font-size: 11px; + line-height: 1.65; + background: #fff8e8; + border: 1px solid #eed9a8; + border-radius: 12px; + padding: 12px; +} +.noscript { + margin: 40px; +} +.empty { + color: var(--muted); + font-style: italic; +} +@media (max-width: 920px) { + .app-shell { + display: block; + } + .rail { + transform: translateX(-105%); + transition: transform 0.25s; + width: min(88vw, 340px); + } + .rail.open { + transform: none; + } + .main { + grid-column: auto; + } + .icon-button { + display: block; + } + .chapter { + padding: 24px 16px 50px; + } + .topbar { + padding: 0 16px; + } + .chapter-hero { + padding: 28px 22px; + } + .chapter-hero h1 { + font-size: 28px; + } + .hero-grid, + .validation-grid { + grid-template-columns: 1fr; + } + .kpis { + grid-template-columns: repeat(2, 1fr); + } + .gallery { + grid-template-columns: 1fr; + } + .report-part { + grid-template-columns: 44px 1fr; + } + .pager { + padding: 0 16px; + } + #print-button { + display: none; + } + .regression-banner { + grid-template-columns: auto 1fr; + } + .regression-banner button { + grid-column: 1/-1; + } + .regression-cases { + grid-template-columns: 1fr; + } + .lightbox { + width: 100vw; + max-width: 100vw; + height: 100dvh; + max-height: 100dvh; + margin: 0; + border-radius: 0; + } + .lightbox-bar { + padding: + calc(10px + env(safe-area-inset-top)) + calc(12px + env(safe-area-inset-right)) + 10px + calc(12px + env(safe-area-inset-left)); + } + .lightbox-heading strong { + font-size: 12px; + } + .lightbox-heading small { + font-size: 9px; + } + .lightbox-controls { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + padding: + 8px + calc(8px + env(safe-area-inset-right)) + calc(8px + env(safe-area-inset-bottom)) + calc(8px + env(safe-area-inset-left)); + } + .lightbox-controls > button, + .lightbox-controls > a { + display: grid; + place-items: center; + padding: 7px 8px; + font-size: 10px; + min-height: 44px; + } + .lightbox-bar button, + .lightbox-zoom-controls button { + min-width: 44px; + min-height: 44px; + } + .lightbox-zoom-controls { + grid-column: 1 / -1; + grid-row: 1; + grid-template-columns: 44px minmax(110px, 1fr) 44px; + } + .lightbox-canvas { + padding: 8px; + } + .lightbox-zoom-hint { + left: 8px; + bottom: 8px; + max-width: calc(100% - 16px); + font-size: 9px; + } + .lightbox-zoom-hint-desktop { + display: none; + } + .lightbox-zoom-hint-mobile { + display: inline; + } + .integrity-dialog { + width: calc(100vw - 32px); + max-width: calc(100vw - 32px); + padding: 20px; + } + .integrity-row { + grid-template-columns: 1fr; + gap: 4px; + } +} +@media print { + body { + background: #fff; + } + .rail, + .topbar, + .pager, + .top-actions, + .shot-caption { + display: none !important; + } + .main { + display: block; + } + .chapter { + max-width: none; + padding: 0; + } + .chapter-hero { + box-shadow: none; + background: #fff; + color: #111; + border: 1px solid #bbb; + break-after: avoid; + } + .chapter-hero .story, + .eyebrow { + color: #333; + } + .section-shell, + .report-part { + box-shadow: none; + break-inside: avoid; + } + .gallery { + grid-template-columns: 1fr 1fr; + } + .shot { + break-inside: avoid; + } + .chapter > * { + break-before: auto; + } +} diff --git a/web/solver-report/source/report.js b/web/solver-report/source/report.js new file mode 100644 index 0000000..9d7ec1d --- /dev/null +++ b/web/solver-report/source/report.js @@ -0,0 +1,478 @@ +;(() => { + 'use strict' + const manifest = window.__REPORT_MANIFEST__ + if (!manifest) throw new Error('report manifest missing') + window.__REPORT_CHAPTERS__ = window.__REPORT_CHAPTERS__ || {} + + const $ = (selector) => document.querySelector(selector) + const esc = (value) => + String(value ?? '').replace( + /[&<>"']/g, + (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char] + ) + const statusTone = (status) => + ['infeasible', 'fail', 'blocked_engine_unavailable'].includes(status) ? 'guard' : 'pass' + const statusLabel = (status) => + ({ + optimal: '最优解', + infeasible: '不可行 · 已诊断', + pass: '验证通过', + fail: '验证失败 · 已阻断', + recovered: '恢复通过', + idle: '已完成', + })[status] || + status || + '已验证' + const familyLabel = (family) => + ({ lp: 'LP', milp: 'MILP', qp: 'QP', miqp: 'MIQP', qcp: 'QCP', miqcp: 'MIQCP', cp: 'CP' })[ + String(family).toLowerCase() + ] || String(family).toUpperCase() + const shortHash = (value) => (value ? `${value.slice(0, 10)}…${value.slice(-8)}` : '不适用') + const formatTime = (value) => (value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '未记录') + const state = { current: 0, filter: 'ALL', query: '', chapter: null, lightboxIndex: 0, visible: manifest.chapters } + const viewer = { + zoom: 1, + fitScale: 1, + mode: 'fit', + pointers: new Map(), + drag: null, + pinch: null, + resizeFrame: 0, + } + const MIN_VIEWER_ZOOM = 0.5 + const MAX_VIEWER_ZOOM = 12 + const clamp = (value, min, max) => Math.min(max, Math.max(min, value)) + + const loadScript = (src) => + new Promise((resolve, reject) => { + const script = document.createElement('script') + script.src = src + script.onload = resolve + script.onerror = reject + document.head.appendChild(script) + }) + async function loadChapter(index) { + const item = manifest.chapters[index] + if (!item) return + if (!window.__REPORT_CHAPTERS__[item.scenarioId]) { + if (location.protocol === 'file:') await loadScript(item.script) + else { + const response = await fetch(item.json) + if (!response.ok) throw new Error(`chapter load failed: ${response.status}`) + window.__REPORT_CHAPTERS__[item.scenarioId] = await response.json() + } + } + state.current = index + state.chapter = window.__REPORT_CHAPTERS__[item.scenarioId] + renderChapter() + renderNav() + updatePager() + updateHash() + $('#chapter').focus({ preventScroll: true }) + window.scrollTo({ top: 0, behavior: 'instant' }) + } + + function updateHash() { + const desired = `#case-${state.current + 1}` + if (location.hash !== desired) history.replaceState(null, '', desired) + } + function hashIndex() { + const caseMatch = /^#case-(\d+)$/.exec(location.hash) + if (caseMatch) return Math.max(0, Math.min(manifest.chapters.length - 1, Number(caseMatch[1]) - 1)) + const id = decodeURIComponent(location.hash.slice(1)) + const index = manifest.chapters.findIndex((item) => item.scenarioId === id) + return index >= 0 ? index : 0 + } + + function renderShell() { + $('#rail-summary').innerHTML = + `${manifest.counts.chapters}场景${manifest.counts.screenshots}真机图${manifest.counts.representativeStabilityPasses}稳定运行` + $('#build-id').textContent = manifest.buildId + const families = ['ALL', ...new Set(manifest.chapters.map((item) => familyLabel(item.problemFamily)))] + $('#filters').innerHTML = families + .map( + (family) => + `` + ) + .join('') + $('#filters').addEventListener('click', (event) => { + const button = event.target.closest('[data-family]') + if (!button) return + state.filter = button.dataset.family + document.querySelectorAll('.filter').forEach((item) => item.classList.toggle('active', item === button)) + applyNavFilter() + }) + $('#chapter-search').addEventListener('input', (event) => { + state.query = event.target.value.trim().toLowerCase() + applyNavFilter() + }) + $('#print-button').addEventListener('click', () => window.print()) + $('#menu-button').addEventListener('click', () => $('.rail').classList.toggle('open')) + $('#prev-button').addEventListener('click', () => + loadChapter((state.current - 1 + manifest.chapters.length) % manifest.chapters.length) + ) + $('#next-button').addEventListener('click', () => loadChapter((state.current + 1) % manifest.chapters.length)) + $('#latest-button').addEventListener('click', openLatestRegression) + $('#integrity-button').addEventListener('click', openIntegrity) + $('#integrity-dialog .dialog-close').addEventListener('click', () => $('#integrity-dialog').close()) + $('#latest-dialog .dialog-close').addEventListener('click', () => $('#latest-dialog').close()) + renderNav() + } + + function renderLatestRegressionBanner() { + const regression = manifest.latestPlatformRegression + if (!regression) return '' + const scenarios = regression.runtime?.scenarios ?? [] + const passed = scenarios.filter((item) => item.status === 'passed').length + return `
NEW
最新平台定向回归 · ${esc(formatTime(regression.testedAt))}${esc(regression.platform.commit.slice(0, 10))} 已通过 ${passed}/${scenarios.length} 个真实场景与决策工作台真机检查下方 24 章仍保持原始证据提交 ${esc(manifest.evidencePlatformCommit)} 的归属,不把历史截图冒充为新版重跑。
` + } + + function applyNavFilter() { + state.visible = manifest.chapters.filter((item) => { + const familyOk = state.filter === 'ALL' || familyLabel(item.problemFamily) === state.filter + const haystack = `${item.title} ${item.story} ${item.problemFamily} ${item.engineId}`.toLowerCase() + return familyOk && (!state.query || haystack.includes(state.query)) + }) + renderNav() + } + + function renderNav() { + $('#chapter-nav').innerHTML = + state.visible + .map((item) => { + const actualIndex = manifest.chapters.indexOf(item) + return `` + }) + .join('') || '

没有匹配的章节

' + $('#chapter-nav') + .querySelectorAll('[data-index]') + .forEach((button) => + button.addEventListener('click', () => { + $('.rail').classList.remove('open') + loadChapter(Number(button.dataset.index)) + }) + ) + $('#chapter-nav .active')?.scrollIntoView({ block: 'nearest' }) + } + + function renderTimeline(chapter) { + const visible = chapter.timeline.filter((item) => item.kind === 'tool' || !item.empty) + const finalMessageSequence = Math.max( + ...visible.filter((item) => item.kind === 'message' && item.role === 'assistant').map((item) => item.sequence) + ) + return visible + .map((item) => { + if (item.kind === 'tool') + return `
受治理工具${esc(item.tool)}${esc(item.status)}${item.durationMs == null ? '' : `${item.durationMs} ms`}
` + const isFinal = item.role === 'assistant' && item.sequence === finalMessageSequence + const body = isFinal + ? `
智能体最终完整报告(原始 Markdown 1:1 渲染)
${item.html}
` + : `
${item.html}
` + return `
${item.role === 'user' ? '用户' : '求解决策顾问'}消息 ${item.sequence} · SHA ${shortHash(item.markdownSha256)}
${body}
` + }) + .join('') + } + + function renderChapter() { + const c = state.chapter + const tone = statusTone(c.resultStatus) + const hard = c.validation?.satisfaction_report?.hard_constraints + const soft = c.validation?.satisfaction_report?.soft_constraints + $('#chapter').innerHTML = `${renderLatestRegressionBanner()} +
+
CHAPTER ${String(c.scenarioOrder).padStart(2, '0')}${familyLabel(c.problemFamily)}${statusLabel(c.resultStatus)}${esc(c.claimBoundary)}
+

${esc(c.title)}

${esc(c.story)}

+
决策问题${esc(c.decision)}
专业结论${esc(c.professionalSummary)}
+
+
+
问题族${familyLabel(c.problemFamily)}
+
实际引擎${esc(c.engineId || '独立校核')}
+
结果 / 校核${statusLabel(c.resultStatus)} / ${esc(c.validationVerdict || '不适用')}
+
真实消息 / 工具${c.counts.rawConversationMessages} / ${c.counts.toolEvents}
+
真机截图${c.counts.screenshots} 张 · 2x
+
+

场景故事与意义

为什么这个问题具有代表性,以及哪些业务规则决定可行性。

PACK ${esc(c.scenarioVersion)} · ${shortHash(c.contentSha256)}
+
目标${c.objectives.map(esc).join(';')}
基线${esc(c.baseline)}
+
硬规则${c.constraints.map(esc).join(';')}
+
+

真实多轮对话

普通消息逐条来自 messages.jsonl;工具事件来自持久回执,未用离线脚本改写对话。

RUN ${shortHash(c.evidence.runId)}
${renderTimeline(c)}
+

智能体最终十部分报告

十个 section ID、数量和顺序固定;正文由最终消息 Markdown 原样构建。

${c.formulaStats.rendered} FORMULAS · HTML+MATHML
${c.sections.map((section) => `
${section.number}

${esc(section.title)}

${section.html}
`).join('')}
+

专业校核与稳定性

求解结果、独立验证、等价 formulation 和重复运行分别留证。

${esc(c.stability.policy)}
+
硬约束${hard ? `${hard.satisfied}/${hard.total} 满足,最大违反 ${hard.max_violation}` : '诊断/候选模式,详见第 8 节'}
软约束${soft ? `${soft.violated}/${soft.total} 违反,罚分 ${soft.weighted_penalty_sum}` : '无或不适用'}
稳定性${c.stability.effectivePasses} 次有效通过${c.stability.semanticConvergence ? ',语义收敛' : ''}
+
OptimizationSpec${shortHash(c.evidence.optimizationSpecSha256)}
Result payload${shortHash(c.evidence.resultPayloadSha256)}
Validation report${shortHash(c.evidence.validationReportSha256)}
+
+

真机完整过程

DesireCore 原生窗口 backing store,应用尺寸不变,点击可查看 3024×1824 原图。

DEVTOOLS CLOSED
` + $('#chapter') + .querySelectorAll('[data-shot]') + .forEach((item) => { + item.addEventListener('click', () => openLightbox(Number(item.dataset.shot))) + item.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + openLightbox(Number(item.dataset.shot)) + } + }) + }) + $('#chapter [data-open-regression]')?.addEventListener('click', openLatestRegression) + document.title = `${String(c.scenarioOrder).padStart(2, '0')} · ${c.title} — ${manifest.title}` + $('#app').setAttribute('aria-busy', 'false') + } + + function updatePager() { + $('#progress-label').textContent = `第 ${state.current + 1} / ${manifest.chapters.length} 章` + $('#progress-bar').style.width = `${((state.current + 1) / manifest.chapters.length) * 100}%` + $('#pager-label').textContent = state.chapter.title + } + + function currentShot() { + return state.chapter?.media?.[state.lightboxIndex] ?? null + } + + function calculateFitScale() { + const shot = currentShot() + const stage = $('#lightbox-stage') + if (!shot || !stage.clientWidth || !stage.clientHeight) return 1 + const inset = window.matchMedia('(max-width: 920px)').matches ? 16 : 36 + return Math.min( + Math.max(1, stage.clientWidth - inset) / shot.width, + Math.max(1, stage.clientHeight - inset) / shot.height, + 1 + ) + } + + function updateViewerStatus() { + const stage = $('#lightbox-stage') + const image = $('#lightbox-image') + const actualScale = viewer.fitScale * viewer.zoom + const percent = Math.max(1, Math.round(actualScale * 100)) + $('#lightbox-zoom').textContent = + viewer.mode === 'fit' ? `${percent}% · 适应` : viewer.mode === 'actual' ? '100% · 1:1' : `${percent}%` + $('#lightbox-zoom-out').disabled = viewer.zoom <= MIN_VIEWER_ZOOM + 0.001 + $('#lightbox-zoom-in').disabled = viewer.zoom >= MAX_VIEWER_ZOOM - 0.001 + $('#lightbox-fit').setAttribute('aria-pressed', String(viewer.mode === 'fit')) + $('#lightbox-actual').setAttribute('aria-pressed', String(viewer.mode === 'actual')) + const rect = image.getBoundingClientRect() + stage.classList.toggle('is-pannable', rect.width > stage.clientWidth + 1 || rect.height > stage.clientHeight + 1) + } + + function setViewerZoom(nextZoom, options = {}) { + const shot = currentShot() + const stage = $('#lightbox-stage') + const image = $('#lightbox-image') + if (!shot || !image.complete || !image.naturalWidth) return + + const before = image.getBoundingClientRect() + const stageRect = stage.getBoundingClientRect() + const focusClientX = options.clientX ?? stageRect.left + stage.clientWidth / 2 + const focusClientY = options.clientY ?? stageRect.top + stage.clientHeight / 2 + const imageRatioX = before.width ? clamp((focusClientX - before.left) / before.width, 0, 1) : 0.5 + const imageRatioY = before.height ? clamp((focusClientY - before.top) / before.height, 0, 1) : 0.5 + + viewer.zoom = clamp(nextZoom, MIN_VIEWER_ZOOM, MAX_VIEWER_ZOOM) + viewer.mode = options.mode ?? 'manual' + const actualScale = viewer.fitScale * viewer.zoom + image.style.width = `${Math.max(1, Math.round(shot.width * actualScale))}px` + image.style.height = `${Math.max(1, Math.round(shot.height * actualScale))}px` + + const after = image.getBoundingClientRect() + stage.scrollLeft += after.left + imageRatioX * after.width - focusClientX + stage.scrollTop += after.top + imageRatioY * after.height - focusClientY + updateViewerStatus() + } + + function resetViewer(mode = 'fit') { + const shot = currentShot() + const image = $('#lightbox-image') + const stage = $('#lightbox-stage') + if (!shot || !image.complete || !image.naturalWidth) return + viewer.fitScale = calculateFitScale() + const nextZoom = mode === 'actual' ? 1 / viewer.fitScale : 1 + viewer.zoom = clamp(nextZoom, MIN_VIEWER_ZOOM, MAX_VIEWER_ZOOM) + viewer.mode = mode === 'actual' && viewer.zoom !== nextZoom ? 'manual' : mode + const actualScale = viewer.fitScale * viewer.zoom + image.style.width = `${Math.max(1, Math.round(shot.width * actualScale))}px` + image.style.height = `${Math.max(1, Math.round(shot.height * actualScale))}px` + stage.scrollTo({ left: 0, top: 0 }) + updateViewerStatus() + } + + function zoomViewerBy(factor, event) { + setViewerZoom(viewer.zoom * factor, { + clientX: event?.clientX, + clientY: event?.clientY, + mode: 'manual', + }) + } + + function openLightbox(index) { + state.lightboxIndex = index + const shot = state.chapter.media[index] + $('#lightbox-title').textContent = `${state.chapter.title} · ${index + 1}/${state.chapter.media.length}` + $('#lightbox-meta').textContent = `${shot.width}×${shot.height} · SHA-256 ${shot.sha256}` + const image = $('#lightbox-image') + image.alt = shot.alt + image.onload = () => resetViewer('fit') + image.src = shot.original + $('#lightbox-original').href = shot.original + if (!$('#lightbox').open) $('#lightbox').showModal() + if (image.complete && image.naturalWidth) requestAnimationFrame(() => resetViewer('fit')) + } + $('#lightbox-close').addEventListener('click', () => $('#lightbox').close()) + $('#lightbox-prev').addEventListener('click', () => + openLightbox((state.lightboxIndex - 1 + state.chapter.media.length) % state.chapter.media.length) + ) + $('#lightbox-next').addEventListener('click', () => + openLightbox((state.lightboxIndex + 1) % state.chapter.media.length) + ) + $('#lightbox-zoom-out').addEventListener('click', () => zoomViewerBy(1 / 1.25)) + $('#lightbox-zoom-in').addEventListener('click', () => zoomViewerBy(1.25)) + $('#lightbox-fit').addEventListener('click', () => resetViewer('fit')) + $('#lightbox-actual').addEventListener('click', () => resetViewer('actual')) + $('#lightbox-stage').addEventListener( + 'wheel', + (event) => { + if (!event.ctrlKey && !event.metaKey) return + event.preventDefault() + zoomViewerBy(event.deltaY < 0 ? 1.18 : 1 / 1.18, event) + }, + { passive: false } + ) + $('#lightbox-stage').addEventListener('dblclick', (event) => { + if (viewer.mode === 'fit') setViewerZoom(2, { clientX: event.clientX, clientY: event.clientY }) + else resetViewer('fit') + }) + $('#lightbox-stage').addEventListener('pointerdown', (event) => { + if (event.pointerType === 'mouse' && event.button !== 0) return + const stage = $('#lightbox-stage') + if (viewer.pointers.size >= 2) return + viewer.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }) + stage.setPointerCapture?.(event.pointerId) + if (viewer.pointers.size === 2) { + const [first, second] = [...viewer.pointers.values()] + viewer.pinch = { + distance: Math.hypot(second.x - first.x, second.y - first.y), + zoom: viewer.zoom, + } + viewer.drag = null + stage.classList.remove('is-dragging') + } else if (stage.classList.contains('is-pannable')) { + viewer.drag = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + left: stage.scrollLeft, + top: stage.scrollTop, + } + stage.classList.add('is-dragging') + } + }) + $('#lightbox-stage').addEventListener('pointermove', (event) => { + if (!viewer.pointers.has(event.pointerId)) return + const stage = $('#lightbox-stage') + viewer.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }) + if (viewer.pointers.size >= 2 && viewer.pinch) { + const [first, second] = [...viewer.pointers.values()] + const distance = Math.hypot(second.x - first.x, second.y - first.y) + setViewerZoom(viewer.pinch.zoom * (distance / Math.max(1, viewer.pinch.distance)), { + clientX: (first.x + second.x) / 2, + clientY: (first.y + second.y) / 2, + }) + return + } + if (viewer.drag?.pointerId === event.pointerId) { + stage.scrollLeft = viewer.drag.left - (event.clientX - viewer.drag.x) + stage.scrollTop = viewer.drag.top - (event.clientY - viewer.drag.y) + } + }) + const releaseViewerPointer = (event) => { + const stage = $('#lightbox-stage') + if (!viewer.pointers.has(event.pointerId)) return + viewer.pointers.delete(event.pointerId) + viewer.drag = null + viewer.pinch = null + stage.classList.remove('is-dragging') + if (viewer.pointers.size === 1 && stage.classList.contains('is-pannable')) { + const [[pointerId, point]] = [...viewer.pointers.entries()] + viewer.drag = { + pointerId, + x: point.x, + y: point.y, + left: stage.scrollLeft, + top: stage.scrollTop, + } + stage.classList.add('is-dragging') + } + } + $('#lightbox-stage').addEventListener('pointerup', releaseViewerPointer) + $('#lightbox-stage').addEventListener('pointercancel', releaseViewerPointer) + $('#lightbox-stage').addEventListener('lostpointercapture', releaseViewerPointer) + $('#lightbox').addEventListener('keydown', (event) => { + if (event.key === 'ArrowLeft') $('#lightbox-prev').click() + else if (event.key === 'ArrowRight') $('#lightbox-next').click() + else if (event.key === '+' || event.key === '=') zoomViewerBy(1.25) + else if (event.key === '-') zoomViewerBy(1 / 1.25) + else if (event.key === '0') resetViewer('fit') + else if (event.key === '1') resetViewer('actual') + else return + event.preventDefault() + }) + $('#lightbox').addEventListener('close', () => { + viewer.pointers.clear() + viewer.drag = null + viewer.pinch = null + $('#lightbox-stage').classList.remove('is-dragging', 'is-pannable') + }) + window.addEventListener('resize', () => { + if (!$('#lightbox').open) return + cancelAnimationFrame(viewer.resizeFrame) + viewer.resizeFrame = requestAnimationFrame(() => { + const previousActualScale = viewer.fitScale * viewer.zoom + viewer.fitScale = calculateFitScale() + const nextZoom = + viewer.mode === 'fit' ? 1 : viewer.mode === 'actual' ? 1 / viewer.fitScale : previousActualScale / viewer.fitScale + setViewerZoom(nextZoom, { mode: viewer.mode }) + }) + }) + + async function openIntegrity() { + let integrity + if (location.protocol === 'file:') + integrity = { + entries: [], + note: '离线模式下逐文件哈希见 integrity.json', + contentRootSha256: manifest.contentRootSha256, + } + else integrity = await (await fetch('integrity.json')).json() + $('#integrity-content').innerHTML = + `
报告内容根哈希${esc(manifest.contentRootSha256)}
历史证据根哈希${esc(manifest.historicalEvidenceContentRootSha256)}
最新版回归哈希${esc(manifest.latestPlatformRegressionSha256)}
章节${manifest.counts.chapters}
截图${manifest.counts.screenshots}
完整性条目${integrity.entries?.length || '见离线 integrity.json'}
历史证据平台 / Skill${esc(manifest.evidencePlatformCommit)} / ${esc(manifest.agentSkillVersion)}
最新回归平台${esc(manifest.latestPlatformRegression.platform.commit)}
` + $('#integrity-dialog').showModal() + } + + function openLatestRegression() { + const regression = manifest.latestPlatformRegression + const scenarios = regression.runtime.scenarios + .map((item) => { + const verdict = + item.expectedValidationVerdict === 'fail' && item.observedValidationVerdict === 'fail' + ? '违规候选按预期被阻断' + : `独立验证 ${item.observedValidationVerdict}` + return `
通过${esc(item.scenarioId)}

${esc(item.label)}

${esc(verdict)} · Solve ${item.settledSolveCalls} 次 · 十部分报告 ${item.tenPartReport ? '完整' : '不完整'}

${esc(shortHash(item.caseRunSha256))}
` + }) + .join('') + const workspace = regression.runtime.decisionWorkspace + $('#latest-content').innerHTML = + `
PASS

最新平台定向回归

${esc(regression.platform.commit.slice(0, 10))} · DesireCore ${esc(regression.platform.version)}

${esc(formatTime(regression.testedAt))} · ${esc(regression.platform.source)}
${scenarios}
通过Decision Workspace r${workspace.revision}

人 + Agent 共管决策工作台

业务、模型、证据三视图可用;定义通过,缺少模型映射时保持阻断;${workspace.viewport.join('×')} 无横向溢出。

review: ${esc(workspace.reviewState)}

证据边界

    ${regression.claimBoundary.map((item) => `
  • ${esc(item)}
  • `).join('')}

历史全量证据:${regression.historicalEvidence.chapters} 章、${regression.historicalEvidence.screenshots} 张截图、${regression.historicalEvidence.representativeStabilityPasses} 次代表性稳定运行,归属于平台 ${esc(regression.historicalEvidence.platformCommit)}。

` + $('#latest-dialog').showModal() + } + + window.addEventListener('hashchange', () => { + const index = hashIndex() + if (index !== state.current) loadChapter(index) + }) + renderShell() + loadChapter(hashIndex()).catch((error) => { + $('#chapter').innerHTML = + `

报告载入失败

${esc(error.stack || error.message)}
` + throw error + }) +})() diff --git a/web/solver-report/tests/public-release-policy.test.mjs b/web/solver-report/tests/public-release-policy.test.mjs new file mode 100644 index 0000000..3b2928d --- /dev/null +++ b/web/solver-report/tests/public-release-policy.test.mjs @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import test from 'node:test' +import { + assertPublicText, + projectPublicProvenance, + resolveSafeRemoteEntryUrl, + resolveSafeOutputRoot, + validatePublicReleaseAttestation, +} from '../scripts/public-release-policy.mjs' + +test('输出目录只能位于证据 report-web 的真实专用子目录', (context) => { + const sandbox = mkdtempSync(join(tmpdir(), 'solver-report-path-policy-')) + context.after(() => rmSync(sandbox, { recursive: true, force: true })) + const roots = { + inputRoot: join(sandbox, 'evidence'), + codeRoot: join(sandbox, 'agent-desirecore'), + appRoot: join(sandbox, 'desirecore-app'), + } + for (const path of [...Object.values(roots), join(roots.inputRoot, 'report-web')]) mkdirSync(path, { recursive: true }) + const safeOutput = join(roots.inputRoot, 'report-web', 'dist') + assert.equal( + resolveSafeOutputRoot({ ...roots, requestedOutputRoot: safeOutput }), + join(realpathSync(join(roots.inputRoot, 'report-web')), 'dist') + ) + for (const requestedOutputRoot of [ + '/', + sandbox, + roots.inputRoot, + join(roots.inputRoot, 'report-web'), + join(roots.codeRoot, 'web-output'), + join(sandbox, 'unmanaged-report-output'), + ]) { + assert.throws(() => resolveSafeOutputRoot({ ...roots, requestedOutputRoot: requestedOutputRoot })) + } + + const symlinkInputRoot = join(sandbox, 'symlink-evidence') + const externalTarget = join(sandbox, 'external-target') + mkdirSync(symlinkInputRoot) + mkdirSync(externalTarget) + symlinkSync(externalTarget, join(symlinkInputRoot, 'report-web')) + assert.throws(() => + resolveSafeOutputRoot({ + inputRoot: symlinkInputRoot, + codeRoot: roots.codeRoot, + appRoot: roots.appRoot, + requestedOutputRoot: join(symlinkInputRoot, 'report-web', 'dist'), + }) + ) +}) + +test('远端完整性条目不能改变 origin 或越过报告路径', () => { + assert.equal( + resolveSafeRemoteEntryUrl('https://build.example.com/solver-agent-team/', 'media/original/case/01@2x.png').href, + 'https://build.example.com/solver-agent-team/media/original/case/01@2x.png' + ) + for (const path of [ + 'https://127.0.0.1/private', + 'http://evil.example/private', + '../admin', + '/absolute', + 'media\\secret', + 'media/%2e%2e/secret', + 'media//secret', + 'media/file?redirect=http://127.0.0.1', + ]) { + assert.throws(() => resolveSafeRemoteEntryUrl('https://build.example.com/solver-agent-team/', path)) + } +}) + +test('provenance 使用递归数据不可穿透的字段白名单', () => { + assert.deepEqual( + projectPublicProvenance({ + engine_id: 'scip-build', + request_id: 'request-1', + endpoint: 'https://private.internal', + nested: { access_token: 'secret-value' }, + }), + { engine_id: 'scip-build', request_id: 'request-1' } + ) +}) + +test('公开文本策略拒绝身份、凭据、私有路径和私有网络地址', () => { + assert.doesNotThrow(() => assertPublicText('SCIP 10.0.2 · request 853ca9c7-440b', 'safe')) + for (const value of [ + 'admin@example.com', + '/Users/reviewer/private/report.json', + 'authorization: Bearer abcdefghijklmnop', + 'password=not-for-public', + 'http://192.168.1.10/private', + ]) { + assert.throws(() => assertPublicText(value, 'unsafe')) + } +}) + +test('公开发布证明必须绑定全部证据根并显式通过', () => { + const evidenceRoots = Object.fromEntries( + [ + 'publicTranscriptContentRootSha256', + 'publicScreenshotContentRootSha256', + 'screenshotPlanSha256', + 'screenshotValidationSha256', + 'screenshotPrivacyValidationSha256', + ].map((key, index) => [key, String(index + 1).repeat(64)]) + ) + const attestation = { + schemaVersion: 'solver.public-release-attestation/v1', + audience: 'public', + review: { + status: 'passed', + reviewedAt: '2026-08-30T09:00:00.000Z', + reviewer: 'release-review', + methods: ['text-scan', 'visual-review'], + assertions: { + transcriptsContainNoSensitiveData: true, + screenshotsContainNoSensitiveData: true, + customerIdentifiersExcluded: true, + }, + }, + evidence: evidenceRoots, + metadata: { platformCommit: 'be0cc2cce', agentSkillVersion: '4.4.4', representativeStabilityPasses: 20 }, + } + assert.equal(validatePublicReleaseAttestation(attestation, evidenceRoots), attestation) + assert.throws(() => + validatePublicReleaseAttestation( + { ...attestation, evidence: { ...evidenceRoots, screenshotPlanSha256: '0'.repeat(64) } }, + evidenceRoots + ) + ) +}) diff --git a/web/solver-report/tests/source-contract.test.mjs b/web/solver-report/tests/source-contract.test.mjs new file mode 100644 index 0000000..4a082bf --- /dev/null +++ b/web/solver-report/tests/source-contract.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const root = join(fileURLToPath(new URL('..', import.meta.url))) +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 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') +) + +test('图片查看器暴露完整的可访问缩放控制', () => { + for (const id of [ + 'lightbox-stage', + 'lightbox-canvas', + 'lightbox-zoom-out', + 'lightbox-zoom', + 'lightbox-zoom-in', + 'lightbox-fit', + 'lightbox-actual', + ]) { + assert.match(html, new RegExp(`id="${id}"`)) + } + assert.match(html, /aria-live="polite"/) + assert.match(html, /aria-describedby="lightbox-zoom-hint"/) + assert.match(javascript, /