# ADR-012 · 多场景评委体系 · Scene 作为 Panel 的上层部署单元

| 字段 | 值 |
|---|---|
| **状态** | `accepted` · 2026-06-25 |
| **日期** | 2026-06-25 |
| **决策者** | CTO + 项目主理 |
| **承接** | [ADR-001](ADR-001-multi-anchor-architecture.md) (multi-anchor) · [ADR-007](ADR-007-boss-review-mode.md) (REVIEW 模式) |
| **相关** | `scripts/scene_loader.py` · `scripts/panel_loader.py` · `scripts/op2_output.py` · `scripts/smoke_e2e.py` · `scenes/` · `panels/default.yaml` |

---

## 1. Context

### 1.1 · 起源

boss 判断流水线最初是单场景架构: 一套 `panels/default.yaml`, 一个飞书机器人绑定, 一套评分维度。随着使用场景扩展, 出现了无法用单 panel 覆盖的结构性差异:

| 差异维度 | 常规评审 | 竞赛 / Workshop |
|---|---|---|
| 是否有锚点基准 | 有 (`anchor_judge: tian`) | 无 (`anchor_judge: null`) |
| 评分维度 | 固定 5 镜头 (推理质量) | 自定义 (业务价值 / 创新度等) |
| 成绩是否公开 | 内部 | 公开展示 |
| 机器人入口 | 独立 | 独立 |
| 总分制 | 100 | 10 (或任意) |

同时, 同一评审模式下也有多个**并列场景**: 公司级 / 各事业群 / 各专项评审 — 它们评委组合不同, 报告品牌不同, 但引擎完全一样。

**核心矛盾**: panel 是"评审规则", 但缺少"部署单元"的概念 — 没有地方描述"哪个飞书机器人触达哪套规则"。

### 1.2 · 在 Panel 上加 Scene 层的动机

直接往 `panel.yaml` 里塞机器人 app_id / 白名单 / scene_type 等字段不合适:

- panel 是评审规则, 理论上可以被多个入口共享
- 机器人凭据属于部署配置, 不应进入"判断引擎"核心
- scene_type (review vs competition) 是入口级约束, panel 层感知不到

---

## 2. Decision

**引入 `Scene` 作为 `Panel` 的上层部署单元。**

### 2.1 · Scene 文件结构

```
scenes/
└── <scene-slug>/
    ├── scene.yaml          ← 场景级配置 (scene_type / bot 绑定 / 白名单 / 报告配置)
    └── panel.yaml          ← 评审规则 (可 extends 默认 panel)
```

`scenes/shared-judges/` 存放跨场景可复用的专项评委 SKILL.md (不绑定特定场景)。

### 2.2 · scene.yaml 最小结构

```yaml
name: <scene-slug>
scene_type: review          # review | competition

report:
  anchor_judge: tian        # competition 场景必须为 null
  show_scores_publicly: false

feishu:
  app_id_env: LARK_APP_ID_<ENV_VAR>
  app_secret_env: LARK_APP_SECRET_<ENV_VAR>
```

### 2.3 · scene_type 的强制约束 (scene_loader 在加载时校验)

| 约束 | review | competition |
|---|---|---|
| `anchor_judge` | 任意 (默认 tian) | **必须为 null** |
| `show_scores_publicly` | 任意 | **必须为 true** |

违反约束 → `scene_loader.load_scene()` 抛 `ValueError`, 阻断后续流水线。

### 2.4 · Panel 继承机制

场景 panel 通过 `extends` 字段继承基础 panel:

```yaml
# scenes/<slug>/panel.yaml
name: <slug>
extends: panels/default.yaml     # 继承, 未覆盖字段沿用

anchor_judge: tian
scoring_mode: sum_max_score

judges_override:                  # 完整替换评委列表 (非 merge)
  - slug: tian
    judge_category: anchor
    skill_path: anchors/tian/perspective/SKILL.md
    always_active: true
  - slug: first-principles
    judge_category: dimension
    skill_path: scenes/shared-judges/first-principles/SKILL.md

scoring_lenses_override:          # 完整替换评分维度
  - { slug: strategic_fit, display_name_cn: 战略契合, max_score: 30 }
  - { slug: feasibility,   display_name_cn: 落地可行, max_score: 40 }
  - { slug: one_page_quality, display_name_cn: 一页表达, max_score: 30 }
```

