baseline: Clover 独立仓库首次基线提交

将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。
当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包),
M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-16 11:30:22 +08:00
commit 6a2632da70
253 changed files with 27467 additions and 0 deletions

View File

@@ -0,0 +1,223 @@
# 俊达增量① · 改稿 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 校验**。