This commit is contained in:
2026-07-12 14:54:50 +08:00
commit a769e4ae58
99 changed files with 9278 additions and 0 deletions
@@ -0,0 +1,2310 @@
# 测试数据生成 Agent 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 基于 Python 构建一个测试数据生成 Agent,解析 COBOL 详细设计书和 COPYBOOK,通过 DeepSeek API 生成符合规范的测试数据 JSON/SQL 文件。
**Architecture:** 线性管道架构。InputParser 解析输入 → RuleLoader 匹配规则 → PromptBuilder 组装 prompt → APIClient 调用 AI → OutputWriter 保存结果。各模块通过 dataclass 传递结构化数据。
**Tech Stack:** Python 3.9+, requests, pytest
---
### Task 1: 项目初始化
**Files:**
- Create: `D:\jcl-cobol-data-create\agent\__init__.py`
- Create: `D:\jcl-cobol-data-create\requirements.txt`
- Create: `D:\jcl-cobol-data-create\tests\__init__.py`
- Create: `D:\jcl-cobol-data-create\tests\conftest.py`
- [ ] **Step 1: 创建 agent 包和 __init__.py**
```python
# agent/__init__.py
__version__ = "0.1.0"
```
- [ ] **Step 2: 创建 requirements.txt**
```
requests>=2.28.0
```
- [ ] **Step 3: 创建 tests 目录和 conftest.py**
```python
# tests/__init__.py
# (空文件)
```
```python
# tests/conftest.py
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
@pytest.fixture
def data_dir():
return os.path.join(os.path.dirname(__file__), 'test_data')
```
- [ ] **Step 4: 创建 tests/test_data 目录存放测试夹具**
Run: `New-Item -ItemType Directory -Path "D:\jcl-cobol-data-create\tests\test_data" -Force`
- [ ] **Step 5: 运行测试验证环境**
Run: `python -m pytest tests/ -v`
Expected: 0 tests collected, no errors.
---
### Task 2: 数据模型定义
**Files:**
- Create: `D:\jcl-cobol-data-create\agent\models.py`
- Create: `D:\jcl-cobol-data-create\tests\test_models.py`
- [ ] **Step 1: 定义数据模型**
```python
# agent/models.py
from dataclasses import dataclass, field
from typing import List, Dict, Optional
@dataclass
class FileInfo:
"""使用ファイル一覧 中的一行"""
no: int
file_db_name: str # 使用ファイル/DB名
identifier: str # 識別子 (R01, W01, etc.)
dd_name: str # DD名
io: str # I/O (I, O, I/U/D)
copy_group: str # COPY群
format: str # 形式 (FB, VB)
record_len: int # レコード長
medium: str # 媒体 (PS, DB)
remarks: str # 備考
@dataclass
class CopyField:
"""COPYBOOK 中的单个字段"""
level: int # 级别 (03, 05, ...)
name: str # 替换后的实际字段名 (如 R01-APPL-ID)
raw_name: str # 原始占位符名 (如 (A)APPL-ID)
pic_type: str # PIC 定义 (如 "X(008)", "9(008)", "S9(009) COMP-3")
pic_bytes: int # 字节数
@dataclass
class KeyInfo:
"""キー項目一覧 中的一行"""
no: int
file_name: str # ファイル名
sort_condition: str # ソート条件(キー項目)
key_condition: str # キー条件(マッチング/キーブレイク)
@dataclass
class ModuleInfo:
"""使用モジュール一覧 中的一行"""
no: int
function: str # 機能
program_id: str # プログラムID
copy_name: str # 使用COPY名
@dataclass
class TableColumn:
"""DB 表中的单个字段"""
no: int
name_jp: str # 项目名(日文)
name_en: str # 项目名(英文)
type: str # 类型 (CHAR, DECIMAL, etc.)
max_len: str # 最大长
decimal_digits: str # 小数桁
byte_count: str # バイト数
nullable: bool # NULL 许可
is_pk: bool # 是否主键
@dataclass
class TableInfo:
"""DB 表定义"""
table_name: str # DB名 (如 LEAVE_RECORDS)
db_id: str # DB ID (如 EMP_MASTER)
copy_id: str # COPY ID
columns: List[TableColumn]
pk_columns: List[str] # 主键字段名列表
@dataclass
class ProgramMeta:
"""程序完整元数据"""
program_id: str
program_name: str
system_name: str
pgm_type: str # PGMタイプ (メイン/サブ)
pgm_pattern: str # PGMパターン
summary_lines: List[str] # 機能概要 (多行)
prerequisites: List[Dict[str, str]] # 前提条件
files: List[FileInfo]
keys: List[KeyInfo]
modules: List[ModuleInfo]
process_detail: str # 処理詳細 全文 (从详细设计书获取)
output_records: str # 出力レコード定義 全文
input_type: str # "file", "db", "mixed"
# 由 COPYBOOK 解析填充
copy_fields: Dict[str, List[CopyField]] # 識別子 → 字段列表
# DB 相关
db_tables: Dict[str, TableInfo] # DB表名 → 表定义
```
- [ ] **Step 2: 写测试验证数据模型可实例化**
```python
# tests/test_models.py
from agent.models import FileInfo, CopyField, ProgramMeta, KeyInfo, TableColumn, TableInfo
def test_file_info():
f = FileInfo(
no=1, file_db_name="OVT-SORTED", identifier="R01",
dd_name="ZAN04R01", io="I", copy_group="ZAN01REC",
format="FB", record_len=80, medium="PS", remarks="有効申請"
)
assert f.identifier == "R01"
assert f.medium == "PS"
def test_copy_field():
cf = CopyField(level=3, name="R01-APPL-ID", raw_name="(A)APPL-ID",
pic_type="X(008)", pic_bytes=8)
assert cf.name == "R01-APPL-ID"
assert cf.pic_bytes == 8
def test_program_meta_defaults():
meta = ProgramMeta(
program_id="TEST", program_name="", system_name="",
pgm_type="", pgm_pattern="",
summary_lines=[], prerequisites=[],
files=[], keys=[], modules=[],
process_detail="", output_records="",
input_type="file",
copy_fields={}, db_tables={}
)
assert meta.program_id == "TEST"
assert meta.input_type == "file"
def test_table_info():
col = TableColumn(no=1, name_jp="社員番号", name_en="EMP_ID",
type="CHAR", max_len="8", decimal_digits="",
byte_count="8", nullable=False, is_pk=True)
table = TableInfo(table_name="EMP_MASTER", db_id="EMP_MASTER",
copy_id="", columns=[col], pk_columns=["EMP_ID"])
assert table.pk_columns == ["EMP_ID"]
assert len(table.columns) == 1
```
- [ ] **Step 3: 运行测试**
Run: `python -m pytest tests/test_models.py -v`
Expected: 4 tests PASS
---
### Task 3: InputParser — 详细设计书解析
**Files:**
- Create: `D:\jcl-cobol-data-create\agent\markdown_utils.py`
- Create: `D:\jcl-cobol-data-create\agent\input_parser.py`
- Create: `D:\jcl-cobol-data-create\tests\test_data\sample_design_ZAN04MAT.md`
- Create: `D:\jcl-cobol-data-create\tests\test_markdown_utils.py`
- Create: `D:\jcl-cobol-data-create\tests\test_input_parser.py`
- [ ] **Step 1: 创建 markdown 工具函数**
```python
# agent/markdown_utils.py
import re
from typing import List, Dict, Optional
def extract_section(md_text: str, section_title: str) -> str:
"""Extract content of a ### section from markdown text.
Returns content between this header and the next ## or ### header, or end of file.
"""
pattern = rf'###\s+{re.escape(section_title)}\s*\n(.*?)(?=\n##|\n###|\Z)'
match = re.search(pattern, md_text, re.DOTALL)
return match.group(1) if match else ''
def parse_table_rows(text: str) -> List[Dict[str, str]]:
"""Parse a markdown table from text, return list of row dicts.
Handles tables with exactly one header row and one separator row.
"""
lines = []
for line in text.split('\n'):
stripped = line.strip()
if stripped.startswith('|') and stripped.endswith('|'):
# Skip separator rows like |---|------|---|
if re.match(r'^\|[\s\-:]+\|', stripped):
continue
lines.append(stripped)
if len(lines) < 1:
return []
headers = [cell.strip() for cell in lines[0].split('|')[1:-1]]
rows = []
for line in lines[1:]:
cells = [cell.strip() for cell in line.split('|')[1:-1]]
if len(cells) == len(headers):
rows.append(dict(zip(headers, cells)))
return rows
def parse_table_from_section(md_text: str, section_title: str) -> List[Dict[str, str]]:
"""Find a ### section and parse its first table."""
section_text = extract_section(md_text, section_title)
if not section_text:
return []
return parse_table_rows(section_text)
def find_row_by_key(rows: List[Dict[str, str]], key_col: str, key_value: str) -> Optional[Dict[str, str]]:
"""Find a table row where a specific column matches the key value."""
for row in rows:
if row.get(key_col, '').strip() == key_value:
return row
return None
```
- [ ] **Step 2: 写 markdown_utils 的测试**
```python
# tests/test_markdown_utils.py
from agent.markdown_utils import extract_section, parse_table_rows, parse_table_from_section, find_row_by_key
SAMPLE_MD = """
### 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 残業統計管理システム |
| 4 | PGMパターン | マッチング(1:1) |
| 5 | 機能概要 | 取消マッチング処理 |
### 使用ファイル一覧
| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 媒体 | 備考 |
|----|------------------|--------|------|-----|--------|------|------|
| 1 | OVT-SORTED | R01 | ZAN04R01 | I | ZAN01REC | PS | |
| 2 | ERROR-LOG | W01 | ZAN04W01 | O | ZAN05REC | PS | |
### 出力レコード定義
### 出力ファイル1W01/OVT-MATCHED
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | APPL-ID | R01.APPL-ID | |
| 2 | EMP-ID | R01.EMP-ID | |
"""
def test_extract_section():
result = extract_section(SAMPLE_MD, "基本情報")
assert "残業統計管理システム" in result
assert "使用ファイル一覧" not in result
def test_extract_section_not_found():
result = extract_section(SAMPLE_MD, "存在しないセクション")
assert result == ''
def test_parse_table_rows():
rows = parse_table_from_section(SAMPLE_MD, "基本情報")
assert len(rows) >= 3
assert rows[0]['項目'] == 'システム名'
def test_find_row_by_key():
rows = parse_table_from_section(SAMPLE_MD, "基本情報")
row = find_row_by_key(rows, '項目', 'PGMパターン')
assert row is not None
assert row['内容'] == 'マッチング(1:1)'
def test_parse_use_file_table():
rows = parse_table_from_section(SAMPLE_MD, "使用ファイル一覧")
assert len(rows) == 2
assert rows[0]['識別子'] == 'R01'
assert rows[0]['DD名'] == 'ZAN04R01'
def test_extract_output_records_section():
result = extract_section(SAMPLE_MD, "出力レコード定義")
assert "出力ファイル1" in result
assert "W01/OVT-MATCHED" in result
```
- [ ] **Step 3: 运行测试确认 markdown_utils 通过**
Run: `python -m pytest tests/test_markdown_utils.py -v`
Expected: all PASS
- [ ] **Step 4: 创建测试用详细设计书 fixture**
使用实际项目中 `詳細設計書_ZAN04MAT.md` 的副本作为测试数据。
Run: `Copy-Item "D:\cobol-tna-system\詳細設計書\詳細設計書_ZAN04MAT.md" "D:\jcl-cobol-data-create\tests\test_data\詳細設計書_ZAN04MAT.md"`
- [ ] **Step 5: 实现 InputParser 设计书部分**
```python
# agent/input_parser.py
import re
import os
from typing import List, Dict, Optional, Tuple
from agent.models import (
FileInfo, KeyInfo, ModuleInfo, ProgramMeta, CopyField, TableColumn, TableInfo
)
from agent.markdown_utils import (
extract_section, parse_table_rows, parse_table_from_section, find_row_by_key
)
class InputParser:
"""解析 COBOL 程序的详细设计书和相关文件。"""
def __init__(self, design_md_path: str, source_cbl_path: str,
file_db_md_path: str, cpy_dir: str, db_md_path: str):
self.design_md_path = design_md_path
self.source_cbl_path = source_cbl_path
self.file_db_md_path = file_db_md_path
self.cpy_dir = cpy_dir
self.db_md_path = db_md_path
self._design_text = ''
self._source_text = ''
def run(self) -> ProgramMeta:
"""执行完整解析,返回 ProgramMeta。"""
self._design_text = self._read_file(self.design_md_path)
self._source_text = self._read_file(self.source_cbl_path)
meta = ProgramMeta(
program_id='', program_name='', system_name='',
pgm_type='', pgm_pattern='',
summary_lines=[], prerequisites=[],
files=[], keys=[], modules=[],
process_detail='', output_records='',
input_type='file',
copy_fields={}, db_tables={}
)
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)
self._parse_copybooks(meta)
self._parse_db_definition(meta)
return meta
@staticmethod
def _read_file(path: str) -> str:
with open(path, 'r', encoding='utf-8') as f:
return f.read()
def _parse_basic_info(self, meta: ProgramMeta):
rows = parse_table_from_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)
elif item == '':
# 多行功能概要用空 # 列标识续行
if value:
meta.summary_lines.append(value)
def _parse_use_files(self, meta: ProgramMeta):
rows = parse_table_from_section(self._design_text, '使用ファイル一覧')
for row in rows:
try:
no = int(row.get('NO', '0'))
rec_len_str = row.get('レコード長', '0')
rec_len = int(rec_len_str) if rec_len_str and rec_len_str != '' 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群', ''),
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_from_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_from_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):
"""Determine input type based on input files' medium.
Only looks at files with I/O containing 'I' (input).
"""
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'
def _parse_copybooks(self, meta: ProgramMeta):
"""TODO: 在 Task 4 中实现"""
pass
def _parse_db_definition(self, meta: ProgramMeta):
"""TODO: 在 Task 5 中实现"""
pass
```
- [ ] **Step 6: 写 InputParser 设计书解析部分的测试**
```python
# tests/test_input_parser.py
import os
from agent.input_parser import InputParser
FIXTURE_DIR = os.path.join(os.path.dirname(__file__), 'test_data')
def test_parse_design_basic_info():
parser = InputParser(
design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'),
source_cbl_path='dummy.cbl',
file_db_md_path='dummy.md',
cpy_dir='dummy_cpy',
db_md_path='dummy_db.md'
)
parser._design_text = parser._read_file(parser.design_md_path)
from agent.models import ProgramMeta
meta = ProgramMeta(program_id='', program_name='', system_name='',
pgm_type='', pgm_pattern='',
summary_lines=[], prerequisites=[],
files=[], keys=[], modules=[],
process_detail='', output_records='',
input_type='file', copy_fields={}, db_tables={})
parser._parse_basic_info(meta)
assert meta.program_id == 'ZAN04MAT'
assert meta.pgm_pattern == 'マッチング(1:1)'
assert meta.pgm_type == 'メイン'
assert '残業統計管理システム' in meta.system_name
def test_parse_use_files():
parser = InputParser(
design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'),
source_cbl_path='dummy.cbl', file_db_md_path='dummy.md',
cpy_dir='dummy_cpy', db_md_path='dummy_db.md'
)
parser._design_text = parser._read_file(parser.design_md_path)
from agent.models import ProgramMeta
meta = ProgramMeta(program_id='', program_name='', system_name='',
pgm_type='', pgm_pattern='',
summary_lines=[], prerequisites=[],
files=[], keys=[], modules=[],
process_detail='', output_records='',
input_type='file', copy_fields={}, db_tables={})
parser._parse_use_files(meta)
assert len(meta.files) == 5
r01 = [f for f in meta.files if f.identifier == 'R01'][0]
assert r01.file_db_name == 'OVT-SORTED'
assert r01.copy_group == 'ZAN01REC'
assert r01.medium == 'PS'
assert r01.io == 'I'
def test_determine_input_type_file():
parser = InputParser(
design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'),
source_cbl_path='dummy.cbl', file_db_md_path='dummy.md',
cpy_dir='dummy_cpy', db_md_path='dummy_db.md'
)
from agent.models import ProgramMeta, FileInfo
meta = ProgramMeta(program_id='ZAN04MAT', program_name='', system_name='',
pgm_type='', pgm_pattern='',
summary_lines=[], prerequisites=[],
files=[], keys=[], modules=[],
process_detail='', output_records='',
input_type='file', copy_fields={}, db_tables={})
meta.files = [
FileInfo(no=1, file_db_name='F1', identifier='R01', dd_name='DD1',
io='I', copy_group='C1', format='FB', record_len=80,
medium='PS', remarks=''),
FileInfo(no=2, file_db_name='F2', identifier='W01', dd_name='DD2',
io='O', copy_group='C2', format='FB', record_len=80,
medium='PS', remarks=''),
]
parser._determine_input_type(meta)
assert meta.input_type == 'file'
def test_determine_input_type_mixed():
parser = InputParser(
design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'),
source_cbl_path='dummy.cbl', file_db_md_path='dummy.md',
cpy_dir='dummy_cpy', db_md_path='dummy_db.md'
)
from agent.models import ProgramMeta, FileInfo
meta = ProgramMeta(program_id='TEST', program_name='', system_name='',
pgm_type='', pgm_pattern='',
summary_lines=[], prerequisites=[],
files=[], keys=[], modules=[],
process_detail='', output_records='',
input_type='file', copy_fields={}, db_tables={})
meta.files = [
FileInfo(no=1, file_db_name='F1', identifier='R01', dd_name='DD1',
io='I', copy_group='C1', format='FB', record_len=80,
medium='PS', remarks=''),
FileInfo(no=2, file_db_name='DB1', identifier='DB', dd_name='',
io='I', copy_group='', format='', record_len=0,
medium='DB', remarks=''),
]
parser._determine_input_type(meta)
assert meta.input_type == 'mixed'
def test_parse_process_detail():
parser = InputParser(
design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'),
source_cbl_path='dummy.cbl', file_db_md_path='dummy.md',
cpy_dir='dummy_cpy', db_md_path='dummy_db.md'
)
parser._design_text = parser._read_file(parser.design_md_path)
from agent.models import ProgramMeta
meta = ProgramMeta(program_id='', program_name='', system_name='',
pgm_type='', pgm_pattern='',
summary_lines=[], prerequisites=[],
files=[], keys=[], modules=[],
process_detail='', output_records='',
input_type='file', copy_fields={}, db_tables={})
parser._parse_process_detail(meta)
assert '1000ITTSOR' in meta.process_detail
assert 'マッチの場合' in meta.process_detail
def test_parse_output_records():
parser = InputParser(
design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'),
source_cbl_path='dummy.cbl', file_db_md_path='dummy.md',
cpy_dir='dummy_cpy', db_md_path='dummy_db.md'
)
parser._design_text = parser._read_file(parser.design_md_path)
from agent.models import ProgramMeta
meta = ProgramMeta(program_id='', program_name='', system_name='',
pgm_type='', pgm_pattern='',
summary_lines=[], prerequisites=[],
files=[], keys=[], modules=[],
process_detail='', output_records='',
input_type='file', copy_fields={}, db_tables={})
parser._parse_output_records(meta)
assert 'OVT-MATCHED' in meta.output_records
assert 'OVT-DBCLEAN' in meta.output_records
```
- [ ] **Step 7: 运行测试**
Run: `python -m pytest tests/test_input_parser.py -v`
Expected: all PASS
---
### Task 4: InputParser — COPYBOOK 解析
**Files:**
- Modify: `D:\jcl-cobol-data-create\agent\input_parser.py` (添加 `_parse_copybooks`)
- Create: `D:\jcl-cobol-data-create\tests\test_copy_parser.py`
- [ ] **Step 1: 添加 COPYBOOK 解析逻辑到 InputParser**
`agent/input_parser.py``_parse_copybooks` 方法中追加实现,替换之前的 `pass`
```python
def _parse_copybooks(self, meta: ProgramMeta):
"""解析 COPYBOOK 文件,应用 REPLACING 替换。"""
# Step 1: 从源码提取 COPY REPLACING 映射
replacing_map = self._extract_copy_replacing()
# Step 2: 遍历使用ファイル一覧 中的文件,找到对应的 COPYBOOK
for f in meta.files:
if not f.copy_group:
continue
copy_file = os.path.join(self.cpy_dir, f.copy_group + '.cpy')
if not os.path.exists(copy_file):
continue
# 从 REPLACING 映射获取替换前缀
prefix = replacing_map.get(f.copy_group, f.identifier)
fields = self._parse_single_copybook(copy_file, prefix)
if f.identifier not in meta.copy_fields:
meta.copy_fields[f.identifier] = fields
def _extract_copy_replacing(self) -> Dict[str, str]:
"""从 COBOL 源码提取 COPY ... REPLACING ... 映射。
Returns: {copy_name: replacing_value} e.g. {'ZAN01REC': 'R01'}
"""
result = {}
# 匹配模式: COPY ZAN01REC REPLACING ==(A)== BY ==R01==.
pattern = r'COPY\s+(\S+)\s+REPLACING\s+==\(A\)==\s+BY\s+==(\S+)=='
for match in re.finditer(pattern, self._source_text, re.IGNORECASE):
copy_name = match.group(1)
replacing_value = match.group(2)
result[copy_name] = replacing_value
return result
def _parse_single_copybook(self, copy_path: str, prefix: str) -> List[CopyField]:
"""解析单个 COPYBOOK 文件,将 (A) 占位符替换为 'prefix-' 前缀。
e.g. (A)APPL-ID + prefix='R01' → R01-APPL-ID
"""
copy_text = self._read_file(copy_path)
fields = []
for line in copy_text.split('\n'):
line = line.strip()
if not line or line.startswith('*'):
continue
# 匹配: 03 (A)FIELD-NAME PIC X(008).
# 或: 03 (A)FIELD-NAME PIC S9(009) COMP-3.
match = re.match(
r'(\d{2})\s+\(A\)(\S+)\s+PIC\s+(.+?)\.',
line, re.IGNORECASE
)
if not match:
continue
level = int(match.group(1))
raw_name = '(A)' + match.group(2)
pic_full = match.group(3).strip()
# 替换占位符
actual_name = prefix + '-' + match.group(2)
pic_bytes = self._calculate_pic_bytes(pic_full)
fields.append(CopyField(
level=level,
name=actual_name,
raw_name=raw_name,
pic_type=pic_full,
pic_bytes=pic_bytes
))
return fields
@staticmethod
def _calculate_pic_bytes(pic: str) -> int:
"""Calculate byte size from PIC definition.
Handles: X(n), 9(n), S9(n)V9(m), COMP-3 variants, COMP variants.
"""
pic_upper = pic.upper().strip()
# COMP-3 packed decimal: S9(n)V9(m) COMP-3 or S9(n) COMP-3 or 9(n) COMP-3
if 'COMP-3' in pic_upper:
# Remove COMP-3 suffix
base = pic_upper.replace('COMP-3', '').strip()
# Extract digits: count all 9s
total_digits = sum(int(num) for num in re.findall(r'9\((\d+)\)', base))
if base.startswith('S'):
return (total_digits + 2) // 2
else:
return (total_digits + 1) // 2
# COMP / BINARY: S9(n) COMP, S9(n)V9(m) COMP
if 'COMP' in pic_upper or 'BINARY' in pic_upper:
base = re.sub(r'\s*(COMP|BINARY)\s*', '', pic_upper).strip()
total_digits = sum(int(num) for num in re.findall(r'9\((\d+)\)', base))
if total_digits <= 4:
return 2
elif total_digits <= 9:
return 4
else:
return 8
# Simple PIC: X(n), 9(n), S9(n), S9(n)V9(m)
total = 0
# Match X(n)
for num in re.findall(r'X\((\d+)\)', pic_upper):
total += int(num)
# Match G(n) (double-byte)
for num in re.findall(r'G\((\d+)\)', pic_upper):
total += int(num) * 2
# Match 9(n)
for num in re.findall(r'9\((\d+)\)', pic_upper):
total += int(num)
if total > 0:
return total
return 0
```
- [ ] **Step 2: 创建 COPYBOOK 解析测试的 fixture**
将实际 COPYBOOK 文件复制到测试目录:
```powershell
Copy-Item "D:\cobol-tna-system\cpy\ZAN01REC.cpy" "D:\jcl-cobol-data-create\tests\test_data\ZAN01REC.cpy"
Copy-Item "D:\cobol-tna-system\cpy\ZAN02REC.cpy" "D:\jcl-cobol-data-create\tests\test_data\ZAN02REC.cpy"
Copy-Item "D:\cobol-tna-system\cpy\ZAN04REC.cpy" "D:\jcl-cobol-data-create\tests\test_data\ZAN04REC.cpy"
```
- [ ] **Step 3: 写 COPYBOOK 解析测试**
```python
# tests/test_copy_parser.py
import os
import tempfile
from agent.input_parser import InputParser
FIXTURE_DIR = os.path.join(os.path.dirname(__file__), 'test_data')
def test_parse_single_copybook():
parser = InputParser(
design_md_path='dummy.md', source_cbl_path='dummy.cbl',
file_db_md_path='dummy.md', cpy_dir=FIXTURE_DIR,
db_md_path='dummy_db.md'
)
fields = parser._parse_single_copybook(
os.path.join(FIXTURE_DIR, 'ZAN01REC.cpy'),
prefix='R01'
)
assert len(fields) == 8
# 第一个字段: (A)APPL-ID → R01-APPL-ID, PIC X(008)
f0 = fields[0]
assert f0.name == 'R01-APPL-ID'
assert f0.raw_name == '(A)APPL-ID'
assert 'X' in f0.pic_type
assert f0.pic_bytes == 8
# EMP-ID: PIC 9(008)
f1 = fields[1]
assert f1.name == 'R01-EMP-ID'
assert '9' in f1.pic_type
assert f1.pic_bytes == 8
# START-TIME: PIC 9(004)
f3 = fields[3]
assert f3.name == 'R01-START-TIME'
assert f3.pic_bytes == 4
# FILLER
f7 = fields[7]
assert 'FILLER' in f7.name
assert f7.pic_bytes == 46
def test_extract_copy_replacing():
source_text = """
FD R01INNFIL.
01 R01INNREC.
COPY ZAN01REC REPLACING ==(A)== BY ==R01==.
FD R02INNFIL.
01 R02INNREC.
COPY ZAN04REC REPLACING ==(A)== BY ==R02==.
"""
parser = InputParser(
design_md_path='dummy.md', source_cbl_path='dummy.cbl',
file_db_md_path='dummy.md', cpy_dir=FIXTURE_DIR,
db_md_path='dummy_db.md'
)
parser._source_text = source_text
result = parser._extract_copy_replacing()
assert result['ZAN01REC'] == 'R01'
assert result['ZAN04REC'] == 'R02'
def test_calculate_pic_bytes_x():
assert InputParser._calculate_pic_bytes('X(008)') == 8
assert InputParser._calculate_pic_bytes('X(10)') == 10
def test_calculate_pic_bytes_9():
assert InputParser._calculate_pic_bytes('9(008)') == 8
assert InputParser._calculate_pic_bytes('9(004)') == 4
def test_calculate_pic_bytes_comp3():
# S9(009) COMP-3: 9 digits → (9+2)//2 = 5
assert InputParser._calculate_pic_bytes('S9(009) COMP-3') == 5
# S9(7) COMP-3: 7 digits → (7+2)//2 = 4
assert InputParser._calculate_pic_bytes('S9(7) COMP-3') == 4
# 9(7) COMP-3: 7 digits unsigned → (7+1)//2 = 4
assert InputParser._calculate_pic_bytes('9(7) COMP-3') == 4
def test_calculate_pic_bytes_comp():
# S9(4) COMP: <=4 → 2 bytes
assert InputParser._calculate_pic_bytes('S9(4) COMP') == 2
# S9(9) COMP: <=9 → 4 bytes
assert InputParser._calculate_pic_bytes('S9(9) COMP') == 4
def test_calculate_pic_bytes_decimal():
# S9(7)V9(2): 7+2=9 bytes
assert InputParser._calculate_pic_bytes('S9(7)V9(2)') == 9
```
- [ ] **Step 4: 运行测试**
Run: `python -m pytest tests/test_copy_parser.py -v`
Expected: all PASS
---
### Task 5: InputParser — DB 定义解析
**Files:**
- Modify: `D:\jcl-cobol-data-create\agent\input_parser.py` (添加 `_parse_db_definition`)
- Create: `D:\jcl-cobol-data-create\tests\test_db_parser.py`
- [ ] **Step 1: 添加 DB 定义解析逻辑**
`agent/input_parser.py``_parse_db_definition` 方法中追加实现:
```python
def _parse_db_definition(self, meta: ProgramMeta):
"""解析 DB 定义书 .md,提取表结构信息。
只在输入类型涉及 DB 时调用。
"""
if meta.input_type not in ('db', 'mixed'):
return
db_text = self._read_file(self.db_md_path)
# 按 ## 切分每个表定义
sections = re.split(r'\n## ', db_text)
for section in sections:
# 提取表名(## 后的第一部分)
table_match = re.match(r'^([\w\-]+)', section)
if not table_match:
continue
table_name = table_match.group(1)
# 查找 "DB基本情報" 子节
db_info_text = extract_section('## ' + section, 'DB基本情報')
db_info_rows = parse_table_rows(db_info_text)
db_id = ''
copy_id = ''
for row in db_info_rows:
db_id = row.get('DB ID', '') or db_id
copy_id = row.get('COPY ID', '') or copy_id
# 查找 "カラム定義" 子节
column_text = extract_section('## ' + section, 'カラム定義')
column_rows = parse_table_rows(column_text)
columns = []
pk_columns = []
for row in column_rows:
try:
no = int(row.get('No', '0'))
except ValueError:
continue
is_pk = row.get('PK', '') == ''
nullable = row.get('NULL', 'NULL許可') == 'NULL許可'
col = TableColumn(
no=no,
name_jp=row.get('項目名', ''),
name_en=row.get('項目名(英字名)', ''),
type=row.get('TYPE', ''),
max_len=row.get('最大長', ''),
decimal_digits=row.get('小数桁', ''),
byte_count=row.get('バイト数', ''),
nullable=nullable,
is_pk=is_pk
)
columns.append(col)
if is_pk:
pk_columns.append(col.name_en)
table = TableInfo(
table_name=table_name,
db_id=db_id,
copy_id=copy_id,
columns=columns,
pk_columns=pk_columns
)
meta.db_tables[table_name] = table
```
- [ ] **Step 2: 创建 DB 测试 fixture**
```powershell
Copy-Item "D:\cobol-tna-system\詳細設計書\DB定義書.md" "D:\jcl-cobol-data-create\tests\test_data\DB定義書.md"
```
- [ ] **Step 3: 写 DB 解析测试**
```python
# tests/test_db_parser.py
import os
from agent.input_parser import InputParser
from agent.models import ProgramMeta, FileInfo
FIXTURE_DIR = os.path.join(os.path.dirname(__file__), 'test_data')
def test_parse_db_definition():
parser = InputParser(
design_md_path='dummy.md', source_cbl_path='dummy.cbl',
file_db_md_path='dummy.md', cpy_dir='dummy_cpy',
db_md_path=os.path.join(FIXTURE_DIR, 'DB定義書.md')
)
meta = ProgramMeta(
program_id='TEST', program_name='テスト', system_name='',
pgm_type='メイン', pgm_pattern='DB更新',
summary_lines=[], prerequisites=[], files=[], keys=[], modules=[],
process_detail='', output_records='',
input_type='mixed', # DB 类型才会触发解析
copy_fields={}, db_tables={}
)
parser._parse_db_definition(meta)
assert len(meta.db_tables) > 0
assert 'EMP_MASTER' in meta.db_tables
emp = meta.db_tables['EMP_MASTER']
assert emp.db_id == 'EMP_MASTER'
assert len(emp.columns) >= 3
assert emp.pk_columns == ['EMP_ID']
def test_parse_db_skipped_for_file_type():
parser = InputParser(
design_md_path='dummy.md', source_cbl_path='dummy.cbl',
file_db_md_path='dummy.md', cpy_dir='dummy_cpy',
db_md_path=os.path.join(FIXTURE_DIR, 'DB定義書.md')
)
meta = ProgramMeta(
program_id='TEST', program_name='テスト', system_name='',
pgm_type='メイン', pgm_pattern='マッチング(1:1)',
summary_lines=[], prerequisites=[], files=[], keys=[], modules=[],
process_detail='', output_records='',
input_type='file', # 文件类型,跳过 DB 解析
copy_fields={}, db_tables={}
)
parser._parse_db_definition(meta)
assert len(meta.db_tables) == 0
```
- [ ] **Step 4: 运行测试**
Run: `python -m pytest tests/test_db_parser.py -v`
Expected: all PASS
---
### Task 6: RuleLoader — 规则匹配与特殊功能检测
**Files:**
- Create: `D:\jcl-cobol-data-create\agent\rule_loader.py`
- Create: `D:\jcl-cobol-data-create\tests\test_rule_loader.py`
- [ ] **Step 1: 实现 RuleLoader**
```python
# agent/rule_loader.py
import os
import re
from typing import List, Tuple, Optional
from agent.models import ProgramMeta
# PGMパターン → 规则文件名 映射表
PGM_PATTERN_MAP = {
'マッチング(1:1)': 'マッチング(1-1).md',
'マッチング(1:N)': 'マッチング(1-N).md',
'マッチング(N:1)': 'マッチング(N-1).md',
'マッチング(M:N)': 'マッチング(M-N).md',
'レイアウト編集のみ(GETPUT)': 'レイアウト編集のみ(GETPUT).md',
'レイアウト編集のみ': 'レイアウト編集のみ(GETPUT).md',
'項目チェック': '項目チェック.md',
'振り分け': '振り分け.md',
'振り分け(IF文、EVALUATE文)': '振り分け.md',
'キーブレイク': 'キーブレイク.md',
'キーブレイク(集計、集約)': 'キーブレイク(集計、集約).md',
'キーブレイク(集計、集約の以外)': 'キーブレイク.md',
'DB更新': 'DB更新.md',
}
# 特殊功能检测:关键词集合 → 规则文件名
SPECIAL_FEATURE_CHECKS = [
(['場合', 'EVALUATE', 'IF'], '条件分支.md'),
]
class RuleLoader:
"""根据程序特征加载对应的数据生成规则。"""
def __init__(self, rules_dir: str):
"""
Args:
rules_dir: 规则根目录,包含 pgm_pattern/ 和 special_feature/ 子目录
"""
self.pgm_pattern_dir = os.path.join(rules_dir, 'pgm_pattern')
self.special_feature_dir = os.path.join(rules_dir, 'special_feature')
def load(self, meta: ProgramMeta) -> Tuple[str, List[str], int]:
"""加载所有相关规则,返回 (合并后的规则文本, 组描述列表, 组数)。
Returns:
combined_rules: 合并后的规则文本
group_descriptions: 各组的用途描述列表
group_count: 组数
"""
# 1. 加载 PGM 模式规则
pgm_rule = self._load_pgm_pattern_rule(meta.pgm_pattern)
if pgm_rule is None:
raise FileNotFoundError(
f"PGM模式 '{meta.pgm_pattern}' 的规则文件未找到。"
f"请在 {self.pgm_pattern_dir} 中添加对应的 .md 文件。"
)
# 2. 解析组信息
group_descriptions, group_count = self._parse_group_info(pgm_rule)
# 3. 检测特殊功能
parts = [pgm_rule]
for keywords, rule_file in SPECIAL_FEATURE_CHECKS:
if self._detect_feature(meta.process_detail, keywords):
feature_rule = self._read_rule_file(
os.path.join(self.special_feature_dir, rule_file)
)
if feature_rule:
parts.append(feature_rule)
combined = '\n\n---\n\n'.join(parts)
return combined, group_descriptions, group_count
def _load_pgm_pattern_rule(self, pgm_pattern: str) -> Optional[str]:
"""根据 PGMパターン 加载对应的规则文件。"""
# 先精确匹配
filename = PGM_PATTERN_MAP.get(pgm_pattern)
if filename:
path = os.path.join(self.pgm_pattern_dir, filename)
if os.path.exists(path):
return self._read_rule_file(path)
# 尝试模糊匹配:检查文件名是否包含关键字
for fname in os.listdir(self.pgm_pattern_dir):
if fname.endswith('.md'):
# 尝试模糊匹配
keyword = pgm_pattern.split('(')[0].strip()
if keyword in fname:
return self._read_rule_file(os.path.join(self.pgm_pattern_dir, fname))
return None
@staticmethod
def _read_rule_file(path: str) -> str:
with open(path, 'r', encoding='utf-8') as f:
return f.read()
@staticmethod
def _parse_group_info(rule_text: str) -> Tuple[List[str], int]:
"""从规则文本中解析组信息。
查找表格中 '' 列对应的行,提取组号和用途。
"""
descriptions = []
group_count = 0
# 匹配规则中的组描述表格
# 格式: | 组 | 用途 | ... |
for line in rule_text.split('\n'):
line = line.strip()
if line.startswith('|') and not re.match(r'^\|[\s\-:]+\|', line):
cells = [c.strip() for c in line.split('|')[1:-1]]
if len(cells) >= 2:
try:
group_num = int(cells[0])
if group_num > group_count:
group_count = group_num
descriptions.append(cells[1])
except ValueError:
pass
return descriptions, group_count
@staticmethod
def _detect_feature(process_detail: str, keywords: List[str]) -> bool:
"""检测処理詳細中是否包含特定关键词。"""
for kw in keywords:
if kw in process_detail:
return True
return False
```
- [ ] **Step 2: 写 RuleLoader 测试**
```python
# tests/test_rule_loader.py
import os
from agent.rule_loader import RuleLoader, PGM_PATTERN_MAP
from agent.models import ProgramMeta
RULES_DIR = os.path.join(os.path.dirname(__file__), '..', 'rules')
def test_pgm_pattern_map_has_known_patterns():
assert 'マッチング(1:1)' in PGM_PATTERN_MAP
assert 'マッチング(1:N)' in PGM_PATTERN_MAP
assert 'DB更新' in PGM_PATTERN_MAP
def test_load_matching_1_1_rule():
loader = RuleLoader(RULES_DIR)
meta = ProgramMeta(
program_id='TEST', program_name='', system_name='',
pgm_type='メイン', pgm_pattern='マッチング(1:1)',
summary_lines=[], prerequisites=[], files=[], keys=[], modules=[],
process_detail='', output_records='',
input_type='file', copy_fields={}, db_tables={}
)
combined, descriptions, count = loader.load(meta)
assert count > 0
assert len(combined) > 0
assert 'マッチング(1:1)' in combined
assert '数据生成步骤' in combined
def test_load_matching_1_n_rule():
loader = RuleLoader(RULES_DIR)
meta = ProgramMeta(
program_id='TEST', program_name='', system_name='',
pgm_type='メイン', pgm_pattern='マッチング(1:N)',
summary_lines=[], prerequisites=[], files=[], keys=[], modules=[],
process_detail='', output_records='',
input_type='file', copy_fields={}, db_tables={}
)
combined, descriptions, count = loader.load(meta)
assert count > 0
assert len(combined) > 0
assert 'マッチング(1:N)' in combined
def test_load_nonexistent_pattern_raises():
loader = RuleLoader(RULES_DIR)
meta = ProgramMeta(
program_id='TEST', program_name='', system_name='',
pgm_type='メイン', pgm_pattern='不存在的パターン',
summary_lines=[], prerequisites=[], files=[], keys=[], modules=[],
process_detail='', output_records='',
input_type='file', copy_fields={}, db_tables={}
)
try:
loader.load(meta)
assert False, 'Should have raised FileNotFoundError'
except FileNotFoundError:
pass
def test_detect_conditional_branch():
process = """
2-1-2.ロジック分岐判定(EVALUATE)
2-1-2-1.STATUS='1'の場合
INSERT処理
"""
assert RuleLoader._detect_feature(process, ['場合', 'EVALUATE', 'IF']) is True
def test_no_conditional_branch():
process = """
2-1.マッチの場合
R01をW01に出力
"""
# '場合' 也匹配了! 日文中 'の場合' 表示 "在...情况下",视为条件分支
assert RuleLoader._detect_feature(process, ['場合', 'EVALUATE', 'IF']) is True
def test_rule_with_conditional_feature():
loader = RuleLoader(RULES_DIR)
meta = ProgramMeta(
program_id='TEST', program_name='', system_name='',
pgm_type='メイン', pgm_pattern='マッチング(1:1)',
summary_lines=[], prerequisites=[], files=[], keys=[], modules=[],
process_detail='2-1-2.EVALUATEで分岐する。STATUS=1の場合INSERTする。',
output_records='',
input_type='file', copy_fields={}, db_tables={}
)
combined, descriptions, count = loader.load(meta)
assert count > 0
# 条件分支规则应该被包含
assert '分支' in combined or '条件分支' in combined or '条件判断' in combined
```
- [ ] **Step 3: 运行测试**
Run: `python -m pytest tests/test_rule_loader.py -v`
Expected: all PASS
---
### Task 7: PromptBuilder — Prompt 组装
**Files:**
- Create: `D:\jcl-cobol-data-create\agent\prompt_builder.py`
- Create: `D:\jcl-cobol-data-create\tests\test_prompt_builder.py`
- [ ] **Step 1: 实现 PromptBuilder**
```python
# agent/prompt_builder.py
from typing import List
from agent.models import ProgramMeta, CopyField, TableInfo
class PromptBuilder:
"""将解析后的程序元数据和规则组装成 API prompt。"""
def build(self, meta: ProgramMeta, rules_text: str,
group_descriptions: List[str], group_count: int) -> str:
"""构建完整的 API prompt。"""
parts = []
# 1. 程序基本情報
parts.append(self._build_basic_info(meta))
# 2. 処理詳細
parts.append(self._build_process_detail(meta))
# 3. 入力構造 (文件 + DB)
parts.append(self._build_input_structures(meta))
# 4. 出力レコード定義
parts.append(self._build_output_records(meta))
# 5. データ生成ルール
parts.append(rules_text)
# 6. 出力格式指示
parts.append(self._build_output_format(meta))
# 7. 生成指示
parts.append(self._build_generation_instruction(
group_descriptions, group_count, meta.input_type
))
return '\n\n'.join(parts)
def _build_basic_info(self, meta: ProgramMeta) -> str:
lines = [
f"## 程序基本情報",
f"- システム名: {meta.system_name}",
f"- プログラムID: {meta.program_id}",
f"- プログラム名: {meta.program_name}",
f"- PGMパターン: {meta.pgm_pattern}",
f"- 入力タイプ: {self._input_type_label(meta.input_type)}",
]
if meta.summary_lines:
lines.append(f"- 機能概要: {' '.join(meta.summary_lines)}")
return '\n'.join(lines)
def _build_process_detail(self, meta: ProgramMeta) -> str:
return f"## 処理詳細\n\n```\n{meta.process_detail}\n```"
def _build_input_structures(self, meta: ProgramMeta) -> str:
parts = ["## 入力構造"]
# 文件输入结构
input_files = [f for f in meta.files if 'I' in f.io]
for f in input_files:
parts.append(f"### ファイル {f.identifier} (DD名: {f.dd_name}, COPY: {f.copy_group}, 媒体: {f.medium})")
fields = meta.copy_fields.get(f.identifier, [])
if fields:
parts.append("| 項目名 | PIC | バイト数 |")
parts.append("|--------|-----|----------|")
for cf in fields:
parts.append(f"| {cf.name} | {cf.pic_type} | {cf.pic_bytes} |")
else:
parts.append("(構造情報なし)")
# DB 表结构
db_inputs = [f for f in meta.files if 'I' in f.io and f.medium == 'DB']
if db_inputs or meta.db_tables:
parts.append("### DBテーブル構造")
for table_name, table in meta.db_tables.items():
parts.append(f"**表名: {table_name}**")
parts.append(f"主キー: {', '.join(table.pk_columns)}")
parts.append("| 項目名 | 英字名 | タイプ | 最大長 | KEY |")
parts.append("|--------|--------|--------|--------|-----|")
for col in table.columns:
key_mark = '' if col.is_pk else ''
parts.append(f"| {col.name_jp} | {col.name_en} | {col.type} | {col.max_len} | {key_mark} |")
return '\n\n'.join(parts)
def _build_output_records(self, meta: ProgramMeta) -> str:
return f"## 出力レコード定義\n\n```\n{meta.output_records}\n```"
def _build_output_format(self, meta: ProgramMeta) -> str:
lines = [
"## 出力形式",
"",
"### JSON形式(ファイル入力の場合)",
f"各グループ1つのJSONファイル: `{{program}}_{{group}}.json`",
"",
"JSON構造:",
"```json",
"{",
' "program": "{プログラムID}",',
' "records": [',
' { "input": { "FD名": { "項目名": "", ... } } },',
' ...',
' ]',
"}",
"```",
"",
"### 項目値のルール(PIC → JSON値)",
"| PIC | JSON内表示 | 例 |",
"|-----|-----------|-----|",
"| PIC X(n) | 左詰め + スペース埋め | `\"A0000001\"` |",
"| PIC 9(n) | 右詰め + 先行ゼロ | `\"00000101\"` |",
"| PIC S9(n) | 符号 + 右詰め + 先行ゼロ | `\"+0000101\"` |",
"| PIC S9(n)V9(m) | 符号 + 右詰め + 小数点含む | `\"+001234567\"` |",
"| PIC S9(n) COMP | 通常の10進数文字列 | `\"300\"` |",
"| PIC S9(n) COMP-3 | 通常の10進数文字列 | `\"1234\"` |",
"| PIC 9(n) COMP-3 | 通常の10進数文字列 | `\"1234\"` |",
"| FILLER(純粋予約) | グループIDとレコード番号を含む識別可能なパターン | `\"D000000...001\"` |",
"| FILLER(業務予約) | 全スペース(PIC X) または 全ゼロ(PIC 9) | |",
"",
"### SQL形式(DB入力の場合)",
"各グループ1つのSQLファイル: `{program}_g{groupId}.sql`",
"",
"SQL例:",
"```sql",
"-- Group: 1",
"INSERT INTO TABLE_NAME (COL1, COL2) VALUES",
"('val1', 'val2');",
"```",
"",
"### データ生成の注意",
"- 項目名に意味がある場合(DATE→日付、NAME→氏名、ADDRESS→住所等)、実際の形式に合った値を生成すること",
"- 隣接するレコード間で、できるだけ同じ項目に異なる値を設定すること",
]
return '\n'.join(lines)
def _build_generation_instruction(self, descriptions: List[str],
count: int, input_type: str) -> str:
lines = [
"## 生成指示",
f"生成するグループ数: {count}",
"各グループの内容:",
]
for i, desc in enumerate(descriptions, 1):
lines.append(f" - g{i}: {desc}")
if input_type == 'file':
lines.append("出力: JSONファイル")
elif input_type == 'db':
lines.append("出力: SQL INSERTファイル")
else:
lines.append("出力: JSONファイル + SQL INSERTファイル(混合)")
lines.append("")
lines.append("すべてのグループのデータを一度にJSONで返してください。")
lines.append("出力形式: { \"groups\": { \"g1\": { \"type\": \"json\"|... }, ... } }")
return '\n'.join(lines)
@staticmethod
def _input_type_label(t: str) -> str:
labels = {'file': 'ファイル', 'db': 'DB', 'mixed': '混合(ファイル+DB'}
return labels.get(t, t)
```
- [ ] **Step 2: 写 PromptBuilder 测试**
```python
# tests/test_prompt_builder.py
from agent.prompt_builder import PromptBuilder
from agent.models import ProgramMeta, FileInfo, CopyField
def test_build_basic_prompt():
meta = ProgramMeta(
program_id='ZAN04MAT', program_name='取消マッチング処理',
system_name='残業統計管理システム',
pgm_type='メイン', pgm_pattern='マッチング(1:1)',
summary_lines=['取消申請のマッチング'], prerequisites=[],
files=[], keys=[], modules=[],
process_detail='1.初期処理...\n2.主処理...',
output_records='### 出力ファイル1...',
input_type='file', copy_fields={}, db_tables={}
)
builder = PromptBuilder()
prompt = builder.build(
meta=meta,
rules_text='# マッチング(1:1) データ生成規則\n...',
group_descriptions=['両端不一致', '逆方向両端不一致', '中間不一致'],
group_count=3
)
assert 'ZAN04MAT' in prompt
assert 'マッチング(1:1)' in prompt
assert '1.初期処理' in prompt
assert '生成するグループ数: 3' in prompt
assert 'g1: 両端不一致' in prompt
assert 'g2: 逆方向両端不一致' in prompt
assert 'g3: 中間不一致' in prompt
assert 'JSON形式' in prompt
def test_build_with_copy_fields():
fields = {
'R01': [
CopyField(level=3, name='R01-APPL-ID', raw_name='(A)APPL-ID',
pic_type='X(008)', pic_bytes=8),
CopyField(level=3, name='R01-EMP-ID', raw_name='(A)EMP-ID',
pic_type='9(008)', pic_bytes=8),
]
}
meta = ProgramMeta(
program_id='TEST', program_name='', system_name='',
pgm_type='メイン', pgm_pattern='マッチング(1:1)',
summary_lines=[], prerequisites=[],
files=[
FileInfo(no=1, file_db_name='INPUT-FILE', identifier='R01',
dd_name='TESTR01', io='I', copy_group='TESTREC',
format='FB', record_len=80, medium='PS', remarks='')
],
keys=[], modules=[],
process_detail='', output_records='',
input_type='file', copy_fields=fields, db_tables={}
)
builder = PromptBuilder()
prompt = builder.build(
meta=meta,
rules_text='# 規則',
group_descriptions=['テスト'],
group_count=1
)
assert 'R01-APPL-ID' in prompt
assert 'X(008)' in prompt
assert '8' in prompt
assert 'R01-EMP-ID' in prompt
def test_build_db_prompt():
meta = ProgramMeta(
program_id='TESTDB', program_name='DB更新', system_name='',
pgm_type='メイン', pgm_pattern='DB更新',
summary_lines=[], prerequisites=[],
files=[
FileInfo(no=1, file_db_name='INPUT-FILE', identifier='R01',
dd_name='TESTR01', io='I', copy_group='TESTREC',
format='FB', record_len=80, medium='DB', remarks='')
],
keys=[], modules=[],
process_detail='', output_records='',
input_type='db', copy_fields={}, db_tables={}
)
builder = PromptBuilder()
prompt = builder.build(
meta=meta,
rules_text='# DB更新規則',
group_descriptions=['テスト'],
group_count=1
)
assert 'DB' in prompt or 'SQL' in prompt
assert 'INSERT' in prompt
```
- [ ] **Step 3: 运行测试**
Run: `python -m pytest tests/test_prompt_builder.py -v`
Expected: all PASS
---
### Task 8: APIClient — API 调用与重试
**Files:**
- Create: `D:\jcl-cobol-data-create\agent\api_client.py`
- Create: `D:\jcl-cobol-data-create\tests\test_api_client.py`
- [ ] **Step 1: 实现 APIClient**
```python
# agent/api_client.py
import json
import time
from typing import Dict, Any, Optional
import requests
class APIClient:
"""DeepSeek API 客户端,含重试逻辑。"""
def __init__(self, api_key: str, model: str = 'deepseek-v4-flash',
base_url: str = 'https://api.deepseek.com/chat/completions',
max_retries: int = 3, timeout: int = 120):
self.api_key = api_key
self.model = model
self.base_url = base_url
self.max_retries = max_retries
self.timeout = timeout
def generate(self, prompt: str) -> Dict[str, Any]:
"""发送 prompt 并返回 AI 生成的结果。
Returns:
AI 返回的 JSON 字典
Raises:
RuntimeError: 超过最大重试次数后仍然失败
"""
system_prompt = (
"你是COBOL程序的测试数据生成专家。"
"请严格按照提供的规则,生成符合格式要求的测试数据。"
"输出必须是可被json.loads()直接解析的JSON,不要包裹在```json```代码块中。"
"不要在JSON前后添加任何说明文字。"
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
]
last_error = None
for attempt in range(1, self.max_retries + 1):
try:
response = self._call_api(messages)
# 提取 AI 返回的文本
content = response['choices'][0]['message']['content']
# 尝试解析 JSON
data = self._parse_json(content)
if data is not None:
return data
# JSON 解析失败,反馈给 AI 重试
error_msg = (
f"前回の出力は有効なJSONではありませんでした。"
f"必ず有効なJSONのみを出力してください。"
f"コードブロック(```)で囲まないでください。"
)
messages.append({"role": "assistant", "content": content})
messages.append({"role": "user", "content": error_msg})
except requests.exceptions.RequestException as e:
last_error = e
if attempt < self.max_retries:
time.sleep(2 ** attempt) # 指数退避
continue
raise RuntimeError(
f"API调用失败,已重试{self.max_retries}次。"
f"最后错误: {last_error}"
)
def _call_api(self, messages: list) -> dict:
"""单次 API 调用。"""
headers = {
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json',
}
payload = {
'model': self.model,
'messages': messages,
'temperature': 0.3,
'max_tokens': 8192,
}
resp = requests.post(
self.base_url,
headers=headers,
json=payload,
timeout=self.timeout
)
resp.raise_for_status()
return resp.json()
@staticmethod
def _parse_json(text: str) -> Optional[dict]:
"""尝试从文本中提取 JSON。"""
text = text.strip()
# 去除可能的 markdown 代码块包裹
if text.startswith('```json'):
text = text[7:]
if text.startswith('```'):
text = text[3:]
if text.endswith('```'):
text = text[:-3]
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
# 尝试找到第一个 { 和最后一个 }
start = text.find('{')
end = text.rfind('}')
if start >= 0 and end > start:
try:
return json.loads(text[start:end + 1])
except json.JSONDecodeError:
pass
return None
```
- [ ] **Step 2: 写 APIClient 测试(mock API**
```python
# tests/test_api_client.py
import json
from unittest.mock import patch, MagicMock
from agent.api_client import APIClient
def test_parse_json_valid():
text = '{"groups": {"g1": {"records": []}}}'
result = APIClient._parse_json(text)
assert result is not None
assert 'groups' in result
def test_parse_json_with_markdown_wrapper():
text = '```json\n{"key": "value"}\n```'
result = APIClient._parse_json(text)
assert result is not None
assert result['key'] == 'value'
def test_parse_json_with_text_before():
text = '少し説明があります。\n{"key": "value"}\n以上です。'
result = APIClient._parse_json(text)
assert result is not None
assert result['key'] == 'value'
def test_parse_json_invalid():
text = 'これは有効なJSONではありません。'
result = APIClient._parse_json(text)
assert result is None
@patch('agent.api_client.requests.post')
def test_generate_success(mock_post):
mock_response = MagicMock()
mock_response.json.return_value = {
'choices': [{'message': {'content': '{"groups": {"g1": {"type": "json"}}}'}}]
}
mock_response.raise_for_status = MagicMock()
mock_post.return_value = mock_response
client = APIClient(api_key='test-key')
result = client.generate("テストプロンプト")
assert result['groups']['g1']['type'] == 'json'
@patch('agent.api_client.requests.post')
def test_generate_retry_on_json_error(mock_post):
# 第一次返回无效 JSON,第二次返回有效 JSON
bad_response = MagicMock()
bad_response.json.return_value = {
'choices': [{'message': {'content': '無効な応答'}}]
}
bad_response.raise_for_status = MagicMock()
good_response = MagicMock()
good_response.json.return_value = {
'choices': [{'message': {'content': '{"result": "ok"}'}}]
}
good_response.raise_for_status = MagicMock()
mock_post.side_effect = [bad_response, good_response]
client = APIClient(api_key='test-key', max_retries=3)
result = client.generate("テスト")
assert result['result'] == 'ok'
assert mock_post.call_count == 2 # 调用了2次
```
- [ ] **Step 3: 运行测试**
Run: `python -m pytest tests/test_api_client.py -v`
Expected: all PASS
---
### Task 9: OutputWriter — 输出写入
**Files:**
- Create: `D:\jcl-cobol-data-create\agent\output_writer.py`
- Create: `D:\jcl-cobol-data-create\tests\test_output_writer.py`
- [ ] **Step 1: 实现 OutputWriter**
```python
# agent/output_writer.py
import json
import os
from typing import Dict, Any
class OutputWriter:
"""将 AI 生成的数据写入文件系统。"""
def __init__(self, output_dir: str):
self.output_dir = output_dir
def write(self, program_id: str, ai_result: Dict[str, Any],
input_type: str) -> Dict[str, str]:
"""写入所有组的输出文件。
Args:
program_id: 程序ID
ai_result: AI 返回的 JSON 数据,格式: {"groups": {"g1": {...}, ...}}
input_type: "file", "db", "mixed"
Returns:
{group_folder: written_file_path} 映射
"""
written = {}
groups = ai_result.get('groups', ai_result) # 兼容 {"records": [...]} 直接格式
for group_key in sorted(groups.keys()):
group_data = groups[group_key]
group_dir = os.path.join(self.output_dir, program_id, group_key)
os.makedirs(group_dir, exist_ok=True)
if input_type in ('file', 'mixed'):
json_path = self._write_json(group_dir, program_id, group_key, group_data)
written[f"{group_key}/json"] = json_path
if input_type in ('db', 'mixed'):
sql_path = self._write_sql(group_dir, program_id, group_key, group_data)
written[f"{group_key}/sql"] = sql_path
return written
def _write_json(self, group_dir: str, program_id: str,
group_key: str, data: Any) -> str:
"""写入 JSON 文件。"""
filename = f"{program_id}_{group_key}.json"
filepath = os.path.join(group_dir, filename)
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return filepath
def _write_sql(self, group_dir: str, program_id: str,
group_key: str, data: Any) -> str:
"""写入 SQL 文件。"""
filename = f"{program_id}_{group_key}.sql"
filepath = os.path.join(group_dir, filename)
# 如果 data 中有 sql 字段,直接使用;否则将整个 data 写入
sql_content = data if isinstance(data, str) else data.get('sql', json.dumps(data, ensure_ascii=False))
with open(filepath, 'w', encoding='utf-8') as f:
f.write(sql_content)
return filepath
```
- [ ] **Step 2: 写 OutputWriter 测试**
```python
# tests/test_output_writer.py
import json
import os
import tempfile
from agent.output_writer import OutputWriter
def test_write_json_files():
with tempfile.TemporaryDirectory() as tmpdir:
writer = OutputWriter(tmpdir)
ai_result = {
"groups": {
"g1": {
"program": "TEST",
"records": [
{"input": {"R01": {"R01-FIELD": "A001"}}}
]
}
}
}
written = writer.write("TESTPGM", ai_result, input_type="file")
assert len(written) == 1
json_path = written["g1/json"]
assert os.path.exists(json_path)
with open(json_path, 'r', encoding='utf-8') as f:
content = json.load(f)
assert content['program'] == 'TEST'
assert len(content['records']) == 1
def test_write_mixed_type():
with tempfile.TemporaryDirectory() as tmpdir:
writer = OutputWriter(tmpdir)
ai_result = {
"groups": {
"g1": {
"program": "MIXED",
"records": [{"input": {"R01": {"F1": "X"}}}],
"sql": "INSERT INTO T VALUES ('x');"
}
}
}
written = writer.write("MIXEDPGM", ai_result, input_type="mixed")
assert len(written) == 2
assert os.path.exists(written["g1/json"])
assert os.path.exists(written["g1/sql"])
def test_write_multiple_groups():
with tempfile.TemporaryDirectory() as tmpdir:
writer = OutputWriter(tmpdir)
ai_result = {
"groups": {
"g1": {"records": [{}]},
"g2": {"records": [{}]},
"g3": {"records": [{}]},
}
}
written = writer.write("MULTI", ai_result, input_type="file")
assert len(written) == 3
for g in ['g1', 'g2', 'g3']:
assert f'{g}/json' in written
assert os.path.exists(written[f'{g}/json'])
def test_directory_structure():
with tempfile.TemporaryDirectory() as tmpdir:
writer = OutputWriter(tmpdir)
ai_result = {"groups": {"g1": {"records": [{}]}}}
writer.write("MYPROG", ai_result, input_type="file")
expected_dir = os.path.join(tmpdir, "MYPROG", "g1")
assert os.path.isdir(expected_dir)
assert os.path.isfile(os.path.join(expected_dir, "MYPROG_g1.json"))
```
- [ ] **Step 3: 运行测试**
Run: `python -m pytest tests/test_output_writer.py -v`
Expected: all PASS
---
### Task 10: main.py — CLI 入口与集成测试
**Files:**
- Create: `D:\jcl-cobol-data-create\main.py`
- Create: `D:\jcl-cobol-data-create\tests\test_integration.py`
- [ ] **Step 1: 确认 agent/__init__.py 导出 generate 函数**
更新 `D:\jcl-cobol-data-create\agent\__init__.py`
```python
# agent/__init__.py
__version__ = "0.1.0"
from agent.input_parser import InputParser
from agent.rule_loader import RuleLoader
from agent.prompt_builder import PromptBuilder
from agent.api_client import APIClient
from agent.output_writer import OutputWriter
from agent.models import ProgramMeta, FileInfo, CopyField, KeyInfo, TableColumn, TableInfo
def generate(design_md: str, source_cbl: str, file_db_md: str,
cpy_dir: str, db_md: str, output_dir: str = "output",
api_key: str = "sk-6156cccdc9c14d949cf5bfc5afc67a03",
api_model: str = "deepseek-v4-flash",
rules_dir: str = "rules") -> dict:
"""生成测试数据的主入口函数。
Args:
design_md: 詳細設計書 .md 路径
source_cbl: COBOL 源码 .cbl 路径
file_db_md: 文件/DB 构造 .md 路径
cpy_dir: COPYBOOK 存放目录路径
db_md: DB 定义书 .md 路径
output_dir: 输出目录
api_key: DeepSeek API key
api_model: 模型名称
rules_dir: 规则目录路径
Returns:
{"output_files": {"g1/json": "path", ...}, "program_id": "...", "groups": N}
"""
print(f"== 解析入力: {design_md}")
# 1. 解析输入
parser = InputParser(design_md, source_cbl, file_db_md, cpy_dir, db_md)
meta = parser.run()
if meta.pgm_type == 'サブ':
raise ValueError(f"程序 {meta.program_id} はサブプログラムです。主プログラムのみ処理対象です。")
print(f" プログラムID: {meta.program_id}, パターン: {meta.pgm_pattern}, 入力タイプ: {meta.input_type}")
# 2. 加载规则
loader = RuleLoader(rules_dir)
rules_text, group_descriptions, group_count = loader.load(meta)
print(f" ルール読み込み完了, グループ数: {group_count}")
# 3. 构建 prompt
builder = PromptBuilder()
prompt = builder.build(meta, rules_text, group_descriptions, group_count)
# 4. 调用 API
client = APIClient(api_key=api_key, model=api_model)
print(f" API呼び出し中...")
result = client.generate(prompt)
print(f" API応答受信")
# 5. 写入输出
writer = OutputWriter(output_dir)
output_files = writer.write(meta.program_id, result, meta.input_type)
print(f" 出力完了: {len(output_files)} ファイル")
return {
"output_files": output_files,
"program_id": meta.program_id,
"groups": group_count,
"input_type": meta.input_type,
}
```
- [ ] **Step 2: 创建 main.py CLI 入口**
```python
# main.py
#!/usr/bin/env python
"""
COBOL テストデータ生成 Agent
使用方法:
python main.py \\
--design "D:\\cobol-tna-system\\詳細設計書\\詳細設計書_ZAN04MAT.md" \\
--source "D:\\cobol-tna-system\\src\\ZAN04MAT.cbl" \\
--file-db-md "D:\\cobol-tna-system\\詳細設計書\\COPY句定義書.md" \\
--cpy "D:\\cobol-tna-system\\cpy" \\
--db-md "D:\\cobol-tna-system\\詳細設計書\\DB定義書.md" \\
--output "output"
"""
import argparse
import os
import sys
from agent import generate
DEFAULT_RULES_DIR = os.path.join(os.path.dirname(__file__), 'rules')
def main():
parser = argparse.ArgumentParser(
description='COBOLテストデータ生成Agent',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
例:
python main.py --design 詳細設計書_ZAN04MAT.md --source ZAN04MAT.cbl \\
--cpy cpy/ --db-md DB定義書.md --output output/
"""
)
parser.add_argument('--design', required=True,
help='詳細設計書 .md のパス')
parser.add_argument('--source', required=True,
help='COBOL ソース .cbl のパス')
parser.add_argument('--file-db-md', required=True,
help='ファイル/DB 構造 .md のパス')
parser.add_argument('--cpy', required=True,
help='COPYBOOK 格納ディレクトリ')
parser.add_argument('--db-md', required=True,
help='DB 定義書 .md のパス')
parser.add_argument('--output', default='output',
help='出力ディレクトリ (デフォルト: output)')
parser.add_argument('--api-key', default='sk-6156cccdc9c14d949cf5bfc5afc67a03',
help='DeepSeek API Key')
parser.add_argument('--model', default='deepseek-v4-flash',
help='API モデル名')
parser.add_argument('--rules', default=DEFAULT_RULES_DIR,
help='ルール格納ディレクトリ (デフォルト: rules/)')
args = parser.parse_args()
# 验证输入文件存在
for name, path in [('--design', args.design), ('--source', args.source)]:
if not os.path.exists(path):
print(f"エラー: {name} のファイルが見つかりません: {path}", file=sys.stderr)
sys.exit(1)
result = generate(
design_md=args.design,
source_cbl=args.source,
file_db_md=args.file_db_md,
cpy_dir=args.cpy,
db_md=args.db_md,
output_dir=args.output,
api_key=args.api_key,
api_model=args.model,
rules_dir=args.rules,
)
print(f"\n== 完了 ==")
print(f"プログラムID: {result['program_id']}")
print(f"グループ数: {result['groups']}")
print(f"入力タイプ: {result['input_type']}")
print(f"出力ファイル:")
for key, path in sorted(result['output_files'].items()):
print(f" {key}: {path}")
if __name__ == '__main__':
main()
```
- [ ] **Step 3: 写集成测试(跳过实际 API 调用)**
```python
# tests/test_integration.py
import os
import tempfile
from unittest.mock import patch, MagicMock
from agent import generate
FIXTURE_DIR = os.path.join(os.path.dirname(__file__), 'test_data')
@patch('agent.api_client.requests.post')
def test_full_pipeline_mock_api(mock_post):
"""完整流水线测试,API 调用使用 mock。"""
# Mock API response
mock_response = MagicMock()
mock_response.json.return_value = {
'choices': [{'message': {'content': '''{
"groups": {
"g1": {
"program": "ZAN04MAT",
"records": [
{
"input": {
"R01INNFIL": {
"R01-APPL-ID": "A0000001",
"R01-EMP-ID": "00000101",
"R01-APPL-DATE": "20260101",
"R01-START-TIME": "0900",
"R01-END-TIME": "1800",
"R01-STATUS": "0",
"R01-OVT-TYPE": "W",
"R01-FILLER": "D000000000000000000000000000000000000000000001"
}
}
}
]
}
}
}'''}}]
}
mock_response.raise_for_status = MagicMock()
mock_post.return_value = mock_response
cpy_dir = FIXTURE_DIR
output_dir = tempfile.mkdtemp()
rules_dir = os.path.join(os.path.dirname(__file__), '..', 'rules')
design_md = os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md')
source_cbl = os.path.join(FIXTURE_DIR, '..', '..', '..',
'cobol-tna-system', 'src', 'ZAN04MAT.cbl')
# 如果 source_cbl 不存在,创建一个最小副本
if not os.path.exists(source_cbl):
source_cbl = 'dummy.cbl'
with open(source_cbl, 'w', encoding='utf-8') as f:
f.write("COPY ZAN01REC REPLACING ==(A)== BY ==R01==.\n")
try:
result = generate(
design_md=design_md,
source_cbl=source_cbl,
file_db_md='dummy.md',
cpy_dir=cpy_dir,
db_md='dummy_db.md',
output_dir=output_dir,
api_key='test-key',
rules_dir=rules_dir,
)
assert result['program_id'] == 'ZAN04MAT'
assert result['groups'] > 0
assert len(result['output_files']) > 0
# 验证输出文件存在
for path in result['output_files'].values():
assert os.path.exists(path), f"输出文件不存在: {path}"
finally:
if source_cbl == 'dummy.cbl':
os.remove(source_cbl)
```
- [ ] **Step 4: 运行集成测试**
Run: `python -m pytest tests/test_integration.py -v`
Expected: PASS
- [ ] **Step 5: 运行全部测试**
Run: `python -m pytest tests/ -v`
Expected: all tests PASS
---
### Task 11: 首次实际运行验证
- [ ] **Step 1: 用 ZAN04MAT 实际运行**
```powershell
python main.py `
--design "D:\cobol-tna-system\詳細設計書\詳細設計書_ZAN04MAT.md" `
--source "D:\cobol-tna-system\src\ZAN04MAT.cbl" `
--file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" `
--cpy "D:\cobol-tna-system\cpy" `
--db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" `
--output "D:\jcl-cobol-data-create\output"
```
- [ ] **Step 2: 验证输出**
确认 `output/ZAN04MAT/` 下生成了 g1、g2、g3 三个文件夹,每个文件夹中有对应的 JSON 文件。
- [ ] **Step 3: 验证 JSON 格式**
检查 JSON 文件是否符合 `JSON格式说明v2.0.md` 规范。