From 59c1ea7abe19daee75200e8cef1431f1d53109f1 Mon Sep 17 00:00:00 2001 From: lhl Date: Sun, 9 Aug 2026 08:03:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20PromptRegistry=20=E6=B3=A8=E5=86=8C/?= =?UTF-8?q?=E7=89=88=E6=9C=AC/=E6=B8=B2=E6=9F=93=EF=BC=88jinja2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _AI_USAGE_LOG.md | 1 + src/genesis/inference/prompt_registry.py | 48 ++++++++++++++++++++++++ tests/test_inference_prompt_registry.py | 42 +++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 src/genesis/inference/prompt_registry.py create mode 100644 tests/test_inference_prompt_registry.py diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index d2bddc1..cc02662 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -44,3 +44,4 @@ | 2026-08-09 下午 | Agent 实现 | 里程碑3.1 实施计划生成(writing-plans):5 任务 TDD 计划落盘 docs/superpowers/plans/2026-08-09-inference-engine.md——Task1 types+exceptions+依赖(httpx/jinja2)、Task2 token(approximate/tiktoken可选)、Task3 PromptRegistry(jinja2)、Task4 HttpLLMClient(重试/退避/超时/鉴权)、Task5 InferenceEngine(chat/chat_structured全流程+注入Fake+文档补丁);修正 helper 导入、方法名(_call)、结构化状态语义(重试成功仍ok) | docs/superpowers/plans/2026-08-09-inference-engine.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free | | 2026-08-09 05:53 | Agent 实现 | 里程碑3.1 Task1 实现:推理引擎数据模型与异常层。新建 src/genesis/inference/(__init__/types/exceptions:TokenUsage、ChatMessage、ChatResult、StructuredResult、Prompt 五 dataclass 与 LLMError 体系五异常,__init__ 暂不导入 engine/client 防循环);pyproject.toml 增加 httpx>=0.28/jinja2>=3.1 依赖;tests/test_inference_types.py(6 用例)与 tests/test_inference_errors.py(2 用例);TDD 验证 RED(ModuleNotFoundError: No module named 'genesis.inference')→ GREEN(聚焦 8 passed);pytest 全量 79 passed 覆盖 100.00%(608 stmts/140 br),fail_under=99 达标;提交见 git log | src/genesis/inference/__init__.py, src/genesis/inference/types.py, src/genesis/inference/exceptions.py, pyproject.toml, tests/test_inference_types.py, tests/test_inference_errors.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free | | 2026-08-09 05:57 | Agent 实现 | 里程碑3.1 Task2 实现:推理引擎 token 估算模块。新建 src/genesis/inference/token.py(approximate_token_count 每 4 字符≈1 token 最少 1、_tiktoken_estimator tiktoken 编码未安装返回 None、make_estimator backend="tiktoken" 默认优先 tiktoken 缺失回落 approximate);tests/test_inference_token.py 按 brief 5 用例 + 补充 2 用例(假 tiktoken 成功路径、encoding 抛异常回落)覆盖 tiktoken 成功/异常两分支,保证全量覆盖不回落;TDD 验证 RED(ModuleNotFoundError: No module named 'genesis.inference.token')→ 聚焦 7 passed,token.py 100%;pytest 全量 86 passed 覆盖 100.00%(626 stmts/142 br);提交见 git log | src/genesis/inference/token.py, tests/test_inference_token.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free | +| 2026-08-09 08:02 | Agent 实现 | 里程碑3.1 Task3 实现:Prompt 注册表。新建 src/genesis/inference/prompt_registry.py(PromptRegistry:register/get/list_versions/render,version=None 取最新,jinja2 渲染,name/name@version 缺失抛 KeyError);tests/test_inference_prompt_registry.py 按 brief 5 用例 + 补 1 用例(指定不存在的 version 抛 KeyError,覆盖 get 中 key not in templates 分支);TDD 验证 RED(ModuleNotFoundError: No module named genesis.inference.prompt_registry)→ GREEN(聚焦 6 passed);pytest 全量 92 passed 覆盖 100.00%(653 stmts/150 br),fail_under=99 达标 | src/genesis/inference/prompt_registry.py, tests/test_inference_prompt_registry.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free | diff --git a/src/genesis/inference/prompt_registry.py b/src/genesis/inference/prompt_registry.py new file mode 100644 index 0000000..f1a2d65 --- /dev/null +++ b/src/genesis/inference/prompt_registry.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import Any + +from jinja2 import Template + +from .types import Prompt + + +class PromptRegistry: + """Prompt 模板库:注册/取用/版本管理/渲染(集中管理待迁移 prompts/ 目录)。""" + + def __init__(self) -> None: + self._templates: dict[tuple[str, str], str] = {} + + def register(self, name: str, version: str, template: str) -> None: + """注册(或覆盖)一个版本的模板。""" + self._templates[(name, version)] = template + + def get( + self, + name: str, + version: str | None = None, + variables: dict[str, Any] | None = None, + ) -> str: + """取模板;version=None 返回该 name 最新注册版本;variables 非空时渲染。""" + if version is None: + versions = self.list_versions(name) + if not versions: + raise KeyError(f"prompt not found: {name}") + version = versions[-1] + key = (name, version) + if key not in self._templates: + raise KeyError(f"prompt version not found: {name}@{version}") + template = self._templates[key] + if variables: + return self.render(template, variables) + return template + + def list_versions(self, name: str) -> list[str]: + """返回某 name 的已注册版本(按注册顺序)。""" + return [v for (n, v) in self._templates if n == name] + + def render(self, template: str, variables: dict[str, Any]) -> str: + """用 jinja2 渲染模板。""" + from jinja2 import Template + + return Template(template).render(**variables) \ No newline at end of file diff --git a/tests/test_inference_prompt_registry.py b/tests/test_inference_prompt_registry.py new file mode 100644 index 0000000..d1364b5 --- /dev/null +++ b/tests/test_inference_prompt_registry.py @@ -0,0 +1,42 @@ +import pytest + +from genesis.inference.prompt_registry import PromptRegistry +from genesis.inference.types import Prompt + + +def test_register_and_render(): + reg = PromptRegistry() + reg.register("writer", "v1", "按规则撰写:{{ chapter }}") + assert reg.get("writer", "v1") == "按规则撰写:{{ chapter }}" + + +def test_get_with_variables_renders(): + reg = PromptRegistry() + reg.register("writer", "v1", "按规则撰写:{{ chapter }}") + assert reg.get("writer", "v1", {"chapter": "帳票設計"}) == "按规则撰写:帳票設計" + + +def test_get_latest_version(): + reg = PromptRegistry() + reg.register("writer", "v1", "t1") + reg.register("writer", "v2", "t2") + assert reg.get("writer") == "t2" + assert reg.list_versions("writer") == ["v1", "v2"] + + +def test_get_missing_raises_keyerror(): + reg = PromptRegistry() + with pytest.raises(KeyError): + reg.get("dne") + + +def test_get_missing_version_raises_keyerror(): + reg = PromptRegistry() + reg.register("writer", "v1", "t1") + with pytest.raises(KeyError): + reg.get("writer", "v9") + + +def test_render_raw(): + reg = PromptRegistry() + assert reg.render("{{ a }} と {{ b }}", {"a": "x", "b": 1}) == "x と 1" \ No newline at end of file