feat(impact): Impact Agent MVP —— 变更点定位 + 影响调查书(追加改修场景)

- 门控:用户提供 existing_system 路径 → 进入影响调查;未提供 → 原流程不变
- CodeParser 解析 Java(@RestController/@Service/@Entity/@TableName)+ ExistingSystemExplorer 组装
- ImpactAgent 变更点定位(变更区分×既存対応 确定性比对,无 LLM)→ ImpactReport(JSON 可下载)
- 影响调查结果作为 Writer 生成概要设计书的主上下文({{impact}},无专用影响章)
- source_aggregator 解除 existing_system=None 硬编码
- 既有系统样本 sunOnly/stock-trade-system(无 LICENSE,仅测试输入,保留来源标注)
- 新造股票交易域追加改修样本 要件定義_追加改修_股票.xlsx(对齐 sunOnly 真实类名)
- 全量 351 passed / 99.27% 覆盖;门禁 PASS(16 要素:新规5/変更8/削除3/未受影响50)
This commit is contained in:
lhl
2026-08-23 17:59:08 +08:00
parent da33df92e1
commit 2e11720a2d
383 changed files with 18411 additions and 10 deletions
+126
View File
@@ -0,0 +1,126 @@
"""CodeParser 测试(Impact Agent MVP):Java 项目解析 → CodeStructure。"""
from pathlib import Path
import pytest
from genesis.impact.code_parser import CodeParseError, CodeParser
FIXTURE_JAVA = {
"trade-order/src/main/java/com/trade/order/controller/OrderController.java": (
"package com.trade.order.controller;\n"
"import org.springframework.web.bind.annotation.*;\n"
"@RestController\n"
"@RequestMapping(\"/api/order\")\n"
"public class OrderController {\n"
" @GetMapping(\"/{id}\")\n"
" public String getOrder(@PathVariable Long id) { return \"ok\"; }\n"
" @PostMapping(\"/create\")\n"
" public String createOrder() { return \"ok\"; }\n"
"}\n"
),
"trade-order/src/main/java/com/trade/order/service/OrderQueryService.java": (
"package com.trade.order.service;\n"
"import org.springframework.stereotype.Service;\n"
"@Service\n"
"public class OrderQueryService {\n"
" public void query() {}\n"
" public void update() {}\n"
"}\n"
),
"trade-order/src/main/java/com/trade/order/entity/OrderEntity.java": (
"package com.trade.order.entity;\n"
"import javax.persistence.Entity;\n"
"import javax.persistence.Table;\n"
"@Entity\n"
"@Table(name = \"t_order\")\n"
"public class OrderEntity {\n"
" private Long id;\n"
" private String orderNo;\n"
"}\n"
),
}
def _make_project(tmp_path, files: dict[str, str]) -> Path:
for rel, content in files.items():
p = tmp_path / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
return tmp_path
def test_parse_controller_detected(tmp_path):
root = _make_project(tmp_path, FIXTURE_JAVA)
result = CodeParser().parse(root)
ctrls = {c.class_name: c for c in result.controllers}
oc = ctrls["OrderController"]
assert oc.base_path == "/api/order"
assert "/api/order/{id}" in oc.endpoints
assert "/api/order/create" in oc.endpoints
assert oc.source_uri.endswith("OrderController.java")
def test_parse_service_detected(tmp_path):
root = _make_project(tmp_path, FIXTURE_JAVA)
result = CodeParser().parse(root)
svcs = {s.class_name: s for s in result.services}
oq = svcs["OrderQueryService"]
assert "query" in oq.methods
assert "update" in oq.methods
def test_parse_entity_detected(tmp_path):
root = _make_project(tmp_path, FIXTURE_JAVA)
result = CodeParser().parse(root)
ents = {e.class_name: e for e in result.entities}
oe = ents["OrderEntity"]
assert oe.table_name == "t_order"
assert "id" in oe.fields
assert "orderNo" in oe.fields
def test_parse_endpoints_detected(tmp_path):
root = _make_project(tmp_path, FIXTURE_JAVA)
result = CodeParser().parse(root)
paths = {(e.method, e.path) for e in result.endpoints}
assert ("GET", "/api/order/{id}") in paths
assert ("POST", "/api/order/create") in paths
assert all(e.controller == "OrderController" for e in result.endpoints)
def test_parse_modules_top_level_dirs(tmp_path):
root = _make_project(tmp_path, FIXTURE_JAVA)
result = CodeParser().parse(root)
assert "trade-order" in result.modules
assert result.language == "java"
def test_parse_no_java_raises(tmp_path):
(tmp_path / "readme.txt").write_text("hello", encoding="utf-8")
with pytest.raises(CodeParseError):
CodeParser().parse(tmp_path)
def test_parse_missing_root_raises(tmp_path):
with pytest.raises(CodeParseError):
CodeParser().parse(tmp_path / "nope")
def test_parse_real_sample_has_order_controller():
root = Path(__file__).resolve().parents[1] / "samples" / "existing-system"
if not root.exists():
pytest.skip("样本缺失")
result = CodeParser().parse(root)
names = {c.class_name for c in result.controllers}
assert "OrderController" in names
assert any(c.base_path for c in result.controllers)
def test_parse_real_sample_has_entities_and_endpoints():
root = Path(__file__).resolve().parents[1] / "samples" / "existing-system"
if not root.exists():
pytest.skip("样本缺失")
result = CodeParser().parse(root)
assert result.entities, "既有系统样本应至少有一个实体类"
assert result.endpoints, "既有系统样本应至少有一个端点"
assert result.services, "既有系统样本应至少有一个 @Service 类"
+56 -2
View File
@@ -1,8 +1,9 @@
from dataclasses import asdict
from genesis.data_models import (
CellComment, CellFormatting, CellValue, Confidence, ElementType,
ExcelTable, ExtractionMethod, ImageAnalysis, ParsedTemplate, Provenance,
CellComment, CellFormatting, CellValue, ChangeAnalysis, ChangeElement,
ChangeType, Confidence, ElementType, ExcelTable, ExtractionMethod,
ImageAnalysis, ImpactReport, ImpactWarning, ParsedTemplate, Provenance,
RelationType, RuleDocument, SheetType, StructuredSource,
)
@@ -75,3 +76,56 @@ def test_asdict_serializable():
prov = Provenance(file_name="f.xlsx", sheet_name="S", row=1, column="A", column_header="h")
d = asdict(CellValue(value=1, provenance=prov))
assert d["provenance"]["row"] == 1
# ---------- Impact Agent MVP 数据模型(2026-08-23 ----------
def test_changetype_members():
assert ChangeType.NEW.value == "新規"
assert ChangeType.MODIFIED.value == "変更"
assert ChangeType.DELETED.value == "削除"
assert ChangeType.UNCHANGED.value == "不变"
def test_change_element_defaults():
el = ChangeElement(element_id="F001", element_type="機能", name="止损风控", change_type=ChangeType.NEW)
assert el.existing_mapping == []
assert el.impacted_existing == []
assert el.evidence == ""
assert el.status == "ok"
def test_change_analysis_holds_lists():
ca = ChangeAnalysis(
project_type="enhancement",
new_elements=[ChangeElement("F001", "機能", "止损风控", ChangeType.NEW)],
modified_elements=[],
deleted_elements=[],
unchanged_elements=[],
warnings=[ImpactWarning(element_id="F002", issue="无法定位修改对象")],
)
assert ca.project_type == "enhancement"
assert ca.new_elements[0].element_id == "F001"
assert ca.warnings[0].issue == "无法定位修改对象"
def test_impact_report_defaults():
report = ImpactReport(metadata={"version": "v1"})
assert report.change_analysis is None
assert report.summary == {}
def test_impact_report_holds_analysis():
ca = ChangeAnalysis(project_type="enhancement", new_elements=[], modified_elements=[],
deleted_elements=[], unchanged_elements=[], warnings=[])
report = ImpactReport(metadata={"version": "v1"}, change_analysis=ca, summary={"new": 0})
assert report.change_analysis.project_type == "enhancement"
assert report.summary["new"] == 0
def test_structured_source_impact_report_default_none():
"""StructuredSource 新增 impact_report 字段,缺省 None(门控未提供时保持 None)。"""
source = StructuredSource(
tables=[], template=None, rule_docs=[], image_analyses=[], existing_system=None, comments=[],
)
assert source.impact_report is None
+45
View File
@@ -0,0 +1,45 @@
"""ExistingSystemExplorer 测试:CodeStructure → ExistingSystemInfo 分层组装。"""
import pytest
from genesis.data_models import CodeStructure, ControllerInfo, EntityInfo, ServiceInfo
from genesis.impact.existing_system_explorer import ExistingSystemExplorer
def _code() -> CodeStructure:
return CodeStructure(
root_path="/fake/root",
language="java",
modules=["trade-order"],
classes=[],
controllers=[
ControllerInfo(name="OrderController", class_name="OrderController", path="OrderController.java",
base_path="/api/order", endpoints=["/api/order/{id}"], source_uri="OrderController.java"),
],
services=[ServiceInfo(name="OrderService", class_name="OrderService", path="OrderService.java",
methods=["query"], source_uri="OrderService.java")],
entities=[EntityInfo(name="OrderEntity", class_name="OrderEntity", path="OrderEntity.java",
table_name="t_order", fields=["id"], source_uri="OrderEntity.java")],
endpoints=[],
raw_imports=[],
)
def test_explore_assembles_layers():
info = ExistingSystemExplorer().explore(_code())
assert len(info.controller_layer) == 1
assert info.controller_layer[0].class_name == "OrderController"
assert len(info.service_layer) == 1
assert info.service_layer[0].class_name == "OrderService"
assert len(info.entity_layer) == 1
assert info.entity_layer[0].table_name == "t_order"
assert info.source_path == "/fake/root"
def test_explore_empty_code():
code = CodeStructure(root_path="/x", language="java", modules=[], classes=[], controllers=[],
services=[], entities=[], endpoints=[], raw_imports=[])
info = ExistingSystemExplorer().explore(code)
assert info.controller_layer == []
assert info.service_layer == []
assert info.entity_layer == []
assert info.api_endpoints == []
+203
View File
@@ -0,0 +1,203 @@
"""ImpactAgent 变更点定位测试(MVP 确定性规则)。"""
import pytest
from genesis.data_models import (
CellValue, ChangeType, ControllerInfo, EntityInfo, ExcelTable,
ExistingSystemInfo, Provenance, ServiceInfo, SheetType, StructuredSource,
)
from genesis.impact.impact_agent import ImpactAgent
def _cv(value) -> CellValue:
return CellValue(value=value, provenance=Provenance("f.xlsx", "S", 1, "A", "h"))
def _row(*values: str) -> dict[str, CellValue]:
headers = ["要素ID", "要素名", "変更区分", "既存対応"]
return {h: _cv(v) for h, v in zip(headers, values)}
def _table(name: str, stype: SheetType, rows: list[dict]) -> ExcelTable:
return ExcelTable(name=name, detected_type=stype, extraction_method="openpyxl",
headers=["要素ID", "要素名", "変更区分", "既存対応"], rows=rows)
def _existing() -> ExistingSystemInfo:
return ExistingSystemInfo(
controller_layer=[
ControllerInfo("OrderController", "OrderController", "OrderController.java", "/trade/order",
["/trade/order/{id}"], "trade-order/OrderController.java"),
ControllerInfo("RealtimeMarketDataController", "RealtimeMarketDataController", "R.java", "/market-data/realtime",
[], "trade-market-data/R.java"),
],
service_layer=[
ServiceInfo("OrderServiceImpl", "OrderServiceImpl", "OrderServiceImpl.java", ["createOrder"], "trade-order/OrderServiceImpl.java"),
],
entity_layer=[
EntityInfo("OrderDO", "OrderDO", "OrderDO.java", "trade_order", ["id"], "trade-order/OrderDO.java"),
EntityInfo("StrategyBacktest", "StrategyBacktest", "SB.java", "ts_strategy_backtest", [], "trade-strategy/SB.java"),
],
api_endpoints=[],
source_path="samples/existing-system",
)
def _ss(existing: ExistingSystemInfo, tables: list[ExcelTable]) -> StructuredSource:
return StructuredSource(tables=tables, template=None, rule_docs=[],
image_analyses=[], existing_system=existing, comments=[])
def test_classify_new_modified_deleted():
tables = [
_table("機能一覧", SheetType.FUNCTION, [
_row("F001", "止损风控机能", "新規", ""),
_row("F002", "订单状态查询扩展", "変更", "OrderController,OrderServiceImpl"),
_row("F003", "实时行情显示扩展", "変更", "RealtimeMarketDataController"),
_row("F004", "旧回测机能废止", "削除", "StrategyBacktest"),
]),
]
report = ImpactAgent().run(_ss(_existing(), tables))
ca = report.change_analysis
assert ca.project_type == "enhancement"
assert [e.element_id for e in ca.new_elements] == ["F001"]
assert ca.new_elements[0].change_type is ChangeType.NEW
assert [e.element_id for e in ca.modified_elements] == ["F002", "F003"]
m2 = ca.modified_elements[0]
assert m2.impacted_existing == ["OrderController", "OrderServiceImpl"]
assert m2.evidence # 命中类 source_uri
assert [e.element_id for e in ca.deleted_elements] == ["F004"]
assert ca.deleted_elements[0].impacted_existing == ["StrategyBacktest"]
assert ca.warnings == []
def test_prefix_mapping_matches_impl_and_entity():
tables = [_table("機能一覧", SheetType.FUNCTION, [
_row("F005", "订单查询", "変更", "OrderService"), # 前缀匹配 OrderServiceImpl
_row("F006", "订单表变更", "変更", "OrderDO"),
])]
ca = ImpactAgent().run(_ss(_existing(), tables)).change_analysis
m = {e.element_id: e for e in ca.modified_elements}
assert "OrderServiceImpl" in m["F005"].impacted_existing
assert m["F006"].impacted_existing == ["OrderDO"]
def test_new_with_mapping_is_conflict_warning():
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F007", "新建订单接口", "新規", "OrderController")])]
report = ImpactAgent().run(_ss(_existing(), tables))
el = report.change_analysis.new_elements[0]
assert el.status == "conflict"
assert any(w.element_id == "F007" for w in report.change_analysis.warnings)
def test_modified_without_mapping_warns():
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F008", "订单变更", "変更", "")])]
report = ImpactAgent().run(_ss(_existing(), tables))
assert report.change_analysis.modified_elements[0].status == "warning"
assert any(w.element_id == "F008" for w in report.change_analysis.warnings)
def test_modified_mapping_no_match_warns():
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F009", "账户变更", "変更", "AccountController")])]
report = ImpactAgent().run(_ss(_existing(), tables))
assert report.change_analysis.modified_elements[0].status == "warning"
assert any("AccountController" in w.issue for w in report.change_analysis.warnings)
def test_unchanged_count_in_summary():
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F002", "订单查询", "変更", "OrderController")])]
report = ImpactAgent().run(_ss(_existing(), tables))
assert report.summary["unchanged"] >= 2 # 既有 5 类中未被引用者
def test_table_without_change_column_skipped():
tbl = ExcelTable(name="帳票一覧", detected_type=SheetType.REPORT, extraction_method="openpyxl",
headers=["帳票ID", "帳票名"], rows=[])
report = ImpactAgent().run(_ss(_existing(), [tbl]))
assert report.change_analysis.new_elements == []
assert report.change_analysis.warnings == []
def test_run_without_existing_raises():
ss = StructuredSource(tables=[], template=None, rule_docs=[], image_analyses=[],
existing_system=None, comments=[])
with pytest.raises(ValueError, match="既有系统"):
ImpactAgent().run(ss)
def test_metadata_and_summary_shape():
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F001", "新功能", "新規", "")])]
report = ImpactAgent().run(_ss(_existing(), tables), session_id="sess-1")
assert report.metadata["version"] == "v1"
assert report.metadata["session_id"] == "sess-1"
assert report.metadata["source"] == "samples/existing-system"
assert report.summary["total"] == 1
assert report.summary["new"] == 1
# ---------- 覆盖率补全(分支覆盖) ----------
def test_scope_logs_warning(caplog):
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F001", "新功能", "新規", "")])]
with caplog.at_level("WARNING", logger="genesis.impact.impact_agent"):
ImpactAgent().run(_ss(_existing(), tables), scope={"modules": ["trade-order"]})
assert any("scope" in r.message for r in caplog.records)
def test_empty_and_unknown_change_values_skipped():
def cv(v):
return CellValue(v, Provenance("f.xlsx", "S", 1, "A", "h"))
headers = ["要素ID", "要素名", "変更区分", "既存対応"]
rows = [
{"要素ID": cv("F100"), "要素名": cv("空区分"), "変更区分": cv(""), "既存対応": cv("")},
{"要素ID": cv("F101"), "要素名": cv("未知区分"), "変更区分": cv("未知"), "既存対応": cv("")},
{"要素ID": cv("F102"), "要素名": cv("正常"), "変更区分": cv("新規"), "既存対応": cv("")},
]
tbl = ExcelTable("機能一覧", SheetType.FUNCTION, "openpyxl", headers, rows)
report = ImpactAgent().run(_ss(_existing(), [tbl]))
assert [e.element_id for e in report.change_analysis.new_elements] == ["F102"]
assert report.change_analysis.warnings == []
def test_deleted_without_mapping_warns_and_lists():
tables = [_table("機能一覧", SheetType.FUNCTION, [_row("F030", "旧功能", "削除", "")])]
report = ImpactAgent().run(_ss(_existing(), tables))
assert report.change_analysis.deleted_elements[0].status == "warning"
assert any(w.element_id == "F030" for w in report.change_analysis.warnings)
def test_entity_without_table_name_matches_by_class():
existing = _existing()
existing.entity_layer.append(EntityInfo("NoTable", "NoTable", "NT.java", None, [], "NT.java"))
tables = [_table("DB定義", SheetType.DATABASE, [_row("T100", "无表实体", "変更", "NoTable")])]
report = ImpactAgent().run(_ss(existing, tables))
assert report.change_analysis.modified_elements[0].impacted_existing == ["NoTable"]
def test_matching_dedup_same_class_via_class_and_table():
tables = [_table("DB定義", SheetType.DATABASE, [
_row("T101", "订单表", "変更", "OrderDO,trade_order"),
_row("T102", "订单综合", "変更", "Order,Order"),
])]
ca = ImpactAgent().run(_ss(_existing(), tables)).change_analysis
by_id = {e.element_id: e for e in ca.modified_elements}
assert by_id["T101"].impacted_existing == ["OrderDO"] # 类名+表名 → 同一实体去重
assert by_id["T102"].impacted_existing.count("OrderController") == 1 # 前缀重复去重
def test_cell_out_of_range_returns_empty():
assert ImpactAgent._cell({}, ["A"], 5) == ""
def test_impact_report_to_dict_serializable():
from genesis.impact.impact_agent import impact_report_to_dict
tables = [_table("機能一覧", SheetType.FUNCTION, [
_row("F001", "止损风控", "新規", ""),
_row("F002", "订单查询", "変更", "OrderController"),
])]
report = ImpactAgent().run(_ss(_existing(), tables), session_id="s")
d = impact_report_to_dict(report)
assert d["metadata"]["session_id"] == "s"
assert d["change_analysis"]["new_elements"][0]["change_type"] == "新規"
assert d["change_analysis"]["modified_elements"][0]["impacted_existing"] == ["OrderController"]
assert d["summary"]["modified"] == 1
+17
View File
@@ -28,3 +28,20 @@ def test_build_contexts_maps_chapters():
assert c.design_rules == []
assert c.template_styles == {"Heading1", "Normal"}
assert c.template_marker.section_placeholder == "section:introduction"
def test_build_contexts_without_impact_report_is_none():
ctxs = build_contexts(_ss(), samples_dir="nonexistent_dir_xyz")
assert ctxs[0].impact_report is None
def test_build_contexts_carries_impact_report():
from genesis.data_models import ChangeAnalysis, ChangeElement, ChangeType, ImpactReport
ca = ChangeAnalysis(project_type="enhancement", new_elements=[], modified_elements=[],
deleted_elements=[], unchanged_elements=[], warnings=[])
report = ImpactReport(metadata={"version": "v1"}, change_analysis=ca, summary={})
ss = _ss()
ss.impact_report = report
ctxs = build_contexts(ss, samples_dir="nonexistent_dir_xyz")
assert ctxs[0].impact_report is report
assert "project_type" in ctxs[0].to_vars()["impact"] or ctxs[0].to_vars()["impact"] != ""
+57 -2
View File
@@ -14,14 +14,69 @@ def test_chapter_content_holds_blocks():
assert c.version == 1
def test_generation_context_no_impact_field():
def test_generation_context_impact_field_default_none():
ctx = GenerationContext(
chapter_id="db_design", title="DB 设计",
template_marker=ChapterSpec(chapter_id="db_design", title="DB 设计", section_placeholder="{{section:db_design}}"),
structured_source=None, write_rules=["规则1"], design_rules=["规则2"],
template_styles={"Heading 1"},
)
assert not hasattr(ctx, "impact")
assert ctx.impact_report is None
# to_vars 始终提供 impact 变量;无影响调查书时为空串
assert ctx.to_vars()["impact"] == ""
def test_generation_context_impact_var_formats_report():
from genesis.data_models import ChangeAnalysis, ChangeElement, ChangeType, ImpactReport
ca = ChangeAnalysis(
project_type="enhancement",
new_elements=[ChangeElement("F001", "機能", "止损风控", ChangeType.NEW)],
modified_elements=[],
deleted_elements=[],
unchanged_elements=[],
warnings=[],
)
report = ImpactReport(metadata={"version": "v1"}, change_analysis=ca, summary={"new": 1})
ctx = GenerationContext(
chapter_id="db_design", title="DB 设计",
template_marker=ChapterSpec(chapter_id="db_design", title="DB 设计", section_placeholder="{{section:db_design}}"),
structured_source=None, write_rules=["规则1"], design_rules=["规则2"],
template_styles={"Heading 1"}, impact_report=report,
)
impact = ctx.to_vars()["impact"]
assert "止损风控" in impact
assert "F001" in impact
def _impact_ctx(report):
return GenerationContext(
chapter_id="db_design", title="DB 设计",
template_marker=ChapterSpec(chapter_id="db_design", title="DB 设计", section_placeholder="{{section:db_design}}"),
structured_source=None, write_rules=[], design_rules=[],
template_styles=set(), impact_report=report,
)
def test_impact_var_empty_when_report_without_analysis():
from genesis.data_models import ImpactReport
report = ImpactReport(metadata={"version": "v1"}, change_analysis=None, summary={})
assert _impact_ctx(report).to_vars()["impact"] == ""
def test_impact_var_formats_deleted_and_warnings():
from genesis.data_models import ChangeAnalysis, ChangeElement, ChangeType, ImpactReport, ImpactWarning
ca = ChangeAnalysis(
project_type="enhancement",
new_elements=[],
modified_elements=[],
deleted_elements=[ChangeElement("F030", "機能", "旧功能", ChangeType.DELETED)],
unchanged_elements=[],
warnings=[ImpactWarning("F030", "缺少既存対応")],
)
report = ImpactReport(metadata={}, change_analysis=ca, summary={"deleted": 1, "warnings": 1})
impact = _impact_ctx(report).to_vars()["impact"]
assert "[削除]" in impact and "旧功能" in impact
assert "[警告]" in impact and "缺少既存対応" in impact
def test_chapter_spec_placeholder_optional():
+6
View File
@@ -60,6 +60,12 @@ def test_prompt_template_enforces_title_language():
assert "{{title}}" in WRITER_PROMPT_TEMPLATE
def test_prompt_template_has_impact_context_var():
# 影响调查结果作为生成主上下文:模板必须包含 impact 变量(无影响书时渲染为空串)
assert "影响调查上下文" in WRITER_PROMPT_TEMPLATE
assert "{{impact}}" in WRITER_PROMPT_TEMPLATE
def test_generate_chapter_retries():
class Boom(FakeEngine):
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
+95 -1
View File
@@ -2,13 +2,20 @@ import pytest
from types import SimpleNamespace
from docx import Document
from genesis.data_models import ParsedTemplate, ChapterMarker
from genesis.data_models import (
ControllerInfo, EntityInfo, ExistingSystemInfo, ImpactReport, ParsedTemplate,
ChapterMarker, ServiceInfo,
)
from genesis.writer.orchestrator import WriteOrchestrator
from genesis.writer.models import ChapterContent
class FakeEngine:
def __init__(self):
self.last_variables = None
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
self.last_variables = variables
# 返回一个固定章节内容(title + 一个段落块)
return SimpleNamespace(
data={"title": variables["title"], "blocks": [{"type": "paragraph", "text": "自动生成的内容"}]},
@@ -108,3 +115,90 @@ def test_generate_warns_on_unanchored_heading(tmp_path, caplog):
template_path=str(tpl),
)
assert any("附録" in r.message for r in caplog.records)
# ---------- Impact Agent MVP:影响调查结果作为生成主上下文(2026-08-23 ----------
def _ss_with_existing(template_path):
from genesis.data_models import CellValue, ExcelTable, Provenance, SheetType
parsed = ParsedTemplate(
file_name=template_path,
sections=[
ChapterMarker(type="heading", name="はじめに", level=1),
ChapterMarker(type="placeholder", name="section:introduction", level=0),
],
placeholders={},
styles={"defined": ["Heading 1"], "used": ["Heading 1"]},
)
existing = ExistingSystemInfo(
controller_layer=[ControllerInfo("OrderController", "OrderController", "OrderController.java",
"/trade/order", [], "trade-order/OrderController.java")],
service_layer=[],
entity_layer=[EntityInfo("OrderDO", "OrderDO", "OrderDO.java", "trade_order", ["id"], "trade-order/OrderDO.java")],
api_endpoints=[],
source_path="samples/existing-system",
)
headers = ["機能ID", "機能名", "変更区分", "既存対応"]
rows = [{
"機能ID": CellValue("F002", Provenance("f.xlsx", "機能一覧", 3, "A", "機能ID")),
"機能名": CellValue("订单状态查询扩展", Provenance("f.xlsx", "機能一覧", 3, "B", "機能名")),
"変更区分": CellValue("変更", Provenance("f.xlsx", "機能一覧", 3, "G", "変更区分")),
"既存対応": CellValue("OrderController", Provenance("f.xlsx", "機能一覧", 3, "H", "既存対応")),
}]
tables = [ExcelTable(name="機能一覧", detected_type=SheetType.FUNCTION, extraction_method="openpyxl",
headers=headers, rows=rows)]
return SimpleNamespace(template=parsed, existing_system=existing, tables=tables,
impact_report=None)
def test_generate_auto_runs_impact_when_existing_system(tmp_path):
"""门控:existing_system 非 None 且未显式传 impact_report → 自动跑影响调查,注入生成上下文。"""
tpl = tmp_path / "tpl.docx"
out = tmp_path / "out.docx"
_make_template(str(tpl))
engine = FakeEngine()
WriteOrchestrator().generate(
_ss_with_existing(str(tpl)), str(out),
samples_dir="nonexistent_dir_xyz", engine=engine,
)
impact = engine.last_variables["impact"]
assert "project_type=enhancement" in impact
assert "OrderController" in impact
def test_generate_without_existing_system_impact_empty(tmp_path):
tpl = tmp_path / "tpl.docx"
out = tmp_path / "out.docx"
_make_template(str(tpl))
engine = FakeEngine()
WriteOrchestrator().generate(
_ss(str(tpl)), str(out),
samples_dir="nonexistent_dir_xyz", engine=engine,
)
assert engine.last_variables["impact"] == ""
def test_generate_explicit_impact_report_used(tmp_path):
from genesis.data_models import ChangeAnalysis, ChangeElement, ChangeType
tpl = tmp_path / "tpl.docx"
out = tmp_path / "out.docx"
_make_template(str(tpl))
ca = ChangeAnalysis(project_type="enhancement", new_elements=[], modified_elements=[],
deleted_elements=[], unchanged_elements=[], warnings=[])
report = ImpactReport(metadata={"version": "v1"}, change_analysis=ca, summary={"new": 0})
engine = FakeEngine()
ss = _ss_with_existing(str(tpl))
WriteOrchestrator().generate(ss, str(out), samples_dir="nonexistent_dir_xyz",
engine=engine, impact_report=report)
assert engine.last_variables["impact"] != ""
# 显式传入时也回填 structured_source,便于 QA/日志读取
assert ss.impact_report is report
def test_generate_missing_template_path_raises(tmp_path):
parsed = ParsedTemplate(file_name=None, sections=[], placeholders={}, styles={"used": []})
with pytest.raises(ValueError, match="template_path"):
WriteOrchestrator().generate(
SimpleNamespace(template=parsed), str(tmp_path / "out.docx"),
samples_dir="nonexistent_dir_xyz", engine=FakeEngine(),
)
+74
View File
@@ -2,6 +2,7 @@ import pytest
from pathlib import Path
from genesis.data_models import StructuredSource
from genesis.impact.code_parser import CodeParseError
from genesis.parsers.source_aggregator import SourceParser
from tests.docx_helpers import make_rule_doc, new_document, save_document
@@ -125,3 +126,76 @@ def test_validate_path_returns_resolved_path(tmp_path):
result = _validate_path(str(good), (".xlsx",))
assert isinstance(result, Path)
assert result.suffix.lower() == ".xlsx"
# ---------- Impact Agent MVPexisting_system_path2026-08-23 ----------
JAVA_PROJECT = {
"trade-order/OrderController.java": (
"package com.trade.order.controller;\n"
"import org.springframework.web.bind.annotation.*;\n"
"@RestController\n"
"@RequestMapping(\"/api/order\")\n"
"public class OrderController {\n"
" @GetMapping(\"/{id}\")\n"
" public String get(Long id) { return \"ok\"; }\n"
"}\n"
),
"trade-order/OrderServiceImpl.java": (
"package com.trade.order.service;\n"
"import org.springframework.stereotype.Service;\n"
"@Service\n"
"public class OrderServiceImpl {\n"
" public void createOrder() {}\n"
"}\n"
),
"trade-order/OrderDO.java": (
"package com.trade.order.entity;\n"
"import com.baomidou.mybatisplus.annotation.TableName;\n"
"@TableName(\"trade_order\")\n"
"public class OrderDO {\n"
" private Long id;\n"
"}\n"
),
}
def _java_project(tmp_path) -> str:
for rel, content in JAVA_PROJECT.items():
p = tmp_path / "existing" / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
return str(tmp_path / "existing")
def test_parse_with_existing_system_path(tmp_path):
xlsx = _xlsx(tmp_path)
existing = _java_project(tmp_path)
result = SourceParser().parse(requirement_paths=[xlsx], existing_system_path=existing)
assert result.existing_system is not None
names = {c.class_name for c in result.existing_system.controller_layer}
assert "OrderController" in names
assert {s.class_name for s in result.existing_system.service_layer} == {"OrderServiceImpl"}
assert {e.class_name for e in result.existing_system.entity_layer} == {"OrderDO"}
def test_parse_without_existing_system_path_keeps_none(tmp_path):
xlsx = _xlsx(tmp_path)
result = SourceParser().parse(requirement_paths=[xlsx])
assert result.existing_system is None
def test_parse_existing_system_path_invalid_dir_raises(tmp_path):
xlsx = _xlsx(tmp_path)
with pytest.raises(CodeParseError):
SourceParser().parse(requirement_paths=[xlsx], existing_system_path=tmp_path / "nope")
def test_parse_existing_system_path_without_java_raises(tmp_path):
xlsx = _xlsx(tmp_path)
empty = tmp_path / "empty"
empty.mkdir()
with pytest.raises(CodeParseError):
SourceParser().parse(requirement_paths=[xlsx], existing_system_path=empty)