From 974bf12dc0acc5dbb6f97be70ca1d5c9a3274cec Mon Sep 17 00:00:00 2001 From: lhl Date: Sat, 29 Aug 2026 23:05:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(rag):=20ImpactAgent=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E5=8F=AF=E9=80=89=20RAG=20=E4=B8=8A=E4=B8=8B=E6=96=87=EF=BC=88?= =?UTF-8?q?use=5Frag=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/genesis/impact/impact_agent.py | 72 ++++++++++++++++++++++++- tests/test_impact_agent_rag.py | 85 ++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 tests/test_impact_agent_rag.py diff --git a/src/genesis/impact/impact_agent.py b/src/genesis/impact/impact_agent.py index bf5e509..5554b22 100644 --- a/src/genesis/impact/impact_agent.py +++ b/src/genesis/impact/impact_agent.py @@ -84,8 +84,78 @@ def _header_index(headers: list[str], *keywords: str) -> int | None: return None +# RAG 检索命中片段注入到 prompt 的明确小节标题(向后兼容:use_rag=False 时不出现) +_RAG_CONTEXT_TITLE = "# 既有系统关联上下文(RAG 检索,辅助判断影响范围)" + + class ImpactAgent: - """变更点定位 → 影响调查书(MVP)。""" + """变更点定位 → 影响调查书(MVP)。 + + MVP 的确定性规则路径由 ``run`` 提供(无 LLM 参与)。 + 另提供 LLM 驱动的 ``run_impact``,可接入可选 RAG 上下文辅助判断影响范围。 + """ + + def __init__( + self, + engine=None, + use_rag: bool = False, + rag: "ImpactRAG | None" = None, + ) -> None: + """初始化(向后兼容:无参 ``ImpactAgent()`` 仍可用)。 + + - engine: LLM 引擎(InferenceEngine 兼容接口,提供 chat_structured)。 + - use_rag: 实例级默认是否启用 RAG 上下文注入;``run_impact`` 可用显参覆盖。 + - rag: 可选 ImpactRAG 检索器(scope=session_id)。 + """ + self.engine = engine + self.use_rag = use_rag + self.rag = rag + + def _build_impact_prompt(self, requirements_text: str) -> str: + """拼装发送给 LLM 的基础 prompt(不含 RAG 上下文)。""" + return ( + "你是一名变更影响分析专家。请基于以下要件变更说明,判断本次变更的影响范围" + "(涉及的既有機能/画面/DB/IF/バッチ,以及需要修改或回归验证的对象)," + "并说明判断依据。\n\n" + "# 要件变更说明\n" + f"{requirements_text}\n" + ) + + def run_impact( + self, + session_id: str, + requirements_text: str, + use_rag: bool | None = None, + k: int = 5, + ): + """LLM 驱动的变更影响分析(可选 RAG 上下文注入)。 + + - use_rag 优先取显参;为 None 时回退到实例级 ``self.use_rag``。 + - 启用且 ``self.rag`` 存在时,以 ``影响调查:`` + 要件前若干字 为查询, + 调用 ``self.rag.retrieve(session_id, query, k)``,将命中片段注入 prompt。 + - use_rag=False 时 prompt 内容与原版完全一致(不含 RAG 小节,向后兼容)。 + """ + if self.engine is None: + raise RuntimeError("run_impact 需要 engine,请在构造 ImpactAgent 时传入") + + if use_rag is None: + use_rag = self.use_rag + + prompt = self._build_impact_prompt(requirements_text) + + if use_rag and self.rag is not None: + query = "影响调查:" + requirements_text[:200] + chunks = self.rag.retrieve(session_id, query, k) + if chunks: + rag_context = "\n".join(chunks) + prompt = prompt + f"\n\n{_RAG_CONTEXT_TITLE}\n{rag_context}" + + return self.engine.chat_structured( + session_id=session_id, + prompt=prompt, + variables={}, + schema={}, + ) def run( self, diff --git a/tests/test_impact_agent_rag.py b/tests/test_impact_agent_rag.py new file mode 100644 index 0000000..74a059d --- /dev/null +++ b/tests/test_impact_agent_rag.py @@ -0,0 +1,85 @@ +"""ImpactAgent RAG 上下文注入测试(RAG 迭代 Task 4)。 + +验证: +- use_rag=True 时,run_impact 发送给 LLM 的 prompt 文本包含 RAG 检索命中片段与明确小节标题。 +- use_rag=False(或默认)时,prompt 文本不含 RAG 小节标题(向后兼容)。 +""" +from genesis.impact.impact_agent import ImpactAgent +from genesis.rag.embeddings import FakeEmbedder +from genesis.rag.impact_rag import ImpactRAG +from genesis.rag.store import RagStore + + +_RAG_SECTION_TITLE = "# 既有系统关联上下文(RAG 检索,辅助判断影响范围)" + + +class FakeEngine: + """捕获真实 LLM 方法(chat_structured)收到的 prompt 文本。 + + 方法名与签名刻意复用本仓库 InferenceEngine.chat_structured 的形参风格, + 以保证 mock 的是真实接口(key=session_id/prompt/variables/schema/retry_count)。 + """ + + def __init__(self) -> None: + self.last_prompt: str | None = None + self.calls = 0 + + def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2): + self.last_prompt = prompt + self.calls += 1 + # 返回结构兼容 ChatResult 的最小占位(测试仅校验 prompt 注入) + return {"session_id": session_id, "prompt": prompt} + + +def _make_rag(session_id: str, sources): + store = RagStore(":memory:") + rag = ImpactRAG(store, FakeEmbedder()) + rag.index(session_id, sources) + return store, rag + + +def test_run_impact_with_rag_injects_context(): + session_id = "sess-rag" + store, rag = _make_rag(session_id, [("TradeApplication.java", "订单创建调用 MyBatis")]) + try: + engine = FakeEngine() + agent = ImpactAgent(engine=engine, rag=rag, use_rag=True) + agent.run_impact(session_id, requirements_text="创建订单的影响", k=5) + prompt = engine.last_prompt + assert prompt is not None + # 命中片段(含文件名 TradeApplication.java)被注入 + assert "TradeApplication" in prompt + # 明确小节标题被注入 + assert _RAG_SECTION_TITLE in prompt + finally: + store.close() + + +def test_run_impact_without_rag_no_context(): + session_id = "sess-no-rag" + store, rag = _make_rag(session_id, [("TradeApplication.java", "订单创建调用 MyBatis")]) + try: + engine = FakeEngine() + agent = ImpactAgent(engine=engine, rag=rag, use_rag=False) + agent.run_impact(session_id, requirements_text="创建订单的影响", k=5) + prompt = engine.last_prompt + # 显式关闭 RAG:不含小节标题,也不含检索片段 + assert _RAG_SECTION_TITLE not in prompt + assert "TradeApplication" not in prompt + finally: + store.close() + + +def test_run_impact_default_no_rag_no_context(): + # 默认 use_rag 为 False(未显式开启),行为与关闭一致 + session_id = "sess-default" + store, rag = _make_rag(session_id, [("TradeApplication.java", "订单创建调用 MyBatis")]) + try: + engine = FakeEngine() + agent = ImpactAgent(engine=engine, rag=rag) # 不传 use_rag + agent.run_impact(session_id, requirements_text="创建订单的影响", k=5) + prompt = engine.last_prompt + assert _RAG_SECTION_TITLE not in prompt + assert "TradeApplication" not in prompt + finally: + store.close()