# 测试数据生成 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 | | ### 出力レコード定義 ### 出力ファイル1(W01/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` 规范。