Skip to content

Commit 8f5239d

Browse files
committed
refactor: tension slingshot Pydantic body, structured LLM parse, drop legacy workflow
Made-with: Cursor
1 parent c64a254 commit 8f5239d

9 files changed

Lines changed: 285 additions & 367 deletions

File tree

Lines changed: 146 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,146 +1,192 @@
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
614
from 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. 诊断要结合统计数据、事件内容与补充上下文(若有)
144190
2. 张力水平分为: low(低)、medium(中)、high(高)
145191
3. 缺失元素可能包括: conflict(冲突)、stakes(利害关系)、action(行动)、consequence(后果)、rising_tension(递增张力)、external_conflict(外部冲突)、internal_conflict(内心冲突)等
146192
4. 建议必须是动作导向的,使用"引入"、"增加"、"设置"、"让"等动词开头
@@ -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+
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""张力诊断:结构化 LLM 输出与辅助逻辑。"""
2+
3+
from application.analyst.tension.schema import TensionDiagnosisLlmPayload
4+
5+
__all__ = ["TensionDiagnosisLlmPayload"]
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""张力诊断 LLM 输出的 Pydantic 模型。"""
2+
from __future__ import annotations
3+
4+
from typing import List
5+
6+
from pydantic import BaseModel, ConfigDict, Field, field_validator
7+
8+
9+
class TensionDiagnosisLlmPayload(BaseModel):
10+
"""与 prompt 约定的 JSON 字段一致;额外字段忽略。"""
11+
12+
model_config = ConfigDict(extra="ignore")
13+
14+
diagnosis: str
15+
tension_level: str
16+
missing_elements: List[str] = Field(default_factory=list)
17+
suggestions: List[str] = Field(default_factory=list)
18+
19+
@field_validator("tension_level", mode="before")
20+
@classmethod
21+
def normalize_tension_level(cls, value: object) -> str:
22+
"""将常见别名与中文档位归一为 low / medium / high。"""
23+
if value is None:
24+
return "low"
25+
26+
raw = str(value).strip()
27+
lower = raw.lower()
28+
29+
direct = {
30+
"low": "low",
31+
"l": "low",
32+
"1": "low",
33+
"medium": "medium",
34+
"mid": "medium",
35+
"m": "medium",
36+
"2": "medium",
37+
"high": "high",
38+
"h": "high",
39+
"peak": "high",
40+
"3": "high",
41+
"4": "high",
42+
}
43+
if lower in direct:
44+
return direct[lower]
45+
46+
if "低" in raw:
47+
return "low"
48+
if "中" in raw:
49+
return "medium"
50+
if "高" in raw or "峰值" in raw:
51+
return "high"
52+
53+
return "medium"
54+
55+
@field_validator("diagnosis", mode="before")
56+
@classmethod
57+
def strip_diagnosis(cls, value: object) -> str:
58+
if value is None:
59+
return ""
60+
return str(value).strip()

0 commit comments

Comments
 (0)