"""Prompt 加载器(Prompt 外置方案A · 步2)。 `load_prompt(name)` 在模块 import 期一次性读盘 `prompts/.md` → 规整 → 内存缓存 → 返回完整 UTF-8 文本。保证 `spec.system_prompt` 字节稳定(缓存断点前块,不变量 #9) 且单测可复现。文件缺失 = fail-fast(`PromptNotFoundError`),不 fallback、不返回 ""。 规整规则(锁死缓存字节,设计 §3.2),依次: 1. 去 BOM:`encoding="utf-8-sig"` 读(`utf-8` 不自动剥 BOM,带 BOM 会污染首字节破缓存); 2. 行尾归一:CRLF/CR → LF; 3. Unicode 归一化:`unicodedata.normalize("NFC", text)`(中文/全角标点的跨平台缝隙); 4. 尾换行策略(方案 B,顺生态):`text.rstrip("\n")`——吞掉文件尾部所有 LF、不补回。 `.md` 允许带 ≤1 个尾换行(顺 Prettier / editorconfig),运行时字符串无尾换行 (与旧常量逐字等价)。 `load_prompt` 不做任何动态插值(无 `.format`);需运行期占位符的 prompt 不走此路径。 """ from __future__ import annotations import unicodedata from pathlib import Path from typing import Final PROMPTS_DIR: Final = Path(__file__).parent / "prompts" _CACHE: dict[str, str] = {} class PromptNotFoundError(FileNotFoundError): """按 spec.name 找不到对应 `prompts/.md`(import 期 fail-fast)。""" def load_prompt(name: str) -> str: """按 spec.name 读 `prompts/.md` → 规整 → 缓存 → 返回完整 UTF-8 文本。""" cached = _CACHE.get(name) if cached is not None: return cached path = PROMPTS_DIR / f"{name}.md" if not path.is_file(): raise PromptNotFoundError(f"prompt not found for spec name {name!r}: {path}") raw = path.read_text(encoding="utf-8-sig") # 去 BOM normalized = raw.replace("\r\n", "\n").replace("\r", "\n") # 行尾归一 normalized = unicodedata.normalize("NFC", normalized) # Unicode 归一化 text = normalized.rstrip("\n") # 尾换行:吞掉、不补回 _CACHE[name] = text return text # 主写章 craft 教条文件名(灵感①)。它是散文教条、不是结构化产物 spec: # 不入 SPECS、不进 prompt_hashes 金标准、不参与计数断言——但复用同一读盘/规整/缓存路径, # 保证字节稳定(不变量 #9)。 CRAFT_DOCTRINE_NAME: Final = "write_craft" def load_craft_doctrine() -> str: """读 `prompts/write_craft.md`(品类无关写章 craft 内核)→ 规整 → 缓存 → 返回。 与 `load_prompt` 同一读盘/规整/缓存逻辑(DRY),文件缺失同样 fail-fast (`PromptNotFoundError`)。调用方在 import 期取常量即触发读盘。 """ return load_prompt(CRAFT_DOCTRINE_NAME)