feat(inference): 接通真实 LLM 引擎工厂(P5-T10 门禁接线)

- 新增 inference/factory.build_inference_engine:读 GENESIS_INFERENCE__* / 裸
  DEEPSEEK_API_KEY·LLM_BASE_URL 环境变量与 .env,构造 HttpLLMClient + InferenceEngine
- orchestrator/qa_loop 的 engine=None 分支改用工厂,真正接通真实 LLM 路径
- 脚本注入校验改为通用(非空段落数 + 残留占位符),适配真实模式
- 补工厂测试(缺密钥/前缀变量/裸变量/默认值/.env 解析),覆盖率 99.04%
This commit is contained in:
lhl
2026-08-13 23:11:31 +08:00
parent e4f533799d
commit 958e2602cc
5 changed files with 197 additions and 7 deletions
+6 -3
View File
@@ -65,11 +65,14 @@ def _main() -> None:
template_path=args.template,
)
loaded = Document(args.output)
joined = "\n".join(p.text for p in loaded.paragraphs)
non_empty = [p.text for p in loaded.paragraphs if p.text.strip()]
remaining = sum(1 for p in loaded.paragraphs if "{{" in p.text)
print(f"[slice] 生成章节数: {len(contents)}")
print(f"[slice] 输出路径: {args.output}")
print(f"[slice] 注入校验: {'OK' if any('自动生成' in p.text for p in loaded.paragraphs) else 'EMPTY'}")
print(f"[slice] 文本内容预览:\n{joined[:200]}")
print(f"[slice] 注入校验: {'OK' if non_empty and remaining == 0 else 'CHECK'}")
print(f"[slice] 非空段落数: {len(non_empty)}; 残留占位符: {remaining}")
preview = "\n".join(non_empty[:5])
print(f"[slice] 文本内容预览:\n{preview[:400]}")
if __name__ == "__main__":
+68
View File
@@ -0,0 +1,68 @@
"""真实 InferenceEngine 工厂:读取环境变量/.env 构造 HttpLLMClient + InferenceEngine。
设计要点:
- 不硬编码任何密钥;优先级 GENESIS_INFERENCE__* 环境变量 > 裸 DEEPSEEK_API_KEY/LLM_BASE_URL > 默认值。
- 自动加载仓库根目录 .env(被 .gitignore 忽略,密钥不入库)。
- engine=None 时由 orchestrator/qa_loop 调用,接通 P5-T10 人工质量门禁的真实 LLM 路径。
"""
from __future__ import annotations
import os
from pathlib import Path
from genesis.config import InferenceModels, ModelSpec
from genesis.inference.client import HttpLLMClient
from genesis.inference.engine import InferenceEngine
from genesis.inference.exceptions import LLMNotConfiguredError
_DEFAULT_BASE_URL = "https://api.deepseek.com"
_DEFAULT_MODEL = "deepseek-chat"
_DEFAULT_FALLBACK = "qwen-max"
def _load_dotenv(path: Path = Path(".env")) -> None:
"""将 .env 中的 KEY=VALUE 注入 os.environ(仅当变量尚未设置时)。"""
if not path.is_file():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
key = key.strip()
val = val.strip()
if len(val) >= 2 and val[0] in "\"'":
if val[-1] == val[0]:
val = val[1:-1]
os.environ.setdefault(key, val)
def build_inference_engine() -> InferenceEngine:
_load_dotenv()
api_key = os.environ.get("GENESIS_INFERENCE__API_KEY") or os.environ.get("DEEPSEEK_API_KEY")
base_url = (
os.environ.get("GENESIS_INFERENCE__BASE_URL")
or os.environ.get("LLM_BASE_URL")
or _DEFAULT_BASE_URL
)
model = (
os.environ.get("GENESIS_INFERENCE__MODEL")
or os.environ.get("LLM_MODEL")
or _DEFAULT_MODEL
)
fallback = (
os.environ.get("GENESIS_INFERENCE__FALLBACK_MODEL")
or os.environ.get("LLM_FALLBACK_MODEL")
or _DEFAULT_FALLBACK
)
if not api_key:
raise LLMNotConfiguredError(
"缺少 LLM API Key:请设置 GENESIS_INFERENCE__API_KEY"
"(或 .env / 环境变量 DEEPSEEK_API_KEY"
)
models = InferenceModels(
primary=ModelSpec(name=model),
fallback=ModelSpec(name=fallback),
)
client = HttpLLMClient(base_url=base_url, api_key=api_key)
return InferenceEngine(client=client, models=models)
+2 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from pathlib import Path
from genesis.inference.engine import InferenceEngine
from genesis.inference.factory import build_inference_engine
from genesis.inference.prompt_registry import PromptRegistry
from genesis.qa.guardrails import DEFAULT_MAX_QA_ROUNDS, QALoopController
from genesis.qa.report import QAReport
@@ -47,7 +47,7 @@ class QALoop:
return [contents_map[cid] for cid in order]
def run(self, structured_source, output_path, session_id="writer", samples_dir="samples", engine=None, prompt_registry=None, template_path=None) -> QAReport:
engine = engine or InferenceEngine()
engine = engine or build_inference_engine()
prompt_registry = prompt_registry or PromptRegistry()
validator = QAValidator()
contents = self._build(structured_source, samples_dir, engine, prompt_registry, template_path, output_path, session_id)
+2 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from pathlib import Path
from genesis.data_models import StructuredSource
from genesis.inference.engine import InferenceEngine
from genesis.inference.factory import build_inference_engine
from genesis.inference.prompt_registry import PromptRegistry
from genesis.writer.context_builder import build_contexts
from genesis.writer.docx_injector import Block, DocxInjector
@@ -31,7 +31,7 @@ class WriteOrchestrator:
prompt_registry=None,
template_path: str | None = None,
) -> list[ChapterContent]:
engine = engine or InferenceEngine()
engine = engine or build_inference_engine()
prompt_registry = prompt_registry or PromptRegistry()
ctxs = build_contexts(structured_source, samples_dir)
state = WriterState([c.chapter_id for c in ctxs])
+119
View File
@@ -0,0 +1,119 @@
"""真实 InferenceEngine 工厂测试(env 驱动,无需真实密钥)。
覆盖:
- 缺 API Key → 抛 LLMNotConfiguredError
- GENESIS_ 前缀环境变量构造
- 裸 DEEPSEEK_API_KEY / LLM_BASE_URL 兼容
- .env 文件加载
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from genesis.inference.exceptions import LLMNotConfiguredError
from genesis.inference.factory import build_inference_engine, _load_dotenv
def _isolate(monkeypatch: pytest.MonkeyPatch) -> None:
"""屏蔽 .env 自动加载与所有相关环境变量,保证测试确定性。"""
monkeypatch.setattr("genesis.inference.factory._load_dotenv", lambda *a, **k: None)
for k in (
"GENESIS_INFERENCE__API_KEY",
"GENESIS_INFERENCE__BASE_URL",
"GENESIS_INFERENCE__MODEL",
"GENESIS_INFERENCE__FALLBACK_MODEL",
"DEEPSEEK_API_KEY",
"LLM_BASE_URL",
"LLM_MODEL",
"LLM_FALLBACK_MODEL",
):
monkeypatch.delenv(k, raising=False)
def test_missing_key_raises(monkeypatch: pytest.MonkeyPatch) -> None:
_isolate(monkeypatch)
with pytest.raises(LLMNotConfiguredError):
build_inference_engine()
def test_builds_from_genesis_env(monkeypatch: pytest.MonkeyPatch) -> None:
_isolate(monkeypatch)
monkeypatch.setenv("GENESIS_INFERENCE__API_KEY", "sk-test")
monkeypatch.setenv("GENESIS_INFERENCE__BASE_URL", "https://llm.example.com")
monkeypatch.setenv("GENESIS_INFERENCE__MODEL", "my-model")
monkeypatch.setenv("GENESIS_INFERENCE__FALLBACK_MODEL", "my-fallback")
eng = build_inference_engine()
assert eng._model_names(None) == ["my-model", "my-fallback"]
assert eng._client._base_url == "https://llm.example.com"
assert eng._client._api_key == "sk-test"
def test_builds_from_bare_env(monkeypatch: pytest.MonkeyPatch) -> None:
_isolate(monkeypatch)
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-bare")
monkeypatch.setenv("LLM_BASE_URL", "https://bare.example.com")
eng = build_inference_engine()
assert eng._client._api_key == "sk-bare"
assert eng._client._base_url == "https://bare.example.com"
assert eng._model_names(None) == ["deepseek-chat", "qwen-max"]
def test_default_base_url_when_absent(monkeypatch: pytest.MonkeyPatch) -> None:
_isolate(monkeypatch)
monkeypatch.setenv("GENESIS_INFERENCE__API_KEY", "sk-test")
eng = build_inference_engine()
assert eng._client._base_url == "https://api.deepseek.com"
def test_load_dotenv(tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch) -> None:
_isolate(monkeypatch)
env_file = tmp_path / ".env"
env_file.write_text(
'GENESIS_INFERENCE__API_KEY=sk-dot\n'
'GENESIS_INFERENCE__BASE_URL=https://dot.example.com\n',
encoding="utf-8",
)
_load_dotenv(env_file)
assert os.environ.get("GENESIS_INFERENCE__API_KEY") == "sk-dot"
assert os.environ.get("GENESIS_INFERENCE__BASE_URL") == "https://dot.example.com"
for k in ("GENESIS_INFERENCE__API_KEY", "GENESIS_INFERENCE__BASE_URL"):
monkeypatch.delenv(k, raising=False)
def test_load_dotenv_missing_file_noop(tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch) -> None:
# 路径不存在时不应抛异常,且不引入任何变量
for k in ("GENESIS_INFERENCE__API_KEY", "GENESIS_INFERENCE__BASE_URL", "GENESIS_INFERENCE__MODEL"):
monkeypatch.delenv(k, raising=False)
_load_dotenv(tmp_path / "nonexistent.env")
assert os.environ.get("GENESIS_INFERENCE__API_KEY") is None
def test_load_dotenv_skips_comments_and_strips_quotes(tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch) -> None:
for k in (
"GENESIS_INFERENCE__API_KEY",
"GENESIS_INFERENCE__BASE_URL",
"GENESIS_INFERENCE__MODEL",
):
monkeypatch.delenv(k, raising=False)
env_file = tmp_path / ".env"
env_file.write_text(
"# 这是注释行\n"
"GENESIS_INFERENCE__API_KEY=\"sk-quoted\"\n"
"GENESIS_INFERENCE__BASE_URL='https://quoted.example.com'\n"
"MALFORMED_LINE_WITHOUT_EQUALS\n"
"GENESIS_INFERENCE__MODEL=deepseek-chat\n",
encoding="utf-8",
)
_load_dotenv(env_file)
assert os.environ["GENESIS_INFERENCE__API_KEY"] == "sk-quoted"
assert os.environ["GENESIS_INFERENCE__BASE_URL"] == "https://quoted.example.com"
assert os.environ["GENESIS_INFERENCE__MODEL"] == "deepseek-chat"
for k in (
"GENESIS_INFERENCE__API_KEY",
"GENESIS_INFERENCE__BASE_URL",
"GENESIS_INFERENCE__MODEL",
):
monkeypatch.delenv(k, raising=False)