架构从"扇出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>
83 lines
3.1 KiB
Python
83 lines
3.1 KiB
Python
"""
|
||
裂变编排层 _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
|