`panel_loader.resolve_panel()` 负责展开继承链, 返回完整 panel dict。

### 2.5 · 评分模式与成绩渲染

当前仅支持 `scoring_mode: sum_max_score`:

- `total_max_score(panel)` = 所有 `scoring_lenses` 的 `max_score` 之和
- 等级计算: `pct = score / total_max * 100` (与 `total_max` 无关, 兼容 10 / 100 等任意总分制)
- 等级档: S ≥ 95 / A ≥ 85 / B ≥ 70 / C ≥ 55 / D < 55

`op2_output.render_op2_6section()` 渲染 6 段式报告:
- §1 总分 · §2 分项得分 · §3 必改项 · §4 补充建议 · §5 删减建议 · §6 一页汇总表

### 2.6 · Smoke 校验入口

`scripts/smoke_e2e.py smoke-scene <slug>` 对单场景执行静态全链路校验 (不调 LLM, 不写磁盘):

1. `scene_loader.load_scene(slug)` — 加载 + 约束校验
2. `panel_loader.resolve_panel(panel_path)` — 继承链展开
3. 所有 judge `skill_path` 文件存在性检查
4. `sum_max_score` 维度总分汇总
5. `anchor_judge` 一致性 (scene vs panel)

```bash
make smoke-scene SCENE=<scene-slug>
```

---

## 3. Consequences

### 3.1 · 优势

| 维度 | 落地后能力 |
|---|---|
| 部署隔离 | 多个飞书机器人各自路由到对应场景, 互不干扰 |
| 配置差异可读 | `panel.yaml` 只写差异, 继承自 `default.yaml`, diff 即设计意图 |
| competition 约束硬编码 | scene_loader 在加载时校验, 无法绕过 |
| 共享评委复用 | `scenes/shared-judges/` 评委 SKILL.md 跨场景引用, 不重复维护 |
| CI 静态校验 | smoke-scene 在任何 LLM 调用前完成全链路静态检查, 适合 PR gate |

### 3.2 · 接受的 tradeoff

- **`judges_override` 是完整替换** (非 merge): 每个 panel 必须显式列出所有评委, 不能只追加一位。代价是配置稍冗长, 收益是配置可读、无隐含合并逻辑。
- **scene_type 仅 2 种** (review / competition): 不支持自定义枚举。如未来出现第 3 类场景, 需修改 `scene_loader.py` 约束逻辑 + 本 ADR。
- **`scoring_mode` 仅支持 `sum_max_score`**: 加权平均 / 取最高等其他模式未实现。现有场景均适用 sum, 延迟实现。

### 3.3 · 不变的承诺

- 无 scene.yaml 时回退到单机器人模式 — 完全向后兼容
- panel.yaml 直接使用 (不走 scene 层) 仍支持
- wiki 知识库、锚点心智模型、5 镜头、30/90/365 attribution 不变
- versions/ 不可变冻结不变
- redact_check + check_public_safe 闸不变

### 3.4 · Invariant impact ★

**本变更让以下 invariant 失效**:

- **inv-old**: "一个 boss 实例绑定一套 panel" → 失效 · Scene 层解耦后, 一个进程可同时服务多个场景 / 多个 panel
- **inv-old**: "anchor_judge 由 panel 决定, 无外部约束" → 失效 · competition scene_type 强制 anchor_judge=null, scene 层凌驾于 panel 层
- **inv-old**: "评分维度总和默认 100" → 失效 · 任意 total_max 均合法; 成绩等级按 pct 计算, 与总分无关

**本变更引入以下新 invariant**:

- **inv-1**: `scene_type: competition` 必须满足 `anchor_judge is null` AND `show_scores_publicly is true` — scene_loader 强制校验
- **inv-2**: `scenes/shared-judges/<slug>/SKILL.md` 的评委必须包含 `adversarial_view` 三字段 (同 CLAUDE.md §4.5) — skill_lint 校验
- **inv-3**: `smoke_e2e.smoke_scene(slug)` 必须在任何该 scene 的 LLM 调用之前通过 — CI gate
- **inv-4**: 所有 panel `scoring_mode: sum_max_score` 场景的 `total_max_score()` 必须与 `render_op2_6section()` 渲染的总分一致

---

