feat(web): 项目级配置 + 会话命名/历史 + 设计文档纳入影响调查

- 会话支持 name/project 字段,上传要件定义后自动命名;前端侧边栏会话历史 + localStorage 恢复,顶部只显示会话名
- 新增 ProjectsStore(SQLite)与 /api/projects CRUD;绑定项目后 _rebuild_source 合并模板/规则/代码库/设计文档,上传区仅要件定义
- StructuredSource.design_docs 与 ImpactReport.design_references;影响调查新增既有设计文档确定性交叉引用(无 LLM)
- 同步更新 docs/design.md §12.7、README、_AI_USAGE_LOG.md;全量测试 558 通过,覆盖率 99.10%
This commit is contained in:
lhl
2026-08-27 12:13:28 +08:00
parent 838a93720e
commit 916c5beed7
18 changed files with 1174 additions and 65 deletions
+81
View File
@@ -95,6 +95,87 @@ def test_status_reports_current_state(tmp_path):
assert "uploading" in r["reply"]
def test_status_reply_with_project_design_docs(tmp_path):
from genesis.server.store import ProjectsStore
store = SessionStore(db_path=str(tmp_path / "s.db"))
dd = tmp_path / "design"
dd.mkdir()
(dd / "d.docx").write_bytes(b"PK\x03\x04")
projects = ProjectsStore(db_path=str(tmp_path / "p.db"))
projects.upsert(name="projA", display_name="P", template="", write_instruction="",
rules=[], existing_system_code_dir="", design_docs_dir=str(dd))
agent = ChatAgent(GenesisService(store=store, data_root=str(tmp_path / "data"),
engine=FakeEngine(), projects=projects), fake=True)
sid = agent.service.create_session("u1", project="projA").session_id
r = agent.handle_message(sid, "现在什么状态?")
assert "design_docs" in r["reply"]
def test_status_reply_lists_existing_system_and_design_docs(tmp_path):
from genesis.server.store import ProjectsStore
store = SessionStore(db_path=str(tmp_path / "s.db"))
dd = tmp_path / "design"; dd.mkdir(); (dd / "d.docx").write_bytes(b"PK\x03\x04")
code = tmp_path / "code"; code.mkdir()
projects = ProjectsStore(db_path=str(tmp_path / "p.db"))
projects.upsert(name="projA", display_name="P", template="", write_instruction="",
rules=[], existing_system_code_dir=str(code), design_docs_dir=str(dd))
agent = ChatAgent(GenesisService(store=store, data_root=str(tmp_path / "data"),
engine=FakeEngine(), projects=projects), fake=True)
sid = agent.service.create_session("u1", project="projA").session_id
reply = agent._status_reply(agent.service.get_session(sid), [])
assert "existing_system" in reply["reply"] and "design_docs" in reply["reply"]
def test_auto_generate_unknown_status_falls_to_status_reply(tmp_path):
from genesis.server.store import ProjectsStore
store = SessionStore(db_path=str(tmp_path / "s.db"))
projects = ProjectsStore(db_path=str(tmp_path / "p.db"))
projects.upsert(name="projA", display_name="P",
template=str(_SAMPLE / "template_design_ja.docx"),
write_instruction="", rules=[], existing_system_code_dir="", design_docs_dir="")
agent = ChatAgent(GenesisService(store=store, data_root=str(tmp_path / "data"),
engine=FakeEngine(), projects=projects), fake=True)
sid = agent.service.create_session("u1", project="projA").session_id
# 上传要件定义,使 has_file 通过;随后将状态置为 done 触发 _status_reply 分支
agent.service.upload_file(sid, "requirements", "requirements_newdev.xlsx",
(_SAMPLE / "requirements_newdev.xlsx").read_bytes())
agent.service.store.update_status(sid, "done")
r = agent._auto_generate(sid, "auto", [])
assert "done" in r["reply"]
def test_run_generate_from_awaiting_impact_confirm_confirms_first(tmp_path):
agent = ChatAgent(_svc(tmp_path), fake=True)
sid = agent.service.create_session("u1").session_id
agent.service.store.update_status(sid, "awaiting_impact_confirm")
rec = agent.service.get_session(sid)
r = agent._run_generate(sid, rec, [], output_language="auto")
# confirm_impact 先执行(line 194),随后 generate 因缺文件抛错被捕获
assert r["status"] in ("writing", "awaiting_impact_confirm")
def test_run_impact_from_awaiting_parse_confirm(tmp_path):
from genesis.server.store import ProjectsStore
store = SessionStore(db_path=str(tmp_path / "s.db"))
code = tmp_path / "code"; code.mkdir(); (code / "A.java").write_text("class A {}")
projects = ProjectsStore(db_path=str(tmp_path / "p.db"))
projects.upsert(name="projA", display_name="P", template="", write_instruction="",
rules=[], existing_system_code_dir=str(code), design_docs_dir="")
agent = ChatAgent(GenesisService(store=store, data_root=str(tmp_path / "data"),
engine=FakeEngine(), projects=projects), fake=True)
sid = agent.service.create_session("u1", project="projA").session_id
agent.service.store.update_status(sid, "awaiting_parse_confirm")
r = agent._run_impact(sid, [])
assert r["status"] == "awaiting_impact_confirm"
def test_impact_brief_malformed_returns_default(tmp_path):
agent = ChatAgent(_svc(tmp_path), fake=True)
rec = agent.service.create_session("u1")
rec.impact_summary = "not-json"
assert agent._impact_brief(rec) == "已完成"
def test_generate_without_files_hints_upload(tmp_path):
agent = ChatAgent(_svc(tmp_path), fake=True)
sid = agent.service.create_session("u1").session_id
+39 -2
View File
@@ -3,9 +3,9 @@ import pytest
from genesis.data_models import (
CellValue, ChangeType, ControllerInfo, EntityInfo, ExcelTable,
ExistingSystemInfo, Provenance, ServiceInfo, SheetType, StructuredSource,
ExistingSystemInfo, Provenance, RuleDocument, ServiceInfo, SheetType, StructuredSource,
)
from genesis.impact.impact_agent import ImpactAgent
from genesis.impact.impact_agent import ImpactAgent, impact_report_to_dict
def _cv(value) -> CellValue:
@@ -201,3 +201,40 @@ def test_impact_report_to_dict_serializable():
assert d["change_analysis"]["new_elements"][0]["change_type"] == "新規"
assert d["change_analysis"]["modified_elements"][0]["impacted_existing"] == ["OrderController"]
assert d["summary"]["modified"] == 1
def _doc(name: str, content: str) -> RuleDocument:
return RuleDocument(file_name=name, category="design", markdown_content=content,
source_path=name, file_type="word", hash="h")
def _ss_with_design(existing, tables, docs) -> StructuredSource:
return StructuredSource(tables=tables, template=None, rule_docs=[], image_analyses=[],
existing_system=existing, comments=[], design_docs=docs)
def test_design_doc_cross_reference_hits_identifier():
docs = [_doc("design_a.docx", "本章描述 OrderController 的限价单处理逻辑与风控。")]
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F001", "新功能", "新規", "")])]
report = ImpactAgent().run(_ss_with_design(_existing(), tables, docs), session_id="d")
refs = report.design_references
ids = {r.identifier for r in refs}
assert "OrderController" in ids
assert refs[0].doc_name == "design_a.docx"
assert "OrderController" in refs[0].snippet
# 序列化也包含
assert any(r["identifier"] == "OrderController"
for r in impact_report_to_dict(report)["design_references"])
def test_design_doc_cross_reference_empty_when_no_docs():
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F001", "新功能", "新規", "")])]
report = ImpactAgent().run(_ss_with_design(_existing(), tables, []), session_id="d")
assert report.design_references == []
def test_design_doc_with_empty_content_skipped():
docs = [_doc("empty.docx", "")] # 无正文 → continueline 164
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F001", "新功能", "新規", "")])]
report = ImpactAgent().run(_ss_with_design(_existing(), tables, docs), session_id="d")
assert report.design_references == [] # 无正文设计文档被跳过,line 165 正常执行
+68
View File
@@ -279,3 +279,71 @@ def test_real_mode_generate_without_key_500(tmp_path, monkeypatch):
client.post(f"/api/sessions/{sid}/confirm-parse")
r = client.post(f"/api/sessions/{sid}/generate", json={})
assert r.status_code == 500
# ---------- 项目配置(S6 ----------
def test_create_and_list_projects(client):
body = {
"name": "stock", "display_name": "股票系统",
"template": str(_SAMPLE / "template_design_ja.docx"),
"write_instruction": "", "rules": [],
"existing_system_code_dir": "", "design_docs_dir": "",
}
r = client.post("/api/projects", json=body)
assert r.status_code == 200
assert r.json()["name"] == "stock"
lst = client.get("/api/projects").json()
assert any(p["name"] == "stock" for p in lst)
one = client.get("/api/projects/stock").json()
assert one["template"] == str(_SAMPLE / "template_design_ja.docx")
def test_project_invalid_template_400(client):
r = client.post("/api/projects", json={"name": "p", "template": "/no/such.docx"})
assert r.status_code == 400
assert r.json()["detail"]["code"] == "PROJECT_CONFIG_INVALID"
def test_delete_project(client):
client.post("/api/projects", json={"name": "p", "template": str(_SAMPLE / "template_design_ja.docx")})
assert client.delete("/api/projects/p").json()["deleted"] is True
assert client.get("/api/projects/p").status_code == 404
def test_session_accepts_name_and_project(client):
r = client.post("/api/sessions", json={"user_id": "u1", "name": "我的会话", "project": "projA"})
assert r.status_code == 200
sid = r.json()["session_id"]
assert r.json()["name"] == "我的会话"
g = client.get(f"/api/sessions/{sid}").json()
assert g["name"] == "我的会话"
assert g["project"] == "projA"
def test_session_list_includes_name_project(client):
client.post("/api/sessions", json={"user_id": "u1", "name": "n1", "project": "pA"})
r = client.get("/api/sessions", params={"user_id": "u1"}).json()
assert any(s["name"] == "n1" and s["project"] == "pA" for s in r)
def test_generate_with_project_config_no_template_upload(client):
"""绑定项目(含模板/规则)后,仅上传要件定义即可解析生成。"""
client.post("/api/projects", json={
"name": "projA", "template": str(_SAMPLE / "template_design_ja.docx"),
"write_instruction": str(_SAMPLE / "rules_design_ja.docx"),
"rules": [str(_SAMPLE / "rules_entry_ja.docx")],
"existing_system_code_dir": "", "design_docs_dir": "",
})
sid = client.post("/api/sessions", json={"user_id": "u1", "project": "projA"}).json()["session_id"]
# 仅上传要件定义
r = client.post(f"/api/sessions/{sid}/files", data={"file_type": "requirements"},
files={"file": ("requirements_newdev.xlsx", (_SAMPLE / "requirements_newdev.xlsx").read_bytes())})
assert r.status_code == 200
p = client.post(f"/api/sessions/{sid}/start-parse")
assert p.status_code == 200, p.text
assert client.post(f"/api/sessions/{sid}/confirm-parse").status_code == 200
# 无既有系统 → 直接 writing,可生成
gen = client.post(f"/api/sessions/{sid}/generate", json={})
assert gen.status_code == 200, gen.text
assert client.get(f"/api/sessions/{sid}/result/download").status_code == 200
+99 -1
View File
@@ -9,7 +9,7 @@ from types import SimpleNamespace
import pytest
from genesis.server.store import SessionStore
from genesis.server.store import SessionStore, ProjectsStore
from genesis.server.service import GenesisService, ServiceStepError, FileTypeError
_SAMPLE = Path(__file__).resolve().parents[1] / "sample"
@@ -59,6 +59,104 @@ def test_create_and_upload_files(svc):
assert got.files["requirements"]["name"] == "requirements_newdev.xlsx"
def test_create_session_name_and_project(tmp_path):
svc = GenesisService(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine=FakeEngine(),
)
s = svc.create_session("u1", name="我的会话", project="projA")
got = svc.get_session(s.session_id)
assert got.name == "我的会话"
assert got.project == "projA"
def test_upload_requirements_auto_names_session(tmp_path):
svc = GenesisService(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine=FakeEngine(),
)
s = svc.create_session("u1") # 默认「新会话」
svc.upload_file(s.session_id, "requirements", "要件定义_v2.xlsx", b"PK\x03\x04")
assert svc.get_session(s.session_id).name == "要件定义_v2"
def test_has_file_falls_back_to_project_config(tmp_path):
store = SessionStore(db_path=str(tmp_path / "s.db"))
projects = ProjectsStore(db_path=str(tmp_path / "p.db"))
projects.upsert(name="projA", display_name="P", template=str(_SAMPLE / "template_design_ja.docx"),
write_instruction="", rules=[], existing_system_code_dir="", design_docs_dir="")
svc = GenesisService(
store=store, data_root=str(tmp_path / "data"), engine=FakeEngine(), projects=projects,
)
s = svc.create_session("u1", project="projA")
svc.upload_file(s.session_id, "requirements", "requirements_newdev.xlsx",
(_SAMPLE / "requirements_newdev.xlsx").read_bytes())
rec = svc.get_session(s.session_id)
# requirements 仅用户上传;template 来自项目配置
assert svc.has_file(rec, "requirements") is True
assert svc.has_file(rec, "template") is True
assert svc.has_file(rec, "write_instruction") is False
assert svc._eff_path(rec, "template") == str(_SAMPLE / "template_design_ja.docx")
def test_rebuild_source_merges_project_config(tmp_path):
store = SessionStore(db_path=str(tmp_path / "s.db"))
projects = ProjectsStore(db_path=str(tmp_path / "p.db"))
projects.upsert(name="projA", display_name="P", template=str(_SAMPLE / "template_design_ja.docx"),
write_instruction=str(_SAMPLE / "rules_design_ja.docx"),
rules=[str(_SAMPLE / "rules_entry_ja.docx")],
existing_system_code_dir="", design_docs_dir="")
svc = GenesisService(
store=store, data_root=str(tmp_path / "data"), engine=FakeEngine(), projects=projects,
)
s = svc.create_session("u1", project="projA")
svc.upload_file(s.session_id, "requirements", "requirements_newdev.xlsx",
(_SAMPLE / "requirements_newdev.xlsx").read_bytes())
ss = svc._rebuild_source(svc.get_session(s.session_id))
assert ss.template is not None
assert len(ss.rule_docs) >= 2 # write_instruction + rules 合并
def test_rebuild_source_enumerates_design_docs_dir(tmp_path):
from docx import Document
store = SessionStore(db_path=str(tmp_path / "s.db"))
projects = ProjectsStore(db_path=str(tmp_path / "p.db"))
dd = tmp_path / "design"
dd.mkdir()
d = Document()
d.add_paragraph("设计文档:订单管理整体方案")
d.save(dd / "d1.docx")
projects.upsert(name="projA", display_name="P", template="", write_instruction="",
rules=[], existing_system_code_dir="", design_docs_dir=str(dd))
svc = GenesisService(
store=store, data_root=str(tmp_path / "data"), engine=FakeEngine(), projects=projects,
)
s = svc.create_session("u1", project="projA")
svc.upload_file(s.session_id, "requirements", "requirements_newdev.xlsx",
(_SAMPLE / "requirements_newdev.xlsx").read_bytes())
ss = svc._rebuild_source(svc.get_session(s.session_id))
assert len(ss.design_docs) == 1
def test_has_file_without_projects_returns_false(svc):
s = svc.create_session("u1")
rec = svc.get_session(s.session_id)
assert svc.has_file(rec, "template") is False
assert svc._eff_path(rec, "template") is None
def test_upload_requirements_does_not_override_named_session(tmp_path):
svc = GenesisService(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"), engine=FakeEngine(),
)
s = svc.create_session("u1", name="已命名会话")
svc.upload_file(s.session_id, "requirements", "other_name.xlsx", b"PK\x03\x04")
assert svc.get_session(s.session_id).name == "已命名会话"
def test_upload_invalid_type_rejected(svc):
s = svc.create_session("u1")
with pytest.raises(FileTypeError):
+136 -2
View File
@@ -1,12 +1,15 @@
"""S2SQLite 会话存储测试(server/store.py)。
覆盖:创建会话 / 列表 / 状态更新 / 文件登记 / 结果路径 / 持久化重建。
覆盖:创建会话 / 列表 / 状态更新 / 文件登记 / 结果路径 / 持久化重建 / 会话命名与项目绑定 / 项目配置存储
"""
from __future__ import annotations
import pytest
from pathlib import Path
from genesis.server.store import SessionStore, SessionRecord, SessionNotFoundError
from genesis.server.store import (
SessionStore, SessionRecord, SessionNotFoundError, ProjectsStore, ProjectConfigError,
)
@pytest.fixture
@@ -79,3 +82,134 @@ def test_store_reload_persists(tmp_path):
got = store2.get_session(s.session_id)
assert got.status == "done"
assert got.result_path == "x.docx"
# ---------- 会话命名与项目绑定 ----------
def test_session_record_name_and_project_defaults():
rec = SessionRecord(session_id="s1", user_id="u1")
assert rec.name == "新会话"
assert rec.project == ""
assert rec.to_dict["name"] == "新会话"
assert rec.to_dict["project"] == ""
def test_create_session_with_name_and_project(store):
s = store.create_session("u1", name="我的会话", project="projA")
got = store.get_session(s.session_id)
assert got.name == "我的会话"
assert got.project == "projA"
def test_session_persists_name_project(tmp_path):
db = str(tmp_path / "s.db")
s = SessionStore(db_path=db).create_session("u1", name="名字", project="p")
reopened = SessionStore(db_path=db).get_session(s.session_id)
assert reopened.name == "名字"
assert reopened.project == "p"
# ---------- 项目配置存储(ProjectsStore ----------
@pytest.fixture
def projects_store(tmp_path):
return ProjectsStore(db_path=str(tmp_path / "projects.db"))
def _make_docx(path: Path) -> str:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"PK\x03\x04") # 最小占位,仅用于存在性/后缀校验
return str(path)
def test_projects_upsert_and_get(projects_store, tmp_path):
tpl = _make_docx(tmp_path / "t.docx")
wi = _make_docx(tmp_path / "wi.docx")
rules_dir = tmp_path / "rules"
rules_dir.mkdir()
_make_docx(rules_dir / "r1.docx")
cfg = projects_store.upsert(
name="stock", display_name="股票系统", template=tpl, write_instruction=wi,
rules=[str(rules_dir)], existing_system_code_dir="", design_docs_dir="",
)
assert cfg.name == "stock"
got = projects_store.get("stock")
assert got is not None
assert got.display_name == "股票系统"
assert got.template == tpl
assert got.rules == [str(rules_dir / "r1.docx")]
def test_projects_list_and_delete(projects_store, tmp_path):
_make_docx(tmp_path / "t.docx")
projects_store.upsert(name="p1", display_name="P1", template=str(tmp_path / "t.docx"),
write_instruction="", rules=[], existing_system_code_dir="", design_docs_dir="")
assert any(p.name == "p1" for p in projects_store.list())
assert projects_store.delete("p1") is True
assert projects_store.get("p1") is None
def test_projects_upsert_enumerates_design_docs_dir(projects_store, tmp_path):
_make_docx(tmp_path / "t.docx")
dd = tmp_path / "design"
dd.mkdir()
_make_docx(dd / "d1.docx")
_make_docx(dd / "d2.docx")
cfg = projects_store.upsert(name="p", display_name="P", template=str(tmp_path / "t.docx"),
write_instruction="", rules=[], existing_system_code_dir="",
design_docs_dir=str(dd))
assert set(cfg.rules) == set() # 仅 design_docs_dirrules 为空
assert len(cfg.rules) == 0
def test_projects_upsert_rejects_missing_template(projects_store, tmp_path):
with pytest.raises(ProjectConfigError):
projects_store.upsert(name="p", display_name="P", template=str(tmp_path / "nope.docx"),
write_instruction="", rules=[], existing_system_code_dir="", design_docs_dir="")
def test_projects_upsert_rejects_non_docx(projects_store, tmp_path):
bad = tmp_path / "x.txt"
bad.write_text("x")
with pytest.raises(ProjectConfigError):
projects_store.upsert(name="p", display_name="P", template=str(bad),
write_instruction="", rules=[], existing_system_code_dir="", design_docs_dir="")
def test_projects_upsert_rejects_missing_code_dir(projects_store, tmp_path):
_make_docx(tmp_path / "t.docx")
with pytest.raises(ProjectConfigError):
projects_store.upsert(name="p", display_name="P", template=str(tmp_path / "t.docx"),
write_instruction="", rules=[], existing_system_code_dir=str(tmp_path / "missing"),
design_docs_dir="")
def test_projects_reupsert_updates(projects_store, tmp_path):
_make_docx(tmp_path / "t.docx")
projects_store.upsert(name="p", display_name="P", template=str(tmp_path / "t.docx"),
write_instruction="", rules=[], existing_system_code_dir="", design_docs_dir="")
projects_store.upsert(name="p", display_name="P2", template=str(tmp_path / "t.docx"),
write_instruction="", rules=[], existing_system_code_dir="", design_docs_dir="")
assert projects_store.get("p").display_name == "P2"
def test_projects_code_dir_must_be_directory(projects_store, tmp_path):
_make_docx(tmp_path / "t.docx")
(tmp_path / "afile.txt").write_text("x")
with pytest.raises(ProjectConfigError):
projects_store.upsert(name="p", display_name="P", template=str(tmp_path / "t.docx"),
write_instruction="", rules=[], existing_system_code_dir=str(tmp_path / "afile.txt"),
design_docs_dir="")
def test_projects_empty_template_skips_validation(projects_store, tmp_path):
# 模板留空:不应触发文件校验(line 271/273 分支)
cfg = projects_store.upsert(name="p", display_name="P", template="",
write_instruction="", rules=[], existing_system_code_dir="", design_docs_dir="")
assert cfg.template == ""
def test_projects_upsert_empty_name_rejected(projects_store, tmp_path):
with pytest.raises(ProjectConfigError):
projects_store.upsert(name="", display_name="P", template="", write_instruction="",
rules=[], existing_system_code_dir="", design_docs_dir="")
+8
View File
@@ -54,6 +54,14 @@ def test_parse_missing_requirement_file(tmp_path):
SourceParser().parse(requirement_paths=[tmp_path / "missing.xlsx"])
def test_parse_design_docs_collected_as_design_category(tmp_path):
xlsx = _xlsx(tmp_path)
design = make_rule_doc(tmp_path, [("H1", "設計書:注文管理の全体方針")])
result = SourceParser().parse(requirement_paths=[xlsx], design_doc_paths=[design])
assert len(result.design_docs) == 1
assert result.design_docs[0].category == "design"
def test_parse_missing_template_file(tmp_path):
xlsx = _xlsx(tmp_path)
with pytest.raises(FileNotFoundError):