11"""张力分析器服务"""
2- import json
3- import os
4- from typing import Dict , List
5- from application .workbench .dtos .writer_block_dto import TensionSlingshotRequest , TensionDiagnosis
2+ from __future__ import annotations
3+
4+ from typing import Dict , List , Optional
5+
6+ from application .ai .structured_json_pipeline import (
7+ parse_and_repair_json ,
8+ sanitize_llm_output ,
9+ validate_json_schema ,
10+ )
11+ from application .analyst .tension .schema import TensionDiagnosisLlmPayload
12+ from application .workbench .dtos .writer_block_dto import TensionDiagnosis , TensionSlingshotRequest
13+ from domain .novel .repositories .chapter_repository import ChapterRepository
614from domain .novel .repositories .narrative_event_repository import NarrativeEventRepository
15+ from domain .novel .repositories .plot_arc_repository import PlotArcRepository
16+ from domain .novel .value_objects .novel_id import NovelId
717
18+ _CHAPTER_EXCERPT_MAX_CHARS = 3500
819
9- class TensionAnalyzer :
10- """张力分析器,分析卡文原因并生成破局建议"""
1120
12- def __init__ (self , event_repository : NarrativeEventRepository , llm_client ):
13- """初始化张力分析器
21+ def _excerpt_chapter_text (text : str , max_chars : int = _CHAPTER_EXCERPT_MAX_CHARS ) -> str :
22+ """截断过长正文,保留头尾以便 prompt 可控。"""
23+ stripped = (text or "" ).strip ()
24+ if len (stripped ) <= max_chars :
25+ return stripped
26+ half = max_chars // 2
27+ return stripped [:half ] + "\n …\n " + stripped [- half :]
1428
15- Args:
16- event_repository: 叙事事件仓储
17- llm_client: LLM 客户端
18- """
19- self .event_repository = event_repository
20- self .llm_client = llm_client
2129
22- async def analyze_tension ( self , request : TensionSlingshotRequest ) -> TensionDiagnosis :
23- """分析张力并生成建议
30+ class TensionAnalyzer :
31+ """张力分析器,分析卡文原因并生成破局建议。"""
2432
25- Args:
26- request: 张力弹弓请求
33+ def __init__ (
34+ self ,
35+ event_repository : NarrativeEventRepository ,
36+ llm_client ,
37+ chapter_repository : Optional [ChapterRepository ] = None ,
38+ plot_arc_repository : Optional [PlotArcRepository ] = None ,
39+ ) -> None :
40+ self ._event_repository = event_repository
41+ self ._llm_client = llm_client
42+ self ._chapter_repository = chapter_repository
43+ self ._plot_arc_repository = plot_arc_repository
2744
28- Returns:
29- 张力诊断结果
30- """
31- # 1. 获取目标章节及前后章节的事件
32- events = self .event_repository .list_up_to_chapter (
45+ async def analyze_tension (self , request : TensionSlingshotRequest ) -> TensionDiagnosis :
46+ events = self ._event_repository .list_up_to_chapter (
3347 request .novel_id ,
34- request .chapter_number
48+ request .chapter_number ,
3549 )
36-
37- # 2. 统计分析
3850 stats = self ._analyze_statistics (events , request .chapter_number )
39-
40- # 3. 构建 LLM prompt
41- prompt = self ._build_prompt (events , stats , request )
42-
43- # 4. 调用 LLM
44- response = await self .llm_client .generate (prompt , model = os .getenv ("SYSTEM_MODEL" , "" ))
45-
46- # 5. 解析响应
47- diagnosis = self ._parse_response (response )
48-
49- return diagnosis
51+ extra_context = self ._build_repository_context (request )
52+ prompt = self ._build_prompt (events , stats , request , extra_context )
53+ response = await self ._llm_client .generate (prompt )
54+ return self ._parse_response (response )
55+
56+ def _build_repository_context (self , request : TensionSlingshotRequest ) -> str :
57+ """从章节正文与剧情弧补充可核验的上下文(仓储缺失时自动跳过)。"""
58+ blocks : List [str ] = []
59+ novel_id_vo = NovelId (value = request .novel_id )
60+
61+ if self ._chapter_repository is not None :
62+ chapters = self ._chapter_repository .list_by_novel (novel_id_vo )
63+ current = next (
64+ (c for c in chapters if c .number == request .chapter_number ),
65+ None ,
66+ )
67+ if current is not None :
68+ excerpt = _excerpt_chapter_text (current .content )
69+ if excerpt :
70+ blocks .append (
71+ f"当前章正文摘录(可能截断):\n { excerpt } "
72+ )
73+ blocks .append (
74+ "库内章节张力字段(0–100,仅作参考): "
75+ f"tension_score={ current .tension_score :.0f} , "
76+ f"plot_tension={ current .plot_tension :.0f} , "
77+ f"emotional_tension={ current .emotional_tension :.0f} , "
78+ f"pacing_tension={ current .pacing_tension :.0f} "
79+ )
80+ else :
81+ blocks .append (
82+ f"库中未找到第 { request .chapter_number } 章实体,暂无正文/张力字段。"
83+ )
84+
85+ if self ._plot_arc_repository is not None :
86+ arc = self ._plot_arc_repository .get_by_novel_id (novel_id_vo )
87+ if arc is not None :
88+ expected = arc .get_expected_tension (request .chapter_number )
89+ line = (
90+ f"情节弧(slug={ arc .slug } )按锚点插值的期望张力档位: "
91+ f"{ expected .name } (数值 { expected .value } ,1=LOW … 4=PEAK)"
92+ )
93+ nxt = arc .get_next_plot_point (request .chapter_number )
94+ if nxt is not None :
95+ desc = (nxt .description or "" ).strip ()
96+ if len (desc ) > 220 :
97+ desc = desc [:220 ] + "…"
98+ line += f";下一剧情点: 第{ nxt .chapter_number } 章 — { desc } "
99+ blocks .append (line )
100+ else :
101+ blocks .append ("库中暂无该小说的剧情弧记录。" )
102+
103+ return "\n \n " .join (blocks ) if blocks else ""
50104
51105 def _analyze_statistics (self , events : List [dict ], target_chapter : int ) -> Dict :
52- """统计分析事件数据
53-
54- Args:
55- events: 事件列表
56- target_chapter: 目标章节号
57-
58- Returns:
59- 统计数据字典
60- """
61- # 筛选目标章节及前后章节
62106 target_events = [e for e in events if e ["chapter_number" ] == target_chapter ]
63107 prev_events = [e for e in events if e ["chapter_number" ] == target_chapter - 1 ]
64108 next_events = [e for e in events if e ["chapter_number" ] == target_chapter + 1 ]
65109
66- # 统计冲突标签
67- conflict_tags = []
68- emotion_tags = []
110+ conflict_tags : List [str ] = []
111+ emotion_tags : List [str ] = []
69112 for event in target_events :
70113 tags = event .get ("tags" , [])
71- conflict_tags .extend ([ t for t in tags if t .startswith ("冲突:" )] )
72- emotion_tags .extend ([ t for t in tags if t .startswith ("情绪:" )] )
114+ conflict_tags .extend (t for t in tags if isinstance ( t , str ) and t .startswith ("冲突:" ))
115+ emotion_tags .extend (t for t in tags if isinstance ( t , str ) and t .startswith ("情绪:" ))
73116
74- # 计算事件密度
75- chapter_count = len (set ( e [ "chapter_number" ] for e in events ) )
76- event_density = len (events ) / chapter_count if chapter_count > 0 else 0
117+ chapters_with_data = { e [ "chapter_number" ] for e in events }
118+ chapter_count = len (chapters_with_data )
119+ event_density = len (events ) / chapter_count if chapter_count > 0 else 0.0
77120
78121 return {
79122 "target_event_count" : len (target_events ),
80123 "prev_event_count" : len (prev_events ),
124+ "next_event_count" : len (next_events ),
81125 "conflict_count" : len (conflict_tags ),
82126 "emotion_diversity" : len (set (emotion_tags )),
83127 "event_density" : event_density ,
128+ "chapters_with_narrative_count" : chapter_count ,
84129 "conflict_tags" : conflict_tags ,
85- "emotion_tags" : emotion_tags
130+ "emotion_tags" : emotion_tags ,
86131 }
87132
88133 def _build_prompt (
89134 self ,
90135 events : List [dict ],
91136 stats : Dict ,
92- request : TensionSlingshotRequest
137+ request : TensionSlingshotRequest ,
138+ repository_context : str ,
93139 ) -> str :
94- """构建 LLM prompt
95-
96- Args:
97- events: 事件列表
98- stats: 统计数据
99- request: 请求对象
100-
101- Returns:
102- prompt 字符串
103- """
104- # 构建事件摘要
105- event_summaries = []
140+ event_summaries : List [str ] = []
106141 for event in events :
107- tags_str = ", " .join (event .get ("tags" , []))
142+ tags = event .get ("tags" , []) or []
143+ tags_str = ", " .join (str (t ) for t in tags )
108144 event_summaries .append (
109145 f"第{ event ['chapter_number' ]} 章: { event ['event_summary' ]} (标签: { tags_str } )"
110146 )
111147
112148 events_text = "\n " .join (event_summaries ) if event_summaries else "暂无事件数据"
113149
114- # 构建统计信息
150+ repo_block = ""
151+ if repository_context .strip ():
152+ repo_block = f"\n 补充上下文(仓储):\n { repository_context .strip ()} \n "
153+
154+ stuck_reason_text = ""
155+ if request .stuck_reason :
156+ stuck_reason_text = f"\n 作者自述的卡文原因: { request .stuck_reason } \n "
157+
158+ density_note = (
159+ "事件密度 = 已加载叙事事件总数 / 其中出现过的不同章节数;"
160+ "分母不是全书总章数。"
161+ )
162+
115163 stats_text = f"""
116164统计数据:
117165- 目标章节事件数: { stats ['target_event_count' ]}
166+ - 上一章事件数: { stats ['prev_event_count' ]}
167+ - 下一章事件数: { stats ['next_event_count' ]}
118168- 冲突标签数: { stats ['conflict_count' ]}
119- - 情绪多样性: { stats ['emotion_diversity' ]}
120- - 事件密度: { stats ['event_density' ]:.2f}
169+ - 情绪多样性(目标章不重复情绪标签数): { stats ['emotion_diversity' ]}
170+ - 有叙事数据的章节数: { stats ['chapters_with_narrative_count' ]}
171+ - 事件密度: { stats ['event_density' ]:.2f} ({ density_note } )
121172- 冲突类型: { ', ' .join (stats ['conflict_tags' ]) if stats ['conflict_tags' ] else '无' }
122173- 情绪类型: { ', ' .join (stats ['emotion_tags' ]) if stats ['emotion_tags' ] else '无' }
123174"""
124175
125- # 构建作者自述部分
126- stuck_reason_text = ""
127- if request .stuck_reason :
128- stuck_reason_text = f"\n 作者自述的卡文原因: { request .stuck_reason } \n "
129-
130176 prompt = f"""你是小说创作顾问,专门帮助作者突破卡文。
131177
132178当前小说ID: { request .novel_id }
133179卡文章节: 第{ request .chapter_number } 章
134180{ stuck_reason_text }
135181事件列表:
136182{ events_text }
137-
183+ { repo_block }
138184{ stats_text }
139185
140186请分析当前章节的张力水平,诊断卡文原因,并提供具体可操作的建议。
141187
142188要求:
143- 1. 诊断要结合统计数据和事件内容
189+ 1. 诊断要结合统计数据、事件内容与补充上下文(若有)
1441902. 张力水平分为: low(低)、medium(中)、high(高)
1451913. 缺失元素可能包括: conflict(冲突)、stakes(利害关系)、action(行动)、consequence(后果)、rising_tension(递增张力)、external_conflict(外部冲突)、internal_conflict(内心冲突)等
1461924. 建议必须是动作导向的,使用"引入"、"增加"、"设置"、"让"等动词开头
@@ -158,28 +204,28 @@ def _build_prompt(
158204 return prompt
159205
160206 def _parse_response (self , response : str ) -> TensionDiagnosis :
161- """解析 LLM 响应
162-
163- Args:
164- response: LLM 响应字符串
165-
166- Returns:
167- TensionDiagnosis 对象
168- """
169- try :
170- # 尝试解析 JSON
171- data = json .loads (response )
207+ cleaned = sanitize_llm_output (response )
208+ data , parse_errors = parse_and_repair_json (cleaned )
209+ if data is None :
172210 return TensionDiagnosis (
173- diagnosis = data [ "diagnosis" ] ,
174- tension_level = data [ "tension_level" ] ,
175- missing_elements = data [ "missing_elements " ],
176- suggestions = data [ "suggestions" ]
211+ diagnosis = "无法解析 LLM 返回的 JSON: " + "; " . join ( parse_errors [: 4 ]) ,
212+ tension_level = "low" ,
213+ missing_elements = [ "parse_error " ],
214+ suggestions = [ "请稍后重试,或检查模型输出是否被截断" ],
177215 )
178- except (json .JSONDecodeError , KeyError ) as e :
179- # 如果解析失败,返回默认结果
216+
217+ payload , schema_errors = validate_json_schema (data , TensionDiagnosisLlmPayload )
218+ if payload is None :
180219 return TensionDiagnosis (
181- diagnosis = f"解析响应失败: { str ( e ) } " ,
220+ diagnosis = "JSON 结构校验失败: " + "; " . join ( schema_errors [: 6 ]) ,
182221 tension_level = "low" ,
183- missing_elements = ["parse_error " ],
184- suggestions = ["请检查 LLM 响应格式" ]
222+ missing_elements = ["schema_error " ],
223+ suggestions = ["请稍后重试" ],
185224 )
225+
226+ return TensionDiagnosis (
227+ diagnosis = payload .diagnosis ,
228+ tension_level = payload .tension_level ,
229+ missing_elements = list (payload .missing_elements ),
230+ suggestions = list (payload .suggestions ),
231+ )
0 commit comments