Files
beige/plans/俊达增量①-改稿diff-实施细案.md
yangqianqian 6a2632da70 baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。
当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包),
M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:30:22 +08:00

224 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 俊达增量① · 改稿 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 填当前 head011。先 `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 校验**。