将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
224 lines
13 KiB
Markdown
224 lines
13 KiB
Markdown
# 俊达增量① · 改稿 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 校验**。
|