2026-08-04 18:02:00 +08:00
|
|
|
|
# -*- coding: utf-8 -*-
|
2026-08-04 23:15:39 +08:00
|
|
|
|
"""iAOP-Core · LLM 网关 —— Prompt 版本管理(Issue #47 完成交付)。
|
2026-08-04 18:02:00 +08:00
|
|
|
|
|
|
|
|
|
|
对应 PRD 5.4「④ LLM 网关 + RAG」:
|
|
|
|
|
|
- **Prompt 版本管理**:所有提示词模板纳入版本库(semver),变更须评审并记录,
|
|
|
|
|
|
支持一键回滚;运行时绑定模板版本,确保可复现。
|
|
|
|
|
|
|
|
|
|
|
|
本模块实现 `PromptRegistry`:
|
|
|
|
|
|
- 模板资产加载(`config/prompts.template.yaml`):每个提示词有 name / version
|
|
|
|
|
|
(semver)/ text / description;
|
|
|
|
|
|
- **运行时按 (name, version) 绑定**:生产流程显式声明使用的模板版本,
|
|
|
|
|
|
即使模板后续变更,已绑定版本行为不变(可复现);
|
|
|
|
|
|
- **版本历史**:同名的多个版本并存,`promote(name, version)` 设定当前默认版本,
|
|
|
|
|
|
`rollback(name)` 回滚到上一版本(一键回滚);
|
|
|
|
|
|
- **变更审计**:`update()` / `promote()` / `rollback()` 均落结构化变更记录。
|
|
|
|
|
|
|
2026-08-04 23:15:39 +08:00
|
|
|
|
与幻觉/事实性校验中间件(hallucination.py)联动:评测样本携带
|
|
|
|
|
|
`prompt_name` / `prompt_version`,`HallucinationGuard.evaluate(registry=...)`
|
|
|
|
|
|
按 `name@version` 分解事实一致性,Prompt 变更后可用同一评测集对比
|
|
|
|
|
|
各版本幻觉率(配套 CLI:`evaluate_hallucination.py`)。
|
2026-08-04 18:02:00 +08:00
|
|
|
|
|
|
|
|
|
|
测试:`python -m unittest discover -s tests -v`(在 core/llm-gateway 目录下执行)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 轻量 YAML 子集解析(与 dlp.py / router.py 同款,模块内自持保持零耦合)。
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_scalar(text: str) -> str:
|
|
|
|
|
|
t = text.split(" #", 1)[0].strip()
|
|
|
|
|
|
if len(t) >= 2 and t[0] == t[-1] and t[0] in ("'", '"'):
|
|
|
|
|
|
return t[1:-1]
|
|
|
|
|
|
return t
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _strip_comments(lines: List[str]) -> List[Tuple[str, int]]:
|
|
|
|
|
|
out = []
|
|
|
|
|
|
for i, ln in enumerate(lines):
|
|
|
|
|
|
s = ln.strip()
|
|
|
|
|
|
if not s or s.startswith("#"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
out.append((ln, i + 1))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_node(lines: List[Tuple[str, int]], i: int, indent: int):
|
|
|
|
|
|
text, no = lines[i]
|
|
|
|
|
|
if text.lstrip(" ").startswith("- "):
|
|
|
|
|
|
items: List[object] = []
|
|
|
|
|
|
while i < len(lines):
|
|
|
|
|
|
t, no2 = lines[i]
|
|
|
|
|
|
stripped = t.lstrip(" ")
|
|
|
|
|
|
if not stripped.startswith("- "):
|
|
|
|
|
|
break
|
|
|
|
|
|
lead_j = len(t) - len(t.lstrip(" "))
|
|
|
|
|
|
if lead_j != indent:
|
|
|
|
|
|
break
|
|
|
|
|
|
item_text = stripped[2:].strip()
|
|
|
|
|
|
if not item_text:
|
|
|
|
|
|
raise ValueError(f"prompts.yaml 第 {no2} 行:list 项为空")
|
|
|
|
|
|
if ":" in item_text:
|
|
|
|
|
|
map_indent = len(t) - len(t.lstrip(" ")) + 2
|
|
|
|
|
|
lines[i] = (" " * map_indent + item_text, no2)
|
|
|
|
|
|
v, i = _parse_node(lines, i, map_indent)
|
|
|
|
|
|
items.append(v)
|
|
|
|
|
|
else:
|
|
|
|
|
|
items.append(_parse_scalar(item_text))
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
return items, i
|
|
|
|
|
|
|
|
|
|
|
|
result: Dict[str, object] = {}
|
|
|
|
|
|
while i < len(lines):
|
|
|
|
|
|
t, no = lines[i]
|
|
|
|
|
|
lead_j = len(t) - len(t.lstrip(" "))
|
|
|
|
|
|
if lead_j < indent or t.lstrip(" ").startswith("- "):
|
|
|
|
|
|
break
|
|
|
|
|
|
if lead_j > indent:
|
|
|
|
|
|
raise ValueError(f"prompts.yaml 第 {no} 行缩进异常(期望 {indent},实际 {lead_j})")
|
|
|
|
|
|
if ":" not in t:
|
|
|
|
|
|
raise ValueError(f"prompts.yaml 第 {no} 行不是合法键值对:{t!r}")
|
|
|
|
|
|
key, _, rest = t.partition(":")
|
|
|
|
|
|
key = key.strip()
|
|
|
|
|
|
rest = rest.strip()
|
|
|
|
|
|
if rest:
|
|
|
|
|
|
result[key] = _parse_scalar(rest)
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
if i + 1 >= len(lines):
|
|
|
|
|
|
raise ValueError(f"prompts.yaml 第 {no} 行 {key!r} 缺少值")
|
|
|
|
|
|
sub_indent = len(lines[i + 1][0]) - len(lines[i + 1][0].lstrip(" "))
|
|
|
|
|
|
if sub_indent <= indent:
|
|
|
|
|
|
raise ValueError(f"prompts.yaml 第 {no} 行 {key!r} 缺少值(无嵌套内容)")
|
|
|
|
|
|
v, i = _parse_node(lines, i + 1, sub_indent)
|
|
|
|
|
|
result[key] = v
|
|
|
|
|
|
return result, i
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_yaml_text(text: str) -> Dict[str, object]:
|
|
|
|
|
|
lines = _strip_comments(text.splitlines())
|
|
|
|
|
|
if not lines:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
top_indent = len(lines[0][0]) - len(lines[0][0].lstrip(" "))
|
|
|
|
|
|
value, next_i = _parse_node(lines, 0, top_indent)
|
|
|
|
|
|
if not isinstance(value, dict):
|
|
|
|
|
|
raise ValueError("prompts.yaml 顶层必须是 map")
|
|
|
|
|
|
if next_i < len(lines):
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"prompts.yaml 第 {lines[next_i][1]} 行:顶层存在多个节点(缩进不一致)"
|
|
|
|
|
|
)
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 版本模型
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
_SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_semver(version: str) -> bool:
|
|
|
|
|
|
"""校验 semver 主.次.补丁格式(不含预发布后缀,够用且严格)。"""
|
|
|
|
|
|
return bool(_SEMVER_RE.match(version))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cmp_semver(a: str, b: str) -> int:
|
|
|
|
|
|
"""按 semver 比较:a < b 返回负数,相等 0,a > b 正数。"""
|
|
|
|
|
|
pa, pb = (tuple(int(x) for x in v.split(".")) for v in (a, b))
|
|
|
|
|
|
return (pa > pb) - (pa < pb)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class PromptVersion:
|
|
|
|
|
|
"""一个不可变的 Prompt 模板版本。"""
|
|
|
|
|
|
|
|
|
|
|
|
name: str
|
|
|
|
|
|
version: str
|
|
|
|
|
|
text: str
|
|
|
|
|
|
description: str = ""
|
|
|
|
|
|
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
|
|
|
|
|
|
|
|
|
|
def render(self, **kwargs: object) -> str:
|
|
|
|
|
|
"""用 `{key}` 占位符渲染模板(缺参保持原样,供调用方校验)。"""
|
|
|
|
|
|
return self.text.format(**kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, object]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"name": self.name,
|
|
|
|
|
|
"version": self.version,
|
|
|
|
|
|
"text": self.text,
|
|
|
|
|
|
"description": self.description,
|
|
|
|
|
|
"created_at": self.created_at,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class PromptChange:
|
|
|
|
|
|
"""一次模板变更/晋升/回滚的审计记录。"""
|
|
|
|
|
|
|
|
|
|
|
|
name: str
|
|
|
|
|
|
action: str # add / update / promote / rollback
|
|
|
|
|
|
version: str
|
|
|
|
|
|
previous_version: Optional[str] = None
|
|
|
|
|
|
change_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
|
|
|
|
|
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, object]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"change_id": self.change_id,
|
|
|
|
|
|
"created_at": self.created_at,
|
|
|
|
|
|
"name": self.name,
|
|
|
|
|
|
"action": self.action,
|
|
|
|
|
|
"version": self.version,
|
|
|
|
|
|
"previous_version": self.previous_version,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Prompt 版本库
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PromptRegistry:
|
|
|
|
|
|
"""提示词模板版本库:多版本并存、当前默认版本、一键回滚、变更审计。
|
|
|
|
|
|
|
|
|
|
|
|
- `current(name)`:返回当前默认版本(最新 promote 的版本);
|
|
|
|
|
|
- `get(name, version=None)`:运行时绑定指定版本(可复现);
|
|
|
|
|
|
- `update(name, text, version, ...)`:登记新版本(同版本号覆盖报错,
|
|
|
|
|
|
防止无评审覆盖——变更须评审并记录,PRD 5.4);
|
|
|
|
|
|
- `promote(name, version)`:设定当前默认版本;
|
|
|
|
|
|
- `rollback(name)`:回滚到 promote 前的版本(一键回滚)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
|
self._versions: Dict[str, List[PromptVersion]] = {} # name -> 版本列表(升序)
|
|
|
|
|
|
self._current: Dict[str, str] = {} # name -> 当前默认版本
|
|
|
|
|
|
self._history: Dict[str, List[str]] = {} # name -> 默认版本历史
|
|
|
|
|
|
self._audit: List[Dict[str, object]] = []
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_template_config(cls, path: str) -> "PromptRegistry":
|
|
|
|
|
|
"""从模板资产加载(`config/prompts.template.yaml`)。"""
|
|
|
|
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|
|
|
|
|
raw = _load_yaml_text(fh.read())
|
|
|
|
|
|
registry = cls()
|
|
|
|
|
|
for m in raw.get("templates", []):
|
|
|
|
|
|
if not isinstance(m, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
name = str(m.get("name", ""))
|
|
|
|
|
|
version = str(m.get("version", ""))
|
|
|
|
|
|
text = str(m.get("text", ""))
|
|
|
|
|
|
if not name or not version or not text:
|
|
|
|
|
|
raise ValueError(f"prompts.yaml 模板缺少 name/version/text:{m!r}")
|
|
|
|
|
|
if not validate_semver(version):
|
|
|
|
|
|
raise ValueError(f"prompts.yaml 模板 {name} 版本非法(须 semver):{version!r}")
|
|
|
|
|
|
registry.update(name, text, version,
|
|
|
|
|
|
description=str(m.get("description", "")))
|
|
|
|
|
|
if str(m.get("current", "false")).lower() == "true":
|
|
|
|
|
|
registry.promote(name, version)
|
|
|
|
|
|
return registry
|
|
|
|
|
|
|
|
|
|
|
|
# -- 版本登记 ----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def update(self, name: str, text: str, version: str,
|
|
|
|
|
|
description: str = "") -> PromptVersion:
|
|
|
|
|
|
"""登记(或覆盖同版本)一个模板版本。变更须显式记录(审计)。"""
|
|
|
|
|
|
if not validate_semver(version):
|
|
|
|
|
|
raise ValueError(f"版本非法(须 semver):{version!r}")
|
|
|
|
|
|
existing = self._versions.setdefault(name, [])
|
|
|
|
|
|
for pv in existing:
|
|
|
|
|
|
if pv.version == version:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"模板 {name}@{version} 已存在,不允许无评审覆盖(PRD 5.4 变更须评审)"
|
|
|
|
|
|
)
|
|
|
|
|
|
pv = PromptVersion(name=name, version=version, text=text, description=description)
|
|
|
|
|
|
existing.append(pv)
|
|
|
|
|
|
existing.sort(key=lambda v: tuple(int(x) for x in v.version.split(".")))
|
|
|
|
|
|
if name not in self._current:
|
|
|
|
|
|
self._current[name] = version
|
|
|
|
|
|
self._history[name] = [version]
|
|
|
|
|
|
self._audit.append(PromptChange(
|
|
|
|
|
|
name=name, action="add", version=version,
|
|
|
|
|
|
).to_dict())
|
|
|
|
|
|
return pv
|
|
|
|
|
|
|
|
|
|
|
|
# -- 读取 / 绑定 ------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def get(self, name: str, version: Optional[str] = None) -> PromptVersion:
|
|
|
|
|
|
"""运行时绑定:未指定版本时返回当前默认版本(可复现:显式传版本)。"""
|
|
|
|
|
|
ver = version or self._current.get(name)
|
|
|
|
|
|
if ver is None:
|
|
|
|
|
|
raise KeyError(f"模板不存在:{name}")
|
|
|
|
|
|
for pv in self._versions.get(name, []):
|
|
|
|
|
|
if pv.version == ver:
|
|
|
|
|
|
return pv
|
|
|
|
|
|
raise KeyError(f"模板 {name}@{ver} 不存在")
|
|
|
|
|
|
|
|
|
|
|
|
def current(self, name: str) -> PromptVersion:
|
|
|
|
|
|
"""返回当前默认版本(不存在则 KeyError)。"""
|
|
|
|
|
|
return self.get(name)
|
|
|
|
|
|
|
|
|
|
|
|
def versions(self, name: str) -> List[str]:
|
|
|
|
|
|
"""该模板的全部可用版本(升序)。"""
|
|
|
|
|
|
return [pv.version for pv in self._versions.get(name, [])]
|
|
|
|
|
|
|
|
|
|
|
|
# -- 晋升 / 回滚 ------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def promote(self, name: str, version: str) -> str:
|
|
|
|
|
|
"""设定当前默认版本。返回生效的版本号。"""
|
|
|
|
|
|
if not any(pv.version == version for pv in self._versions.get(name, [])):
|
|
|
|
|
|
raise KeyError(f"模板 {name}@{version} 不存在,无法晋升")
|
|
|
|
|
|
previous = self._current.get(name)
|
|
|
|
|
|
self._current[name] = version
|
|
|
|
|
|
self._history.setdefault(name, []).append(version)
|
|
|
|
|
|
self._audit.append(PromptChange(
|
|
|
|
|
|
name=name, action="promote", version=version,
|
|
|
|
|
|
previous_version=previous,
|
|
|
|
|
|
).to_dict())
|
|
|
|
|
|
return version
|
|
|
|
|
|
|
|
|
|
|
|
def rollback(self, name: str) -> Optional[str]:
|
|
|
|
|
|
"""一键回滚到 promote 前的默认版本;无历史则返回 None。"""
|
|
|
|
|
|
hist = self._history.get(name, [])
|
|
|
|
|
|
if len(hist) < 2:
|
|
|
|
|
|
return None
|
|
|
|
|
|
previous = hist[-2]
|
|
|
|
|
|
self._current[name] = previous
|
|
|
|
|
|
hist.append(previous)
|
|
|
|
|
|
self._audit.append(PromptChange(
|
|
|
|
|
|
name=name, action="rollback", version=previous,
|
|
|
|
|
|
).to_dict())
|
|
|
|
|
|
return previous
|
|
|
|
|
|
|
|
|
|
|
|
# -- 审计 / 只读 ------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def drain_audit(self) -> List[Dict[str, object]]:
|
|
|
|
|
|
out, self._audit = self._audit, []
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def template_names(self) -> List[str]:
|
|
|
|
|
|
return sorted(self._versions.keys())
|
|
|
|
|
|
|
|
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - 调试辅助
|
|
|
|
|
|
return f"<PromptRegistry templates={self.template_names}>"
|