Files
2026Technology-Competition/src/genesis/impact/impact_agent.py
T
lhl 916c5beed7 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%
2026-08-27 12:13:28 +08:00

311 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ImpactAgent:变更点定位(Impact Agent MVP,确定性规则,无 LLM)。
从要件定義各表(機能/画面/DB/IF/バッチ)取 変更区分 + 既存対応 列,
与 ExistingSystemInfo 连接比对,输出 ImpactReport(影响调查书)。
"""
from __future__ import annotations
import logging
from datetime import date
from pathlib import Path
from genesis.data_models import (
ChangeAnalysis,
ChangeElement,
ChangeType,
DesignReference,
ExcelTable,
ImpactReport,
ImpactWarning,
SheetType,
StructuredSource,
)
_LOGGER = logging.getLogger(__name__)
_SHEET_ELEMENT_TYPE = {
SheetType.FUNCTION: "機能",
SheetType.SCREEN: "画面",
SheetType.REPORT: "帳票",
SheetType.DATABASE: "DB",
SheetType.INTERFACE: "IF",
SheetType.BATCH: "バッチ",
}
_CHANGE_TYPE_MAP = {
"新規": ChangeType.NEW,
"変更": ChangeType.MODIFIED,
"削除": ChangeType.DELETED,
"不变": ChangeType.UNCHANGED,
}
def _element_to_dict(el: ChangeElement) -> dict:
"""ChangeElement → JSON 可序列化 dictChangeType 枚举转 value)。"""
return {
"element_id": el.element_id,
"element_type": el.element_type,
"name": el.name,
"change_type": el.change_type.value,
"existing_mapping": list(el.existing_mapping),
"impacted_existing": list(el.impacted_existing),
"evidence": el.evidence,
"status": el.status,
}
def impact_report_to_dict(report: ImpactReport) -> dict:
"""影响调查书 → JSON 可序列化 dict(供下载/日志,api-design §2.4 impact-result)。"""
ca = report.change_analysis
return {
"metadata": dict(report.metadata),
"change_analysis": {
"project_type": ca.project_type,
"new_elements": [_element_to_dict(e) for e in ca.new_elements],
"modified_elements": [_element_to_dict(e) for e in ca.modified_elements],
"deleted_elements": [_element_to_dict(e) for e in ca.deleted_elements],
"unchanged_elements": [_element_to_dict(e) for e in ca.unchanged_elements],
"warnings": [{"element_id": w.element_id, "issue": w.issue} for w in ca.warnings],
},
"summary": dict(report.summary),
"design_references": [
{"doc_name": r.doc_name, "identifier": r.identifier, "snippet": r.snippet}
for r in report.design_references
],
}
def _header_index(headers: list[str], *keywords: str) -> int | None:
"""按关键词定位列索引(如 変更区分 / 既存対応)。"""
for i, h in enumerate(headers):
hl = str(h).strip()
if any(k in hl for k in keywords):
return i
return None
class ImpactAgent:
"""变更点定位 → 影响调查书(MVP)。"""
def run(
self,
structured_source: StructuredSource,
session_id: str = "impact",
scope: dict | None = None,
) -> ImpactReport:
existing = structured_source.existing_system
if existing is None:
raise ValueError("未提供既有系统(existing_system),无法执行影响调查")
lookup = self._build_lookup(existing)
new_elements: list[ChangeElement] = []
modified_elements: list[ChangeElement] = []
deleted_elements: list[ChangeElement] = []
warnings: list[ImpactWarning] = []
matched: set[str] = set()
for table in structured_source.tables:
self._classify_table(
table, lookup, new_elements, modified_elements, deleted_elements, warnings, matched
)
if scope:
# scope 参数预留:MVP 默认全量调查;模块/深度收窄由调用方确认后传入,当前忽略
_LOGGER.warning("scope 参数预留(MVP 默认全量调查),当前忽略: %s", scope)
unchanged = self._unchanged_count(existing, matched)
change_analysis = ChangeAnalysis(
project_type="enhancement",
new_elements=new_elements,
modified_elements=modified_elements,
deleted_elements=deleted_elements,
unchanged_elements=[],
warnings=warnings,
)
summary = {
"total": len(new_elements) + len(modified_elements) + len(deleted_elements),
"new": len(new_elements),
"modified": len(modified_elements),
"deleted": len(deleted_elements),
"unchanged": unchanged,
"warnings": len(warnings),
}
design_references = self._cross_ref_design_docs(structured_source, lookup)
return ImpactReport(
metadata={
"version": "v1",
"session_id": session_id,
"created_at": date.today().isoformat(),
"llm_model": "none", # MVP 确定性规则,无 LLM 参与
"source": existing.source_path,
},
change_analysis=change_analysis,
summary=summary,
design_references=design_references,
)
def _cross_ref_design_docs(
self, structured_source: StructuredSource, lookup: dict[str, list[dict]]
) -> list[DesignReference]:
"""既有设计文档确定性交叉引用(Type A 辅助证据)。
以既有系统(existing_system)解析出的标识符(类/方法/模块名)为锚,
在 design_docs 的 markdown 文本中检索命中,输出 DesignReference 列表。
无 LLM 参与,纯字符串匹配。
"""
design_docs = getattr(structured_source, "design_docs", []) or []
if not design_docs:
return []
identifiers = [k for k in lookup.keys() if len(k) >= 3]
refs: list[DesignReference] = []
for doc in design_docs:
text = getattr(doc, "markdown_content", "") or ""
if not text:
continue
doc_name = Path(getattr(doc, "source_path", "design.docx")).name
text_lower = text.lower()
for ident in identifiers:
il = ident.lower()
idx = text_lower.find(il)
if idx >= 0:
token = text[idx:idx + len(il)] # 设计文档中的原始大小写
start = max(0, idx - 30)
end = min(len(text), idx + len(il) + 30)
snippet = text[start:end].replace("\n", " ").strip()
refs.append(DesignReference(doc_name=doc_name, identifier=token, snippet=snippet))
return refs
# ---------- 内部 ----------
def _classify_table(
self,
table: ExcelTable,
lookup: dict[str, list[dict]],
new_elements: list[ChangeElement],
modified_elements: list[ChangeElement],
deleted_elements: list[ChangeElement],
warnings: list[ImpactWarning],
matched: set[str],
) -> None:
headers = [str(h) for h in table.headers]
change_idx = _header_index(headers, "変更区分", "区分")
if change_idx is None:
return # 无变更区分列的表(如新規開発的帳票一覧)不参与变更点定位
mapping_idx = _header_index(headers, "既存対応")
element_type = _SHEET_ELEMENT_TYPE.get(table.detected_type, table.name)
name_idx = _header_index(headers, "名") or 1
for row in table.rows:
change_val = self._cell(row, headers, change_idx)
if not change_val:
continue
change_type = _CHANGE_TYPE_MAP.get(str(change_val).strip())
if change_type is None:
continue
element_id = str(self._cell(row, headers, 0) or "")
name = str(self._cell(row, headers, name_idx) or "")
mapping = (
str(self._cell(row, headers, mapping_idx) or "")
if mapping_idx is not None
else ""
)
tokens = [
t.strip()
for t in mapping.replace("", ",").replace(" ", "").split(",")
if t.strip()
]
hits = self._match_tokens(tokens, lookup)
impacted = [h["class_name"] for h in hits]
evidence = hits[0]["source_uri"] if hits else ""
for h in hits:
matched.add(h["class_name"])
status = "ok"
issue: str | None = None
if change_type is ChangeType.NEW and tokens:
status = "conflict"
issue = f"新規要素却声明了既存対応: {mapping}"
elif change_type in (ChangeType.MODIFIED, ChangeType.DELETED):
if not tokens:
status = "warning"
issue = "缺少既存対応,无法定位修改/删除对象"
elif not hits:
status = "warning"
issue = f"既存対応无法匹配既有类: {mapping}"
element = ChangeElement(
element_id=element_id,
element_type=element_type,
name=name,
change_type=change_type,
existing_mapping=tokens,
impacted_existing=impacted,
evidence=evidence,
status=status,
)
if change_type is ChangeType.NEW:
new_elements.append(element)
elif change_type is ChangeType.MODIFIED:
modified_elements.append(element)
elif change_type is ChangeType.DELETED:
deleted_elements.append(element)
if issue:
warnings.append(ImpactWarning(element_id=element_id, issue=issue))
@staticmethod
def _build_lookup(existing) -> dict[str, list[dict]]:
"""类名/表名(小写)→ 既有要素索引,供 token 匹配。"""
lookup: dict[str, list[dict]] = {}
def add(key: str, item: dict) -> None:
lookup.setdefault(key.lower(), []).append(item)
for c in existing.controller_layer:
add(c.class_name, {"kind": "controller", "class_name": c.class_name, "source_uri": c.source_uri})
for s in existing.service_layer:
add(s.class_name, {"kind": "service", "class_name": s.class_name, "source_uri": s.source_uri})
for e in existing.entity_layer:
add(e.class_name, {"kind": "entity", "class_name": e.class_name, "source_uri": e.source_uri})
if e.table_name:
add(e.table_name, {"kind": "entity", "class_name": e.class_name, "source_uri": e.source_uri})
return lookup
def _match_tokens(self, tokens: list[str], lookup: dict[str, list[dict]]) -> list[dict]:
"""token → 既有类命中列表(去重)。匹配规则:类名/表名完全相等 或 类名前缀匹配。"""
hits: list[dict] = []
seen: set[tuple[str, str]] = set()
for token in tokens:
key = token.lower()
for candidate in lookup.get(key, []):
if (candidate["kind"], candidate["class_name"]) not in seen:
seen.add((candidate["kind"], candidate["class_name"]))
hits.append(candidate)
# 前缀匹配:token 是类名前缀(如 OrderService → OrderServiceImpl
for k, items in lookup.items():
if k.startswith(key) and k != key:
for candidate in items:
if (candidate["kind"], candidate["class_name"]) not in seen:
seen.add((candidate["kind"], candidate["class_name"]))
hits.append(candidate)
return hits
@staticmethod
def _cell(row: dict, headers: list[str], idx: int):
if idx is None or idx >= len(headers):
return ""
cell = row.get(headers[idx])
return cell.value if cell is not None else ""
@staticmethod
def _unchanged_count(existing, matched: set[str]) -> int:
all_classes = (
{c.class_name for c in existing.controller_layer}
| {s.class_name for s in existing.service_layer}
| {e.class_name for e in existing.entity_layer}
)
return len(all_classes - matched)