"""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