feat: 多轮运行 + GCOV 合并 + JSON 出力 + DesignDataGenerator
This commit is contained in:
+11
-8
@@ -1,22 +1,25 @@
|
||||
"""LLM 智能体包
|
||||
|
||||
公开 API:
|
||||
LLMClient — LLM API 客户端(含缓存 + 重试)
|
||||
Agent1Parser — COPYBOOK → FieldTree
|
||||
Agent2Data — FieldTree → TestSuite(测试数据设计)
|
||||
Agent3Diagnostic — FieldResult → 诊断建议文本
|
||||
LLMClient — LLM API 客户端(含缓存 + 重试)
|
||||
Agent1Parser — COPYBOOK → FieldTree
|
||||
DesignDataGenerator — 式样书 → 机能测试数据
|
||||
Agent2Data — FieldTree → TestSuite(测试数据设计)
|
||||
Agent3Diagnostic — FieldResult → 诊断建议文本
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .llm import LLMClient
|
||||
from .agent1_parser import Agent1Parser
|
||||
from .design_data import DesignDataGenerator
|
||||
from .agent2_data import Agent2Data
|
||||
from .agent3_diagnostic import Agent3Diagnostic
|
||||
|
||||
__all__ = [
|
||||
"LLMClient", # class
|
||||
"Agent1Parser", # class
|
||||
"Agent2Data", # class
|
||||
"Agent3Diagnostic", # class
|
||||
"LLMClient", # class
|
||||
"Agent1Parser", # class
|
||||
"DesignDataGenerator", # class
|
||||
"Agent2Data", # class
|
||||
"Agent3Diagnostic", # class
|
||||
]
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""DesignDataGenerator — 式样书驱动测试数据生成器。
|
||||
|
||||
从详细设计书 .md + COBOL 源码 + COPYBOOK 中提取业务信息,
|
||||
通过 LLM 生成有业务意义的機能テストデータ。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from agents.llm import LLMClient
|
||||
from agents.design_data_input_parser import (
|
||||
DesignDataInputParser,
|
||||
ProgramMeta,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# LLM 提示词
|
||||
_SYSTEM_PROMPT = """你是 COBOL 测试数据生成专家。根据详细设计书、COPYBOOK 结构和 DB 定义,
|
||||
生成测试数据。数据必须覆盖正常路径和边界条件。
|
||||
|
||||
输出格式: {"records": [{"field_name": "value", ...}]} JSON only。"""
|
||||
|
||||
|
||||
def _resolve_field_names(
|
||||
records: list[dict],
|
||||
replacing_rules: dict[str, str] | None,
|
||||
v3_field_names: set[str] | None,
|
||||
) -> list[dict]:
|
||||
"""将外部 Agent 输出的字段名映射为 V3 兼容名称。
|
||||
|
||||
处理顺序:
|
||||
1. REPLACING 展开((A) → R01)
|
||||
2. 去掉前缀和字段名之间的多余连字符(R01-EMP-ID → R01EMP-ID)
|
||||
3. 尝试直接匹配 V3 字段名
|
||||
4. 尝试以 V3 字段名 prefix 截断匹配
|
||||
5. 无法映射的字段丢弃
|
||||
"""
|
||||
if not replacing_rules and not v3_field_names:
|
||||
return records
|
||||
|
||||
# Build prefix map: from REPLACING rules, e.g. (A) → R01, then R01 is the prefix
|
||||
prefixes = set()
|
||||
prefix_from_replacing = {}
|
||||
if replacing_rules:
|
||||
for old, new in replacing_rules.items():
|
||||
if new.strip():
|
||||
prefixes.add(new)
|
||||
prefix_from_replacing[old] = new
|
||||
|
||||
if not v3_field_names:
|
||||
# Just apply replacing, no V3 validation
|
||||
result = []
|
||||
for rec in records:
|
||||
mapped = {}
|
||||
for key, val in rec.items():
|
||||
new_key = key
|
||||
for old, new in prefix_from_replacing.items():
|
||||
new_key = new_key.replace(old, new)
|
||||
mapped[new_key] = val
|
||||
result.append(mapped)
|
||||
return result
|
||||
|
||||
result = []
|
||||
for rec in records:
|
||||
mapped = {}
|
||||
for key, val in rec.items():
|
||||
new_key = key
|
||||
|
||||
# Step 1: REPLACING 展开
|
||||
for old, new in prefix_from_replacing.items():
|
||||
new_key = new_key.replace(old, new)
|
||||
|
||||
# Step 2: 去掉前缀和字段名间的连字符
|
||||
# Agent 输出: R01-EMP-ID, V3 期望: R01EMP-ID
|
||||
# 去掉 {prefix}- 前缀(如果前缀是 R01,去掉 R01-)
|
||||
for p in prefixes:
|
||||
if new_key.startswith(p + "-"):
|
||||
new_key = p + new_key[len(p) + 1 :]
|
||||
break
|
||||
|
||||
# Step 3: 直接匹配
|
||||
if new_key in v3_field_names:
|
||||
mapped[new_key] = val
|
||||
continue
|
||||
|
||||
# Step 4: 去掉所有连字符尝试匹配
|
||||
no_hyphen = new_key.replace("-", "")
|
||||
if no_hyphen in v3_field_names:
|
||||
mapped[no_hyphen] = val
|
||||
continue
|
||||
|
||||
# Step 5: 去掉下划线尝试匹配
|
||||
no_underscore = no_hyphen.replace("_", "")
|
||||
if no_underscore in v3_field_names:
|
||||
mapped[no_underscore] = val
|
||||
continue
|
||||
|
||||
logger.debug(f" field '{key}' -> '{new_key}' not in V3 fields, dropped")
|
||||
|
||||
result.append(mapped)
|
||||
return result
|
||||
|
||||
|
||||
def _extract_replacing_rules(source_text: str) -> dict[str, str]:
|
||||
"""从 COBOL 源码的 COPY ... REPLACING 提取替换规则。"""
|
||||
rules = {}
|
||||
for m in re.finditer(
|
||||
r"COPY\s+(\w+)\s+REPLACING\s+==\(A\)==\s+BY\s+==(\w+)==",
|
||||
source_text,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
rules["(A)"] = m.group(2)
|
||||
return rules
|
||||
|
||||
|
||||
def _dedup(
|
||||
main_records: list[dict],
|
||||
additional_records: list[dict],
|
||||
key_fields: list[str] | None = None,
|
||||
) -> list[dict]:
|
||||
"""合并+去重,additional 优先保留。"""
|
||||
seen = set()
|
||||
result = []
|
||||
|
||||
for rec in additional_records:
|
||||
h = _record_hash(rec, key_fields)
|
||||
if h not in seen:
|
||||
seen.add(h)
|
||||
result.append(rec)
|
||||
|
||||
for rec in main_records:
|
||||
h = _record_hash(rec, key_fields)
|
||||
if h not in seen:
|
||||
seen.add(h)
|
||||
result.append(rec)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _record_hash(rec: dict, key_fields: list[str] | None) -> tuple:
|
||||
if key_fields:
|
||||
return tuple(rec.get(k, "") for k in key_fields)
|
||||
return tuple(sorted(rec.items()))
|
||||
|
||||
|
||||
def _load_rules(rules_dir: str) -> str:
|
||||
"""Load all rules from pgm_pattern/ and special_feature/ directories."""
|
||||
texts = []
|
||||
base = Path(rules_dir)
|
||||
|
||||
for subdir in ["pgm_pattern", "special_feature"]:
|
||||
d = base / subdir
|
||||
if d.exists():
|
||||
for f in sorted(d.glob("*.md")):
|
||||
texts.append(f"=== {subdir}/{f.name} ===\n{f.read_text(encoding='utf-8')}")
|
||||
|
||||
return "\n\n".join(texts)
|
||||
|
||||
|
||||
class DesignDataGenerator:
|
||||
"""式样书驱动测试数据生成器。
|
||||
|
||||
使用例:
|
||||
llm = LLMClient(model="deepseek-v4-flash")
|
||||
gen = DesignDataGenerator(llm, cpy_dirs=["cpy"])
|
||||
records = gen.generate(
|
||||
design_md_text=design_text,
|
||||
source_text=source_text,
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm_client: LLMClient,
|
||||
cpy_dirs: list[str | Path],
|
||||
rules_dir: str | Path = "rules",
|
||||
):
|
||||
self.llm = llm_client
|
||||
self.cpy_dirs = cpy_dirs
|
||||
self.rules_dir = Path(rules_dir)
|
||||
|
||||
def generate(
|
||||
self,
|
||||
design_md_text: str,
|
||||
source_text: str,
|
||||
file_db_md_text: str | None = None,
|
||||
db_md_text: str | None = None,
|
||||
replacing_rules: dict[str, str] | None = None,
|
||||
v3_field_names: list[str] | None = None,
|
||||
) -> list[dict]:
|
||||
"""生成机能测试数据。
|
||||
|
||||
Args:
|
||||
design_md_text: 式样书 .md 全文
|
||||
source_text: COBOL 源码全文
|
||||
file_db_md_text: COPY句定义书 .md(可选)
|
||||
db_md_text: DB 定义书 .md(可选)
|
||||
replacing_rules: REPLACING 展开规则
|
||||
v3_field_names: V3 字段名参考列表(用于映射验证)
|
||||
|
||||
Returns:
|
||||
list[dict]: 每条记录为 {field_name: value} 格式
|
||||
"""
|
||||
logger.info(" DesignDataGenerator: parsing design document...")
|
||||
|
||||
try:
|
||||
parser = DesignDataInputParser()
|
||||
meta = parser.parse(design_md_text, source_text)
|
||||
except Exception as e:
|
||||
logger.warning(f" Design doc parsing failed: {e}")
|
||||
return []
|
||||
|
||||
if not meta.pgm_pattern:
|
||||
logger.info(" No PGM pattern found in design doc, skipping")
|
||||
return []
|
||||
|
||||
logger.info(
|
||||
f" Program: {meta.program_id}, pattern: {meta.pgm_pattern}, "
|
||||
f"type: {meta.input_type}"
|
||||
)
|
||||
|
||||
# 加载规则
|
||||
rules_text = _load_rules(str(self.rules_dir))
|
||||
|
||||
# 构建描述信息
|
||||
files_desc = "\n".join(
|
||||
f" {f.identifier}: {f.file_db_name} (I/O={f.io}, 媒体={f.medium})"
|
||||
for f in meta.files
|
||||
)
|
||||
keys_desc = "\n".join(
|
||||
f" {k.file_name}: sort={k.sort_condition}, key={k.key_condition}"
|
||||
for k in meta.keys
|
||||
)
|
||||
|
||||
user_prompt = f"""## プログラム情報
|
||||
- プログラムID: {meta.program_id}
|
||||
- PGMパターン: {meta.pgm_pattern}
|
||||
- 入力タイプ: {meta.input_type}
|
||||
|
||||
## 使用ファイル一覧
|
||||
{files_desc or '(なし)'}
|
||||
|
||||
## キー項目一覧
|
||||
{keys_desc or '(なし)'}
|
||||
|
||||
## 処理詳細
|
||||
{meta.process_detail[:2000] if meta.process_detail else '(なし)'}
|
||||
|
||||
## 出力レコード定義
|
||||
{meta.output_records[:1000] if meta.output_records else '(なし)'}
|
||||
|
||||
## データ生成ルール
|
||||
{rules_text[:2000] if rules_text else '(なし)'}
|
||||
|
||||
以下の JSON 形式でテストデータを生成してください:
|
||||
{{"records": [{{"field1": "value1", "field2": "value2", ...}}]}}"""
|
||||
|
||||
try:
|
||||
response = self.llm.call(
|
||||
[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
)
|
||||
logger.info(" LLM response received")
|
||||
except Exception as e:
|
||||
logger.warning(f" LLM call failed: {e}")
|
||||
return []
|
||||
|
||||
try:
|
||||
parsed = json.loads(response)
|
||||
raw_records = parsed.get("records", [])
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logger.warning(f" LLM response parse failed: {e}")
|
||||
return []
|
||||
|
||||
if not raw_records:
|
||||
logger.info(" No records generated")
|
||||
return []
|
||||
|
||||
# 字段名映射
|
||||
v3_names_set = set(v3_field_names) if v3_field_names else None
|
||||
mapped = _resolve_field_names(raw_records, replacing_rules, v3_names_set)
|
||||
|
||||
logger.info(f" Generated {len(mapped)} records")
|
||||
return mapped
|
||||
@@ -0,0 +1,270 @@
|
||||
"""式样书解析器 — 读取详细设计书 .md,提取 ProgramMeta 信息。
|
||||
|
||||
从外部 agent(jcl-cobol-data-create)移植,适配 V3 接口。
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileInfo:
|
||||
no: int = 0
|
||||
file_db_name: str = ""
|
||||
identifier: str = ""
|
||||
dd_name: str = ""
|
||||
io: str = ""
|
||||
copy_group: str = ""
|
||||
record_format: str = ""
|
||||
record_len: int = 0
|
||||
medium: str = ""
|
||||
remarks: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyInfo:
|
||||
no: int = 0
|
||||
file_name: str = ""
|
||||
sort_condition: str = ""
|
||||
key_condition: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModuleInfo:
|
||||
no: int = 0
|
||||
function: str = ""
|
||||
program_id: str = ""
|
||||
copy_name: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CopyField:
|
||||
level: int = 0
|
||||
name: str = ""
|
||||
raw_name: str = ""
|
||||
pic_type: str = ""
|
||||
pic_bytes: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TableColumn:
|
||||
no: int = 0
|
||||
name_jp: str = ""
|
||||
name_en: str = ""
|
||||
type: str = ""
|
||||
max_len: str = ""
|
||||
decimal_digits: str = ""
|
||||
byte_count: str = ""
|
||||
nullable: bool = True
|
||||
is_pk: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class TableInfo:
|
||||
table_name: str = ""
|
||||
db_id: str = ""
|
||||
copy_id: str = ""
|
||||
columns: list = field(default_factory=list)
|
||||
pk_columns: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProgramMeta:
|
||||
program_id: str = ""
|
||||
program_name: str = ""
|
||||
system_name: str = ""
|
||||
pgm_type: str = ""
|
||||
pgm_pattern: str = ""
|
||||
summary_lines: list = field(default_factory=list)
|
||||
prerequisites: list = field(default_factory=list)
|
||||
files: list = field(default_factory=list)
|
||||
keys: list = field(default_factory=list)
|
||||
modules: list = field(default_factory=list)
|
||||
process_detail: str = ""
|
||||
output_records: str = ""
|
||||
input_type: str = "file"
|
||||
copy_fields: dict = field(default_factory=dict)
|
||||
db_tables: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def _extract_section(text: str, section_name: str) -> str:
|
||||
"""Extract a section by name from markdown text (case-insensitive)."""
|
||||
lines = text.split("\n")
|
||||
result = []
|
||||
in_section = False
|
||||
section_pattern = re.compile(
|
||||
rf"^#+\s*{re.escape(section_name)}\s*$", re.IGNORECASE
|
||||
)
|
||||
next_section = re.compile(r"^#+\s", re.IGNORECASE)
|
||||
|
||||
for line in lines:
|
||||
if section_pattern.match(line):
|
||||
in_section = True
|
||||
continue
|
||||
if in_section and next_section.match(line):
|
||||
break
|
||||
if in_section:
|
||||
result.append(line)
|
||||
|
||||
return "\n".join(result).strip()
|
||||
|
||||
|
||||
def _parse_table_rows(text: str) -> list[dict]:
|
||||
"""Parse a markdown table into list of dicts."""
|
||||
lines = [l.strip() for l in text.split("\n") if l.strip()]
|
||||
if not lines:
|
||||
return []
|
||||
|
||||
header_line = None
|
||||
sep_line = None
|
||||
data_start = 0
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("|") and not header_line:
|
||||
header_line = line
|
||||
elif line.startswith("|") and header_line and not sep_line:
|
||||
if set(line.strip("|").replace("-", "").replace(" ", "").replace("|", "")) == set():
|
||||
sep_line = line
|
||||
data_start = i + 1
|
||||
break
|
||||
|
||||
if not header_line:
|
||||
return []
|
||||
|
||||
headers = [h.strip() for h in header_line.strip("|").split("|")]
|
||||
|
||||
rows = []
|
||||
for line in lines[data_start:]:
|
||||
if not line.startswith("|"):
|
||||
continue
|
||||
cells = [c.strip() for c in line.strip("|").split("|")]
|
||||
row = {}
|
||||
for i, h in enumerate(headers):
|
||||
if i < len(cells):
|
||||
row[h] = cells[i]
|
||||
else:
|
||||
row[h] = ""
|
||||
rows.append(row)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
class DesignDataInputParser:
|
||||
"""解析式样书 .md,返回 ProgramMeta。"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def parse(self, design_md_text: str, source_text: str) -> ProgramMeta:
|
||||
"""解析式样书文本和源码文本,返回 ProgramMeta。"""
|
||||
meta = ProgramMeta()
|
||||
self._design_text = design_md_text
|
||||
self._source_text = source_text
|
||||
|
||||
self._parse_basic_info(meta)
|
||||
self._parse_use_files(meta)
|
||||
self._parse_keys(meta)
|
||||
self._parse_modules(meta)
|
||||
self._parse_process_detail(meta)
|
||||
self._parse_output_records(meta)
|
||||
self._determine_input_type(meta)
|
||||
|
||||
return meta
|
||||
|
||||
def _parse_basic_info(self, meta: ProgramMeta):
|
||||
rows = _parse_table_rows(_extract_section(self._design_text, "基本情報"))
|
||||
for row in rows:
|
||||
item = row.get("項目", "")
|
||||
value = row.get("内容", "")
|
||||
if item == "システム名":
|
||||
meta.system_name = value
|
||||
elif item == "プログラムID":
|
||||
meta.program_id = value
|
||||
elif item == "プログラム名":
|
||||
meta.program_name = value
|
||||
elif item == "PGMタイプ":
|
||||
meta.pgm_type = value
|
||||
elif item == "PGMパターン":
|
||||
meta.pgm_pattern = value
|
||||
elif item == "機能概要":
|
||||
meta.summary_lines.append(value)
|
||||
|
||||
def _parse_use_files(self, meta: ProgramMeta):
|
||||
rows = _parse_table_rows(_extract_section(self._design_text, "使用ファイル一覧"))
|
||||
for row in rows:
|
||||
try:
|
||||
no_str = row.get("NO", "0")
|
||||
no = int(no_str) if no_str and no_str.strip() not in ("", "—") else 0
|
||||
rec_len_str = row.get("レコード長", "0")
|
||||
rec_len = (
|
||||
int(rec_len_str)
|
||||
if rec_len_str and rec_len_str.strip() not in ("", "—")
|
||||
else 0
|
||||
)
|
||||
f = FileInfo(
|
||||
no=no,
|
||||
file_db_name=row.get("使用ファイル/DB名", ""),
|
||||
identifier=row.get("識別子", ""),
|
||||
dd_name=row.get("DD名", ""),
|
||||
io=row.get("I/O", ""),
|
||||
copy_group=row.get("COPY群", ""),
|
||||
record_format=row.get("形式", ""),
|
||||
record_len=rec_len,
|
||||
medium=row.get("媒体", ""),
|
||||
remarks=row.get("備考", ""),
|
||||
)
|
||||
meta.files.append(f)
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
|
||||
def _parse_keys(self, meta: ProgramMeta):
|
||||
rows = _parse_table_rows(_extract_section(self._design_text, "キー項目一覧"))
|
||||
for row in rows:
|
||||
try:
|
||||
k = KeyInfo(
|
||||
no=int(row.get("NO", "0")),
|
||||
file_name=row.get("ファイル名", ""),
|
||||
sort_condition=row.get("ソート条件(キー項目)", ""),
|
||||
key_condition=row.get("キー条件(マッチング/キーブレイク)", ""),
|
||||
)
|
||||
meta.keys.append(k)
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
|
||||
def _parse_modules(self, meta: ProgramMeta):
|
||||
rows = _parse_table_rows(
|
||||
_extract_section(self._design_text, "使用モジュール一覧")
|
||||
)
|
||||
for row in rows:
|
||||
try:
|
||||
m = ModuleInfo(
|
||||
no=int(row.get("NO", "0")),
|
||||
function=row.get("機能", ""),
|
||||
program_id=row.get("プログラムID", ""),
|
||||
copy_name=row.get("使用COPY名", ""),
|
||||
)
|
||||
meta.modules.append(m)
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
|
||||
def _parse_process_detail(self, meta: ProgramMeta):
|
||||
meta.process_detail = _extract_section(self._design_text, "処理詳細")
|
||||
|
||||
def _parse_output_records(self, meta: ProgramMeta):
|
||||
meta.output_records = _extract_section(self._design_text, "出力レコード定義")
|
||||
|
||||
def _determine_input_type(self, meta: ProgramMeta):
|
||||
input_mediums = set()
|
||||
for f in meta.files:
|
||||
if "I" in f.io:
|
||||
input_mediums.add(f.medium)
|
||||
|
||||
if not input_mediums:
|
||||
meta.input_type = "file"
|
||||
elif input_mediums == {"PS"}:
|
||||
meta.input_type = "file"
|
||||
elif input_mediums == {"DB"}:
|
||||
meta.input_type = "db"
|
||||
else:
|
||||
meta.input_type = "mixed"
|
||||
Reference in New Issue
Block a user