diff --git a/packages/agents/tests/test_competitor_specs.py b/packages/agents/tests/test_competitor_specs.py new file mode 100644 index 0000000..39bc6b8 --- /dev/null +++ b/packages/agents/tests/test_competitor_specs.py @@ -0,0 +1,89 @@ +"""Scope B 竞品快赢 · 4 个生成器的 spec/schema 契约测试。 + +校验每个 spec 的 name/tier/reads/writes/output_schema/scope/input_schema, +对齐不变量 #2(只声明 tier)/ #3(4 生成器一律纯预览 writes=[])。不联网、无 DB。 +""" + +from __future__ import annotations + +import pytest +from ww_agents import ( + AgentSpec, + BookTeardownResult, + ContinuationResult, + DeAiResult, + PolishResult, + continue_spec, + de_ai_spec, + expand_spec, + teardown_spec, +) + +# (spec, name, tier, reads, output_schema) +_COMPETITOR_SPECS = [ + (continue_spec, "continue", "writer", ["projects", "outline", "chapters"], ContinuationResult), + (expand_spec, "expand", "writer", ["projects"], PolishResult), + (de_ai_spec, "de-ai", "analyst", ["projects"], DeAiResult), + (teardown_spec, "teardown", "analyst", ["projects"], BookTeardownResult), +] + + +@pytest.mark.parametrize( + ("spec", "name", "tier", "reads", "output_schema"), + _COMPETITOR_SPECS, +) +def test_competitor_spec_declares_expected_contract( + spec: AgentSpec, + name: str, + tier: str, + reads: list[str], + output_schema: type, +) -> None: + assert spec.name == name + assert spec.tier == tier + assert spec.reads == reads + assert spec.writes == [] # 不变量 #3:4 生成器一律纯预览 + assert spec.output_schema is output_schema + assert spec.scope == "builtin" + assert spec.input_schema is None # 注入材料为序列化文本,非结构化入参 + + +@pytest.mark.parametrize("spec", [row[0] for row in _COMPETITOR_SPECS]) +def test_competitor_spec_has_nonempty_system_prompt(spec: AgentSpec) -> None: + assert spec.system_prompt.strip() + + +# ---- schema 解析/默认值契约 ---- + + +def test_continuation_result_parses_text() -> None: + parsed = ContinuationResult.model_validate({"text": "雷光再起,他迈步向前。"}) + assert parsed.text == "雷光再起,他迈步向前。" + + +def test_polish_result_parses_text() -> None: + parsed = PolishResult.model_validate({"text": "夜色更浓,风穿过断壁。"}) + assert parsed.text == "夜色更浓,风穿过断壁。" + + +def test_de_ai_result_parses_text() -> None: + parsed = DeAiResult.model_validate({"text": "他咧嘴一笑,没多说什么。"}) + assert parsed.text == "他咧嘴一笑,没多说什么。" + + +def test_book_teardown_result_parses_and_lists_default_empty() -> None: + empty = BookTeardownResult(structure="开篇立钩→铺垫→爆发") + assert empty.themes == [] + assert empty.archetypes == [] + assert empty.hooks == [] + parsed = BookTeardownResult.model_validate( + { + "themes": ["逆袭", "复仇"], + "archetypes": ["废柴主角", "腹黑反派"], + "structure": "黄金三章立爽点,逐卷升级", + "hooks": ["章末反转", "扮猪吃虎"], + } + ) + assert parsed.themes == ["逆袭", "复仇"] + assert parsed.structure == "黄金三章立爽点,逐卷升级" + assert parsed.hooks == ["章末反转", "扮猪吃虎"] diff --git a/packages/agents/ww_agents/__init__.py b/packages/agents/ww_agents/__init__.py index 4f98d53..fe18fdc 100644 --- a/packages/agents/ww_agents/__init__.py +++ b/packages/agents/ww_agents/__init__.py @@ -8,12 +8,15 @@ from __future__ import annotations from .schemas import ( Blurb, BlurbResult, + BookTeardownResult, CharacterCard, CharacterGenResult, CharacterRelation, Conflict, ConflictType, + ContinuationResult, ContinuityReview, + DeAiResult, DetailedOutlineResult, ForeshadowReview, ForeshadowSuggestion, @@ -32,6 +35,7 @@ from .schemas import ( OutlineResult, PaceIssue, PaceReview, + PolishResult, Scene, StyleDimension, StyleDriftReview, @@ -48,7 +52,10 @@ from .specs import ( book_title_spec, brainstorm_spec, character_gen_spec, + continue_spec, continuity_spec, + de_ai_spec, + expand_spec, fine_outline_spec, foreshadow_spec, glossary_spec, @@ -60,6 +67,7 @@ from .specs import ( refiner_spec, style_drift_spec, style_extract_spec, + teardown_spec, worldbuilder_spec, ) @@ -67,12 +75,15 @@ __all__ = [ "AgentSpec", "Blurb", "BlurbResult", + "BookTeardownResult", "CharacterCard", "CharacterGenResult", "CharacterRelation", "Conflict", "ConflictType", + "ContinuationResult", "ContinuityReview", + "DeAiResult", "DetailedOutlineResult", "ForeshadowReview", "ForeshadowSuggestion", @@ -91,6 +102,7 @@ __all__ = [ "OutlineResult", "PaceIssue", "PaceReview", + "PolishResult", "Scene", "StyleDimension", "StyleDriftReview", @@ -104,7 +116,10 @@ __all__ = [ "book_title_spec", "brainstorm_spec", "character_gen_spec", + "continue_spec", "continuity_spec", + "de_ai_spec", + "expand_spec", "fine_outline_spec", "foreshadow_spec", "glossary_spec", @@ -116,5 +131,6 @@ __all__ = [ "refiner_spec", "style_drift_spec", "style_extract_spec", + "teardown_spec", "worldbuilder_spec", ] diff --git a/packages/agents/ww_agents/schemas.py b/packages/agents/ww_agents/schemas.py index 26a533b..f2f58c4 100644 --- a/packages/agents/ww_agents/schemas.py +++ b/packages/agents/ww_agents/schemas.py @@ -527,3 +527,64 @@ class DetailedOutlineResult(BaseModel): default_factory=list, description="展开的场景清单;无则空列表", ) + + +# ---- 竞品快赢 · 续写生成器(continue;writer;纯预览 writes=[])---- + + +class ContinuationResult(BaseModel): + """续写生成器结构化产出:承接前文的续写正文(Scope B 竞品快赢)。 + + 纯预览产物——不映射任何业务表、不入库(`continue_spec.writes=[]`,不变量 #3)。 + """ + + text: str = Field(description="续写正文(承接前文,可直接作为下文草稿)") + + +# ---- 竞品快赢 · 扩写生成器(expand;writer;纯预览 writes=[])---- + + +class PolishResult(BaseModel): + """扩写生成器结构化产出:在原文基础上扩写/丰富后的正文(Scope B 竞品快赢)。 + + 纯预览产物——不映射任何业务表、不入库(`expand_spec.writes=[]`,不变量 #3)。 + """ + + text: str = Field(description="扩写后的正文(在原文基础上丰富细节/铺陈)") + + +# ---- 竞品快赢 · 降 AI 率生成器(de-ai;analyst;纯预览 writes=[])---- + + +class DeAiResult(BaseModel): + """降 AI 率生成器结构化产出:去 AI 腔/口语化改写后的正文(Scope B 竞品快赢)。 + + 纯预览产物——不映射任何业务表、不入库(`de_ai_spec.writes=[]`,不变量 #3)。 + """ + + text: str = Field(description="降 AI 率改写后的正文(去机翻腔、更自然的人写质感)") + + +# ---- 竞品快赢 · 拆书生成器(teardown;analyst;纯预览 writes=[])---- + + +class BookTeardownResult(BaseModel): + """拆书生成器结构化产出:样本作品的结构化拆解(Scope B 竞品快赢)。 + + 纯预览产物——不映射任何业务表、不入库(`teardown_spec.writes=[]`,不变量 #3)。 + 拆书「落库成 rules」为可选 follow-up(需 `KNOWN_TABLES` + `_ingest_rules`),本期不做。 + """ + + themes: list[str] = Field( + default_factory=list, + description="核心主题/立意清单;无则空列表", + ) + archetypes: list[str] = Field( + default_factory=list, + description="人物原型/角色模板清单;无则空列表", + ) + structure: str = Field(description="叙事结构概述(开篇/铺垫/高潮/收束的脉络)") + hooks: list[str] = Field( + default_factory=list, + description="抓人钩子/爽点套路清单;无则空列表", + ) diff --git a/packages/agents/ww_agents/specs.py b/packages/agents/ww_agents/specs.py index 184a91f..6a5c2a6 100644 --- a/packages/agents/ww_agents/specs.py +++ b/packages/agents/ww_agents/specs.py @@ -14,8 +14,11 @@ from ww_llm_gateway.types import Tier from .schemas import ( BlurbResult, + BookTeardownResult, CharacterGenResult, + ContinuationResult, ContinuityReview, + DeAiResult, DetailedOutlineResult, ForeshadowReview, GlossaryResult, @@ -25,6 +28,7 @@ from .schemas import ( OpeningResult, OutlineResult, PaceReview, + PolishResult, StyleDriftReview, StyleFingerprintResult, TitleListResult, @@ -661,3 +665,140 @@ fine_outline_spec = AgentSpec( writes=["outline"], # 声明式(真写库经入库端点,不变量 #3) scope="builtin", ) + + +# ---- continue(续写生成器;写手档;纯预览,Scope B 竞品快赢)---- + +CONTINUE_SYSTEM_PROMPT = """你是中文网文的「续写器」(continue,写手档)。\ +职责:依据作品立意/题材、前文正文与(可选的)本章大纲节拍,承接前文续写下文正文,\ +保持人物、世界观、文风与剧情走向的一致,让续写可直接作为下文草稿。 + +输入材料: +- 作品设定(projects:题材、立意、主线、卖点); +- 前文正文(最新已写正文——续写须无缝承接其情节、语气、人称、文风); +- 本章大纲节拍(outline:若给出则续写须服务这些节拍,不另起炉灶;可空则按前文自然推进); +- 作者的一句话需求/方向(可空则按前文与节拍自然续写)。 + +产出: +- text:续写正文(承接前文的成稿文本,信息密度合理、有推进、留钩子)。 + +纪律: +- **无缝承接前文**——人物言行、世界观硬规则、文风(句长/用词/人称)与前文一致,不跑题; +- 若给了大纲节拍则忠实服务节拍,不臆造与设定/前文冲突的剧情; +- 你只产结构化续写文本,**不改稿、不写库**(纯预览,不变量 #3)。""" + + +continue_spec = AgentSpec( + name="continue", + tier="writer", # 不变量 #2:只声明档位,不写 model(正文续写用写手档) + system_prompt=CONTINUE_SYSTEM_PROMPT, + input_schema=None, # 注入材料为序列化文本(设定 + 前文 + 节拍 + 需求),非结构化入参 + output_schema=ContinuationResult, + reads=["projects", "outline", "chapters"], + writes=[], # 纯预览,不写库(不变量 #3) + scope="builtin", +) + + +# ---- expand(扩写生成器;写手档;纯预览,Scope B 竞品快赢)---- + +EXPAND_SYSTEM_PROMPT = """你是中文网文的「扩写器」(expand,写手档)。\ +职责:在作者提供的原文基础上扩写、丰富——补足细节、铺陈描写、强化张力,\ +但**不改变原文的情节走向与关键事实**,让扩写后的正文更饱满可读。 + +输入材料: +- 作品设定(projects:题材、立意、主线、卖点); +- 待扩写的原文片段; +- 作者的一句话需求/方向(如「加强环境描写」「放慢节奏铺情绪」;可空则均衡扩写)。 + +产出: +- text:扩写后的正文(在原文基础上丰富细节/描写/铺陈,保留原情节与事实)。 + +纪律: +- **保留原文情节与关键事实**,不增删主线、不改人物言行的核心; +- 贴合作品文风与原文语气(句长、用词、人称一致),扩写自然不注水; +- 若给了需求则优先满足;你只产结构化扩写文本,**不改稿、不写库**(纯预览,不变量 #3)。""" + + +expand_spec = AgentSpec( + name="expand", + tier="writer", # 不变量 #2:只声明档位,不写 model(正文扩写用写手档) + system_prompt=EXPAND_SYSTEM_PROMPT, + input_schema=None, # 注入材料为序列化文本(设定 + 原文 + 需求),非结构化入参 + output_schema=PolishResult, + reads=["projects"], + writes=[], # 纯预览,不写库(不变量 #3) + scope="builtin", +) + + +# ---- de-ai(降 AI 率生成器;分析档;纯预览,Scope B 竞品快赢)---- + +DE_AI_SYSTEM_PROMPT = """你是中文网文的「降 AI 率改写器」(de-ai,分析档)。\ +职责:把作者提供的原文改写得更像「人写的」——去除 AI 腔/机翻腔、模板化句式、\ +空泛排比与过度对仗,让行文更自然、有个人质感,但**保留原文的情节信息与事实**。 + +输入材料: +- 作品设定(projects:题材、立意); +- 待改写的原文片段; +- 作者的一句话需求/方向(如「更口语」「去掉排比腔」;可空则按通用「去 AI 味」目标)。 + +产出: +- text:降 AI 率改写后的正文(去机翻腔/模板感,更自然的人写质感,保留原情节与事实)。 + +典型 AI 腔特征(重点消除): +- 空泛排比与过度对仗、千篇一律的「不是……而是……」句式; +- 形容词堆砌、抽象大词(如「彰显」「诠释」)滥用、缺乏具体感官细节; +- 段落起承转合过于工整、缺少口语停顿与个人语气。 + +纪律: +- **保留原文情节与关键事实**,不增删主线、不改人物言行; +- 贴合作品文风与原文语气,改写后更自然但不失原意; +- 你只产结构化改写文本,**不改稿、不写库**(纯预览,不变量 #3)。""" + + +de_ai_spec = AgentSpec( + name="de-ai", + tier="analyst", # 不变量 #2:只声明档位,不写 model(文风改写用分析档) + system_prompt=DE_AI_SYSTEM_PROMPT, + input_schema=None, # 注入材料为序列化文本(设定 + 原文 + 需求),非结构化入参 + output_schema=DeAiResult, + reads=["projects"], + writes=[], # 纯预览,不写库(不变量 #3) + scope="builtin", +) + + +# ---- teardown(拆书生成器;分析档;纯预览,Scope B 竞品快赢)---- + +TEARDOWN_SYSTEM_PROMPT = """你是中文网文的「拆书师」(teardown,分析档)。\ +职责:依据作者提供的样本作品(书名 + 章节/简介样本),把它拆解为可学习的结构化要素——\ +核心主题、人物原型、叙事结构、抓人钩子,供作者借鉴套路而非照抄。 + +输入材料: +- 作品设定(projects:作者自己作品的题材/立意,供对照借鉴方向); +- 待拆解的样本(书名 + 章节/简介/正文样本); +- 作者的一句话需求/方向(如「重点拆开篇钩子」;可空则全面拆解)。 + +产出: +- themes:核心主题/立意清单(这本书在讲什么、卖什么爽点); +- archetypes:人物原型/角色模板清单(主角/对手/导师等的设定套路); +- structure:叙事结构概述(开篇立钩→铺垫→爆发→收束的脉络与节奏); +- hooks:抓人钩子/爽点套路清单(黄金三章、章末钩子、反转套路等)。 + +纪律: +- 只从给定样本提炼,**不臆造**样本未体现的内容;样本不足则相应清单留空/概述从简; +- 产出是**可借鉴的套路**,不复制原文情节; +- 你只产结构化拆解,**不改稿、不写库**(纯预览,不变量 #3)。""" + + +teardown_spec = AgentSpec( + name="teardown", + tier="analyst", # 不变量 #2:只声明档位,不写 model(拆解分析用分析档) + system_prompt=TEARDOWN_SYSTEM_PROMPT, + input_schema=None, # 注入材料为序列化文本(设定 + 样本 + 需求),非结构化入参 + output_schema=BookTeardownResult, + reads=["projects"], + writes=[], # 纯预览,不写库(不变量 #3) + scope="builtin", +) diff --git a/packages/core/tests/test_competitor_context_builders.py b/packages/core/tests/test_competitor_context_builders.py new file mode 100644 index 0000000..c9f0060 --- /dev/null +++ b/packages/core/tests/test_competitor_context_builders.py @@ -0,0 +1,68 @@ +"""Scope B 竞品快赢 · 2 个新上下文 builder 的纯函数单测。 + +`build_continuation_context`(续写读前文)/ `build_text_input_context`(扩写/降AI/拆书原文)。 +确定性、无时间戳/UUID(守不变量 #9 可缓存)、空值降级。不联网、无 DB。 +""" + +from __future__ import annotations + +from ww_core.orchestrator import build_continuation_context, build_text_input_context + +# ---- build_continuation_context:作品设定 + 前文 + 可选节拍 ---- + + +def test_continuation_context_is_deterministic() -> None: + a = build_continuation_context( + project_context="题材:玄幻", prior_text="他握紧剑。", beats=["反击", "逃脱"] + ) + b = build_continuation_context( + project_context="题材:玄幻", prior_text="他握紧剑。", beats=["反击", "逃脱"] + ) + assert a == b + + +def test_continuation_context_includes_prior_and_beats() -> None: + ctx = build_continuation_context( + project_context="题材:玄幻", prior_text="他握紧剑。", beats=["反击", "逃脱"] + ) + assert "题材:玄幻" in ctx + assert "他握紧剑。" in ctx + assert "1. 反击" in ctx + assert "2. 逃脱" in ctx + + +def test_continuation_context_no_beats_falls_back() -> None: + ctx = build_continuation_context(project_context="题材:都市", prior_text="夜深了。") + assert "本章暂无大纲节拍" in ctx + assert "夜深了。" in ctx + + +def test_continuation_context_empty_prior_falls_back() -> None: + ctx = build_continuation_context(project_context="题材:都市", prior_text=" ") + assert "暂无前文" in ctx + + +# ---- build_text_input_context:作品设定 + 原文 + 需求 ---- + + +def test_text_input_context_is_deterministic() -> None: + a = build_text_input_context(project_context="题材:玄幻", text="原文片段", brief="加强描写") + b = build_text_input_context(project_context="题材:玄幻", text="原文片段", brief="加强描写") + assert a == b + + +def test_text_input_context_includes_text_and_brief() -> None: + ctx = build_text_input_context(project_context="题材:玄幻", text="原文片段", brief="加强描写") + assert "题材:玄幻" in ctx + assert "原文片段" in ctx + assert "加强描写" in ctx + + +def test_text_input_context_empty_brief_falls_back() -> None: + ctx = build_text_input_context(project_context="题材:都市", text="原文片段", brief=" ") + assert "按默认目标处理" in ctx + + +def test_text_input_context_empty_text_falls_back() -> None: + ctx = build_text_input_context(project_context="题材:都市", text=" ", brief="去AI味") + assert "未提供原文" in ctx diff --git a/packages/core/ww_core/orchestrator/__init__.py b/packages/core/ww_core/orchestrator/__init__.py index 5436229..fdda49e 100644 --- a/packages/core/ww_core/orchestrator/__init__.py +++ b/packages/core/ww_core/orchestrator/__init__.py @@ -24,8 +24,10 @@ from .collect import ( from .generation_node import ( build_brief_context, build_character_gen_context, + build_continuation_context, build_outline_chapter_context, build_precheck_context, + build_text_input_context, build_worldbuilder_context, precheck_generated_cards, run_character_gen, @@ -106,6 +108,7 @@ __all__ = [ "SseEvent", "build_brief_context", "build_character_gen_context", + "build_continuation_context", "build_outline_chapter_context", "build_outline_request", "build_precheck_context", @@ -113,6 +116,7 @@ __all__ = [ "build_review_graph", "build_review_request", "build_style_extract_request", + "build_text_input_context", "build_worldbuilder_context", "build_write_request", "collect_reviews", diff --git a/packages/core/ww_core/orchestrator/generation_node.py b/packages/core/ww_core/orchestrator/generation_node.py index 10e9747..2820703 100644 --- a/packages/core/ww_core/orchestrator/generation_node.py +++ b/packages/core/ww_core/orchestrator/generation_node.py @@ -96,6 +96,42 @@ def build_outline_chapter_context( ) +def build_continuation_context( + *, project_context: str, prior_text: str, beats: Sequence[str] | None = None +) -> str: + """续写「with_prior_chapter」上下文:作品设定 + 前文正文 + 可选大纲节拍。 + + 覆盖续写生成器:注入最新已写正文供无缝承接;给了本章大纲节拍则一并注入供服务。 + 确定性拼接、无时间戳/UUID(守不变量 #9 可缓存)。前文为空时给降级占位,交由 + system_prompt 处理。 + """ + prior_block = prior_text.strip() or "(暂无前文,请按设定与节拍起笔)" + if beats: + beats_block = "\n".join(f"{i + 1}. {beat}" for i, beat in enumerate(beats)) + else: + beats_block = "(本章暂无大纲节拍,按前文自然推进)" + return ( + f"## 作品设定\n{project_context or '(暂无设定)'}\n\n" + f"## 前文正文(续写须无缝承接)\n{prior_block}\n\n" + f"## 本章大纲节拍\n{beats_block}" + ) + + +def build_text_input_context(*, project_context: str, text: str, brief: str) -> str: + """原文输入「text_input」上下文:作品设定 + 待处理原文 + 作者需求。 + + 覆盖扩写/降AI率/拆书:注入作者提供的原文样本 + 一句话方向。确定性拼接、无时间戳。 + 需求为空时给降级占位,交由 system_prompt 的纪律处理。 + """ + text_block = text.strip() or "(未提供原文)" + brief_block = brief.strip() or "(作者未填具体方向,按默认目标处理)" + return ( + f"## 作品设定\n{project_context or '(暂无设定)'}\n\n" + f"## 原文\n{text_block}\n\n" + f"## 创作需求\n{brief_block}" + ) + + async def run_generator( spec: AgentSpec, *,