From 32b58e0a4a81929367418c18876d82850947f0c5 Mon Sep 17 00:00:00 2001 From: zizi Date: Fri, 28 Aug 2026 05:32:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A1=86=E6=9E=B6:=20DSH=20=E6=8E=A5=20Pi=20?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E3=80=81=E7=9C=8B=E6=9D=BF=E4=BB=8E=E4=BB=93?= =?UTF-8?q?=E6=A0=B9=E8=A7=A3=E6=9E=90=E8=B7=AF=E5=BE=84=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E8=90=BD=E8=83=BD=E5=8A=9B=E5=8E=9F=E5=9E=8B=E9=97=B4=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-08-24-dsh-pi-browser-e2e.md | 54 ++++ docs/plans/2026-08-28-能力原型间收敛方案.md | 262 ++++++++++++++++++ framework/README.md | 2 +- framework/adapters/dsh/README.md | 11 +- framework/adapters/dsh/__init__.py | 4 + framework/adapters/dsh/pi_ai.py | 155 +++++++++++ framework/adapters/dsh/runner.py | 25 +- muse/authority/studio/read/server.py | 21 +- .../studio/read/test_server_display.py | 12 + .../studio/write/decision/decision_channel.py | 45 +-- .../studio/write/lesson/lesson_confirm.py | 31 ++- .../harness/manifests/test-inventory.json | 44 ++- tests/architecture/test_dsh_pi_route.py | 141 ++++++++++ .../architecture/test_studio_path_contract.py | 65 +++++ 14 files changed, 826 insertions(+), 46 deletions(-) create mode 100644 docs/acceptance/2026-08-24-dsh-pi-browser-e2e.md create mode 100644 docs/plans/2026-08-28-能力原型间收敛方案.md create mode 100644 framework/adapters/dsh/pi_ai.py create mode 100644 tests/architecture/test_dsh_pi_route.py create mode 100644 tests/architecture/test_studio_path_contract.py diff --git a/docs/acceptance/2026-08-24-dsh-pi-browser-e2e.md b/docs/acceptance/2026-08-24-dsh-pi-browser-e2e.md new file mode 100644 index 0000000..8040925 --- /dev/null +++ b/docs/acceptance/2026-08-24-dsh-pi-browser-e2e.md @@ -0,0 +1,54 @@ +# DSH / PI 浏览器创作闭环验收 + +日期:2026-08-24 +范围:`agent-example/` 当前工作树 + +## 运行配置 + +本次 DSH 运行使用 PI 本机 provider catalog 的非敏感元数据: + +- provider:`catproxy-openai` +- model:`gpt-5.6-sol` +- protocol:`openai-responses` +- base URL:`https://catproxy.lilifamily.com/v1` +- DSH route:`llm-pi-ai.providers.catproxy-openai` +- 凭据:由 `pi auth print-api-key --provider catproxy-openai` 在当前进程注入 `DEEPSEEK_API_KEY` + +密钥没有写入 patch、settings、raw、Git 或本记录。协议、地址和模型目录由 `framework/adapters/dsh/pi_ai.py` 从 PI `models.json` 读取;DSH patch 只保存 `apiKeyEnv` 引用。 + +## 创作链 + +隔离作品:`work_id=53`,标题前缀为 `E2E验证·DeepSeek·`。 + +1. planner 通过 DSH 生成第一章章纲并落运行、事件、raw 与模型回执。 +2. writer 通过 DSH 生成正文候选。首轮短正文被篇幅门拒绝;第二轮正文为 4,216 字。 +3. judge 通过 DSH 审查硬事件、顺序、语义和章末钩子。首轮指出漏掉一个硬事件;按作者修订授权重新生成后通过。 +4. 受控 E2E fixture 将通过三步检查的 DSH 结果按候选合同装配为 `candidate_id=170`,以 `production / passed / semantic=passed` 进入 Shadow,并带 DSH 事件工件、运行回执、质量结果、上下文冻结和 raw 关联。该装配用于验证 Studio 人闸与 Canonical 写入,不替代生产 `produce_next_chapter.py` 编排。 +5. 使用 `agent-browser` 打开 `http://127.0.0.1:8767/candidates/170`,以 `e2e-author` 提交采纳及理据。 +6. 数据库确认候选为 `accepted`,正式正文块为 revision 1,正文哈希与候选哈希一致,来源归因为 `candidate_id=170`。 +7. `agent-browser` 在 `http://127.0.0.1:8765/works/53/ch/1` 读取到正式正文标题、4,216 字和正文内容;运行详情显示 `dsh`、`0.1.1-rc.2`、`catproxy-openai/gpt-5.6-sol` 与 `framework-events`。 + +## 修复项 + +- Studio 三个入口改用 `AGENTS.md + .git` 查找独立仓根,修复目录迁移后的业务脚本导入和工件路径。 +- DSH runner 显式写入 `agent-default-model`,并支持 PI catalog 到 `llm-pi-ai` 的无密钥路由投影。 +- 过滤 PI `thinkingLevelMap` 中表示“不支持”的非 `off` 空档位,避免 DSH 插件树拒绝启动。 +- 决策与经验写通道采用 `POST -> 303 -> GET`,刷新不会重复提交写命令。 +- 看板浅色模式辅助文字、链接和 chip 编码对比度修正;写通道增加 `main` 地标和键盘可聚焦正文滚动区。 +- 修复 AI 味页面缺重验证回执时引用未定义变量的离线回退错误。 + +## 机械证据 + +- Skill strict:58 个 Skill,阻断 0,质量发现 0。 +- 最终离线清单(显式放行 PostgreSQL):122 项,121 通过,1 项 blocked,0 项失败。唯一 blocked 是未授权的 `diagnose-ai-flavor` 模型/凭据评测入口。 +- PostgreSQL 集成:8 项通过,包含冻结、事实增量、投影、候选接受、经验登记、CAS、规则 smoke 和回滚 smoke。 +- DSH / framework / Studio 架构测试、import boundary、Markdown 链接和 compileall:通过。 +- 变更文件 Pyright:0 errors、0 warnings;旧的完整看板模块仍有未改动的宽松类型和 editable import 诊断,不作为本次变更的通过依据。 +- agent-browser axe:总览、正式正文、候选决策三页均为 0 violations、0 incomplete。 +- 真实 DSH 最小探针:实际模型 `catproxy-openai/gpt-5.6-sol`,事件序列包含 `agent.started`、`model.completed`、`agent.completed`,生成 `framework-events.jsonl`。 + +## 边界 + +本记录证明 DSH + PI 路由、无工具 fresh DSH 任务、Muse 证据落库和 Studio 人闸/正式正文写入的联动可运行。DSH 当前适配器仍拒绝非空 Muse 工具白名单,因此这不是生产 `write-next-chapter` 两阶段只读工具探索链的替代证据;生产默认适配器仍为 Pi。 + +失败的短正文、评委拒绝和落库约束运行均保留在数据库运行账本中,未被删除或覆盖。 diff --git a/docs/plans/2026-08-28-能力原型间收敛方案.md b/docs/plans/2026-08-28-能力原型间收敛方案.md new file mode 100644 index 0000000..a1c298f --- /dev/null +++ b/docs/plans/2026-08-28-能力原型间收敛方案.md @@ -0,0 +1,262 @@ +# 能力原型间收敛方案(意图 / 问题 / 解决方案) + +日期:2026-08-28 +范围:`agent-example/` 全仓结构裁决;不新增运行时能力。 +状态:取代 [2026-08-24-双底座适配接缝](2026-08-24-双底座适配接缝.md) 作为当前执行入口。该 plan 的 DSH 真实模型、只读工具闭集、Muse 业务旅程三项未验收工作**冻结降级**为本方案 P2;已完成的无宿主门禁与失败闭环保留。 +证据基线:Git 跟踪 914 文件(2026-08-28 复核);核心实现 41 文件占 4.5%;`dispatch_agent_task.py` 743 行;Pi runner 4 处调用、`muse_role` 直调 8 处、`chat_governed` 直调 6 处;`muse-cloud` 已有 9 个业务模块。远程 PG `muse-example` 库存量 978 MB / 23.5 万行(2026-08-28 实测,含抽取成果与向量)。 +修正记录:2026-08-28 存储裁决修正——「文件系统代替数据库」改为「文件 + 本地 SQLite 分治」;万级章节、千级卡片、高频 run 与向量检索必须有库,纯文件方案会产生几十万小文件且丧失关联查询。 + +--- + +## 1. 我的意图(第一性原理) + +终极目标只有一句话: + +> **写出能上起点月票榜前十、番茄月榜前十的小说。** + +它由三个支撑性质构成,每个性质对应一组**最小必要实现**,其余皆为可删重量: + +| 性质 | 定义 | 最小必要实现 | 不需要 | +|---|---|---|---| +| 写得好 | 题材承诺、前三章启动、前十章追读、人物、声音、钩子 | 方法 skill(文本)+ 角色定义 + 一条写作流程链 | 43 个带脚本的 muse skill 仪式 | +| 可回放 | 同一冻结输入、同一条链、可重跑、可对比 | 冻结快照 + 本地库 append-only 运行表 + 同链重跑 | 远程数据库服务、raw vault、证据账本系统 | +| 可复利 | 观察 → 教训 → 验证 → 升格 → 下次实际消费 | 会话事件流 + 人审记录(含修订 diff,web 审查工作台录入)→ lesson(证据强制绑定)→ 人批准 → merge 进 skill → A/B 回放归因 | 无记录支撑的 skill 文本自我迭代、通用作品管理产品外壳 | +| 上榜(外部结果) | 内部质量门无法证明,需要外部反馈归因 | 起点/番茄数据人工导出 + 导入格式 + 归因对比 | 自动爬虫(合规风险) | + +判定原则:**凡是不在这张表右二列的实现,都是通往目标的摩擦,不是资产。** + +## 2. 现有问题(按证据排序) + +| # | 问题 | 证据 | +|---|---|---| +| P1 | 四个系统叠在一个仓库:创作生产、Agent 执行底座、评测实验室、观测控制台 | `muse/` 340 + `framework/` 299 + `tests/` 109 文件的职责分布 | +| P2 | 三条模型执行路线并存且不共享接口 | Pi runner(dispatch + 2 bridge + two_phase_writer);`muse_role` 直调 8 处;`chat_governed` 直调 6 处 | +| P3 | 名词多义:Agent = 角色 + 进程;Skill = 方法文本 + 业务命令 + 测试对象;Harness = 运行支架 + 回放评测 + 开发测试 | 同一 `skills.json` 同时充当运行时清单与开发测试清单 | +| P4 | 方法 skill 存了三份:源 + 两份字节级相同的生成投影 | `.agent/skills`(148 md)+ `framework/catalog/{dsh,pi}`(280 文件,占全仓 30.7%) | +| P5 | 数据层形态错误,且两面都不成立:卡片/向量/运行/快照这类高频与检索数据需要库;但库不应是远程共享 PG 加证据账本系统;纯文件方案在万级章节 + 千级卡片 + 高频 run 下产生几十万小文件 | 卡片与向量依赖远程 PG(100.64.0.8 共享实例、pgvector 挂在共享容器内、凭据明文入库);record-run-evidence 10 脚本构成账本系统;运行记录走文件目录会随频率爆炸 | +| P6 | 与产品栈平行:monorepo 内 `muse-cloud` 已有 9 个业务模块,`agent-example/muse/` 用 Python 又实现一遍 content/knowledge/authority | `muse/authority` 54 文件(其中 db 31)与 muse-module-content/knowledge 职责重叠 | +| P9 | 复利的数据基础缺口:候选采纳无人审结构与人的标识(creator/updater 分不清人与脚本);人改写产物的修订 diff 无处落;skill 与会话证据无配对链 | `example_candidate` 只有 agent 语义审查位;108 的 lesson 状态机有 decided_by 但蒸馏来源无 review 配对;`.agent/skills` 148 md 无证据链,属未验证假设 | +| P7 | 外部反馈缺口:无读者/榜单数据的采集、导入与归因链 | 全仓搜索无留存/追读/月票反馈回路;这是唯一真正的**能力**缺口 | +| P8 | 核心占比失衡:核心实现 41/914 = 4.5%,95% 是脚本/测试/投影/规则/文本 | 互斥分类统计(2026-08-28) | + +问题的共同根因:**把"治理"做成了默认路径**——每个动作都要过装配、策略、落库、回执的流水线,而不是把治理做成可插拔的旁路。 + +## 3. 解决方案 + +### 3.1 定位裁决 + +> **`agent-example` = 能力原型间**:验证"AI 能否写出上榜小说"这条链。 +> 产品功能(作品管理、用户采纳、知识库、看板)归 `muse-cloud` / `muse-studio`。 +> 验证成立的能力**移植**进产品栈;原型间不长期持有产品实现,不与产品栈平行生长。 + +### 3.2 结构裁决:4 层 / 3+1 域 + +不采用六层合同层——正交穷尽是分析工具,不是实施蓝图;层级越多,每次任务的读取成本越高。 + +| 层 | 内容 | 规模上限 | +|---|---|---| +| L0 资产 | 角色定义、方法 skill(纯文本) | 按需增长 | +| L1 执行底座 | 协议 + 两个执行器 + 宿主适配 | ≤ 10 个文件 | +| L2 业务流程 | 写作链、上下文冻结、回放链 | 收敛不扩张 | +| L3 数据 | works / runs / lessons | gitignore 为主 | + +| 领域 | 职责 | +|---|---| +| 资产域 | `agents/` + `skills/` | +| 执行域 | `runtime/` | +| 业务域 | `muse/` | +| 验证平面 | `tests/`(含适配器一致性测试;"adapter-harness"不是独立域) | + +Skill 三分法随之落地:方法 skill(模型消费,纯文本)归 `skills/`;Muse 业务能力(有副作用)归 `muse/**/`;回放场景(驱动评测)归 `muse/replay/cases/`。三者不共享 catalog。 + +### 3.3 目标目录结构 + +```text +agent-example/ +├── agents/ # L0:角色定义 +│ └── .md +├── skills/ # L0:方法 skill 唯一源(扁平) +│ └── / +│ ├── SKILL.md +│ └── references/*.md # 升格的 lesson merge 到这里 +├── runtime/ # L1:执行底座(替代 framework/,≤10 文件) +│ ├── protocol.py # Request / Event / Result + 校验 +│ ├── agent_executor.py # 多轮 Agent 调用 +│ ├── completion_executor.py # 单次受治理调用(吸收 muse_role) +│ ├── runs.py # 不可变 run 目录读写 +│ └── adapters/{pi,dsh}.py # 每宿主一薄文件 +├── muse/ # L2:业务流程 +│ ├── flow/ # plan-chapter → write → gate → adopt +│ ├── context/ # assemble / freeze +│ └── replay/ # cases/ + 重跑 + 对比 +├── web/ # L2 人机接口:人审工作台(薄) +│ ├── app.py # 只读 muse.db + 三个写端点 +│ └── static/ # 待审队列 / 审查详情(diff 并排) / lesson 审批 +├── lessons/ # L3:复利源(文件,进 git) +│ ├── pending/ # agent 产出的候选教训 +│ └── confirmed/ # 人确认后待升格 +├── data/ # L3:运行数据(gitignore) +│ ├── muse.db # 本地 SQLite 单文件:卡片/向量/运行/快照/候选 +│ └── works//chapters/ # Canonical 正文(文件权威,库只存索引) +├── tests/ +│ ├── protocol/ # 合同测试 +│ ├── adapters/ # 适配器一致性 +│ └── e2e/ # 一章全链 + 一次回放 +└── docs/ +``` + +### 3.4 关键合同 + +**存储分治合同(按访问模式,不按偏好)** + +| 数据 | 访问模式 | 存储 | +|---|---|---| +| 方法 skill / 角色 | 人读改、要 diff | 文件 + git | +| lessons | 人读改内容 + 状态机流转 | 内容文件;状态、证据绑定、decided_by 在 db | +| 正文 Canonical(万级/作品) | 人审稿改稿 | 文件为权威,库只存索引(path + 哈希 + 关系) | +| 实体卡片(千级) | 关联查询、倒排 | `data/muse.db` | +| 嵌入向量(万级 × 1024 维) | 近邻检索 | db 内 blob + 暴力扫;量级超出再上 sqlite-vec | +| 运行记录(高频 append) | 回放取单条、analytics | db 内 append-only 表 | +| 冻结快照(每 run 一份) | 回放整取 | db | + +库的形态是**本地单文件 SQLite**,不是远程服务。理由:回放确定性(run 前 `cp muse.db` 即数据基线快照,哈希即冻结);自包含(不依赖 infra 在线、不与产品栈共享实例);无服务进程;凭据退出仓库。现有 2334 行 PG DDL 为标准 SQL,平移成本低;`vector(1024)` 列改 blob + numpy。 + +**run 记录合同(回放的全部秘密)** + +```sql +runs(id, created_at, kind, input_json, output_text, meta_json, skill_set_hash, baseline_hash) +-- input_json:冻结快照内容 + prompt + 角色版本 + 模型 + 参数 +-- output_text:原始产出,append 后不可变 +-- meta_json:门禁结果、外部反馈指针 +-- skill_set_hash:本次消费的 skill 集哈希(效果归因的关键) +-- baseline_hash:运行开始时 muse.db 基线哈希(可选快照副本) +``` + +回放 = 取一行 `input_json` 重跑 `muse/flow` 同一入口,diff `output_text`。一次运行 = 一行 append,不在磁盘新增小文件。不存在第二条回放系统。 + +**记录合同(复利的数据基础,平移自现有 example_agent_event / example_lesson 合同)** + +```sql +events(run_id, seq, kind, role, tool_name, payload_json, created_at) +-- 完整会话流:user/assistant 消息、工具调用与返回、门禁结果、模型元数据 +-- 判断 agent 说了什么、对不对的原始事实 + +reviews(id, run_id, target, action, reviewer, reason, created_at) +-- 人审动作:action ∈ adopt / reject / revise;target ∈ candidate / lesson / chapter +-- reviewer 必填——人与 agent 自评必须可区分 + +revisions(id, review_id, kind, before_text, after_text, created_at) +-- 人改写 agent 产物的完整前后文——蒸馏的最高价值监督信号 + +lessons(id, source_run_ids, source_review_ids, kind, title, content_path, + status, decided_by, decided_at, rationale, target_ref) +-- status: proposed → reviewing → promoted/rejected,自动升格 DB 级拒绝 +-- source_run_ids / source_review_ids 强制非空:无证据指针的教训不许登记 +``` + +lesson 内容存 `lessons/` 文件(人读改),状态机与证据绑定在 db。砍掉的是远程 PG 与十脚本流水线的形态,保留的是事件流、人审状态机、证据绑定这些合同。 + +**存量数据迁移合同(PG → SQLite,分类迁移非全量搬迁)** + +现状:远程 PG `muse-example` 库 978 MB / 23.5 万行(2026-08-28 实测)。分三类处置: + +| 类别 | 内容 | 去向 | +|---|---|---| +| 成果数据(含真实 LLM 成本,必迁) | knowledge_draft 25,020 / knowledge_embedding 41,551 / content_chapter+block 23,566 / knowledge_entity·document·base / reference_work / ai_flavor_case 788 / rule 26 / voice_baseline 2 | cards、向量 blob、`data/sources//` 文件 + 索引;人感规则进 skill | +| 记录数据(复利原料,迁) | run_receipt 31 / candidate+cas 43 / **user_decision 6(回填 reviews,reviewer 保留)** / planning_section 10 / context_freeze 10 | runs / snapshots / reviews | +| 过程数据(不迁) | upgrade_audit 91,600 / parse_task+scaffold 23,554 / upgrade_card_state·presence·alias·window 21,406 / clean_log / revalidation 3,156 | PG 库迁移验收后转只读归档,不删除 | + +变换规则:剥离 Yudao 框架字段(tenant_id/creator/updater/deleted);`vector(1024)` 逐行转 float32 bytes 入 blob;参考书章节导出为文件。历史 `example_agent_event` / `raw_content` 为空表,无存量会话记录可迁——记录合同自新架构起积累。 + +**两个执行器接口** + +```text +AgentExecutor 多轮会话、工具调用、事件流、宿主适配 +CompletionExecutor 单次 prompt/response、结构化输出、模型治理 +DeterministicTool 无模型确定性操作(快照、校验、状态迁移) +``` + +`muse_role` 并入 CompletionExecutor;`chat_governed` 各调用点改走同一接口。框架不认识 `writer`、`work_id`、候选状态。 + +**蒸馏闭环(复利的完整回路)** + +```text +events(会话)+ reviews(人审)+ revisions(修订 diff) + → 蒸馏任务(本身也是 run,kind=distill,input 携带 run_ids/review_ids) + → lesson 登记(db:证据指针强制 + 状态机;内容:lessons/ 文件) + → 人批准(reviews 再记一条:reviewer + rationale) + → merge 进 skills//references/(git commit 可追溯) + → 下次运行 skill_set_hash 变化 + → A/B 回放:同题、同 baseline、新旧 skill 集各跑 → 盲评对比 → 归因 +``` + +判断 agent 对错的三个信号源:机械门禁(gate 结果进 events,便宜即时)、人审(reviews/revisions,最高权重)、外部反馈(P2,延迟终极)。缺任何一层,复利都退化为无监督的自我总结。 + +**skill 定位修正**:`skills/` 现有文本是来自通用方法论的初始先验,不是已验证知识。复利闭环的职责是逐步用本项目 events + reviews 蒸馏出的验证知识替换/修订它们;无归因证据的 skill 变更不进 references。 + +**投影合同**:`skills/` 是唯一源;投影生成到各宿主原生位置(`.pi/skills/` 等)并 gitignore。Git 中不存在第二份 skill 副本。 + +### 3.5 现有 → 目标映射 + +| 现有 | 去向 | +|---|---| +| `.agent/agents`、`.agent/skills` | 移到顶层 `agents/`、`skills/`,扁平化 | +| `framework/catalog/{dsh,pi}`(280 文件) | 删出 git;投影生成到宿主原生位置 | +| `framework/primitives` + `adapters` | 收进 `runtime/` | +| `dispatch_agent_task.py`(743 行) | 拆:装配 → `muse/flow`;执行 → `runtime/agent_executor`;登记 → `runtime/runs.py` | +| `muse_role` | 并入 `runtime/completion_executor.py` | +| `muse/content`、`flow`、`context` | 保留,收敛为 `muse/flow` + `muse/context` | +| `quality/replay` | 移为 `muse/replay`,改用生产链入口 | +| `humanization` | 降为 `skills/humanization-*` 方法 skill + 少量门禁脚本 | +| `authority/db`(31 个 PG DDL) | schema 平移为 `data/muse.db` 的版本化 SQLite 迁移;远程 PG 依赖与明文凭据退出 | +| `record-run-evidence`(10 脚本) | 合同平移进 SQLite:event 流、raw 全文、lesson 状态机的表设计照搬;十脚本流水线形态废弃 | +| `studio/` | 收缩为 `web/` 人审工作台:审查阅读保留(复利闭环的数据采集端),丢弃产品外壳(作品管理、用户体系、多租户归 muse-studio) | +| `tests/skills/`(102 文件) | 按 owner 归档到各模块;停止为每个脚本新增仪式性测试 | + +### 3.6 迁移顺序(每步配机械门禁) + +执行状态(每完成一步在此打标,格式:`[x] YYYY-MM-DD <一句话证据>`;新会话从第一个未勾选项繼续): + +- [ ] P0.1 删 catalog 双投影 +- [ ] P0.2 拆 dispatch_agent_task +- [ ] P0.3 建 data/muse.db(runs/events/reviews/revisions/cards) +- [ ] P0.4 存量迁移 PG → SQLite +- [ ] P1.4 蒸馏闭环(lesson 证据强制 + 人批准 + skill 升格) +- [ ] P1.5 回放链改用生产 flow 入口 +- [ ] P1.6 人审工作台 web/ +- [ ] P2.6 外部反馈导入与归因 +- [ ] P2.7 dsh/claude 适配 + 归因分析页 + +**P0 —— 冻结一切新功能,先减重** + +| 步骤 | 内容 | 验收门禁 | +|---|---|---| +| P0.1 | 删 catalog 双投影出 git,投影直生成到宿主目录 | `git ls-files | grep catalog` 为空;干净 clone 后投影命令可重跑;架构测试更新为校验宿主原生位置 | +| P0.2 | 拆 `dispatch_agent_task.py` | 该文件删除或 < 100 行;`runtime/` 无 muse 业务 import(复用 `test_framework_port_purity` 模式) | +| P0.3 | `data/muse.db` 落地:runs / events / reviews / revisions / cards 表 + 迁移骨架;freeze-context 与 search-knowledge 对接(向量 blob + 暴力扫) | 一次真实写作运行写入完整 run + events 行;一条人审动作写入 reviews(revise 时含 revisions diff);同 input 重跑可 diff;`git status` 不因运行产生新文件 | +| P0.4 | 存量迁移工具 `migrate_pg_to_sqlite.py`:按上表分类导出成果与记录数据,过程数据留 PG | 成果表行数逐一对账相等;向量抽样 PG vs SQLite 余弦相似度 = 1.0;章节导出文件数 = 行数且抽读完好;user_decision 回填 reviews 行数 = 6;报告 muse.db 体积(预期 < 300MB);验收后 PG 库转只读 | + +**P1 —— 复利闭环** + +| 步骤 | 内容 | 验收门禁 | +|---|---|---| +| P1.4 | 蒸馏闭环:lesson 登记(证据指针强制)+ 人批准 + skill 升格 + skill_set_hash 记录 | 一条 lesson 从 source_run_ids/review_ids 到 merge 进 references 全链可追溯;无证据指针的 lesson 被 DB 拒绝;一次 A/B 回放产出新旧 skill 集归因对比 | +| P1.6 | 人审工作台 `web/`:待审队列 + 审查详情(候选/冻结输入/门禁/diff 并排,revise 即录 revisions)+ lesson 审批;写面仅 reviews / revisions / adopt(adopt 调用 flow,不自行写文件) | 一次完整 adopt → revise(含 before/after diff)→ reviews 落库全程在 web 完成;web 不产生 reviews/revisions 之外的表写路径 | +| P1.5 | 回放链改用生产 flow 入口 | replay 入口与生产 flow import 同一模块;不存在平行执行代码 | + +**P2 —— 补缺口与后置项** + +| 步骤 | 内容 | 验收门禁 | +|---|---|---| +| P2.6 | 外部反馈导入格式(起点/番茄人工导出)+ 归因对比 | 一份导入数据 → 一份归因报告 | +| P2.7 | dsh / claude 适配;归因/分析页(A/B 对比、runs 浏览,并入 web/) | 适配器一致性测试通过 | + +### 3.7 明确不做(防复发清单) + +- 不新增第六层合同层、第八个领域、"统一治理入口" +- 不引入远程数据库服务;`data/muse.db` 的 schema 变更走版本化迁移文件,凭据不入库 +- 不为每个业务脚本配仪式性测试;测试只留在 protocol / adapters / e2e 三处 +- 不把 DSH 扩展排在 P2 之前 +- 不让运行记录落到文件系统:一次运行 = 一行 append;正文文件仅在 Canonical 采纳后产生 +- 不接受无 source_run_ids / source_review_ids 的 lesson;人审动作只落 reviews 表,reviewer 必填 +- `web/` 只做审查与阅读:待审队列、审查详情、lesson 审批、归因分析;不做作品管理、用户体系、编辑器全家桶——产品外壳归 muse-studio diff --git a/framework/README.md b/framework/README.md index ad02052..2fabe69 100644 --- a/framework/README.md +++ b/framework/README.md @@ -12,4 +12,4 @@ framework/ Muse 业务侧先解析角色合同、模型策略、冻结输入和输出 Schema,再把通用执行请求交给端口;框架适配器不直接决定创作流程,也不绕过 Muse 的证据与主权链。`catalog/generate_skill_projections.py` 只生成宿主发现投影,Skill 正文的所有权仍在 Muse 源目录。 -当前生产默认仍是 Pi。DSH 适配器是 opt-in 的 headless 对照接缝:它只开放无工具 fresh 任务,读取 flush 后的 append-only session 工件;尚未形成 Muse 只读工具闭集和真实业务旅程证据前,不得把 DSH 当作生产宿主。 +当前生产默认仍是 Pi。DSH 适配器是 opt-in 的 headless 对照接缝:它只开放无工具 fresh 任务,读取 flush 后的 append-only session 工件;可通过 `framework.adapters.dsh.load_pi_ai_route()` 复用 PI 的 provider 协议和地址,但 key 仍由当前进程环境提供。尚未形成 Muse 只读工具闭集和完整生产业务旅程接入前,不得把 DSH 当作生产宿主。 diff --git a/framework/adapters/dsh/README.md b/framework/adapters/dsh/README.md index e024d37..09ef909 100644 --- a/framework/adapters/dsh/README.md +++ b/framework/adapters/dsh/README.md @@ -9,6 +9,7 @@ - 工具:只接受空 `toolAllowlist`。当前 DSH 内建工具没有与 Muse 五个只读工具形成可证明的闭集,因此非空白名单在启动前失败。 - 轨迹:通过一次性 patch 把会话持久化切到本次运行私有的 `compression=none` JSONL;flush 后再归一为 `FrameworkEvent` 工件。stdout 不是审计事实源。 - 模型:provider/model 必须由调用方显式传入,适配器只把它们写入 DSH 的 `agent-default-model` patch 并记录实际 session route。 +- PI 路由:`load_pi_ai_route()` 只读 PI 的 `models.json`,把 provider、`api` 协议、`baseUrl` 和模型目录投影为 `llm-pi-ai` patch;凭据只通过 `apiKeyEnv` 引用,不能进入 patch。 - 权限:默认 `read-only`;适配器关闭 headless profile 中未经过 Muse 授权证明的工具、Skill 自动注入和会话标题模型调用。 这条路径是 DSH 对照臂和宿主接缝,不改变当前生产默认 Pi,也不允许 DSH 直接写 Canonical 或数据库。 @@ -23,7 +24,15 @@ dsh --profile web --dump-config dsh --profile headless --dump-config ``` -真实模型运行还需要由用户显式提供 DSH 自己的凭据和预算;上述命令不构成真实模型或 Muse 业务旅程证据。 +PI 兼容网关的真实运行由调用方在当前进程注入凭据,例如: + +```bash +PI_KEY="$(pi auth print-api-key --provider "$PI_PROVIDER")" +DEEPSEEK_API_KEY="$PI_KEY" .venv/bin/python <调用方脚本> +unset PI_KEY +``` + +调用方应使用 `load_pi_ai_route()` 读取协议和地址,再把 `pi_ai_route` 传给 `DshExecutionPolicy`;密钥不写入仓库。真实模型运行还需要用户显式提供凭据和预算。 ## 代码入口 diff --git a/framework/adapters/dsh/__init__.py b/framework/adapters/dsh/__init__.py index ab7de91..a1112c4 100644 --- a/framework/adapters/dsh/__init__.py +++ b/framework/adapters/dsh/__init__.py @@ -1,5 +1,6 @@ """DeepSeek Harness 宿主适配器。""" +from .pi_ai import PiAiRouteConfig, PiAiRouteConfigError, load_pi_ai_route from .normalization import ( DshModelCall, DshNormalizationError, @@ -21,6 +22,9 @@ from .runner import ( ) __all__ = [ + "PiAiRouteConfig", + "PiAiRouteConfigError", + "load_pi_ai_route", "DshModelCall", "DshNormalizationError", "DshSessionOutcome", diff --git a/framework/adapters/dsh/pi_ai.py b/framework/adapters/dsh/pi_ai.py new file mode 100644 index 0000000..4a55031 --- /dev/null +++ b/framework/adapters/dsh/pi_ai.py @@ -0,0 +1,155 @@ +"""将 Pi 的 provider catalog 投影为 DSH pi-ai profile。""" + +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping + + +_ENV_NAME = re.compile(r"^[A-Z_][A-Z0-9_]*$") + + +class PiAiRouteConfigError(ValueError): + """Pi 配置缺失、格式非法或不能描述一个 DSH 路由。""" + + +@dataclass(frozen=True) +class PiAiRouteConfig: + """不含密钥的 DSH pi-ai 路由配置。""" + + provider: str + model: str + api: str + base_url: str + api_key_env: str = "DEEPSEEK_API_KEY" + model_name: str | None = None + context_window: int | None = None + max_tokens: int | None = None + reasoning_efforts: Mapping[str, str | None] | None = None + + def __post_init__(self) -> None: + for field, value in ( + ("provider", self.provider), + ("model", self.model), + ("api", self.api), + ("base_url", self.base_url), + ): + if not isinstance(value, str) or not value.strip(): + raise PiAiRouteConfigError(f"{field} 必须是非空字符串") + if not isinstance(self.api_key_env, str) or _ENV_NAME.fullmatch(self.api_key_env) is None: + raise PiAiRouteConfigError("api_key_env 必须是大写 POSIX 环境变量名") + for field, value in (("context_window", self.context_window), ("max_tokens", self.max_tokens)): + if value is not None and (isinstance(value, bool) or not isinstance(value, int) or value <= 0): + raise PiAiRouteConfigError(f"{field} 必须是正整数") + + def as_profile(self) -> dict[str, Any]: + """生成 DSH `llm-pi-ai.providers.` 配置,不携带 secret。""" + + model: dict[str, Any] = {"id": self.model} + if self.model_name: + model["name"] = self.model_name + if self.context_window is not None: + model["contextWindow"] = self.context_window + if self.max_tokens is not None: + model["maxTokens"] = self.max_tokens + if self.reasoning_efforts: + model["reasoningEfforts"] = dict(self.reasoning_efforts) + return { + "apiKeyEnv": self.api_key_env, + "api": self.api, + "baseURL": self.base_url, + "models": [model], + } + + +def _default_models_path() -> Path: + agent_dir = os.environ.get("PI_CODING_AGENT_DIR") + return Path(agent_dir).expanduser() / "models.json" if agent_dir else Path.home() / ".pi" / "agent" / "models.json" + + +def _as_mapping(value: Any, field: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise PiAiRouteConfigError(f"{field} 必须是对象") + return value + + +def _positive_int(value: Any, field: str) -> int | None: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise PiAiRouteConfigError(f"{field} 必须是正整数") + return value + + +def load_pi_ai_route( + models_path: str | Path | None = None, + *, + provider: str | None = None, + model: str | None = None, + api_key_env: str = "DEEPSEEK_API_KEY", +) -> PiAiRouteConfig: + """读取 Pi 的非敏感 provider/model 元数据,返回 DSH 路由配置。 + + 密钥不从 `auth.json` 读取;调用方应在当前进程把它放入 `api_key_env`。 + """ + + path = Path(models_path).expanduser() if models_path else _default_models_path() + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise PiAiRouteConfigError(f"Pi models.json 不可读: {path}") from exc + if not isinstance(raw, Mapping): + raise PiAiRouteConfigError("Pi models.json 顶层必须是对象") + providers = _as_mapping(raw.get("providers"), "providers") + selected_provider = provider or os.environ.get("PI_PROVIDER") + selected_model = model or os.environ.get("PI_MODEL") + if not selected_provider: + raise PiAiRouteConfigError("未指定 provider,且 PI_PROVIDER 未配置") + if not selected_model: + raise PiAiRouteConfigError("未指定 model,且 PI_MODEL 未配置") + provider_config = _as_mapping(providers.get(selected_provider), f"provider {selected_provider}") + base_url = provider_config.get("baseUrl") + api = provider_config.get("api") + if not isinstance(base_url, str) or not base_url.strip(): + raise PiAiRouteConfigError(f"provider {selected_provider} 缺少 baseUrl") + if not isinstance(api, str) or not api.strip(): + raise PiAiRouteConfigError(f"provider {selected_provider} 缺少 api 协议") + models = provider_config.get("models") + if not isinstance(models, list): + raise PiAiRouteConfigError(f"provider {selected_provider} 的 models 必须是数组") + model_config = next( + (item for item in models if isinstance(item, Mapping) and item.get("id") == selected_model), + None, + ) + if model_config is None: + raise PiAiRouteConfigError(f"model {selected_model} 未登记在 provider {selected_provider}") + thinking_map = model_config.get("thinkingLevelMap") + reasoning_efforts = None + if isinstance(thinking_map, Mapping): + reasoning_efforts = {} + for level, value in thinking_map.items(): + level_name = str(level) + # Pi 用 null 表示该档位不支持;DSH 只允许 off 以 null 声明。 + if value is None and level_name != "off": + continue + reasoning_efforts[level_name] = ( + value if value is None or isinstance(value, str) else str(value) + ) + return PiAiRouteConfig( + provider=str(selected_provider), + model=str(selected_model), + api=str(api), + base_url=base_url.rstrip("/"), + api_key_env=api_key_env, + model_name=str(model_config.get("name")) if model_config.get("name") else None, + context_window=_positive_int(model_config.get("contextWindow"), "contextWindow"), + max_tokens=_positive_int(model_config.get("maxTokens"), "maxTokens"), + reasoning_efforts=reasoning_efforts, + ) + + +__all__ = ["PiAiRouteConfig", "PiAiRouteConfigError", "load_pi_ai_route"] diff --git a/framework/adapters/dsh/runner.py b/framework/adapters/dsh/runner.py index fcee9af..f62f38d 100644 --- a/framework/adapters/dsh/runner.py +++ b/framework/adapters/dsh/runner.py @@ -18,6 +18,7 @@ from .normalization import ( DshSessionOutcome, normalize_dsh_session, ) +from .pi_ai import PiAiRouteConfig DEFAULT_DSH_BIN = "dsh" @@ -93,6 +94,7 @@ class DshExecutionPolicy: cwd: str | None = None dsh_home: str | None = None permission_mode: str = "read-only" + pi_ai_route: PiAiRouteConfig | None = None def __post_init__(self) -> None: if not isinstance(self.provider, str) or not self.provider.strip(): @@ -103,6 +105,11 @@ class DshExecutionPolicy: raise ValueError("当前 DSH 适配器只支持 headless profile") if self.permission_mode not in {"read-only", "workspace-write", "danger-full-access"}: raise ValueError("permission_mode 不受支持") + if self.pi_ai_route is not None: + if self.pi_ai_route.provider != self.provider: + raise ValueError("pi_ai_route.provider 必须与 policy.provider 一致") + if self.pi_ai_route.model != self.model: + raise ValueError("pi_ai_route.model 必须与 policy.model 一致") @property def framework(self) -> str: @@ -185,11 +192,24 @@ def build_dsh_patch( "当前 DSH headless 适配器尚未接入 Muse 只读工具插件,非空工具白名单拒绝启动", ) cwd = Path(policy.cwd or os.getcwd()).resolve() - return [ + patch = [ { "id": "agent-default-model", "config": {"provider": policy.provider, "model": policy.model}, }, + ] + if policy.pi_ai_route is not None: + patch.append( + { + "id": "llm-pi-ai", + "config": { + "providers": { + policy.pi_ai_route.provider: policy.pi_ai_route.as_profile(), + } + }, + } + ) + patch.extend([ { "id": "system-prompt", "config": {"persona": request.system_prompt}, @@ -214,7 +234,8 @@ def build_dsh_patch( "config": {"mode": "native"}, }, *({"id": row_id, "disabled": True} for row_id in _DISABLED_HEADLESS_ROWS), - ] + ]) + return patch def build_dsh_argv( diff --git a/muse/authority/studio/read/server.py b/muse/authority/studio/read/server.py index 784b841..ac3bde1 100644 --- a/muse/authority/studio/read/server.py +++ b/muse/authority/studio/read/server.py @@ -24,8 +24,13 @@ from urllib.parse import parse_qs, urlencode, urlparse from muse_db import WINDOW_BUDGET_USD, WINDOW_CALL_CAP, connect HOST, PORT = "127.0.0.1", 8765 +PROJECT_ROOT = next( + parent + for parent in (Path(__file__).resolve().parent, *Path(__file__).resolve().parents) + if (parent / "AGENTS.md").is_file() and (parent / ".git").exists() +) # 生产编排 step2 的本地留档目录(只读;不进库的 pipeline/诊断摘要在此) -WRITE_CHAPTER_ARTIFACTS = Path(__file__).resolve().parents[1] / "docs" / "write-chapter" / "artifacts" +WRITE_CHAPTER_ARTIFACTS = PROJECT_ROOT / "docs" / "write-chapter" / "artifacts" # AI 味案例的正式来源是 muse-example;离线 JSON 只作为数据库不可用时的恢复/回退证据。 AI_FLAVOR_REPORT_DIR = Path(__file__).resolve().parent / "fixtures" @@ -109,9 +114,9 @@ def table_counts(names): CSS = """ :root { color-scheme: light; - --plane:#f9f9f7; --surface:#fcfcfb; --ink:#0b0b0b; --ink2:#52514e; --muted:#898781; + --plane:#f9f9f7; --surface:#fcfcfb; --ink:#0b0b0b; --ink2:#52514e; --muted:#6b6a65; --grid:#e1e0d9; --baseline:#c3c2b7; --ring:rgba(11,11,11,.10); - --s1:#2a78d6; --s2:#008300; --s3:#e87ba4; --s4:#eda100; --s5:#1baf7a; + --s1:#1d5fae; --s2:#008300; --s3:#e87ba4; --s4:#eda100; --s5:#1baf7a; --s6:#eb6834; --s7:#4a3aa7; --s8:#e34948; --good:#0ca30c; --warn:#fab219; --serious:#ec835a; --crit:#d03b3b; --goodtxt:#006300; } @@ -186,7 +191,7 @@ td a:hover { text-decoration:underline; } .chip.crit .dot { background:var(--crit); } .chip.gap { color:var(--muted); border:1px dashed var(--baseline); background:transparent; } .chip.gap .dot { background:var(--baseline); } -.chip code, .field-code { font-size:11px; font-weight:500; color:inherit; opacity:.78; } +.chip code, .field-code { font-size:11px; font-weight:500; color:inherit; opacity:1; } .enum-code { font-size:11px; color:var(--muted); white-space:nowrap; } .knowledge-grid, .planning-grid { display:grid; grid-template-columns:minmax(0,1.1fr) minmax(320px,.9fr); @@ -632,7 +637,7 @@ NAV = [ ("/runs", "运行记录", "流程 05 · 质量 06"), ] -# 人审写库不在本只读看板;确认通道默认端口(见 dashboard/lesson_confirm.py)。 +# 人审写库不在本只读看板;确认通道默认端口(见 ../write/lesson/lesson_confirm.py)。 LESSON_CONFIRM_BASE = "http://127.0.0.1:8766" @@ -977,7 +982,7 @@ def view_ai_flavor(params=None): report_rel = f"capture://ai-flavor/{Path(str(report_rel)).name}" if authority == "offline": try: - report_rel = str(_latest_ai_flavor_report().relative_to(Path(__file__).resolve().parents[1])) + report_rel = str(_latest_ai_flavor_report().relative_to(PROJECT_ROOT)) except (AttributeError, ValueError): report_rel = "仓内离线回退文件" @@ -1043,13 +1048,13 @@ def view_ai_flavor(params=None): receipt_ref = f"batch_id={esc((revalidation or {}).get('_batch_id'))} · report_sha256={esc((revalidation or {}).get('_report_sha256'))}" else: latest = _latest_ai_flavor_revalidation_report() - receipt_ref = (str(latest.relative_to(Path(__file__).resolve().parents[1])) + receipt_ref = (str(latest.relative_to(PROJECT_ROOT)) if latest else "仓内离线回执") revalidation_note = (f"重验证 {esc(receipt_ref)} · " f"检查于 {esc((revalidation or {}).get('checked_on', 'unknown'))} · " f"来源已验证 {fmt_num(verification_counts.get('verified', 0))}/{fmt_num(len(cards))}") else: - revalidation_note = f"重验证:{esc(revalidation_error or '未提供回执')}" + revalidation_note = f"重验证:{esc(load_note or '未提供回执')}" authority_label = "PostgreSQL muse-example 正式库" if authority == "postgresql" else "离线回退(数据库当前不可读)" fallback_note = f"
回退 {esc(load_note)}" if authority == "offline" and load_note else "" source_storage_note = "研究限定/未授权来源原文片段未保存" if source_license in {"research_only", "unauthorized"} else "授权来源可在评审后保存受控片段" diff --git a/muse/authority/studio/read/test_server_display.py b/muse/authority/studio/read/test_server_display.py index e7e3690..af432b6 100644 --- a/muse/authority/studio/read/test_server_display.py +++ b/muse/authority/studio/read/test_server_display.py @@ -3,6 +3,7 @@ import pathlib import sys import unittest +from unittest import mock sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) @@ -111,6 +112,17 @@ class DashboardDisplayTest(unittest.TestCase): self.assertIn("788/788", text) self.assertIn("batch_id=", text) + def testAI味页面缺重验证回执仍失败关闭渲染(self): + report, _error = server._load_ai_flavor_report() + with mock.patch.object( + server, + "_load_ai_flavor_bundle", + return_value=(report, None, "重验证回执缺失", "offline"), + ): + text = server.view_ai_flavor({}) + self.assertIn("重验证:重验证回执缺失", text) + self.assertNotIn("NameError", text) + def test数据库AI味页面支持作品分页与卡片详情(self): filtered = server.view_ai_flavor({"work": ["ref:机破星河_当年离歌"], "page": ["2"]}) self.assertIn("当前第 2/6 页", filtered) diff --git a/muse/authority/studio/write/decision/decision_channel.py b/muse/authority/studio/write/decision/decision_channel.py index 0060b3c..9406683 100644 --- a/muse/authority/studio/write/decision/decision_channel.py +++ b/muse/authority/studio/write/decision/decision_channel.py @@ -3,14 +3,13 @@ 与只读看板(:8765)分离:看板合同禁止写;人审决策必须在本通道完成。 与经验确认通道(:8766)并列:本通道只做候选采纳/丢弃。 -跑法:.venv/bin/python dashboard/decision_channel.py +跑法:.venv/bin/python muse/authority/studio/write/decision/decision_channel.py 默认 http://127.0.0.1:8767 """ from __future__ import annotations import html -import json import sys import traceback import uuid @@ -21,13 +20,17 @@ from urllib.parse import parse_qs, urlparse HOST, PORT = "127.0.0.1", 8767 READONLY_BASE = "http://127.0.0.1:8765" -ROOT = Path(__file__).resolve().parents[1] -SKILLS = ROOT / ".agent" / "skills" -for sub in ( - "decide-candidate/scripts", - "record-run-evidence/scripts", +PROJECT_ROOT = next( + parent + for parent in (Path(__file__).resolve().parent, *Path(__file__).resolve().parents) + if (parent / "AGENTS.md").is_file() and (parent / ".git").exists() +) +SKILLS = PROJECT_ROOT / "muse" / "content" / "work" / "skills" +for path in ( + SKILLS / "sovereignty" / "decide-candidate" / "scripts", + PROJECT_ROOT / "muse" / "authority" / "evidence" / "skills" / "record-run-evidence" / "scripts", ): - p = str(SKILLS / sub) + p = str(path) if p not in sys.path: sys.path.insert(0, p) @@ -58,10 +61,10 @@ pre{{white-space:pre-wrap;background:#f6f6f6;padding:10px;border-radius:6px;font a{{color:#0645ad}} .meta{{color:#555;font-size:14px}} - -{body} +(经验升格仍走 :8766) +
{body}
""" @@ -212,7 +215,7 @@ def view_candidate( flash_html + err_html + f"

候选 #{candidate_id}

{ro}{meta}" - + f"

正文预览

{body_preview}
" + + f"

正文预览

{body_preview}
" + forms + "

← 返回列表

", ) @@ -230,6 +233,14 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(data) + def _redirect(self, location: str) -> None: + """成功写入后用 PRG 收口,刷新不会重复提交写命令。""" + + self.send_response(303) + self.send_header("Location", location) + self.send_header("Content-Length", "0") + self.end_headers() + def _read_form(self) -> dict[str, str]: length = int(self.headers.get("Content-Length") or 0) raw = self.rfile.read(length) if length else b"" @@ -260,14 +271,13 @@ class Handler(BaseHTTPRequestHandler): rationale = (form.get("rationale") or "").strip() or None command_id = f"decision-channel-{action}-{cid}-{uuid.uuid4().hex[:12]}" if action == "accept": - result = accept( + accept( cid, decided_by=decided_by, rationale=rationale, command_id=command_id, ) - msg = f"已采纳:{json.dumps(result, ensure_ascii=False, default=str)}" - self._send(200, view_candidate(cid, flash=msg)) + self._redirect(f"/candidates/{cid}") return if action == "discard": if not rationale: @@ -276,14 +286,13 @@ class Handler(BaseHTTPRequestHandler): view_candidate(cid, error="丢弃必须填写理据"), ) return - result = discard( + discard( cid, decided_by=decided_by, rationale=rationale, command_id=command_id, ) - msg = f"已丢弃:{json.dumps(result, ensure_ascii=False, default=str)}" - self._send(200, view_candidate(cid, flash=msg)) + self._redirect(f"/candidates/{cid}") return self._send(404, _page("404", "

未知写路径

")) except ConflictError as exc: diff --git a/muse/authority/studio/write/lesson/lesson_confirm.py b/muse/authority/studio/write/lesson/lesson_confirm.py index 41620be..b149e9f 100644 --- a/muse/authority/studio/write/lesson/lesson_confirm.py +++ b/muse/authority/studio/write/lesson/lesson_confirm.py @@ -2,7 +2,7 @@ """经验登记确认通道 —— 会写库(proposed→reviewing→promoted/rejected)。 与只读看板(:8765)分离:看板合同禁止写;人审必须在本通道完成。 -跑法:.venv/bin/python dashboard/lesson_confirm.py +跑法:.venv/bin/python muse/authority/studio/write/lesson/lesson_confirm.py 默认 http://127.0.0.1:8766 """ @@ -19,8 +19,12 @@ from urllib.parse import parse_qs, urlparse HOST, PORT = "127.0.0.1", 8766 READONLY_BASE = "http://127.0.0.1:8765" -ROOT = Path(__file__).resolve().parents[1] -EVIDENCE = ROOT / "muse" / "authority" / "evidence" / "skills" / "record-run-evidence" / "scripts" +PROJECT_ROOT = next( + parent + for parent in (Path(__file__).resolve().parent, *Path(__file__).resolve().parents) + if (parent / "AGENTS.md").is_file() and (parent / ".git").exists() +) +EVIDENCE = PROJECT_ROOT / "muse" / "authority" / "evidence" / "skills" / "record-run-evidence" / "scripts" if str(EVIDENCE) not in sys.path: sys.path.insert(0, str(EVIDENCE)) @@ -89,9 +93,9 @@ pre{{white-space:pre-wrap;background:#f6f6f6;padding:10px;border-radius:6px;font .ok{{color:#0a6b0a}} a{{color:#0645ad}} - -{body} + +
{body}
""" @@ -172,7 +176,7 @@ def view_lesson(lesson_id: int, *, flash: str | None = None, error: str | None =

类型 {esc(lesson['kind'])} · 状态 {esc(status)} · 登记方 {esc(lesson['creator'])}

run_id {esc(lesson['run_id']) or '—'}

决定人 {esc(lesson['decided_by']) or '—'} · 理据 {esc(lesson['decision_rationale']) or '—'}

-

详情

{esc(detail)}
+

详情

{esc(detail)}
{forms} """ @@ -213,7 +217,6 @@ class Handler(BaseHTTPRequestHandler): rationale = (form.get("rationale") or "").strip() or None if action == "review": start_review(lesson_id, decided_by=by, rationale=rationale) - msg = "已进入 reviewing" elif action == "promote": promote( lesson_id, @@ -222,14 +225,12 @@ class Handler(BaseHTTPRequestHandler): target_ref=(form.get("target_ref") or "").strip(), rationale=rationale, ) - msg = "已升格 promoted" elif action == "reject": reject(lesson_id, decided_by=by, rationale=rationale) - msg = "已驳回 rejected" else: self._send(404, _page("404", "

未知动作

")) return - self._send(200, view_lesson(lesson_id, flash=msg)) + self._redirect(f"/lessons/{lesson_id}") except LessonError as exc: lid = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0 self._send(400, view_lesson(lid, error=str(exc)) if lid else _page("失败", f"

{esc(exc)}

")) @@ -245,6 +246,14 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(data) + def _redirect(self, location: str) -> None: + """成功写入后用 PRG 收口,刷新不会重复提交写命令。""" + + self.send_response(303) + self.send_header("Location", location) + self.send_header("Content-Length", "0") + self.end_headers() + def log_message(self, fmt, *args): pass diff --git a/muse/lifecycle/quality/harness/manifests/test-inventory.json b/muse/lifecycle/quality/harness/manifests/test-inventory.json index 3f21b18..5b34fbb 100644 --- a/muse/lifecycle/quality/harness/manifests/test-inventory.json +++ b/muse/lifecycle/quality/harness/manifests/test-inventory.json @@ -1968,18 +1968,52 @@ "skill_behavior_eval": false, "classification_confidence": "high", "classification_basis": "扫描入口、SoT 和 framework/muse 活文档,解析仓内 Markdown 链接并失败关闭死链。" + }, + { + "path": "tests/architecture/test_dsh_pi_route.py", + "scope": "domain", + "owner_skill_or_domain": "architecture", + "kind": "tool_contract", + "evidence_level": "deterministic_offline", + "requires": [ + "offline", + "filesystem" + ], + "side_effects": [ + "none" + ], + "skill_behavior_eval": false, + "classification_confidence": "high", + "classification_basis": "验证 Pi models.json 的 provider、协议、地址和模型目录投影为不含密钥的 DSH pi-ai patch,并阻断 policy 与路由身份不一致。" + }, + { + "path": "tests/architecture/test_studio_path_contract.py", + "scope": "domain", + "owner_skill_or_domain": "architecture", + "kind": "tool_contract", + "evidence_level": "deterministic_offline", + "requires": [ + "offline", + "filesystem" + ], + "side_effects": [ + "none" + ], + "skill_behavior_eval": false, + "classification_confidence": "high", + "classification_basis": "验证迁移后的只读看板、候选决策和经验确认入口从独立仓根加载业务脚本,并覆盖写通道页面地标与键盘可达滚动区。" } ], "summary": { - "entry_count": 120, + "entry_count": 122, "by_scope": { "other": 1, "runtime_skill": 103, "harness": 3, - "domain": 13 + "domain": 15 }, "by_kind": { - "tool_contract": 40, + "tool_contract": 42, "skill_behavior_eval": 1, "harness_self_test": 3, "domain_eval": 4, @@ -1990,10 +2024,10 @@ "runtime_contract": 7 }, "by_evidence_level": { - "deterministic_offline": 99, + "deterministic_offline": 101, "real_dependency_integration": 11, "static_structure": 10 }, - "total": 120 + "total": 122 } } diff --git a/tests/architecture/test_dsh_pi_route.py b/tests/architecture/test_dsh_pi_route.py new file mode 100644 index 0000000..3b0777c --- /dev/null +++ b/tests/architecture/test_dsh_pi_route.py @@ -0,0 +1,141 @@ +"""PI provider 配置到 DSH pi-ai 接缝的合同测试。""" + +from __future__ import annotations + +import json +import pathlib +import tempfile +import unittest + + +ROOT = next( + parent + for parent in (pathlib.Path(__file__).resolve().parent, *pathlib.Path(__file__).resolve().parents) + if (parent / "AGENTS.md").is_file() and (parent / ".git").exists() +) +if str(ROOT) not in __import__("sys").path: + __import__("sys").path.insert(0, str(ROOT)) + +from framework.adapters.dsh.pi_ai import PiAiRouteConfigError, load_pi_ai_route # noqa: E402 +from framework.adapters.dsh.runner import DshExecutionPolicy, build_dsh_patch # noqa: E402 +from framework.primitives.execution import FrameworkExecutionRequest # noqa: E402 + + +class DshPiRouteTest(unittest.TestCase): + def test_pi_models_config_becomes_secret_free_dsh_profile(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "models.json" + path.write_text( + json.dumps( + { + "providers": { + "catproxy-openai": { + "baseUrl": "https://gateway.example/v1", + "api": "openai-responses", + "models": [ + { + "id": "gpt-test", + "name": "GPT Test", + "contextWindow": 600000, + "maxTokens": 128000, + "thinkingLevelMap": {"off": "none", "high": "high"}, + } + ], + } + } + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + route = load_pi_ai_route( + path, + provider="catproxy-openai", + model="gpt-test", + api_key_env="DEEPSEEK_API_KEY", + ) + + self.assertEqual(route.api, "openai-responses") + self.assertEqual(route.base_url, "https://gateway.example/v1") + self.assertEqual(route.model, "gpt-test") + self.assertEqual(route.api_key_env, "DEEPSEEK_API_KEY") + profile = route.as_profile() + self.assertEqual(profile["apiKeyEnv"], "DEEPSEEK_API_KEY") + self.assertNotIn("key", json.dumps(profile)) + self.assertEqual(profile["models"][0]["contextWindow"], 600000) + self.assertNotIn("minimal", profile["models"][0]["reasoningEfforts"]) + + def test_malformed_pi_catalog_fails_with_config_error(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "models.json" + path.write_text("[]", encoding="utf-8") + with self.assertRaises(PiAiRouteConfigError): + load_pi_ai_route(path, provider="p", model="m") + + def test_runner_patch_routes_custom_provider_and_model(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "models.json" + path.write_text( + json.dumps( + { + "providers": { + "catproxy-openai": { + "baseUrl": "https://gateway.example/v1", + "api": "openai-responses", + "models": [{"id": "gpt-test"}], + } + } + } + ), + encoding="utf-8", + ) + route = load_pi_ai_route(path, provider="catproxy-openai", model="gpt-test") + policy = DshExecutionPolicy( + provider=route.provider, + model=route.model, + pi_ai_route=route, + ) + patch = build_dsh_patch( + FrameworkExecutionRequest(system_prompt="s", user_content="u"), + policy, + session_root=directory, + ) + + rows = {row["id"]: row for row in patch} + self.assertEqual( + rows["agent-default-model"]["config"], + {"provider": "catproxy-openai", "model": "gpt-test"}, + ) + self.assertEqual( + rows["llm-pi-ai"]["config"]["providers"]["catproxy-openai"]["api"], + "openai-responses", + ) + self.assertEqual( + rows["llm-pi-ai"]["config"]["providers"]["catproxy-openai"]["apiKeyEnv"], + "DEEPSEEK_API_KEY", + ) + + def test_route_and_policy_must_name_the_same_model(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "models.json" + path.write_text( + json.dumps( + { + "providers": { + "p": { + "baseUrl": "https://gateway.example/v1", + "api": "openai-responses", + "models": [{"id": "m"}], + } + } + } + ), + encoding="utf-8", + ) + route = load_pi_ai_route(path, provider="p", model="m") + with self.assertRaisesRegex(ValueError, "model"): + DshExecutionPolicy(provider="p", model="other", pi_ai_route=route) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/architecture/test_studio_path_contract.py b/tests/architecture/test_studio_path_contract.py new file mode 100644 index 0000000..c767071 --- /dev/null +++ b/tests/architecture/test_studio_path_contract.py @@ -0,0 +1,65 @@ +"""Studio 三个 HTTP 入口的物理路径合同。""" + +from __future__ import annotations + +import pathlib +import unittest +from unittest import mock + + +ROOT = next( + parent + for parent in (pathlib.Path(__file__).resolve().parent, *pathlib.Path(__file__).resolve().parents) + if (parent / "AGENTS.md").is_file() and (parent / ".git").exists() +) +if str(ROOT) not in __import__("sys").path: + __import__("sys").path.insert(0, str(ROOT)) + + +class StudioPathContractTest(unittest.TestCase): + def test_studio入口都解析到独立仓根并能加载业务脚本(self) -> None: + from muse.authority.studio.read import server + from muse.authority.studio.write.decision import decision_channel + from muse.authority.studio.write.lesson import lesson_confirm + + self.assertEqual(server.PROJECT_ROOT, ROOT) + self.assertEqual( + server.WRITE_CHAPTER_ARTIFACTS, + ROOT / "docs" / "write-chapter" / "artifacts", + ) + self.assertEqual(decision_channel.PROJECT_ROOT, ROOT) + self.assertTrue( + (ROOT / "muse" / "content" / "work" / "skills" / "sovereignty" / "decide-candidate" / "scripts" / "write_canonical.py").is_file() + ) + self.assertEqual(lesson_confirm.PROJECT_ROOT, ROOT) + self.assertEqual( + lesson_confirm.EVIDENCE, + ROOT / "muse" / "authority" / "evidence" / "skills" / "record-run-evidence" / "scripts", + ) + + def test写通道页面具备地标和键盘可达正文滚动区(self) -> None: + from muse.authority.studio.write.decision import decision_channel + from muse.authority.studio.write.lesson import lesson_confirm + + decision_page = decision_channel._page("测试", "
正文
") + lesson_page = lesson_confirm._page("测试", "
详情
") + self.assertIn("
", decision_page) + self.assertIn("
", lesson_page) + with mock.patch.object( + decision_channel, "_load_candidate", return_value={ + "id": 1, "work_id": 1, "chapter": 1, "run_id": "run", + "attempt": 1, "run_type": "production", "version": "1", + "sha256": "a" * 64, "body": "正文", "state": "passed", + "semantic": "passed", "create_time": "now", + } + ): + candidate_page = decision_channel.view_candidate(1) + self.assertIn("tabindex='0'", candidate_page) + self.assertIn( + "--muted:#6b6a65", + (ROOT / "muse" / "authority" / "studio" / "read" / "server.py").read_text(encoding="utf-8"), + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2)