Coverage for src\genesis\impact\impact_agent.py: 99%
122 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 14:20 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 14:20 +0800
1"""ImpactAgent:变更点定位(Impact Agent MVP,确定性规则,无 LLM)。
3从要件定義各表(機能/画面/DB/IF/バッチ)取 変更区分 + 既存対応 列,
4与 ExistingSystemInfo 连接比对,输出 ImpactReport(影响调查书)。
5"""
6from __future__ import annotations
8import logging
9from datetime import date
11from genesis.data_models import (
12 ChangeAnalysis,
13 ChangeElement,
14 ChangeType,
15 ExcelTable,
16 ImpactReport,
17 ImpactWarning,
18 SheetType,
19 StructuredSource,
20)
22_LOGGER = logging.getLogger(__name__)
24_SHEET_ELEMENT_TYPE = {
25 SheetType.FUNCTION: "機能",
26 SheetType.SCREEN: "画面",
27 SheetType.REPORT: "帳票",
28 SheetType.DATABASE: "DB",
29 SheetType.INTERFACE: "IF",
30 SheetType.BATCH: "バッチ",
31}
33_CHANGE_TYPE_MAP = {
34 "新規": ChangeType.NEW,
35 "変更": ChangeType.MODIFIED,
36 "削除": ChangeType.DELETED,
37 "不变": ChangeType.UNCHANGED,
38}
41def _element_to_dict(el: ChangeElement) -> dict:
42 """ChangeElement → JSON 可序列化 dict(ChangeType 枚举转 value)。"""
43 return {
44 "element_id": el.element_id,
45 "element_type": el.element_type,
46 "name": el.name,
47 "change_type": el.change_type.value,
48 "existing_mapping": list(el.existing_mapping),
49 "impacted_existing": list(el.impacted_existing),
50 "evidence": el.evidence,
51 "status": el.status,
52 }
55def impact_report_to_dict(report: ImpactReport) -> dict:
56 """影响调查书 → JSON 可序列化 dict(供下载/日志,api-design §2.4 impact-result)。"""
57 ca = report.change_analysis
58 return {
59 "metadata": dict(report.metadata),
60 "change_analysis": {
61 "project_type": ca.project_type,
62 "new_elements": [_element_to_dict(e) for e in ca.new_elements],
63 "modified_elements": [_element_to_dict(e) for e in ca.modified_elements],
64 "deleted_elements": [_element_to_dict(e) for e in ca.deleted_elements],
65 "unchanged_elements": [_element_to_dict(e) for e in ca.unchanged_elements],
66 "warnings": [{"element_id": w.element_id, "issue": w.issue} for w in ca.warnings],
67 },
68 "summary": dict(report.summary),
69 }
72def _header_index(headers: list[str], *keywords: str) -> int | None:
73 """按关键词定位列索引(如 変更区分 / 既存対応)。"""
74 for i, h in enumerate(headers):
75 hl = str(h).strip()
76 if any(k in hl for k in keywords):
77 return i
78 return None
81class ImpactAgent:
82 """变更点定位 → 影响调查书(MVP)。"""
84 def run(
85 self,
86 structured_source: StructuredSource,
87 session_id: str = "impact",
88 scope: dict | None = None,
89 ) -> ImpactReport:
90 existing = structured_source.existing_system
91 if existing is None:
92 raise ValueError("未提供既有系统(existing_system),无法执行影响调查")
94 lookup = self._build_lookup(existing)
95 new_elements: list[ChangeElement] = []
96 modified_elements: list[ChangeElement] = []
97 deleted_elements: list[ChangeElement] = []
98 warnings: list[ImpactWarning] = []
99 matched: set[str] = set()
101 for table in structured_source.tables:
102 self._classify_table(
103 table, lookup, new_elements, modified_elements, deleted_elements, warnings, matched
104 )
106 if scope:
107 # scope 参数预留:MVP 默认全量调查;模块/深度收窄由调用方确认后传入,当前忽略
108 _LOGGER.warning("scope 参数预留(MVP 默认全量调查),当前忽略: %s", scope)
110 unchanged = self._unchanged_count(existing, matched)
111 change_analysis = ChangeAnalysis(
112 project_type="enhancement",
113 new_elements=new_elements,
114 modified_elements=modified_elements,
115 deleted_elements=deleted_elements,
116 unchanged_elements=[],
117 warnings=warnings,
118 )
119 summary = {
120 "total": len(new_elements) + len(modified_elements) + len(deleted_elements),
121 "new": len(new_elements),
122 "modified": len(modified_elements),
123 "deleted": len(deleted_elements),
124 "unchanged": unchanged,
125 "warnings": len(warnings),
126 }
127 return ImpactReport(
128 metadata={
129 "version": "v1",
130 "session_id": session_id,
131 "created_at": date.today().isoformat(),
132 "llm_model": "none", # MVP 确定性规则,无 LLM 参与
133 "source": existing.source_path,
134 },
135 change_analysis=change_analysis,
136 summary=summary,
137 )
139 # ---------- 内部 ----------
141 def _classify_table(
142 self,
143 table: ExcelTable,
144 lookup: dict[str, list[dict]],
145 new_elements: list[ChangeElement],
146 modified_elements: list[ChangeElement],
147 deleted_elements: list[ChangeElement],
148 warnings: list[ImpactWarning],
149 matched: set[str],
150 ) -> None:
151 headers = [str(h) for h in table.headers]
152 change_idx = _header_index(headers, "変更区分", "区分")
153 if change_idx is None:
154 return # 无变更区分列的表(如新規開発的帳票一覧)不参与变更点定位
156 mapping_idx = _header_index(headers, "既存対応")
157 element_type = _SHEET_ELEMENT_TYPE.get(table.detected_type, table.name)
158 name_idx = _header_index(headers, "名") or 1
160 for row in table.rows:
161 change_val = self._cell(row, headers, change_idx)
162 if not change_val:
163 continue
164 change_type = _CHANGE_TYPE_MAP.get(str(change_val).strip())
165 if change_type is None:
166 continue
168 element_id = str(self._cell(row, headers, 0) or "")
169 name = str(self._cell(row, headers, name_idx) or "")
170 mapping = (
171 str(self._cell(row, headers, mapping_idx) or "")
172 if mapping_idx is not None
173 else ""
174 )
175 tokens = [
176 t.strip()
177 for t in mapping.replace(",", ",").replace(" ", "").split(",")
178 if t.strip()
179 ]
180 hits = self._match_tokens(tokens, lookup)
182 impacted = [h["class_name"] for h in hits]
183 evidence = hits[0]["source_uri"] if hits else ""
184 for h in hits:
185 matched.add(h["class_name"])
187 status = "ok"
188 issue: str | None = None
189 if change_type is ChangeType.NEW and tokens:
190 status = "conflict"
191 issue = f"新規要素却声明了既存対応: {mapping}"
192 elif change_type in (ChangeType.MODIFIED, ChangeType.DELETED):
193 if not tokens:
194 status = "warning"
195 issue = "缺少既存対応,无法定位修改/删除对象"
196 elif not hits:
197 status = "warning"
198 issue = f"既存対応无法匹配既有类: {mapping}"
200 element = ChangeElement(
201 element_id=element_id,
202 element_type=element_type,
203 name=name,
204 change_type=change_type,
205 existing_mapping=tokens,
206 impacted_existing=impacted,
207 evidence=evidence,
208 status=status,
209 )
210 if change_type is ChangeType.NEW:
211 new_elements.append(element)
212 elif change_type is ChangeType.MODIFIED:
213 modified_elements.append(element)
214 elif change_type is ChangeType.DELETED: 214 ↛ 217line 214 didn't jump to line 217 because the condition on line 214 was always true
215 deleted_elements.append(element)
217 if issue:
218 warnings.append(ImpactWarning(element_id=element_id, issue=issue))
220 @staticmethod
221 def _build_lookup(existing) -> dict[str, list[dict]]:
222 """类名/表名(小写)→ 既有要素索引,供 token 匹配。"""
223 lookup: dict[str, list[dict]] = {}
225 def add(key: str, item: dict) -> None:
226 lookup.setdefault(key.lower(), []).append(item)
228 for c in existing.controller_layer:
229 add(c.class_name, {"kind": "controller", "class_name": c.class_name, "source_uri": c.source_uri})
230 for s in existing.service_layer:
231 add(s.class_name, {"kind": "service", "class_name": s.class_name, "source_uri": s.source_uri})
232 for e in existing.entity_layer:
233 add(e.class_name, {"kind": "entity", "class_name": e.class_name, "source_uri": e.source_uri})
234 if e.table_name:
235 add(e.table_name, {"kind": "entity", "class_name": e.class_name, "source_uri": e.source_uri})
236 return lookup
238 def _match_tokens(self, tokens: list[str], lookup: dict[str, list[dict]]) -> list[dict]:
239 """token → 既有类命中列表(去重)。匹配规则:类名/表名完全相等 或 类名前缀匹配。"""
240 hits: list[dict] = []
241 seen: set[tuple[str, str]] = set()
242 for token in tokens:
243 key = token.lower()
244 for candidate in lookup.get(key, []):
245 if (candidate["kind"], candidate["class_name"]) not in seen:
246 seen.add((candidate["kind"], candidate["class_name"]))
247 hits.append(candidate)
248 # 前缀匹配:token 是类名前缀(如 OrderService → OrderServiceImpl)
249 for k, items in lookup.items():
250 if k.startswith(key) and k != key:
251 for candidate in items:
252 if (candidate["kind"], candidate["class_name"]) not in seen:
253 seen.add((candidate["kind"], candidate["class_name"]))
254 hits.append(candidate)
255 return hits
257 @staticmethod
258 def _cell(row: dict, headers: list[str], idx: int):
259 if idx is None or idx >= len(headers):
260 return ""
261 cell = row.get(headers[idx])
262 return cell.value if cell is not None else ""
264 @staticmethod
265 def _unchanged_count(existing, matched: set[str]) -> int:
266 all_classes = (
267 {c.class_name for c in existing.controller_layer}
268 | {s.class_name for s in existing.service_layer}
269 | {e.class_name for e in existing.entity_layer}
270 )
271 return len(all_classes - matched)