From 8bc5e5e571a995f4fe65fe1e9455c11185f47a86 Mon Sep 17 00:00:00 2001 From: lhl Date: Sun, 9 Aug 2026 08:26:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20InferenceEngine=20chat/chat=5Fstructure?= =?UTF-8?q?d=20=E5=85=A8=E6=B5=81=E7=A8=8B=20+=20=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E8=A1=A5=E4=B8=81=EF=BC=88=E8=A1=A5=E4=B8=811/2/3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _AI_USAGE_LOG.md | 1 + docs/agent-runtime-design.md | 2 + docs/api-design.md | 25 +-- docs/config-design.md | 2 +- src/genesis/inference/__init__.py | 4 + src/genesis/inference/engine.py | 185 +++++++++++++++++++++ tests/inference_helpers.py | 27 +++ tests/test_inference_engine.py | 262 ++++++++++++++++++++++++++++++ 8 files changed, 495 insertions(+), 13 deletions(-) create mode 100644 src/genesis/inference/engine.py create mode 100644 tests/inference_helpers.py create mode 100644 tests/test_inference_engine.py diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index b60c4a9..a871921 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -46,3 +46,4 @@ | 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 | | 2026-08-09 09:40 | Agent 实现 | 里程碑3.1 Task4 实现:LLM 客户端。新建 src/genesis/inference/client.py(LLMClient Protocol + HttpLLMClient:api_key 空抛 LLMNotConfiguredError,POST {base_url}/v1/chat/completions Bearer 鉴权,指数退避重试 5xx/网络错误(attempts=1+len(retry_backoff)),超时抛 LLMTimeoutError,重试耗尽/4xx 抛 LLMNetworkError);tests/test_inference_client.py 按 brief 6 用例(结构协议兼容/成功/无key/超时/5xx重试耗尽/4xx不重试)+ 补 1 用例(非超时 ConnectError 走 httpx.HTTPError 分支覆盖剩余分支);TDD 验证 RED(ModuleNotFoundError: No module named 'genesis.inference.client')→ GREEN(聚焦 7 passed);pytest 全量 99 passed 覆盖 100.00%(699 stmts/162 br),fail_under=99 达标;提交见 git log | src/genesis/inference/client.py, tests/test_inference_client.py, _AI_USAGE_LOG.md | deepseek-v4-flash-free | +| 2026-08-09 08:26 | Agent 实现 | 里程碑3.1 Task5 实现:InferenceEngine 编排器。新建 src/genesis/inference/engine.py(InferenceEngine:_render_prompt/_apply_truncation/_model_names(primary→fallback,无配置回退 deepseek-chat)/chat(渲染→token 超限回调→主模型→失败备用→status ok/fallback/failed)/chat_structured(schema 提示→逐次解析≤retry_count+1→parse_error+raw_text));tests/inference_helpers.py(FakeLLMClient 校验调)与 tests/test_inference_engine.py 按 brief 10 用例 + 补 5 用例(无 fallback、空 models、默认 registry/estimator、显式 model、truncate_cb 返回 None);2 处 brief 测试数值修正(truncate 内 max_context_tokens=3→2 因渲染 11 字≈3 token 不超限、fallback 断言 calls[-1]→calls[0] 因最后调用为备用模型);__init__.py 补导出 InferenceEngine/PromptRegistry;文档补丁 3 处(agent-runtime §2.2 StructuredResult.status、api-design §7 错误码表 LLM_NOT_CONFIGURED 行并扩表加来源列、config-design §4 token_estimation 注明 tiktoken 缺失回落 approximate);TDD 验证 RED(ModuleNotFoundError: No module named 'genesis.inference.engine')→ GREEN(聚焦 16 passed);pytest 全量 115 passed 覆盖 100.00%(779 stmts/184 br),fail_under=99 达标;提交见 git log | src/genesis/inference/engine.py, src/genesis/inference/__init__.py, tests/inference_helpers.py, tests/test_inference_engine.py, docs/agent-runtime-design.md, docs/api-design.md, docs/config-design.md, _AI_USAGE_LOG.md | deepseek-v4-flash-free | diff --git a/docs/agent-runtime-design.md b/docs/agent-runtime-design.md index 60b7698..a4fd9a8 100644 --- a/docs/agent-runtime-design.md +++ b/docs/agent-runtime-design.md @@ -111,6 +111,8 @@ class StructuredResult: prompt_version: str usage: TokenUsage duration_ms: int + status: Literal["ok", "fallback", "parse_error", "failed"] # 补丁 1:诊断状态 + error: str | None = None # 失败原因(parse_error/failed 时附带) ``` ### 2.3 模型管理 diff --git a/docs/api-design.md b/docs/api-design.md index 0d0965c..336c0d5 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -296,18 +296,19 @@ docker-compose.yml(扩展): ## 7. 错误码约定 -| 错误码 | HTTP | 含义 | 用户选项 | -|--------|------|------|---------| -| `FILE_TYPE_INVALID` | 400 | 文件类型不支持 | 更换文件 | -| `FILE_TOO_LARGE` | 400 | 超过 100MB 限制 | 压缩/分割 | -| `STATE_TRANSITION_INVALID` | 409 | 非法状态转移(如 uploading 直接 generate)| 提示正确流程 | -| `LLM_TIMEOUT` | 502 | LLM 调用超时 | retry / skip / abort | -| `LLM_PARSE_ERROR` | 502 | 结构化输出解析失败 | retry | -| `EMBEDDING_FAILED` | 503 | Embedding 服务故障(降级为 BM25)| 继续(降级提示)| -| `RULES_HANDBOOK_MISSING` | 404 | 规则手册不存在 | 上传规则文档 | -| `CHAPTER_NOT_FOUND` | 404 | 章节不存在 | — | -| `CONFLICT_PENDING` | 409 | 规则冲突待用户决策 | 决策后继续 | -| `INTERNAL_ERROR` | 500 | 未知错误 | 重试/联系支持 | +| 错误码 | HTTP | 含义 | 用户选项 | 来源 | +|--------|------|------|---------|------| +| `FILE_TYPE_INVALID` | 400 | 文件类型不支持 | 更换文件 | — | +| `FILE_TOO_LARGE` | 400 | 超过 100MB 限制 | 压缩/分割 | — | +| `STATE_TRANSITION_INVALID` | 409 | 非法状态转移(如 uploading 直接 generate)| 提示正确流程 | — | +| `LLM_TIMEOUT` | 502 | LLM 调用超时 | retry / skip / abort | exceptions.LLMTimeoutError | +| `LLM_NOT_CONFIGURED` | 503 | LLM Key 未配置 | 配置 Key | exceptions.LLMNotConfiguredError | +| `LLM_PARSE_ERROR` | 502 | 结构化输出解析失败 | retry | exceptions.LLMResponseError | +| `EMBEDDING_FAILED` | 503 | Embedding 服务故障(降级为 BM25)| 继续(降级提示)| — | +| `RULES_HANDBOOK_MISSING` | 404 | 规则手册不存在 | 上传规则文档 | — | +| `CHAPTER_NOT_FOUND` | 404 | 章节不存在 | — | — | +| `CONFLICT_PENDING` | 409 | 规则冲突待用户决策 | 决策后继续 | — | +| `INTERNAL_ERROR` | 500 | 未知错误 | 重试/联系支持 | — | --- diff --git a/docs/config-design.md b/docs/config-design.md index 2d532d0..8c42359 100644 --- a/docs/config-design.md +++ b/docs/config-design.md @@ -133,7 +133,7 @@ models: timeout_sec: 90 llm_calls: - token_estimation: tiktoken # tiktoken | approximate + token_estimation: tiktoken # tiktoken | approximate;tiktoken 缺失自动回落 approximate(内置估算器) max_context_tokens: 32000 # 上下文窗口上限 truncation_policy: # 超限裁剪策略(runtime §2.6) priority: diff --git a/src/genesis/inference/__init__.py b/src/genesis/inference/__init__.py index c191f84..17fc300 100644 --- a/src/genesis/inference/__init__.py +++ b/src/genesis/inference/__init__.py @@ -1,5 +1,7 @@ """Genesis 推理引擎(统一 LLM 调用入口)。""" +from .engine import InferenceEngine +from .prompt_registry import PromptRegistry from .types import ChatMessage, ChatResult, Prompt, StructuredResult, TokenUsage from .exceptions import ( LLMError, @@ -10,6 +12,8 @@ from .exceptions import ( ) __all__ = [ + "InferenceEngine", + "PromptRegistry", "ChatMessage", "ChatResult", "Prompt", diff --git a/src/genesis/inference/engine.py b/src/genesis/inference/engine.py new file mode 100644 index 0000000..dd91c02 --- /dev/null +++ b/src/genesis/inference/engine.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import json +import time +from typing import Any, Callable + +from .client import LLMClient +from .exceptions import LLMError +from .prompt_registry import PromptRegistry +from .token import make_estimator +from .types import ( + ChatMessage, + ChatResult, + Prompt, + StructuredResult, + TokenUsage, +) + + +class InferenceEngine: + """统一 LLM 调用入口:模型选择/降级、重试、解析、Token 超限回调。""" + + def __init__( + self, + *, + client: LLMClient, + models: Any | None = None, + registry: PromptRegistry | None = None, + estimator: Callable[[str], int] | None = None, + truncate_cb: Callable[[str, dict], dict] | None = None, + max_context_tokens: int = 32000, + ) -> None: + self._client = client + self._models = models + self._registry = registry or PromptRegistry() + self._estimator = estimator or make_estimator() + self._truncate_cb = truncate_cb + self._max_context_tokens = max_context_tokens + + # ---------- 内部 ---------- + + def _render_prompt(self, prompt: Prompt | str, variables: dict) -> str: + if isinstance(prompt, Prompt): + return self._registry.render(prompt.template, variables) if variables else prompt.template + return prompt + + def _apply_truncation(self, text: str, variables: dict) -> dict: + """Token 超限时触发裁剪回调(注入),返回新 variables。""" + if self._truncate_cb is not None: + new_vars = self._truncate_cb(text, variables) + if new_vars is not None: + return new_vars + return variables + + def _model_names(self, model: str | None) -> list[str]: + """返回尝试顺序;显式指定 model 时只用它,否则 primary→fallback。""" + if model: + return [model] + if self._models: + names = [] + if getattr(self._models, "primary", None): + names.append(self._models.primary.name) + if getattr(self._models, "fallback", None): + names.append(self._models.fallback.name) + if names: + return names + return ["deepseek-chat"] + + def _call( + self, + *, + model: str, + rendered: str, + temperature: float, + max_tokens: int, + ) -> tuple[str, TokenUsage]: + messages = [ChatMessage(role="user", content=rendered)] + return self._client.chat( + model=model, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + ) + + # ---------- 公开 ---------- + + def chat( + self, + *, + session_id: str, + prompt: Prompt | str, + variables: dict, + model: str | None = None, + temperature: float = 0.2, + max_tokens: int = 4096, + ) -> ChatResult: + rendered = self._render_prompt(prompt, variables) + if self._estimator(rendered) > self._max_context_tokens: + variables = self._apply_truncation(rendered, variables) + rendered = self._render_prompt(prompt, variables) + + start = time.monotonic() + last_error: str | None = None + for idx, name in enumerate(self._model_names(model)): + try: + text, usage = self._call( + model=name, rendered=rendered, + temperature=temperature, max_tokens=max_tokens, + ) + status = "ok" if idx == 0 else "fallback" + return ChatResult( + text=text, model=name, prompt_version=getattr(prompt, "version", "inline"), + usage=usage, duration_ms=int((time.monotonic() - start) * 1000), + status=status, + ) + except LLMError as exc: + last_error = str(exc) + + return ChatResult( + text="", model=name, + prompt_version=getattr(prompt, "version", "inline"), + usage=TokenUsage(), duration_ms=int((time.monotonic() - start) * 1000), + status="failed", error=last_error, + ) + + def chat_structured( + self, + *, + session_id: str, + prompt: Prompt | str, + variables: dict, + schema: dict, + retry_count: int = 2, + ) -> StructuredResult: + rendered = self._render_prompt(prompt, variables) + # 追加 schema 约束说明(不强制模板支持) + schema_hint = json.dumps(schema, ensure_ascii=False) if schema else "" + base_rendered = rendered + (f'\n\n请输出符合以下 JSON Schema 的 JSON:{schema_hint}' if schema_hint else "") + + start = time.monotonic() + attempts = 0 + last_raw = "" + last_error: str | None = None + + while attempts <= retry_count: + attempts += 1 + try: + text, usage = self._call( + model=self._model_names(None)[0], # 解析重试用首选模型 + rendered=base_rendered, + temperature=0.0, max_tokens=4096, + ) + last_raw = text + data = json.loads(text) + return StructuredResult( + data=data, raw_text=text, parse_attempts=attempts, + model=self._model_names(None)[0], + prompt_version=getattr(prompt, "version", "inline"), + usage=usage, + duration_ms=int((time.monotonic() - start) * 1000), + # fallback 语义保留给模型降级;解析重试成功仍为 ok + status="ok", + ) + except json.JSONDecodeError as exc: + last_error = f"JSON 解析失败: {exc}" + # 带错误信息重试 + base_rendered = base_rendered + f"\n\n上次解析失败:{exc}。请重新输出合法 JSON。" + except LLMError as exc: + return StructuredResult( + data={}, raw_text="", parse_attempts=attempts, + model=self._model_names(None)[0], + prompt_version=getattr(prompt, "version", "inline"), + usage=TokenUsage(), + duration_ms=int((time.monotonic() - start) * 1000), + status="failed", error=str(exc), + ) + + return StructuredResult( + data={}, raw_text=last_raw, parse_attempts=attempts, + model=self._model_names(None)[0], + prompt_version=getattr(prompt, "version", "inline"), + usage=TokenUsage(), + duration_ms=int((time.monotonic() - start) * 1000), + status="parse_error", error=last_error, + ) \ No newline at end of file diff --git a/tests/inference_helpers.py b/tests/inference_helpers.py new file mode 100644 index 0000000..3c50107 --- /dev/null +++ b/tests/inference_helpers.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from genesis.inference.types import TokenUsage + + +class FakeLLMClient: + """可编程的假 LLM 客户端:记录调用,按脚本返回(离线)。""" + + def __init__(self, script=None): + # script: list[(status, content)];status: "ok" | "raise_timeout" | "raise_network" | "parse_fail" + self.script = script or [("ok", "hello")] + self.calls: list[dict] = [] + + def chat(self, *, model, messages, temperature, max_tokens): + self.calls.append({"model": model, "messages": [m.content for m in messages]}) + status, content = self.script.pop(0) + if status == "raise_timeout": + from genesis.inference.exceptions import LLMTimeoutError + + raise LLMTimeoutError("timeout") + if status == "raise_network": + from genesis.inference.exceptions import LLMNetworkError + + raise LLMNetworkError("network") + if status == "parse_fail": + content = "NOT JSON" + return content, TokenUsage(input_tokens=10, output_tokens=2) \ No newline at end of file diff --git a/tests/test_inference_engine.py b/tests/test_inference_engine.py new file mode 100644 index 0000000..3d07704 --- /dev/null +++ b/tests/test_inference_engine.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import json + +import pytest + +from genesis.inference.engine import InferenceEngine +from genesis.inference.exceptions import LLMError +from genesis.inference.prompt_registry import PromptRegistry +from genesis.inference.token import approximate_token_count +from genesis.inference.types import ChatMessage, Prompt +from tests.inference_helpers import FakeLLMClient + + +class Models: + """模拟 config.InferenceModels(pydantic 结构,测试中直接构建)""" + def __init__(self): + import types as t + self.primary = t.SimpleNamespace(name="deepseek-chat", provider="deepseek") + self.fallback = t.SimpleNamespace(name="qwen-max", provider="qwen") + + +class ModelsNoFallback: + """无 fallback 的模型配置(覆盖 getattr(fallback) 假值分支)""" + def __init__(self): + import types as t + self.primary = t.SimpleNamespace(name="deepseek-chat", provider="deepseek") + self.fallback = None + + +class ModelsEmpty: + """主/备用模型均为空(覆盖 names 为空 → 返回内置默认)""" + def __init__(self): + self.primary = None + self.fallback = None + + +def make_engine(client=None, *, constants=None): + eng = InferenceEngine( + client=client or FakeLLMClient(), + models=Models(), + registry=PromptRegistry(), + estimator=approximate_token_count, + ) + if constants: + eng._max_context_tokens = constants # 内部测试钩子 + return eng + + +# ---------- chat 主路径 ---------- + +def test_chat_ok(): + client = FakeLLMClient([("ok", "正文")]) + eng = make_engine(client) + r = eng.chat( + session_id="s1", + prompt=Prompt(name="writer", version="v1", template="章节:{{ chapter }}"), + variables={"chapter": "DB設計"}, + ) + assert r.status == "ok" and r.text == "正文" + assert client.calls[0]["model"] == "deepseek-chat" + + +def test_chat_fallback_after_primary_failure(): + client = FakeLLMClient([("raise_timeout", ""), ("ok", "备用输出")]) + eng = make_engine(client) + r = eng.chat( + session_id="s1", + prompt=Prompt(name="p", version="v1", template="t:{{ x }}"), + variables={"x": "1"}, + ) + assert r.status == "fallback" + # 先主模型,后备模型(修正:断言首次调用为主模型) + assert client.calls[0]["model"] == "deepseek-chat" + + +def test_chat_all_failed_returns_failed(): + client = FakeLLMClient([("raise_network", ""), ("raise_network", "")]) + eng = make_engine(client) + r = eng.chat( + session_id="s1", + prompt=Prompt(name="p", version="v1", template="t"), + variables={}, + ) + assert r.status == "failed" and r.error + + +def test_chat_plain_string_prompt(): + eng = make_engine(FakeLLMClient([("ok", "hi")])) + r = eng.chat(session_id="s1", prompt="直接文本", variables={}) + assert r.text == "hi" and r.status == "ok" + + +def test_chat_truncation_callback_triggered(): + seen = {} + + def truncate_cb(prompt_text, variables): + seen["called"] = True + seen["len"] = len(prompt_text) + return {**variables, "chapter": "裁剪版"} + + eng = InferenceEngine( + client=FakeLLMClient([("ok", "x")]), + models=Models(), + registry=PromptRegistry(), + estimator=approximate_token_count, + truncate_cb=truncate_cb, + ) + eng._max_context_tokens = 2 # 强制超限(复习:'abcd很长很长的标题' 约 3 token > 2) + r = eng.chat( + session_id="s1", + prompt=Prompt(name="p", version="v1", template="abcd{{ chapter }}"), + variables={"chapter": "很长很长的标题"}, + ) + assert seen["called"] is True + assert r.status == "ok" + + +# ---------- chat_structured 主路径 ---------- + +def test_chat_structured_ok(): + client = FakeLLMClient([("ok", '{"a": 1}')]) + eng = make_engine(client) + r = eng.chat_structured( + session_id="s1", prompt=Prompt(name="p", version="v1", template="提取"), + variables={"text": "内容"}, + schema={"type": "object", "properties": {"a": {"type": "number"}}}, + ) + assert r.status == "ok" and r.data == {"a": 1} + + +def test_chat_structured_retry_parse(): + client = FakeLLMClient([("parse_fail", ""), ("ok", '{"a": 2}')]) + eng = make_engine(client) + r = eng.chat_structured( + session_id="s1", prompt=Prompt(name="p", version="v1", template="提取"), + variables={}, schema={}, + ) + assert r.status == "ok" and r.data == {"a": 2} and r.parse_attempts == 2 + + +def test_chat_structured_parse_error_returns_raw(): + client = FakeLLMClient([("parse_fail", ""), ("parse_fail", "")]) + eng = make_engine(client) + r = eng.chat_structured( + session_id="s1", prompt=Prompt(name="p", version="v1", template="提取"), + variables={}, schema={}, retry_count=1, + ) + assert r.status == "parse_error" + assert r.raw_text == "NOT JSON" + assert r.parse_attempts == 2 + + +def test_chat_structured_failed_on_network(): + client = FakeLLMClient([("raise_network", "")]) + eng = make_engine(client) + r = eng.chat_structured( + session_id="s1", prompt=Prompt(name="p", version="v1", template="提取"), + variables={}, schema={}, + ) + assert r.status == "failed" + + +# ---------- 补充分支覆盖(defensive / 缺失配置) ---------- + +def test_chat_fallback_all_failed_when_only_primary(): + """models 无 fallback:仅尝试主模型,失败后直接 failed。""" + client = FakeLLMClient([("raise_network", "")]) + eng = InferenceEngine( + client=client, + models=ModelsNoFallback(), + registry=PromptRegistry(), + estimator=approximate_token_count, + ) + r = eng.chat(session_id="s1", prompt="t", variables={}) + assert r.status == "failed" + assert len(client.calls) == 1 + assert client.calls[0]["model"] == "deepseek-chat" + + +def test_chat_empty_models_uses_default(): + """主/备用均为空配置时回退内置默认模型 deepseek-chat。""" + eng = InferenceEngine( + client=FakeLLMClient([("ok", "x")]), + models=ModelsEmpty(), + registry=PromptRegistry(), + estimator=approximate_token_count, + ) + r = eng.chat(session_id="s1", prompt="t", variables={}) + assert r.status == "ok" + assert eng._model_names(None) == ["deepseek-chat"] + + +def test_chat_defaults_without_registry_estimator(): + """registry/estimator/models 均未配置时使用内置默认(PromptRegistry + approximate + deepseek-chat)。""" + client = FakeLLMClient([("ok", "默认")]) + eng = InferenceEngine(client=client, models=None) + r = eng.chat( + session_id="s1", + prompt=Prompt(name="p", version="v1", template="tt"), + variables={}, + ) + assert r.status == "ok" and r.text == "默认" + assert eng._model_names(None) == ["deepseek-chat"] + + +def test_chat_explicit_model_param(): + """显式指定 model,跳过主/备选择,仅调用该模型。""" + client = FakeLLMClient([("ok", "c")]) + eng = make_engine(client) + r = eng.chat(session_id="s1", prompt="t", variables={}, model="qwen-custom") + assert r.status == "ok" + assert client.calls[0]["model"] == "qwen-custom" + assert len(client.calls) == 1 + + +def test_chat_truncation_without_callback_keeps_variables(): + """超限但无 truncate_cb,variables 原样保留(裁剪分支不触发)。""" + client = FakeLLMClient([("ok", "ok")]) + eng = make_engine(client) + eng._max_context_tokens = 2 # 强制超限(渲染后约 11 token) + r = eng.chat( + session_id="s1", + prompt=Prompt(name="p", version="v1", template="abcd{{ chapter }}"), + variables={"chapter": "很长很长的标题"}, + ) + assert r.status == "ok" + assert client.calls[0]["model"] == "deepseek-chat" + + +def test_chat_structured_empty_schema_no_hint(): + """schema 为空时不追加 schema 提示,仍可正常解析。""" + client = FakeLLMClient([("ok", '{"v": true}')]) + eng = make_engine(client) + r = eng.chat_structured( + session_id="s1", prompt="直接文本", variables={}, schema={}, + ) + assert r.status == "ok" and r.data == {"v": True} + + +def test_chat_truncation_callback_returns_none_keeps_variables(): + """truncate_cb 返回 None 时回退原 variables(覆盖 new_vars is None 分支)。""" + def truncate_cb(prompt_text, variables): + return None + + client = FakeLLMClient([("ok", "x")]) + eng = InferenceEngine( + client=client, + models=Models(), + registry=PromptRegistry(), + estimator=approximate_token_count, + truncate_cb=truncate_cb, + ) + eng._max_context_tokens = 2 + r = eng.chat( + session_id="s1", + prompt=Prompt(name="p", version="v1", template="abcd{{ chapter }}"), + variables={"chapter": "很长很长的标题"}, + ) + assert r.status == "ok" + # 未替换变量:发送内容仍为原样渲染 + assert "很长很长的标题" in client.calls[0]["messages"][0] \ No newline at end of file