# 俊达增量① · 改稿 diff 飞轮信号 — 实施细案

> 配套 `俊达对标-增量落地映射.md` §二。本文是可直接交给开发窗口执行的细案。
> 代码勘验：2026-06-15，迁移已到 011。**所有定位用函数名/类名，不用行号**（另一窗口在改 P0，行号会漂）。
> 一句话：飞轮当前只有"选/审"弱信号，本增量加"运营改稿"强信号——AI 不只学到"这条不好"，还学到"该改成什么样"。

---

## §0 并发窗口避让总则（最重要，先读）

另一个窗口正在改 **P0：第5环文案 / 第6环配图 / 第8环审核**（见 `plans/总核销表.md` M1/M2/M3）。本增量挂点与之**部分重叠**，按下表避让：

| 本增量要动的文件 | P0 是否在动 | 冲突等级 | 避让策略 |
|---|---|---|---|
| `constants/enums.py` | 可能（改模型/枚举） | 🟡中 | 只**追加**枚举值和权重项，不改存量行；合并时手动 review 这一处 |
| `ai_engine/constants.py` | 可能（FLYWHEEL_WEIGHTS） | 🟡中 | 同上，只追加 `text_edit` 一行 |
| `models/task.py` `TextCandidate` | M1文案可能动 | 🟠高 | **只加2个新字段**（nullable），不碰现有字段；新迁移012独立 |
| `api/v1/task_actions.py` | 选文案在此 | 🟠高 | **新增独立端点函数**，不改 `select_text`；放文件末尾减少 diff 碰撞 |
| `ai_engine/preference_aggregator.py` | 第5环可能动 | 🟠高 | 新增独立 helper 函数 `_extract_edit_samples`，主函数只插一行调用 |
| `review.py` | M3审核**正在改** | 🔴最高 | **本增量不碰 review.py**（改稿入口走 task_actions，不走审核） |
| 前端 `TextCandidateCard.tsx` | 可能 | 🟡中 | 加可编辑态用新 prop，默认关闭，不影响现有只读渲染 |

**总原则**：能新增就不修改；新增函数/端点/字段集中放，减少与 P0 的 diff 行重叠；`enums.py` 两处权重是唯一必须手动合并点，开工前跟那个窗口对一句"我要往 SignalType 加 text_edit"。

**落库顺序建议**：等 P0 的 M1/M3 合并完，再起本增量分支，避免 rebase 地狱。后端 S1-S4 先上线（飞轮真变强），前端 S5 随后。

---

## §1 五步细化（每步：动什么·代码骨架·验证）

### S1 加信号类型 `text_edit`（⚠️两处权重都要加）

**1a. `backend/app/constants/enums.py` → `class SignalType`**
追加一个枚举值（放 REGENERATE 后）：
```python
    TEXT_EDIT = "text_edit"     # 运营改稿（强信号，俊达"改稿diff最值钱"）
```
同文件 `SIGNAL_WEIGHTS` 字典追加一行：
```python
    SignalType.TEXT_EDIT: 4,    # 强于普通选择(+3)，弱于审过(+5)
```

**1b. `backend/app/services/ai_engine/constants.py` → `FLYWHEEL_WEIGHTS`**
> ⚠️ 这是**第二处权重定义**（聚合器用的是这个，不是enums那个），漏了会导致改稿信号权重算成0。追加：
```python
    "text_edit": 4,
```

**权重为什么是+4**：选文案=运营从N条挑1条（弱监督，只说"这条还行"）；改稿=运营动手写出"正确答案"（强监督，给了方向）。所以高于选择(+3)；但单条改稿样本量小、未经组长终审，低于审核通过(+5)。北哥可在调教时校准。

**验证**：`python -c "from app.constants.enums import SIGNAL_WEIGHTS, SignalType; print(SIGNAL_WEIGHTS[SignalType.TEXT_EDIT])"` → 4。

---

### S2 存改后内容（`TextCandidate` +2字段 + 迁移012）

**2a. `backend/app/models/task.py` → `class TextCandidate`**
在 `is_selected` 字段后**追加**（不动现有字段，遵§0🟠避让）：
```python
    edited_content: Mapped[str | None] = mapped_column(Text)        # 运营改后正文，NULL=没改过
    edited_at: Mapped[datetime | None] = mapped_column(DateTime)    # 改稿时间
```

**设计取舍：为什么不单建一张 diff 表**
- 存"AI原文 `content` + 改后 `edited_content`"两份即可，diff 在聚合时实时算（Python `difflib` 或直接把"原→改"两段喂 prompt）。
- 省一张表、省一次 join，且原文已在 `content` 里，天然成对。

