"""Task 5(D3 强端到端):服务层 RAG 接线验证。 真实驱动完整链路:上传 → 解压 → index_dir → retrieve → LLM prompt(RAG 注入), 不 mock 任何环节。验证上传即索引(D1)与 use_rag 默认关闭的向后兼容。 """ from __future__ import annotations import asyncio import glob import io import json import zipfile from pathlib import Path import pytest from fastapi.testclient import TestClient from genesis.server.app import create_app from genesis.impact.impact_agent import _RAG_CONTEXT_TITLE from genesis.rag.embeddings import FakeEmbedder from genesis.rag.impact_rag import ImpactRAG from genesis.rag.store import RagStore from genesis.server.service import GenesisService from genesis.server.store import SessionStore _REPO = Path(__file__).resolve().parents[1] def _sample_req() -> bytes: hits = glob.glob(str(_REPO / "sample" / "requirements_*.xlsx")) assert hits, "sample 下未找到 requirements xlsx" return Path(hits[0]).read_bytes() def _sample_tpl() -> bytes: hits = glob.glob(str(_REPO / "sample" / "template_*.docx")) assert hits, "sample 下未找到 template docx" return Path(hits[0]).read_bytes() def _make_existing_zip() -> bytes: """构造含已知源码的既有系统 zip(src/KnownOrder.java 含『订单创建调用 MyBatis』)。""" buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: zf.writestr( "src/KnownOrder.java", "package demo;\n" "public class KnownOrder {\n" " // 订单创建调用 MyBatis\n" " public void createOrder() { /* ... */ }\n}\n", ) return buf.getvalue() class FakeEngine: """异步确定性引擎:捕获 LLM 收到的 prompt。""" def __init__(self) -> None: self.captured: str | None = None async def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2): from types import SimpleNamespace self.captured = prompt return SimpleNamespace( data={"title": variables.get("title", "x"), "blocks": []}, raw_text=prompt, status="ok", ) @pytest.fixture def svc(tmp_path): store = SessionStore(db_path=str(tmp_path / "s.db")) rag = ImpactRAG(RagStore(str(tmp_path / "rag.db")), FakeEmbedder()) return GenesisService( store=store, data_root=str(tmp_path / "data"), engine=FakeEngine(), rag=rag, use_rag=False, # 默认关闭,由 run_impact 显参开启 ) def test_rag_e2e_upload_then_impact_injects_source(tmp_path): """D3 强验证:上传源码被 RAG 检索并注入 LLM prompt。""" store = SessionStore(db_path=str(tmp_path / "s.db")) engine = FakeEngine() rag = ImpactRAG(RagStore(str(tmp_path / "rag.db")), FakeEmbedder()) svc = GenesisService( store=store, data_root=str(tmp_path / "data"), engine=engine, rag=rag, use_rag=False, ) sid = svc.create_session("u1").session_id svc.upload_file(sid, "requirements", "requirements_newdev.xlsx", _sample_req()) svc.upload_file(sid, "template", "template_design_ja.docx", _sample_tpl()) # 上传即索引(D1) svc.upload_file(sid, "existing_system", "existing.zip", _make_existing_zip()) # 上传后直接检索应命中非空 hits = rag.retrieve(sid, "订单创建", k=1) assert hits, "上传即索引后应能检索到源码片段" assert "KnownOrder" in hits[0] svc.run_parse(sid) svc.confirm_parse(sid) assert svc.get_session(sid).status == "impact_running" # 真实驱动 RAG 路径 rec = asyncio.run(svc.run_impact(sid, use_rag=True)) assert rec.status == "awaiting_impact_confirm" # 核心断言:RAG 检索片段(含 KnownOrder)注入 LLM prompt assert engine.captured is not None assert "KnownOrder" in engine.captured # 摘要标记 rag_enabled import json assert json.loads(rec.impact_summary).get("rag_enabled") is True def test_rag_e2e_use_rag_false_backward_compat(tmp_path): """向后兼容:rag=None 且 use_rag 默认关闭时,不调用 LLM、无 RAG 小节。""" store = SessionStore(db_path=str(tmp_path / "s.db")) engine = FakeEngine() svc = GenesisService( store=store, data_root=str(tmp_path / "data"), engine=engine, rag=None, # 不启用 RAG use_rag=False, ) sid = svc.create_session("u1").session_id svc.upload_file(sid, "requirements", "requirements_newdev.xlsx", _sample_req()) svc.upload_file(sid, "template", "template_design_ja.docx", _sample_tpl()) svc.upload_file(sid, "existing_system", "existing.zip", _make_existing_zip()) svc.run_parse(sid) svc.confirm_parse(sid) rec = asyncio.run(svc.run_impact(sid)) # 默认 use_rag=None → 走确定性路径 assert rec.status == "awaiting_impact_confirm" # 向后兼容:确定性路径不调用 LLM(captured 保持 None),prompt 不含 RAG 小节 assert engine.captured is None assert _RAG_CONTEXT_TITLE not in (engine.captured or "") # 确定性报告存在 assert rec.impact_report_path and Path(rec.impact_report_path).exists() def test_service_rag_lazy_engine_build(tmp_path, monkeypatch): """覆盖 service.py:233-236:构造未传 engine 且 run_impact(use_rag=True) 时懒构建引擎。""" store = SessionStore(db_path=str(tmp_path / "s.db")) rag = ImpactRAG(RagStore(str(tmp_path / "rag.db")), FakeEmbedder()) # 注入的懒构建产物:一个会捕获 prompt 的 FakeEngine injected_engine = FakeEngine() monkeypatch.setattr( "genesis.inference.factory.build_inference_engine", lambda: injected_engine ) svc = GenesisService( store=store, data_root=str(tmp_path / "data"), rag=rag, use_rag=False, # 构造时不传 engine、RAG 默认关闭 ) assert svc.engine is None # 尚未构建 sid = svc.create_session("u1").session_id svc.upload_file(sid, "requirements", "requirements_newdev.xlsx", _sample_req()) svc.upload_file(sid, "template", "template_design_ja.docx", _sample_tpl()) # 上传即索引(D1),existing_system 含 KnownOrder 源码 svc.upload_file(sid, "existing_system", "existing.zip", _make_existing_zip()) svc.run_parse(sid) svc.confirm_parse(sid) assert svc.get_session(sid).status == "impact_running" # 显参开启 RAG → 触发 engine 为 None 的懒构建分支 rec = asyncio.run(svc.run_impact(sid, use_rag=True)) assert rec.status == "awaiting_impact_confirm" # 懒构建的引擎被注入并实际使用 assert svc.engine is injected_engine summary = json.loads(rec.impact_summary) assert summary.get("rag_enabled") is True # 引擎捕获的 prompt 含 RAG 检索注入的 KnownOrder 片段 assert injected_engine.captured is not None assert "KnownOrder" in injected_engine.captured def test_http_start_impact_use_rag_e2e(tmp_path): """覆盖 app.py start_impact 异步端点 + _FakeEngine.chat_structured(RAG 路径,HTTP 级)。""" store = SessionStore(db_path=str(tmp_path / "s.db")) rag = ImpactRAG(RagStore(str(tmp_path / "rag.db")), FakeEmbedder()) app = create_app( store=store, data_root=str(tmp_path / "data"), engine="fake", # 触发内部 _FakeEngine(异步 chat_structured) rag=rag, ) client = TestClient(app) sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"] # multipart 上传:要件/模板/既有系统 client.post(f"/api/sessions/{sid}/files", data={"file_type": "requirements"}, files={"file": ("requirements_newdev.xlsx", _sample_req())}) client.post(f"/api/sessions/{sid}/files", data={"file_type": "template"}, files={"file": ("template_design_ja.docx", _sample_tpl())}) client.post(f"/api/sessions/{sid}/files", data={"file_type": "existing_system"}, files={"file": ("existing.zip", _make_existing_zip())}) assert client.post(f"/api/sessions/{sid}/start-parse").status_code == 200 # 有既有系统 → impact_running assert client.post(f"/api/sessions/{sid}/confirm-parse").json()["status"] == "impact_running" # 异步端点:start-impact?use_rag=True(显参开启 RAG) r = client.post(f"/api/sessions/{sid}/start-impact?use_rag=True") assert r.status_code == 200 ir = client.get(f"/api/sessions/{sid}/impact-result").json() # 经 HTTP 真实走到 RAG 路径,摘要标记 rag_enabled assert ir.get("rag_enabled") is True def test_http_rag_stats_endpoint(tmp_path): """P0‑A:app.py /api/sessions/{sid}/rag-stats 仅读端点:上传既有系统后 chunks>0。""" store = SessionStore(db_path=str(tmp_path / "s.db")) rag = ImpactRAG(RagStore(str(tmp_path / "rag.db")), FakeEmbedder()) app = create_app( store=store, data_root=str(tmp_path / "data"), engine="fake", rag=rag, ) client = TestClient(app) sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"] # 上传前:chunks=0 s0 = client.get(f"/api/sessions/{sid}/rag-stats").json() assert s0["rag_enabled"] is True assert s0["chunks"] == 0 assert s0["session_id"] == sid # 上传既有系统 → index_dir 触发 → chunks>0 client.post(f"/api/sessions/{sid}/files", data={"file_type": "existing_system"}, files={"file": ("existing.zip", _make_existing_zip())}) s1 = client.get(f"/api/sessions/{sid}/rag-stats").json() assert s1["chunks"] > 0 # 未上传既有系统 → chunks=0 且 200(与 impact-result 行为一致) def test_rag_stats_count_raises_returns_zero(tmp_path): """P0‑A:RagStore.count 抛错时 service.rag_stats 返回 chunks=0,不上抛。""" store = SessionStore(db_path=str(tmp_path / "s.db")) rag = ImpactRAG(RagStore(str(tmp_path / "rag.db")), FakeEmbedder()) svc = GenesisService( store=store, data_root=str(tmp_path / "data"), engine=FakeEngine(), rag=rag, use_rag=False, ) s = svc.create_session("u1") # monkey-patch 一次 count 抛错 original = rag.store.count def boom(_scope): raise RuntimeError("db locked") rag.store.count = boom try: stats = svc.rag_stats(s.session_id) assert stats["chunks"] == 0 assert stats["rag_enabled"] is True finally: rag.store.count = original