# 俊达增量① · 改稿 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` 是只读 `
`。加可编辑用**新 prop 默认关闭**,不影响现有只读渲染与 P0 可能的改动。 **5a. `frontend/src/components/tasks/TextCandidateCard.tsx`** - props 加 `editable?: boolean`(默认 false)和 `onEdit?: (cid, newContent) => void`。 - `editable` 为真时,把 `
{candidate.content}
` 换成受控 `