**2b. 新迁移 `backend/app/alembic/versions/012_text_edit_flywheel.py`**
> 版本号续 011（总核销表已用到011），命名避开撞车。
```python
"""012 改稿diff飞轮：TextCandidate加edited_content/edited_at"""
def upgrade():
    op.add_column('text_candidates', sa.Column('edited_content', sa.Text(), nullable=True))
    op.add_column('text_candidates', sa.Column('edited_at', sa.DateTime(), nullable=True))
def downgrade():
    op.drop_column('text_candidates', 'edited_at')
    op.drop_column('text_candidates', 'edited_content')
```
> down_revision 填当前 head（011）。先 `alembic heads` 确认 head 是不是011——**若那个窗口已加了012/013，顺延改号**。

**验证**：`alembic upgrade head` 后 `DESC text_candidates;` 见两新列且可空（存量数据不受影响）。

### S3 采集端点（`task_actions.py` 新增独立函数）

> ⚠️§0🟠：**不改 `select_text`**，在 `task_actions.py` **末尾**新增端点，减少与 P0 选文案逻辑的 diff 碰撞。

参照同文件 `select_text` 的写法（已有 `record_signal` import 范式）：
```python
from pydantic import BaseModel

class EditTextRequest(BaseModel):
    edited_content: str    # 运营改后的正文全文

@router.post("/{task_id}/text-candidates/{cid}/edit")
def edit_text(
    task_id: int, cid: int, body: EditTextRequest,
    current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
    db: Session = Depends(get_db),
):
    """运营改稿（飞轮信号 text_edit +4，强信号）。"""
    from app.services.flywheel_service import record_signal
    from datetime import datetime, timezone
    task = _check_task_ownership(
        db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
        current_user.workspace_id,
    )
    tc = db.query(TextCandidate).filter(
        TextCandidate.id == cid, TextCandidate.task_id == task_id
    ).first()
    if not tc:
        raise_not_found("文案候选不存在")
    original = tc.content                      # 留底，给 reason 存"原→改"
    tc.edited_content = body.edited_content
    tc.edited_at = datetime.now(timezone.utc)
    db.commit()
    # diff 摘要存 reason（复用现成 Text 字段，不新增列）
    diff_summary = _build_edit_diff(original, body.edited_content)
    record_signal(db, current_user, task, "text_edit",
                  candidate_id=tc.id, angle_label=tc.angle_label, reason=diff_summary)
    return ok({"candidate_id": cid, "edited": True})
```

**`_build_edit_diff` 放哪**：放 `task_actions.py` 末尾或 `flywheel_service.py`。MVP 可极简——直接存"原文→改文"两段拼接（聚合时再让大模型理解差异），不必上 difflib：
```python
def _build_edit_diff(original: str, edited: str) -> str:
    # MVP：原文+改后并存，差异交给生成时的大模型理解（不做规则diff，遵"打回原因不AI归纳"同源克制）
    return f"原稿：{(original or '')[:200]}｜改后：{edited[:200]}"
```

**为什么不走 review.py**：审核台正被 P0(M3) 改（🔴最高冲突），且改稿是"运营在选择/确认阶段动手"的动作，归 task_actions 更合理——审核是组长的事，改稿是运营的事，职责也对。

**验证**：`curl POST /api/v1/tasks/{id}/text-candidates/{cid}/edit -d '{"edited_content":"..."}'` → 查 `preference_events` 有 `signal_type=text_edit, signal_weight=4` 行 + `text_candidates.edited_content` 已写。

---

### S4 聚合注入 prompt（`preference_aggregator.py`，改稿片段置顶）

> ⚠️§0🟠：主函数 `aggregate_preference_context` 只插一行调用，逻辑放**新增 helper**，减少与 P0 第5环改动的重叠。

**4a. 新增 helper**（放 `preference_aggregator.py` 内 `_build_prompt_fragment` 旁）：
```python
def _extract_edit_samples(relevant: list[dict], limit: int = 3) -> list[str]:
    """抽改稿信号的 reason（即'原→改'摘要），最近 limit 条。强信号置顶用。"""
    samples = [
        str(e.get("reason", "")).strip()
        for e in relevant
        if e.get("signal_type") == "text_edit" and e.get("reason")
    ]
    return samples[-limit:]
```

**4b. 主函数 `aggregate_preference_context` 里取样**（在已有的 angle/reject 统计循环后加）：
```python
    edit_samples = _extract_edit_samples(relevant)
```
传给 `_build_prompt_fragment`（给该函数加一个 `edit_samples` 形参）。

