feat: 多轮运行 + GCOV 合并 + JSON 出力 + DesignDataGenerator
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user