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

1"""ImpactAgent:变更点定位(Impact Agent MVP,确定性规则,无 LLM)。 

2 

3从要件定義各表(機能/画面/DB/IF/バッチ)取 変更区分 + 既存対応 列, 

4与 ExistingSystemInfo 连接比对,输出 ImpactReport(影响调查书)。 

5""" 

6from __future__ import annotations 

7 

8import logging 

9from datetime import date 

10 

11from genesis.data_models import ( 

12 ChangeAnalysis, 

13 ChangeElement, 

14 ChangeType, 

15 ExcelTable, 

16 ImpactReport, 

17 ImpactWarning, 

18 SheetType, 

19 StructuredSource, 

20) 

21 

22_LOGGER = logging.getLogger(__name__) 

23 

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} 

32 

33_CHANGE_TYPE_MAP = { 

34 "新規": ChangeType.NEW, 

35 "変更": ChangeType.MODIFIED, 

36 "削除": ChangeType.DELETED, 

37 "不变": ChangeType.UNCHANGED, 

38} 

39 

40 

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 } 

53 

54 

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 } 

70 

71 

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 

79 

80 

81class ImpactAgent: 

82 """变更点定位 → 影响调查书(MVP)。""" 

83 

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),无法执行影响调查") 

93 

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() 

100 

101 for table in structured_source.tables: 

102 self._classify_table( 

103 table, lookup, new_elements, modified_elements, deleted_elements, warnings, matched 

104 ) 

105 

106 if scope: 

107 # scope 参数预留:MVP 默认全量调查;模块/深度收窄由调用方确认后传入,当前忽略 

108 _LOGGER.warning("scope 参数预留(MVP 默认全量调查),当前忽略: %s", scope) 

109 

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 ) 

138 

139 # ---------- 内部 ---------- 

140 

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 # 无变更区分列的表(如新規開発的帳票一覧)不参与变更点定位 

155 

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 

159 

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 

167 

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) 

181 

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"]) 

186 

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}" 

199 

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) 

216 

217 if issue: 

218 warnings.append(ImpactWarning(element_id=element_id, issue=issue)) 

219 

220 @staticmethod 

221 def _build_lookup(existing) -> dict[str, list[dict]]: 

222 """类名/表名(小写)→ 既有要素索引,供 token 匹配。""" 

223 lookup: dict[str, list[dict]] = {} 

224 

225 def add(key: str, item: dict) -> None: 

226 lookup.setdefault(key.lower(), []).append(item) 

227 

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 

237 

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 

256 

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 "" 

263 

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)