按"不考虑时间、考虑正确合理"原则逐块实施。
P0-B 放开 select
- chat_state.js: shouldHideUploadSelect 始终 false; resolveUploadType 尊重用户选择
- chat.html: 选项目时 select 不再隐藏 + 不再强改 file_type=requirements
- 客户端 file_type↔扩展名校验(existing_system=.zip, requirements=.xlsx 等)
- 项目已预置同类型时 confirm() 显式覆盖确认(不藏起入口)
P0-C 高级字段折叠 + 字段级红框
- 项目抽屉主面板仅留 项目名/显示名;4 个服务器路径字段收进 details.advanced
- saveDrawerProject 错误时按 ProjectConfigError label 关键字(模板/做成说明书/
既有系统代码库/既有设计文档目录/项目名)给对应输入加 .invalid 3s 清除
- 删除按钮 pf-delete 改用 hidden 而非 style.display
P0-A RAG 入口产品化
- 后端 RagStore.count(scope) 线程安全读加锁
- GenesisService.rag_stats(sid) 含 except 兜底(count 抛错返回 0)
- GET /api/sessions/{sid}/rag-stats 端点
- 前端顶栏:RAG 开关 + RAG 已索引 N 片段 状态徽标 + 开始影响调查按钮
- 上传 existing_system 后自动 refreshRagStats 刷新徽标
- 开关持久化到 localStorage(genesis_rag_enabled)
P1-D loadSession 不再隐式覆盖 draftProject
- activeProject vs draftProject 分层;不一致时由 renderProjectMismatchHint
提示用户主动"切到该项目"或"保留当前项目"
- 保留 写入 sessionStorage 标记,避免每次 load 都提示
P1-F 删除抽屉项目 fallback 收敛
- 先记 deleted 再清 drawerSelected,修复"先 null 后比较"恒假 bug
- buildWelcome 唯一来源(chat_state.js 单点,前端内联重复移除)
P2-H 杂项
- send() in-flight 锁防双击
- input maxlength=2000
- 启动恢复前校验项目存在 + loading 占位
- select 关联 label for
- esc 转义加 引号
- sid 全程 encodeURIComponent
- avatar 按 name hash 选色 + 中文首字(Array.from)+ aria-label 含名字
测试:619 passed / 99.01% 99.0% 达标(+8 覆盖 store.count/service.rag_stats/
rag-stats 端点/rag_stats 异常);Node chat_state 9 passed
279 lines
10 KiB
Python
279 lines
10 KiB
Python
"""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
|