""" Fanfic Writer v2.0 - Prompt Assembly Handles prompt construction, variable injection, and audit logging """ import re import json from pathlib import Path from typing import Dict, Any, List, Optional, Tuple from datetime import datetime from .prompt_registry import PromptRegistry from .atomic_io import atomic_write_text, atomic_append_jsonl from .utils import generate_event_id, get_timestamp_iso, sanitize_filename class PromptAssembler: """ Assembles prompts by combining templates with context Handles variable substitution and constraint injection """ def __init__(self, registry: PromptRegistry): self.registry = registry def assemble( self, template_name: str, variables: Dict[str, Any], constraints: Optional[Dict[str, Any]] = None, context_blocks: Optional[List[str]] = None ) -> str: """ Assemble a complete prompt Process: 1. Load template 2. Inject context blocks 3. Substitute variables 4. Inject constraints 5. Add task instruction Args: template_name: Name of template to use variables: Variables to substitute {key} -> value constraints: Runtime constraints (style, format, etc.) context_blocks: Ordered list of context sections Returns: Complete assembled prompt """ # Load template template_content = self.registry.get_template_content(template_name) if not template_content: raise ValueError(f"Template not found: {template_name}") # Start with template prompt_parts = [template_content] # Add context blocks if provided if context_blocks: prompt_parts.append("\n\n【上下文】") for i, block in enumerate(context_blocks, 1): prompt_parts.append(f"\n[上下文块 {i}]\n{block}") # Join parts prompt = "\n".join(prompt_parts) # Substitute variables prompt = self._substitute_variables(prompt, variables) # Inject constraints if constraints: prompt = self._inject_constraints(prompt, constraints) return prompt def _substitute_variables(self, template: str, variables: Dict[str, Any]) -> str: """Substitute {variable_name} with values""" result = template for key, value in variables.items(): placeholder = f"{{{key}}}" if placeholder in result: result = result.replace(placeholder, str(value)) return result def _inject_constraints(self, prompt: str, constraints: Dict[str, Any]) -> str: """Inject runtime constraints into prompt""" constraint_parts = ["\n\n【约束条件】"] if 'word_count' in constraints: constraint_parts.append(f"- 字数要求: {constraints['word_count']}字左右") if 'style' in constraints: constraint_parts.append(f"- 风格: {constraints['style']}") if 'tone' in constraints: constraint_parts.append(f"- 基调: {constraints['tone']}") if 'pov' in constraints: constraint_parts.append(f"- 视角: {constraints['pov']}") if 'forbidden' in constraints: constraint_parts.append(f"- 禁止: {', '.join(constraints['forbidden'])}") if 'must_include' in constraints: constraint_parts.append(f"- 必须包含: {', '.join(constraints['must_include'])}") # Add constraints to prompt return prompt + "\n".join(constraint_parts) class PromptAuditor: """ Handles prompt audit logging Every model call must have its final prompt logged """ def __init__(self, run_dir: Path): self.run_dir = Path(run_dir) self.logs_prompts_dir = self.run_dir / "logs" / "prompts" self.logs_prompts_dir.mkdir(parents=True, exist_ok=True) self.token_report_path = self.run_dir / "logs" / "token-report.jsonl" def log_prompt( self, run_id: str, phase: str, chapter: Optional[int], attempt: Optional[int], event: str, template_name: str, final_prompt: str, model: str, event_id: Optional[str] = None ) -> Path: """ Log the final assembled prompt for audit Returns: Path to log file """ if event_id is None: from .utils import generate_event_id event_id = generate_event_id(run_id, phase, chapter) # Create filename if chapter is not None: filename = f"{phase}_ch{chapter:03d}_{event_id}.md" else: filename = f"{phase}_{event_id}.md" log_path = self.logs_prompts_dir / filename # Build log content content_parts = [ f"", f"", f"", f"", f"", f"", f"", f"", f"", f"", "", "---", "", final_prompt ] content = "\n".join(content_parts) # Write atomically - MANDATORY per design spec # Audit chain missing is a blocking error (fatal) success = atomic_write_text(log_path, content) if not success: raise RuntimeError( f"CRITICAL: Failed to write prompt audit log to {log_path}. " f"Audit chain is mandatory per design spec - cannot proceed without it." ) # Update token report with prompt reference self._update_token_report(event_id, str(log_path)) return log_path def _update_token_report(self, event_id: str, prompt_path: str): """Add prompt_path reference to token report""" # This will be called after the actual API call with token counts # For now, we just ensure the record can be linked pass class ContextBuilder: """ Builds context blocks for prompt assembly Reads from state panels and formats context for model consumption """ def __init__(self, state_manager): from .state_manager import StateManager self.state_manager: StateManager = state_manager def build_character_context(self, character_names: Optional[List[str]] = None) -> str: """Build character context block""" lines = ["## 角色状态"] if character_names: # Specific characters for name in character_names: entity = self.state_manager.characters.get_entity(name) if entity: lines.append(f"\n### {name}") for field, value in entity.get('values', {}).items(): lines.append(f"- {field}: {value}") else: # All characters for name in self.state_manager.characters.list_entities(): entity = self.state_manager.characters.get_entity(name) if entity: lines.append(f"\n### {name}") for field, value in entity.get('values', {}).items(): if field != 'relationships': # Skip complex fields lines.append(f"- {field}: {value}") return "\n".join(lines) def build_plot_context(self, max_threads: int = 10) -> str: """Build plot thread context block""" lines = ["## 剧情线索"] active = self.state_manager.plot_threads.get_active_threads() for thread in active[:max_threads]: lines.append(f"\n- {thread['name']}: {thread.get('promised_payoff', '待揭示')}") lines.append(f" (第{thread.get('introduced_chapter', '?')}章引入,紧迫度: {thread.get('urgency', 'pending')})") return "\n".join(lines) def build_timeline_context(self, recent_events: int = 5) -> str: """Build timeline context block""" data = self.state_manager.timeline.load() lines = ["## 时间线"] lines.append(f"\n当前: {data.get('current_date', '未知')}") events = data.get('events', []) if events: lines.append(f"\n最近事件:") for event in events[-recent_events:]: lines.append(f"- 第{event.get('chapter', '?')}章: {event.get('event', '')}") return "\n".join(lines) def build_inventory_context(self, owner: Optional[str] = None) -> str: """Build inventory context block""" lines = ["## 道具/物品"] if owner: items = self.state_manager.inventory.get_items_by_owner(owner) lines.append(f"\n{owner}拥有:") for item in items: lines.append(f"- {item['name']}: {item.get('description', '')} ({item.get('status', 'unknown')})") else: # List all active items data = self.state_manager.inventory.load() for name, entity in data.get('entities', {}).items(): if entity.get('values', {}).get('status') == 'active': owner = entity['values'].get('owner', '未知') lines.append(f"- {name}: 属于 {owner}") return "\n".join(lines) def build_sanitizer_output(self, chapter_num: int) -> str: """Build sanitizer output context for chapter N""" # Read from sanitizer_output.jsonl sanitizer_path = self.state_manager.run_dir / "4-state" / "sanitizer_output.jsonl" if not sanitizer_path.exists(): return "## 状态净化\n(无输出)" lines = ["## 状态净化输出"] with open(sanitizer_path, 'r', encoding='utf-8') as f: for line in f: try: record = json.loads(line.strip()) if record.get('chapter') == chapter_num: lines.append(f"\n**不变量 (Invariants):**") for inv in record.get('invariants_enforced', []): lines.append(f"- {inv}") lines.append(f"\n**微调 (Soft Retcons):**") for retcon in record.get('soft_retcons_applied', []): lines.append(f"- {retcon}") lines.append(f"\n**理由:** {record.get('reason', '')}") break except json.JSONDecodeError: continue return "\n".join(lines) def build_summary_context(self, current_chapter: int, window_size: int = 3) -> str: """Build rolling summary context from session_memory""" data = self.state_manager.characters.run_dir # hack to get run_dir session_memory_path = self.state_manager.characters.file_path.parent / "session_memory.json" if not session_memory_path.exists(): return "## 前文摘要\n(无记录)" with open(session_memory_path, 'r', encoding='utf-8') as f: memory = json.load(f) chapters = memory.get('chapters', []) lines = ["## 前文摘要"] for ch in chapters[-window_size:]: ch_num = ch.get('chapter_number', 0) if ch_num < current_chapter: lines.append(f"\n### 第{ch_num}章") lines.append(ch.get('summary', '')) # Key changes key_changes = ch.get('key_changes', []) if key_changes: lines.append("\n关键变更:") for change in key_changes: lines.append(f"- {change}") return "\n".join(lines) # ============================================================================ # High-level Prompt Builder # ============================================================================ class PromptBuilder: """ High-level interface for building prompts for specific phases """ def __init__( self, registry: PromptRegistry, state_manager, run_dir: Path ): from .state_manager import StateManager self.assembler = PromptAssembler(registry) self.auditor = PromptAuditor(run_dir) self.context_builder = ContextBuilder(state_manager) self.registry = registry def build_chapter_outline_prompt( self, run_id: str, chapter_num: int, chapter_title: str, chapter_summary: str, previous_content: str, target_words: int, event_id: Optional[str] = None ) -> Tuple[str, Path]: """Build prompt for chapter outline generation (Phase 6.1)""" # Build context blocks context_blocks = [ self.context_builder.build_character_context(), self.context_builder.build_plot_context(), self.context_builder.build_timeline_context(), self.context_builder.build_inventory_context(), self.context_builder.build_summary_context(chapter_num) ] # Variables variables = { 'previous_chapter_content': previous_content[:2000] if previous_content else "(首章无前文)", 'chapter_summary': chapter_summary, 'chapter_title': chapter_title, 'target_words': target_words } # Constraints constraints = { 'word_count': target_words, 'style': '网文风格,节奏紧凑' } # Assemble prompt = self.assembler.assemble( template_name='chapter_outline', variables=variables, constraints=constraints, context_blocks=context_blocks ) # Audit log log_path = self.auditor.log_prompt( run_id=run_id, phase='6.1', chapter=chapter_num, attempt=1, event='outline_generate', template_name='chapter_outline', final_prompt=prompt, model='nvidia/moonshotai/kimi-k2.5', event_id=event_id ) return prompt, log_path def build_chapter_draft_prompt( self, run_id: str, chapter_num: int, chapter_title: str, detailed_outline: str, previous_content: str, segment_summary: str, segment_words: int, is_first_segment: bool, written_content: str = "", event_id: Optional[str] = None ) -> Tuple[str, Path]: """Build prompt for chapter draft generation (Phase 6.3)""" # Build context blocks context_blocks = [ self.context_builder.build_sanitizer_output(chapter_num), self.context_builder.build_character_context(), self.context_builder.build_plot_context() ] # Select template based on segment template_name = 'chapter_draft_first' if is_first_segment else 'chapter_draft_continue' # Variables variables = { 'previous_chapter_content': previous_content[:1500] if previous_content else "(首章)", 'detailed_outline': detailed_outline, 'chapter_title': chapter_title, 'segment_summary': segment_summary, 'segment_words': segment_words, 'written_content': written_content if not is_first_segment else "" } # Constraints constraints = { 'word_count': segment_words, 'style': '网文风格,避免AI味' } # Assemble prompt = self.assembler.assemble( template_name=template_name if self.registry.get_template(template_name) else 'chapter_draft', variables=variables, constraints=constraints, context_blocks=context_blocks ) # Audit log log_path = self.auditor.log_prompt( run_id=run_id, phase='6.3', chapter=chapter_num, attempt=1, event='draft_generate', template_name=template_name, final_prompt=prompt, model='nvidia/moonshotai/kimi-k2.5', event_id=event_id ) return prompt, log_path # ============================================================================ # Module Test # ============================================================================ if __name__ == "__main__": import tempfile print("=== Prompt Assembly Test ===\n") with tempfile.TemporaryDirectory() as tmpdir: # Create mock structures skill_dir = Path(tmpdir) / "skill" prompts_v1 = skill_dir / "prompts" / "v1" prompts_v1.mkdir(parents=True) # Create test template template_content = """【输入】 上一章:{previous_chapter_content} 大纲:{chapter_summary} 【任务】写{chapter_title}的详细大纲 目标字数:{target_words} """ (prompts_v1 / "chapter_outline.md").write_text(template_content) # Create run structure run_dir = Path(tmpdir) / "run" state_dir = run_dir / "4-state" state_dir.mkdir(parents=True) # Initialize registry from .prompt_registry import PromptRegistry registry = PromptRegistry(run_dir, skill_dir) registry.initialize("20260215_TEST", "2.0.0") # Test assembler assembler = PromptAssembler(registry) variables = { 'previous_chapter_content': '这是上一章的内容...', 'chapter_summary': '本章主角获得系统', 'chapter_title': '第一章:系统觉醒', 'target_words': 2500 } constraints = { 'word_count': 2500, 'style': '网文风格' } context_blocks = [ "## 角色状态\n主角:张大胆,外卖员", "## 剧情线索\n系统来源待揭示" ] prompt = assembler.assemble( template_name='chapter_outline', variables=variables, constraints=constraints, context_blocks=context_blocks ) print("[Test] Assembled prompt:") print("-" * 40) print(prompt[:500]) print("-" * 40) # Test auditor auditor = PromptAuditor(run_dir) log_path = auditor.log_prompt( run_id="20260215_TEST", phase="6.1", chapter=1, attempt=1, event="outline_generate", template_name="chapter_outline", final_prompt=prompt, model="nvidia/moonshotai/kimi-k2.5" ) print(f"\n[Test] Audit log created: {log_path.exists()}") print("\n=== All tests completed ===")