Files
cultivation-world-simulator/src/classes/story_teller.py
2026-01-03 21:25:24 +08:00

132 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from typing import Dict, TYPE_CHECKING
import random
if TYPE_CHECKING:
from src.classes.avatar import Avatar
from src.utils.config import CONFIG
from src.utils.llm import call_llm_with_task_name
from src.classes.relations import (
process_relation_changes,
get_relation_change_context
)
story_styles = [
"平淡叙述:语句克制、少修饰、像旁观者记录。",
"寓情于景:以景见情,情随境迁,情景交融。",
"写意古风:重意象与比兴,点到为止,少生僻文言。",
"市井烟火:行文接地气,多用口语化表达,朴实自然,不拿腔拿调。",
"诗意抒情:短句与对仗点缀,少量用典,不堆砌辞藻。",
"哲思寓言:借事设问,含一两句点睛之语,不说教。",
"编年纪事:近史官笔法,记事有序,少形容词。",
"景物拟人:对景施以轻微拟人,景中含志,不滥。",
"道法自然:以道家语汇点染,不艰涩,收束于一念。",
"佛理空相:无常、空相的领悟穿插事中,轻淡不玄。",
"民间说书:似说书人口吻但用书面语,叙事节奏明快,生动有趣。",
"雅致书卷:书卷气、引文气息浅尝辄止,不显摆。",
"豪放旷达:用词大开大合,气势磅礴,不拘小节,直抒胸臆。",
"绮丽诡谲:重感官描写,辞藻华丽妖冶,强调光影与色彩的奇异感。",
"冷峻简练:短句为主,字字珠玑,如金石相击,不做多余情感渲染。",
"细笔白描:不加藻饰,通过捕捉极细微的动作与神态来传神,真实细腻。",
]
class StoryTeller:
"""
故事生成器:基于模板与 LLM将给定事件扩展为简短的小故事。
同时负责处理可能的后天关系变化。
"""
TEMPLATE_SINGLE_PATH = CONFIG.paths.templates / "story_single.txt"
TEMPLATE_DUAL_PATH = CONFIG.paths.templates / "story_dual.txt"
@staticmethod
def _build_avatar_infos(*actors: "Avatar") -> Dict[str, dict]:
"""
构建角色信息字典。
- 双人故事:第一个角色使用 expanded_info包含共同事件第二个使用普通 info
- 单人故事:使用 expanded_info
"""
non_null = [a for a in actors if a is not None]
avatar_infos: Dict[str, dict] = {}
if len(non_null) >= 2:
avatar_infos[non_null[0].name] = non_null[0].get_expanded_info(other_avatar=non_null[1], detailed=True)
avatar_infos[non_null[1].name] = non_null[1].get_info(detailed=True)
elif non_null:
avatar_infos[non_null[0].name] = non_null[0].get_expanded_info(detailed=True)
return avatar_infos
@staticmethod
def _build_template_data(event: str, res: str, avatar_infos: Dict[str, dict], prompt: str, *actors: "Avatar") -> dict:
"""构建模板渲染所需的数据字典"""
# 默认空关系列表
avatar_name_1 = ""
avatar_name_2 = ""
world_info = actors[0].world.static_info
# 如果有两个有效角色,计算可能的关系
non_null = [a for a in actors if a is not None]
if len(non_null) >= 2:
avatar_name_1 = non_null[0].name
avatar_name_2 = non_null[1].name
return {
"world_info": world_info,
"avatar_infos": avatar_infos,
"avatar_name_1": avatar_name_1,
"avatar_name_2": avatar_name_2,
"event": event,
"res": res,
"style": random.choice(story_styles),
"story_prompt": prompt,
}
@staticmethod
def _make_fallback_story(event: str, res: str, style: str) -> str:
"""生成降级文案"""
# 不再显示 style避免出戏
return f"{event}{res}"
@staticmethod
async def tell_story(event: str, res: str, *actors: "Avatar", prompt: str = "", allow_relation_changes: bool = False) -> str:
"""
生成小故事(异步版本)。
根据 allow_relation_changes 参数选择模板:
- True: 使用 story_dual.txt支持关系变化需要至少2个角色
- False: 使用 story_single.txt仅生成故事无论角色数量
Args:
event: 事件描述
res: 结果描述
*actors: 参与的角色1-2个
prompt: 可选的故事提示词
allow_relation_changes: 是否允许故事导致关系变化默认为False单人模式
"""
non_null = [a for a in actors if a is not None]
# 只有当允许关系变化且有至少2个角色时才使用双人模板
is_dual = allow_relation_changes and len(non_null) >= 2
template_path = StoryTeller.TEMPLATE_DUAL_PATH if is_dual else StoryTeller.TEMPLATE_SINGLE_PATH
avatar_infos = StoryTeller._build_avatar_infos(*actors)
infos = StoryTeller._build_template_data(event, res, avatar_infos, prompt, *actors)
# 移除了 try-except 块,允许异常向上冒泡,以便 Fail Fast
data = await call_llm_with_task_name("story_teller", template_path, infos)
story = data.get("story", "").strip()
if story:
return story
return StoryTeller._make_fallback_story(event, res, infos["style"])
__all__ = ["StoryTeller"]