# 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 def _parse_copybooks(self, meta: ProgramMeta): replacing_list = self._extract_copy_replacing() 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 prefix = f.identifier for i, (cpy_name, rep_val) in enumerate(replacing_list): if cpy_name == f.copy_group and rep_val == f.identifier: prefix = rep_val replacing_list.pop(i) break else: for i, (cpy_name, rep_val) in enumerate(replacing_list): if cpy_name == f.copy_group: prefix = rep_val replacing_list.pop(i) break fields = self._parse_single_copybook(copy_file, prefix) if f.identifier not in meta.copy_fields: meta.copy_fields[f.identifier] = fields 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) section_with_header = '## ' + section db_info_text = extract_section(section_with_header, '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_with_header, 'カラム定義') column_rows = parse_table_rows(column_text) if not column_rows: continue 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 def _extract_copy_replacing(self) -> List[Tuple[str, str]]: """从 COBOL 源码提取 COPY ... REPLACING ... 映射。 Returns: [(copy_name, replacing_value), ...] 按出现顺序保存。 """ result = [] pattern = r'COPY\s+(\S+)\s+REPLACING\s+==\(A\)==\s+BY\s+==(\S+)==' for match in re.finditer(pattern, self._source_text, re.IGNORECASE): result.append((match.group(1), match.group(2))) return result def _parse_single_copybook(self, copy_path: str, prefix: str) -> List[CopyField]: 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 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: pic_upper = pic.upper().strip() if 'COMP-3' in pic_upper: base = pic_upper.replace('COMP-3', '').strip() 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 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 total = 0 for num in re.findall(r'X\((\d+)\)', pic_upper): total += int(num) for num in re.findall(r'G\((\d+)\)', pic_upper): total += int(num) * 2 for num in re.findall(r'9\((\d+)\)', pic_upper): total += int(num) if total > 0: return total return 0 @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_str = row.get('NO', '0') try: no = int(no_str) if no_str and no_str.strip() not in ('', '—') else 0 except ValueError: no = 0 rec_len_str = row.get('レコード長', '0') try: rec_len = int(rec_len_str) if rec_len_str and rec_len_str.strip() not in ('', '—') else 0 except ValueError: rec_len = 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): 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'