- 会话支持 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%
311 lines
12 KiB
Python
311 lines
12 KiB
Python
"""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 可序列化 dict(ChangeType 枚举转 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)
|