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:
@@ -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__":
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user