第11环裂变重写:对齐上线版 split.js 一次LLM出N套完整笔记包

架构从"扇出N个GenerationTask各跑完整管道"改为"一次LLM调用直接出N套
完整笔记包(N=1~5)",落 FissionNote 表 + 独立展示页。

后端:
- 018迁移:fission_notes 表(文案JSON+score+passed+imagePlan+images+status)
- fission_prompt:FISSION_SYSTEM+三档参考度(low/mid/high)+normalize_tags+品类兜底
- fission_pipeline:一次LLM出N套→各评分(@80合格线)→排序→落库,不达标标
  needs_optimization 非丢弃;apiports 503 回落 codeproxy gpt-5.5 强档兜底
- fission_images:每套串行调现有生图接口(零改动image_gen/storyboard)
- tasks.py:run_fission_pipeline Celery task,删旧扇出注入
- api/v1/fission:进度聚合FissionNote + GET /fission/{id}/notes(剥内部字段)

前端:FissionProgress对齐状态机 + N套独立展示页 + FissionNoteCard

测试:test_fission_engine(19)+test_fission_pipeline(5) 全过;104 全量回归绿

实测task5(fanout=2,mid)端到端跑通:一次出2套→seq0=85过/seq1=79标优化→
生图codeproxy/edits→1024×1536去AI化→task completed→notes端点返完整数据

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-18 11:17:37 +08:00
parent 7f419f4c8b
commit d85dcd401b
18 changed files with 1772 additions and 106 deletions

View File

@@ -0,0 +1,82 @@
"""
裂变编排层 _score_notes 测试(合格线@80 + 违禁词硬拦截逻辑)
mock llm_score_copy验证 _passed = passed AND not _block 的安全关键规则。
排序/降级落库逻辑(needs_optimization=0 if passed else 1)在 execute_fission_pipeline
本文件只锁评分写回的 _score/_block/_passed 三字段语义。
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import unittest.mock as mock
from app.services.fission_pipeline import _score_notes
SOURCE = {"title": "", "content": "源正文"}
def _patch_scorer(monkeypatch, results):
"""按调用顺序返回 results 里的评分 dict。"""
calls = {"i": 0}
async def fake(client, copy, source, banned=None, pass_score=80):
r = results[calls["i"] % len(results)]
calls["i"] += 1
return r
monkeypatch.setattr("app.services.ai_engine.llm_scorer.llm_score_copy", fake)
def test_score_high_no_banned_passes(monkeypatch):
_patch_scorer(monkeypatch, [
{"score": 88, "passed": True, "banned_words_found": []},
])
notes = [{"title": "a", "content": "c", "tags": []}]
_score_notes(mock.MagicMock(), notes, SOURCE, [])
assert notes[0]["_score"] == 88
assert notes[0]["_block"] is False
assert notes[0]["_passed"] is True
def test_score_low_marks_not_passed(monkeypatch):
# 低于合格线 → 不达标,但分数照写(落库时标 needs_optimization 不丢弃)
_patch_scorer(monkeypatch, [
{"score": 62, "passed": False, "banned_words_found": []},
])
notes = [{"title": "a", "content": "c", "tags": []}]
_score_notes(mock.MagicMock(), notes, SOURCE, [])
assert notes[0]["_score"] == 62
assert notes[0]["_passed"] is False
def test_score_banned_word_hard_blocks_even_if_passed(monkeypatch):
# 违禁词硬拦截:即便分数达标 passed=True命中违禁词也强制 _passed=False
_patch_scorer(monkeypatch, [
{"score": 95, "passed": True, "banned_words_found": ["美白"]},
])
notes = [{"title": "美白神器", "content": "c", "tags": []}]
_score_notes(mock.MagicMock(), notes, SOURCE, ["美白"])
assert notes[0]["_block"] is True
assert notes[0]["_passed"] is False
def test_score_exception_records_zero(monkeypatch):
# 评分接口抛异常 → 记0分不达标不让整批裂变崩
async def boom(*a, **k):
raise RuntimeError("中转站429")
monkeypatch.setattr("app.services.ai_engine.llm_scorer.llm_score_copy", boom)
notes = [{"title": "a", "content": "c", "tags": []}]
_score_notes(mock.MagicMock(), notes, SOURCE, [])
assert notes[0]["_score"] == 0
assert notes[0]["_passed"] is False
def test_score_multiple_notes_each_scored(monkeypatch):
_patch_scorer(monkeypatch, [
{"score": 90, "passed": True, "banned_words_found": []},
{"score": 50, "passed": False, "banned_words_found": []},
])
notes = [{"title": "a", "content": "c", "tags": []},
{"title": "b", "content": "d", "tags": []}]
_score_notes(mock.MagicMock(), notes, SOURCE, [])
assert notes[0]["_passed"] is True
assert notes[1]["_passed"] is False