将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
13 KiB
俊达增量① · 改稿 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 后):
TEXT_EDIT = "text_edit" # 运营改稿(强信号,俊达"改稿diff最值钱")
同文件 SIGNAL_WEIGHTS 字典追加一行:
SignalType.TEXT_EDIT: 4, # 强于普通选择(+3),弱于审过(+5)
1b. backend/app/services/ai_engine/constants.py → FLYWHEEL_WEIGHTS
⚠️ 这是第二处权重定义(聚合器用的是这个,不是enums那个),漏了会导致改稿信号权重算成0。追加:
"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🟠避让):
edited_content: Mapped[str | None] = mapped_column(Text) # 运营改后正文,NULL=没改过
edited_at: Mapped[datetime | None] = mapped_column(DateTime) # 改稿时间
设计取舍:为什么不单建一张 diff 表
- 存"AI原文
content+ 改后edited_content"两份即可,diff 在聚合时实时算(Pythondifflib或直接把"原→改"两段喂 prompt)。 - 省一张表、省一次 join,且原文已在
content里,天然成对。
2b. 新迁移 backend/app/alembic/versions/012_text_edit_flywheel.py
版本号续 011(总核销表已用到011),命名避开撞车。
"""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 范式):
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:
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 旁):
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 统计循环后加):
edit_samples = _extract_edit_samples(relevant)
传给 _build_prompt_fragment(给该函数加一个 edit_samples 形参)。
4c. _build_prompt_fragment 把改稿片段插到最前(强信号最高优先):
# 函数开头、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 给北哥/倩倩的拍板点
- 改稿权重 +4 是否合理?(建议先 +4 跑,北哥用一周后校准——权重表本就设计成可调)。
- 改稿样本要不要经组长终审才入飞轮?MVP="运营一改就入",学得快;保守="改稿+该条最终被审核通过"才记 text_edit(防学到运营改错的)。建议 MVP 先激进,三期加 outcome 校验。