diff --git a/pyproject.toml b/pyproject.toml index 34480b3..5b9bc5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ dependencies = [ "pyyaml>=6.0", "openpyxl>=3.1", "python-docx>=1.0", + "httpx>=0.28", + "jinja2>=3.1", ] [project.optional-dependencies] diff --git a/src/genesis/inference/__init__.py b/src/genesis/inference/__init__.py new file mode 100644 index 0000000..c191f84 --- /dev/null +++ b/src/genesis/inference/__init__.py @@ -0,0 +1,23 @@ +"""Genesis 推理引擎(统一 LLM 调用入口)。""" + +from .types import ChatMessage, ChatResult, Prompt, StructuredResult, TokenUsage +from .exceptions import ( + LLMError, + LLMNetworkError, + LLMNotConfiguredError, + LLMResponseError, + LLMTimeoutError, +) + +__all__ = [ + "ChatMessage", + "ChatResult", + "Prompt", + "StructuredResult", + "TokenUsage", + "LLMError", + "LLMNetworkError", + "LLMNotConfiguredError", + "LLMResponseError", + "LLMTimeoutError", +] \ No newline at end of file diff --git a/src/genesis/inference/exceptions.py b/src/genesis/inference/exceptions.py new file mode 100644 index 0000000..8fed13d --- /dev/null +++ b/src/genesis/inference/exceptions.py @@ -0,0 +1,21 @@ +from __future__ import annotations + + +class LLMError(Exception): + """LLM 调用相关的异常基类(api-design §7 映射基底)""" + + +class LLMNetworkError(LLMError): + """网络失败 / 5xx 重试耗尽(可重试语义)""" + + +class LLMTimeoutError(LLMError): + """LLM 调用超时(api-error: LLM_TIMEOUT 502)""" + + +class LLMNotConfiguredError(LLMError): + """Key / 模型缺失(api-error: LLM_NOT_CONFIGURED 503)""" + + +class LLMResponseError(LLMError): + """响应结构损坏(JSON 解析失败等)""" \ No newline at end of file diff --git a/src/genesis/inference/types.py b/src/genesis/inference/types.py new file mode 100644 index 0000000..2e8e472 --- /dev/null +++ b/src/genesis/inference/types.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + + +@dataclass +class TokenUsage: + """一次 LLM 调用的 token 用量(可观测性事件/统计用)""" + input_tokens: int = 0 + output_tokens: int = 0 + + +@dataclass +class ChatMessage: + """Chat Completions 消息""" + role: Literal["system", "user", "assistant"] + content: str + + +@dataclass +class ChatResult: + """chat() 的返回值""" + text: str + model: str + prompt_version: str + usage: TokenUsage + duration_ms: int + status: Literal["ok", "fallback", "failed"] + error: str | None = None + + +@dataclass +class StructuredResult: + """chat_structured() 的返回值(补丁 1:含 status 字段)""" + data: dict + raw_text: str + parse_attempts: int + model: str + prompt_version: str + usage: TokenUsage + duration_ms: int + status: Literal["ok", "fallback", "parse_error", "failed"] + error: str | None = None + + +@dataclass +class Prompt: + """Prompt 模板条目(name+version 唯一)""" + name: str + version: str + template: str \ No newline at end of file diff --git a/tests/test_inference_errors.py b/tests/test_inference_errors.py new file mode 100644 index 0000000..7020f70 --- /dev/null +++ b/tests/test_inference_errors.py @@ -0,0 +1,21 @@ +import pytest + +from genesis.inference.exceptions import ( + LLMError, + LLMNetworkError, + LLMNotConfiguredError, + LLMResponseError, + LLMTimeoutError, +) + + +def test_error_hierarchy(): + assert issubclass(LLMNetworkError, LLMError) + assert issubclass(LLMTimeoutError, LLMError) + assert issubclass(LLMNotConfiguredError, LLMError) + assert issubclass(LLMResponseError, LLMError) + + +def test_error_message_roundtrip(): + e = LLMTimeoutError("timeout!") + assert str(e) == "timeout!" \ No newline at end of file diff --git a/tests/test_inference_types.py b/tests/test_inference_types.py new file mode 100644 index 0000000..682fe78 --- /dev/null +++ b/tests/test_inference_types.py @@ -0,0 +1,40 @@ +from genesis.inference.types import ChatMessage, ChatResult, Prompt, StructuredResult, TokenUsage + + +def test_token_usage_defaults(): + u = TokenUsage() + assert u.input_tokens == 0 and u.output_tokens == 0 + + +def test_chat_message_roles(): + assert ChatMessage(role="system", content="x").content == "x" + + +def test_chat_result_defaults(): + r = ChatResult( + text="t", model="m", prompt_version="v1", usage=TokenUsage(), + duration_ms=10, status="ok", + ) + assert r.status == "ok" and r.error is None + + +def test_structured_result_status_ok(): + r = StructuredResult( + data={"a": 1}, raw_text='{"a":1}', parse_attempts=1, model="m", + prompt_version="v1", usage=TokenUsage(), duration_ms=10, status="ok", + ) + assert r.data == {"a": 1} and r.raw_text == '{"a":1}' + + +def test_structured_result_status_parse_error(): + r = StructuredResult( + data={}, raw_text="NOT JSON", parse_attempts=3, model="m", + prompt_version="v1", usage=TokenUsage(), duration_ms=50, + status="parse_error", error="bad json", + ) + assert r.status == "parse_error" and r.error == "bad json" + + +def test_prompt_fields(): + p = Prompt(name="writer", version="v2", template="章节 {{chapter}}") + assert p.name == "writer" and p.version == "v2" \ No newline at end of file