框架: DSH 接 Pi 路由、看板从仓根解析路径,并落能力原型间收敛方案
This commit is contained in:
parent
9bed4a426e
commit
32b58e0a4a
54
docs/acceptance/2026-08-24-dsh-pi-browser-e2e.md
Normal file
54
docs/acceptance/2026-08-24-dsh-pi-browser-e2e.md
Normal file
@ -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。
|
||||
|
||||
失败的短正文、评委拒绝和落库约束运行均保留在数据库运行账本中,未被删除或覆盖。
|
||||
262
docs/plans/2026-08-28-能力原型间收敛方案.md
Normal file
262
docs/plans/2026-08-28-能力原型间收敛方案.md
Normal file
@ -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:角色定义
|
||||
│ └── <role>.md
|
||||
├── skills/ # L0:方法 skill 唯一源(扁平)
|
||||
│ └── <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/<book>/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/<book>/` 文件 + 索引;人感规则进 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/<skill>/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
|
||||
@ -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 当作生产宿主。
|
||||
|
||||
@ -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`;密钥不写入仓库。真实模型运行还需要用户显式提供凭据和预算。
|
||||
|
||||
## 代码入口
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
155
framework/adapters/dsh/pi_ai.py
Normal file
155
framework/adapters/dsh/pi_ai.py
Normal file
@ -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.<route>` 配置,不携带 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"]
|
||||
@ -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(
|
||||
|
||||
@ -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"重验证 <code>{esc(receipt_ref)}</code> · "
|
||||
f"检查于 <code>{esc((revalidation or {}).get('checked_on', 'unknown'))}</code> · "
|
||||
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"<br><span class='chip serious'>回退</span> {esc(load_note)}" if authority == "offline" and load_note else ""
|
||||
source_storage_note = "研究限定/未授权来源原文片段未保存" if source_license in {"research_only", "unauthorized"} else "授权来源可在评审后保存受控片段"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}}
|
||||
</style></head><body>
|
||||
<div class="banner"><b>决策通道会写库</b> · 只读浏览请用
|
||||
<header class="banner"><b>决策通道会写库</b> · 只读浏览请用
|
||||
<a href="{READONLY_BASE}/">看板 :8765</a> · 本页仅做候选采纳/丢弃
|
||||
(经验升格仍走 <a href="http://127.0.0.1:8766/">:8766</a>)</div>
|
||||
{body}
|
||||
(经验升格仍走 <a href="http://127.0.0.1:8766/">:8766</a>)</header>
|
||||
<main>{body}</main>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
@ -212,7 +215,7 @@ def view_candidate(
|
||||
flash_html
|
||||
+ err_html
|
||||
+ f"<h1>候选 #{candidate_id}</h1>{ro}{meta}"
|
||||
+ f"<div class='card'><h2>正文预览</h2><pre>{body_preview}</pre></div>"
|
||||
+ f"<div class='card'><h2>正文预览</h2><pre tabindex='0'>{body_preview}</pre></div>"
|
||||
+ forms
|
||||
+ "<p><a href='/'>← 返回列表</a></p>",
|
||||
)
|
||||
@ -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", "<h1>未知写路径</h1>"))
|
||||
except ConflictError as exc:
|
||||
|
||||
@ -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}}
|
||||
</style></head><body>
|
||||
<div class="banner"><b>确认通道会写库</b> · 只读浏览请用
|
||||
<a href="{READONLY_BASE}/lessons">看板 /lessons</a> · 本页仅做经验状态流转</div>
|
||||
{body}
|
||||
<header class="banner"><b>确认通道会写库</b> · 只读浏览请用
|
||||
<a href="{READONLY_BASE}/lessons">看板 /lessons</a> · 本页仅做经验状态流转</header>
|
||||
<main>{body}</main>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
@ -172,7 +176,7 @@ def view_lesson(lesson_id: int, *, flash: str | None = None, error: str | None =
|
||||
<p>类型 <code>{esc(lesson['kind'])}</code> · 状态 <code>{esc(status)}</code> · 登记方 {esc(lesson['creator'])}</p>
|
||||
<p>run_id <code>{esc(lesson['run_id']) or '—'}</code></p>
|
||||
<p>决定人 {esc(lesson['decided_by']) or '—'} · 理据 {esc(lesson['decision_rationale']) or '—'}</p>
|
||||
<h3>详情</h3><pre>{esc(detail)}</pre>
|
||||
<h3>详情</h3><pre tabindex="0">{esc(detail)}</pre>
|
||||
</div>
|
||||
{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", "<h1>未知动作</h1>"))
|
||||
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"<p class='err'>{esc(exc)}</p>"))
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
141
tests/architecture/test_dsh_pi_route.py
Normal file
141
tests/architecture/test_dsh_pi_route.py
Normal file
@ -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)
|
||||
65
tests/architecture/test_studio_path_contract.py
Normal file
65
tests/architecture/test_studio_path_contract.py
Normal file
@ -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("测试", "<pre>正文</pre>")
|
||||
lesson_page = lesson_confirm._page("测试", "<pre>详情</pre>")
|
||||
self.assertIn("<main>", decision_page)
|
||||
self.assertIn("<main>", 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)
|
||||
Loading…
x
Reference in New Issue
Block a user