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

13 KiB
Raw Permalink Blame History

俊达增量① · 改稿 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.pyclass SignalType 追加一个枚举值(放 REGENERATE 后):

    TEXT_EDIT = "text_edit"     # 运营改稿(强信号,俊达"改稿diff最值钱"

同文件 SIGNAL_WEIGHTS 字典追加一行:

    SignalType.TEXT_EDIT: 4,    # 强于普通选择(+3),弱于审过(+5)

1b. backend/app/services/ai_engine/constants.pyFLYWHEEL_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.pyclass TextCandidateis_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 在聚合时实时算Python difflib 或直接把"原→改"两段喂 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 填当前 head011。先 alembic heads 确认 head 是不是011——若那个窗口已加了012/013顺延改号

验证alembic upgrade headDESC 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_eventssignal_type=text_edit, signal_weight=4 行 + text_candidates.edited_content 已写。


S4 聚合注入 promptpreference_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_fragmentlines.append 顺序拼接,把改稿段放在函数最前面 append即可天然置顶;返回值同时把 edit_count 加进去供前端显示。

4d. 同步两个调用方

  • flywheel_service.py:get_preference_contextAPI侧实时聚合——它是另一套简化重实现,也要补 text_edit 的取样(否则 API 返回的"已注入"摘要漏算改稿)。两处聚合逻辑重复是现状技术债,本增量先各补各的,不顺手合并(避免扩大改动面、撞 P0
  • worker 侧 pipeline_steps.py:load_flywheel_context → 它调 aggregate_preference_context4a-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(默认 falseonEdit?: (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.pytest_text_edit_signal_injected,仿现有飞轮用例写。


§3 回滚

  • DBalembic downgrade -1012 down 删两列,存量 content 不动)。
  • 代码enums 两处删 text_edit 行、task_actions 删 edit_text 端点、aggregator 删 helper+调用、前端 editable 传 false。
  • 因全程"只新增不改存量",回滚=删新增,不留残端。

§4 给北哥/倩倩的拍板点

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