diff --git a/scripts/run_phase5_slice.py b/scripts/run_phase5_slice.py index ca63a0f..486c49b 100644 --- a/scripts/run_phase5_slice.py +++ b/scripts/run_phase5_slice.py @@ -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__": diff --git a/src/genesis/inference/factory.py b/src/genesis/inference/factory.py new file mode 100644 index 0000000..e1af0d1 --- /dev/null +++ b/src/genesis/inference/factory.py @@ -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) diff --git a/src/genesis/qa/qa_loop.py b/src/genesis/qa/qa_loop.py index 80f2f03..950a97f 100644 --- a/src/genesis/qa/qa_loop.py +++ b/src/genesis/qa/qa_loop.py @@ -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) diff --git a/src/genesis/writer/orchestrator.py b/src/genesis/writer/orchestrator.py index 325e635..331b8be 100644 --- a/src/genesis/writer/orchestrator.py +++ b/src/genesis/writer/orchestrator.py @@ -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]) diff --git a/tests/test_inference_factory.py b/tests/test_inference_factory.py new file mode 100644 index 0000000..e6c442f --- /dev/null +++ b/tests/test_inference_factory.py @@ -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)