## 4. Alternatives Considered

| 方案 | 为什么没选 |
|---|---|
| **A1 · 直接在 panel.yaml 加 app_id / scene_type** | 混淆"评审规则"与"部署配置"两个关注点; panel 理论上可多入口共享, 塞入 app_id 就绑死了 |
| **A2 · `scenes/` 目录仅放 scene.yaml, panel.yaml 留在 `panels/`** | panel 与 scene 是一对一关系 (目前), 分目录存放让 diff 不直观; 同一 slug 下的 scene.yaml + panel.yaml 放在一起更清晰 |
| **A3 · judges_override 改为 merge (只写增量)** | 合并逻辑隐含继承顺序, 难以推理"这个 panel 实际有哪些评委"; 完整替换让 panel.yaml 即文档即配置 |
| **A4 · 多个 scene_type 值 (review / competition / showcase / ...)** | 过早泛化; review 与 competition 已覆盖所有现有场景, 其余类型留到出现真实需求时再加 |
| **A5 · smoke 校验内嵌到 scene_loader 导入时** | 导入即触发校验会阻断 pytest collect 阶段; 独立 smoke_scene() 函数让测试与工具分离 |

---

## 5. Migration Plan

### 5.1 · 已实施 (v1.2.0, 2026-06-25)

| 步骤 | 文件 | 状态 |
|---|---|---|
| scene_loader.py | `scripts/scene_loader.py` — `SceneConfig` dataclass, `load_scene()`, `list_scenes()` | ✅ |
| panel_loader.py | `scripts/panel_loader.py` — `resolve_panel()`, `total_max_score()` | ✅ |
| op2_output.py | `scripts/op2_output.py` — `render_op2_6section()`, `DimensionScore`, `Op2ReviewData` | ✅ |
| smoke_e2e | `scripts/smoke_e2e.py` — `smoke_scene()` + `smoke-scene` CLI 子命令 | ✅ |
| shared-judges | `scenes/shared-judges/` — 3 类专项外部视角评委 SKILL.md | ✅ |
| 10 个场景 | `scenes/` — review 场景 × N + competition 场景 × 1 | ✅ |
| e2e 测试 | `tests/unit/test_scene_e2e.py` — 40 tests (review / competition / smoke-all / server / isolation) | ✅ |
| Makefile | `make smoke-scene SCENE=<slug>` | ✅ |

### 5.2 · 待办 (M5)

- VM 联调: ≥2 飞书机器人分别触发 → 各自 panel → 报告用对应场景评委。验收见 PRD §12。

---

## 6. 关键反共识立场

**"Scene 层多余, 直接给 panel 加 scene_type 字段就够了"** — 错。

scene_type 是入口级约束, 它决定的不是"评审规则"(panel 的职责), 而是"这套规则允许出现在什么上下文里"。competition 场景的 `anchor_judge: null` 约束必须在 panel 被载入之前检查, 不能依赖 panel 内部自律。Scene 层是这条约束的自然归属地。

**"panel 继承用 merge 更灵活"** — 错。

灵活的代价是隐含性。"这个 panel 实际有哪些评委"如果需要追溯继承链才能回答, 就给排查问题带来了不必要的认知负担。`judges_override` 的完整替换语义让 panel.yaml 即文档即配置 — 打开文件即知真相。

**"`smoke-scene` 应该调真 LLM 做端到端测试"** — 错。

静态校验 (文件存在 / 字段完整 / 约束满足) 应该在 LLM 调用之前完成并且快速。`smoke_scene()` 的价值在于: CI 门控 + 本地开发时的即时反馈 + 不产生 LLM 费用。真实的 LLM 端到端测试是另一层, 不应混进 smoke。

---

## 7. 何时回看本 ADR

- 出现第 3 种 scene_type (如 `showcase` / `audit`) 时: 修订 §2.2 约束表 + scene_loader
- `total_max_score` 计算逻辑变化时: 修订 §2.5 等级档说明
- M5 VM 联调完成后: 验证 inv-1~4 是否在真实飞书环境下守住
- 任何 smoke_scene() 检查漏掉的静态错误在生产暴露时: 加 inv-N + 补 smoke 检查项

---

*ADR-012 · accepted · 2026-06-25 · 多场景评委体系 · Scene 上层架构 · 10 场景 / 790 tests*
