src/commands/prompt_composer.py
Rama main · réplica de lectura del repositorio original.
"""GM prompt 与上下文构造器。
集中处理基础 GM prompt、规则附录、剧情追踪文本和 context_builder 调用,
避免 process_round / generate_swipe 重复拼装。
"""
from __future__ import annotations
from src.engine.modules import game_config
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Literal
from src.adventures.scene_context import SceneContextProvider, format_scene_context
from src.commands.affection_effects import affection_prompt_appendix
from src.compat.callbacks import load_world_template as load_world_template_compat
from src.content.gm_style import render_gm_style_section
from src.engine.game_instance import GameInstance
from src.engine.language import DEFAULT_LANGUAGE, gm_language_instruction, localized_text
from src.engine.modules import ruleset_runtime
from src.engine import module_hooks
from src.engine.narrative_perspective import narrative_perspective_instruction
from src.llm.context_builder import (
build_context,
build_player_safe_context as build_player_safe_llm_context,
)
from src.memory.delta import MemoryStore
from src.rules.loader import RuleBundleLoader
from src.rules.rule_system import RuleSystem
from src.rulesets.contracts import NarrativeAdvancementRuntime
from src.rulesets.registry import RulesetRuntimeRegistry
logger = logging.getLogger("trpg")
_GM_PROMPT_CACHE: dict[str, str] | None = {}
@dataclass
class RulePromptContext:
"""当前世界规则对 prompt / 判定流程的影响。"""
world_data: dict | None = None
rule: RuleSystem | None = None
rule_appendix: str = ""
combat_model: str = "hp_based"
dice_system: str = "d20"
class PromptComposer:
"""构造 GM prompt 和 LLM user context。"""
def __init__(
self,
prompts_dir: Path,
rules_dir: Path,
memory_store: MemoryStore | None = None,
ruleset_registry: RulesetRuntimeRegistry | None = None,
context_recent_rounds: int = 0,
):
self.prompts_dir = prompts_dir
self.rules_dir = rules_dir
self.memory_store = memory_store
self.ruleset_registry = ruleset_registry
self.context_recent_rounds = context_recent_rounds
self.auto_storyboard = False
self._scene_context_provider: SceneContextProvider | None = None
def set_scene_context_provider(self, provider: SceneContextProvider | None) -> None:
self._scene_context_provider = provider
def scene_objective_view(self, instance: GameInstance, *, viewer_is_gm: bool) -> dict:
if self._scene_context_provider is None:
return {}
return self._scene_context_provider(instance, viewer_is_gm=viewer_is_gm)
def scene_context_text(self, instance: GameInstance) -> str:
return format_scene_context(
self.scene_objective_view(instance, viewer_is_gm=True), instance.language, max_chars=4096,
)
def set_auto_storyboard(self, enabled: bool) -> None:
self.auto_storyboard = bool(enabled)
def _runtime(self, instance: GameInstance):
binding = dict(ruleset_runtime.binding(instance) or {})
runtime_id = str(binding.get("id") or "")
if self.ruleset_registry is None or not runtime_id:
return None
return self.ruleset_registry.get(
runtime_id, minimum_version=int(binding.get("version", 1) or 1),
)
def load_gm_prompt(self, rule_appendix: str = "", language: str = DEFAULT_LANGUAGE) -> str:
"""读取基础 GM prompt,并按需附加当前规则说明。"""
global _GM_PROMPT_CACHE
if _GM_PROMPT_CACHE is None:
_GM_PROMPT_CACHE = {}
cache_key = localized_text(
language, {"en": "en", "zh-CN": "zh-CN", "ja": "ja", "de": "de", "ru": "ru"}
)
if cache_key not in _GM_PROMPT_CACHE:
filename = {
"en": "gm_system_en.md",
"ja": "gm_system_ja.md",
"de": "gm_system_de.md",
"ru": "gm_system_ru.md",
}.get(cache_key, "gm_system_zh.md")
path = self.prompts_dir / filename
if path.exists():
_GM_PROMPT_CACHE[cache_key] = path.read_text(encoding="utf-8")
elif cache_key == "en":
zh = self.prompts_dir / "gm_system_zh.md"
_GM_PROMPT_CACHE[cache_key] = (
zh.read_text(encoding="utf-8")
if zh.exists()
else "You are the GM for a TRPG text adventure. Narrate in natural English. The GM prompt file is missing."
)
else:
# ja/de 等非 en 语言的 prompt 缺失时先回退英文文件,再回退中文。
# 输出语言仍由 gm_language_instruction 单独控制,不受此回退影响。
en = self.prompts_dir / "gm_system_en.md"
zh = self.prompts_dir / "gm_system_zh.md"
if en.exists():
_GM_PROMPT_CACHE[cache_key] = en.read_text(encoding="utf-8")
elif zh.exists():
_GM_PROMPT_CACHE[cache_key] = zh.read_text(encoding="utf-8")
else:
_GM_PROMPT_CACHE[cache_key] = "你是 TRPG 游戏的主持人(GM)。请用流畅中文进行叙述。(GM prompt 文件缺失)"
prompt = _GM_PROMPT_CACHE[cache_key]
if rule_appendix:
heading = localized_text(
cache_key,
{
"en": "## Current Rules",
"zh-CN": "## 当前规则",
"ja": "## 現在のルール",
"de": "## Aktuelle Regeln",
"ru": "## Текущие правила",
},
)
prompt += f"\n\n{heading}\n{rule_appendix}"
return prompt
def load_rule_context(
self,
instance: GameInstance,
load_world_template: Callable[[str, str], dict],
) -> RulePromptContext:
"""加载存档实际选择的规则,并构造规则 prompt 附录。
``instance.rule_id`` 是开档时已经确定的权威规则。世界模板里的
``default_rule`` 只负责创建页默认选择,不能在运行时把用户选择的
D&D/CoC 规则悄悄换回世界默认规则。
"""
return self._load_rule_context(instance, load_world_template)
def _load_rule_context(
self,
instance: GameInstance,
load_world_template: Callable[[str, str], dict],
) -> RulePromptContext:
ctx = RulePromptContext()
if not instance.world_id:
return ctx
try:
language = getattr(instance, "language", DEFAULT_LANGUAGE)
world_data = load_world_template_compat(load_world_template, instance.world_id, language)
ctx.world_data = world_data
if world_data:
rule = None
active_rule_id = str(getattr(instance, "rule_id", "") or "").strip()
if active_rule_id:
core = self.rules_dir / f"{active_rule_id}.json"
if core.exists():
rule = RuleSystem(RuleBundleLoader().load_rule(self.rules_dir, active_rule_id, language))
else:
active_path = RuleSystem.path_for(self.rules_dir, active_rule_id, language)
if active_path.exists():
rule = RuleSystem.load(active_path)
# 插件规则不一定在内置 rules_dir;缺失时仍由世界贡献路径兜底。
if rule is None:
rule = RuleSystem.load_for_world(world_data, self.rules_dir, language)
if rule:
ctx.rule = rule
ctx.rule_appendix = rule.get_gm_prompt_appendix(language)
ctx.combat_model = rule.combat_model
ctx.dice_system = rule.dice_system
difficulty_text = rule.get_difficulty_instructions(game_config.difficulty(instance), language)
if difficulty_text:
ctx.rule_appendix = ctx.rule_appendix + "\n\n" + difficulty_text
stat_appendix = rule.resource_tag_appendix(language)
if stat_appendix:
ctx.rule_appendix = ctx.rule_appendix + "\n\n" + stat_appendix
affection_appendix = affection_prompt_appendix(instance, rule.affection, language)
if affection_appendix:
ctx.rule_appendix = ctx.rule_appendix + "\n\n" + affection_appendix
except ValueError:
raise
except Exception:
logger.warning("规则上下文加载失败,回退默认值: world_id=%s", instance.world_id, exc_info=True)
return ctx
def load_swipe_rule_context(
self,
instance: GameInstance,
load_world_template: Callable[[str, str], dict],
) -> RulePromptContext:
"""swipe 与正常回合必须使用同一套存档规则。"""
return self._load_rule_context(instance, load_world_template)
def compose_gm_prompt(
self,
instance: GameInstance,
rule_appendix: str = "",
world_data: dict | None = None,
) -> str:
"""构造系统 prompt:基础 prompt + 规则附录 + 世界 GM 风格 + 剧情追踪 + 多人权限范围。"""
language = getattr(instance, "language", DEFAULT_LANGUAGE)
gm_prompt = self.load_gm_prompt(rule_appendix, language)
if not self.auto_storyboard:
gm_prompt = "\n".join(
line for line in gm_prompt.splitlines()
if not line.lstrip().startswith("SCENE_PANEL:")
)
# 有效风格二选一:对局覆盖非 None 时只渲染覆盖,否则渲染世界 gm_style。
style_section = render_gm_style_section(
world_data,
language,
override=module_hooks.narration_preferences(instance)["gm_style_override"],
)
if style_section:
gm_prompt = gm_prompt + "\n\n" + style_section
plot_text = instance.plot_tracker.format_for_context() if instance.plot_tracker else ""
if plot_text:
gm_prompt = gm_prompt + "\n\n" + plot_text
if len(getattr(instance, "players", {}) or {}) > 1:
gm_prompt = gm_prompt + "\n\n" + localized_text(language, {
"en": (
"## Multiplayer Authority Scope\n"
"Each player line is attributed to a named speaker. A speaker may only act, speak, and "
"perceive as their own character. Declarations that move, speak for, or change other "
"players' characters are converted into attempts and the other characters' reactions; "
"never treat one player's text as authority over another player's character."
),
"zh-CN": (
"## 多人权限范围\n"
"每条玩家发言都归属具名说话人;说话人只能以自己的角色行动、说话、感知。"
"支配、替言或修改其他玩家角色的声明,一律转化为尝试与其他角色的反应,"
"不得把任一玩家的文本当作对其他玩家角色的权威。"
),
"ja": (
"## マルチプレイヤー権限範囲\n"
"各プレイヤー発言は実名の話し手に帰属する。話し手は自分のキャラクターとしてのみ"
"行動・発言・知覚できる。他プレイヤーのキャラクターを操作・代弁・変更する宣言は、"
"全て試みと他キャラクターの反応に変換すること。あるプレイヤーのテキストを"
"他プレイヤーのキャラクターへの権威として扱ってはならない。"
),
"de": (
"## Mehrspieler-Autoritätsbereich\n"
"Jede Spielerzeile ist einem namentlich genannten Sprecher zugeordnet. Ein Sprecher darf nur "
"als seine eigene Spielfigur handeln, sprechen und wahrnehmen. Erklärungen, die andere "
"Spielerfiguren bewegen, für sie sprechen oder sie verändern, werden in Versuche und die "
"Reaktionen der anderen Figuren umgewandelt; der Text eines Spielers darf niemals als "
"Autorität über die Figur eines anderen Spielers behandelt werden."
),
})
gm_prompt = gm_prompt + "\n\n" + narrative_perspective_instruction(instance, language)
runtime = self._runtime(instance)
if isinstance(runtime, NarrativeAdvancementRuntime):
advancement_prompt = runtime.narrative_advancement_prompt(instance, language)
if advancement_prompt:
gm_prompt = gm_prompt + "\n\n" + advancement_prompt
gm_prompt = gm_prompt + "\n\n" + gm_language_instruction(getattr(instance, "language", "zh-CN"))
if self.auto_storyboard:
gm_prompt = gm_prompt + "\n\n" + localized_text(language, {
"en": (
"## Automatic storyboard (enabled; required)\n"
"For every response that contains public narration, append at least one SCENE_PANEL "
"line in the protocol block after `---`. Use exactly one panel for one continuous visual "
"beat, and split into multiple panels only for simultaneous locations or visually independent "
"actions, results, reveals, or clear scene/time changes. Preserve story order and context. "
"Each panel must use stable player IDs, a publicly supported location, and a concise visible "
"description. Put each panel on its own line; do not use `|` inside a description or concatenate "
"multiple SCENE_PANEL records on one line. Never omit the panel line just to save tokens; "
"never include private facts, secrets, or merely mentioned destinations. Do not output a standalone "
"NONE instead of the required panel; if there are no other state changes, NONE may appear only "
"after the SCENE_PANEL line."
),
"zh-CN": (
"## 自动分镜(已启用,必须输出)\n"
"每次回复包含公开剧情正文时,都必须在 `---` 后的状态标签块中至少输出一条 "
"SCENE_PANEL。只有一个连续视觉节拍时输出一格;同时异地、独立的关键动作/结果、重大揭示 "
"或明确场景/时间转换才拆成多格。保持剧情顺序和上下文。每格使用稳定玩家 ID、公开剧情中有依据的地点,"
"以及简短的可见画面描述。每格必须单独占一行;描述中不要使用 `|`,不要把多个 SCENE_PANEL "
"拼在同一行。不要为了省 token 省略分镜;不得写私聊、秘密或只是被提及的目的地。"
"自动分镜开启时禁止只输出 NONE 代替分镜;如果没有其他状态变更,NONE 只能放在 SCENE_PANEL 之后。"
),
"ja": (
"## 自動分鏡(有効・必須)\n"
"公開ナレーションを含む全ての返答では、`---` 後のタグブロックに少なくとも一行の "
"SCENE_PANEL を出力する。連続した一つの視覚的ビートなら一コマ、同時に異なる場所、独立した重要な "
"行動/結果、重大な発見、明確な場面/時間転換だけ複数コマに分ける。物語の順序と文脈を保ち、安定した "
"プレイヤー ID、公開根拠のある場所、簡潔で見える描写を使う。秘密や言及だけの目的地は含めない。"
"各コマは必ず別行に書き、描写内に `|` を使わず、複数の SCENE_PANEL を同じ行に連結しない。"
"自動分鏡が有効な場合、SCENE_PANEL の代わりに NONE だけを出力してはならない。"
"他の状態変更がない場合のみ、NONE は SCENE_PANEL の後に置く。"
),
"de": (
"## Automatisches Storyboard (aktiv; erforderlich)\n"
"Jede Antwort mit öffentlicher Erzählung muss im Tag-Block nach `---` mindestens eine "
"SCENE_PANEL-Zeile enthalten. Eine zusammenhängende visuelle Szene bleibt ein Panel; nur "
"gleichzeitige Orte, unabhängige wichtige Handlungen/Ergebnisse, Enthüllungen oder klare Szenen-/"
"Zeitwechsel werden geteilt. Verwende stabile Spieler-IDs, öffentlich belegte Orte und sichtbare, "
"knappe Beschreibungen. Schreibe jedes Panel in eine eigene Zeile, verwende kein `|` in der "
"Beschreibung und verbinde mehrere SCENE_PANEL nicht in einer Zeile; keine Geheimnisse oder nur "
"erwähnte Ziele. Gib bei aktivem Storyboard niemals nur NONE statt eines Panels aus; falls "
"keine anderen Statusänderungen vorliegen, darf NONE erst nach der SCENE_PANEL-Zeile stehen."
),
})
for section in module_hooks.read(instance, module_hooks.GM_PROMPT_SECTIONS):
gm_prompt += "\n\n" + str(section)
return gm_prompt
async def build_user_context(
self,
instance: GameInstance,
gm_prompt: str,
lorebook_matches: list[dict],
actions_text: str,
provider_name: str = "",
world_data: dict | None = None,
history_override: list[dict] | None = None,
directives_text: str = "",
overreach_text: str = "",
world_state_text: str = "",
world_legality_text: str = "",
world_events_text: str = "",
authoritative_events_text: str = "",
) -> str:
"""调用 context_builder 生成本轮 user context。"""
state_view = None
runtime = self._runtime(instance)
if runtime is not None:
state_view = runtime.build_llm_view(instance)
return await build_context(
instance,
gm_prompt,
lorebook_matches,
actions_text,
memory_store=self.memory_store,
provider_name=provider_name,
lorebook_budget=world_data.get("lorebook_token_budget", 0) if world_data else 0,
history_override=history_override,
directives_text=directives_text,
overreach_text=overreach_text,
world_state_text=world_state_text,
world_legality_text=world_legality_text,
world_events_text=world_events_text,
state_view=state_view,
authoritative_events_text=authoritative_events_text,
context_recent_rounds=self.context_recent_rounds,
scene_objective_view=self.scene_objective_view(instance, viewer_is_gm=True),
)
async def build_player_safe_context(
self,
instance: GameInstance,
gm_prompt: str,
lorebook_matches: list[dict],
player_message: str,
actor_uid: str,
provider_name: str = "",
world_data: dict | None = None,
visibility: Literal["private", "party"] = "private",
) -> str:
"""Build the restricted context used only by player-facing GM Q&A."""
return await build_player_safe_llm_context(
instance,
gm_prompt,
lorebook_matches,
player_message,
actor_uid,
provider_name=provider_name,
lorebook_budget=world_data.get("lorebook_token_budget", 0) if world_data else 0,
visibility=visibility,
scene_objective_view=self.scene_objective_view(instance, viewer_is_gm=False),
)