**4c. `_build_prompt_fragment` 把改稿片段插到最前**（强信号最高优先）：
```python
    # 函数开头、top_angles 之前 insert：
    if edit_samples:
        formatted = "；".join(f"「{s}」" for s in edit_samples)
        lines.append(f"【改稿示范·最高优先】运营曾这样修改文案：{formatted}。请直接对齐这种写法与措辞。")
    # ...原有 top_angles / reject_reasons / custom 逻辑接在后面
```
> 因为 `_build_prompt_fragment` 是 `lines.append` 顺序拼接，把改稿段放在**函数最前面 append**即可天然置顶；返回值同时把 `edit_count` 加进去供前端显示。

**4d. 同步两个调用方**：
- `flywheel_service.py:get_preference_context`（API侧实时聚合）——它是另一套**简化重实现**，也要补 `text_edit` 的取样（否则 API 返回的"已注入"摘要漏算改稿）。两处聚合逻辑重复是现状技术债，本增量先各补各的，**不顺手合并**（避免扩大改动面、撞 P0）。
- worker 侧 `pipeline_steps.py:load_flywheel_context` → 它调 `aggregate_preference_context`，4a-4c 改完自动生效，无需改 worker。

**验证**：造3条 text_edit 信号后调生成，看 `flywheel_injected` SSE 事件 + MongoDB debug trace 里 user_prompt 含"【改稿示范】"。

---

### S5 前端改稿框（`TextCandidateCard.tsx`，可编辑态可选开）

> ⚠️§0🟡：现卡片 `content` 是只读 `<p>`。加可编辑用**新 prop 默认关闭**，不影响现有只读渲染与 P0 可能的改动。

**5a. `frontend/src/components/tasks/TextCandidateCard.tsx`**
- props 加 `editable?: boolean`（默认 false）和 `onEdit?: (cid, newContent) => void`。
- `editable` 为真时，把 `<p>{candidate.content}</p>` 换成受控 `<textarea>`，失焦(onBlur)且内容有变化时调 `onEdit`。
- 默认 false → 现有所有调用方行为不变（零回归）。

**5b. 文案选择页 `frontend/src/app/tasks/[id]/text/page.tsx`**
- 给 `<TextCandidateCard>` 传 `editable={true}` 和 `onEdit` 回调。
- `onEdit` 里调：`api.post(\`/api/v1/tasks/${taskId}/text-candidates/${cid}/edit\`, { edited_content })`（参照同文件已有的 `select` 调用范式）。

**5c. 注入提示** `frontend/src/components/FlywheelBanner.tsx`
- 已显示"本次已注入：最近偏好/打回原因"，加一行 `已注入 N 条改稿示范`（读 S4c 返回的 `edit_count`）。

**前端可延后**：若赶 6/27，后端 S1-S4 上线飞轮就已真变强。前端临时兜底：运营把改写后文案当"打回原因"填（走现有 reject）先喂信号。但**不推荐长期兜底**——reject 是 -3 负信号，语义和 +4 改稿相反，会污染权重，只能短期应急。

---

## §2 端到端验证脚本（飞轮四点全测）

按架构方案铁律"飞轮必须可测，不靠感觉更准"，跑一遍：

```
1. events写入   ：改一条文案 → SELECT * FROM preference_events WHERE signal_type='text_edit'
                  断言：有行、signal_weight=4、reason含"原→改"、workspace_id+product_id正确
2. 聚合返片段   ：同product造≥5条信号(含3条text_edit) → GET /tasks/{id}/preference/context
                  断言：返回含改稿示范摘要、edit_count≥1
3. 下次prompt含 ：发起同product新生成任务 → 查 MongoDB debug trace 的 user_prompt
                  断言：含"【改稿示范·最高优先】"且在 top_angles 之前
4. UI显示       ：FlywheelBanner 显示"已注入 N 条改稿示范"
```
对应已有测试：`backend/tests/test_flywheel_wiring.py` 加 `test_text_edit_signal_injected`，仿现有飞轮用例写。

---

## §3 回滚

- DB：`alembic downgrade -1`（012 down 删两列，存量 content 不动）。
- 代码：enums 两处删 text_edit 行、task_actions 删 edit_text 端点、aggregator 删 helper+调用、前端 editable 传 false。
- 因全程"只新增不改存量"，回滚=删新增，不留残端。

---

## §4 给北哥/倩倩的拍板点
1. **改稿权重 +4 是否合理**？（建议先 +4 跑，北哥用一周后校准——权重表本就设计成可调）。
2. **改稿样本要不要经组长终审才入飞轮**？MVP="运营一改就入"，学得快；保守="改稿+该条最终被审核通过"才记 text_edit（防学到运营改错的）。**建议 MVP 先激进，三期加 outcome 校验**。
