from __future__ import annotations """数据智能体输入文件解析工具。 这里的工具只负责把产品定义、badcase 表格、走查文档等输入稳定抽成 结构化证据。语义判断和生成策略仍交给 skill/模型完成,避免工具里写死业务规则。 """ import csv import json import re import zipfile from pathlib import Path from typing import Any from xml.etree import ElementTree class DataAgentInputError(ValueError): """输入文件无法解析或参数结构不符合预期。""" SUPPORTED_SUFFIXES = {'.csv', '.docx', '.json', '.jsonl', '.md', '.pdf', '.txt', '.xlsx'} QUERY_ROLE = 'query' EXPECTED_LABEL_ROLE = 'expected_label' PREDICTED_LABEL_ROLE = 'predicted_label' CONTEXT_ROLE = 'context' NOTES_ROLE = 'notes' CASE_TYPE_ROLE = 'case_type' def load_input_sources( root: str | Path, paths: list[str], *, max_files: int = 20, max_paragraphs_per_file: int = 80, max_tables_per_file: int = 20, max_rows_per_table: int = 30, max_cell_chars: int = 240, ) -> dict[str, Any]: resolved_paths = _resolve_input_paths(root, paths, max_files=max_files) sources = [ _load_one_source( path, root=Path(root).resolve(), max_paragraphs=max_paragraphs_per_file, max_tables=max_tables_per_file, max_rows=max_rows_per_table, max_cell_chars=max_cell_chars, ) for path in resolved_paths ] return {'sources': sources, 'source_count': len(sources)} def extract_case_evidence( root: str | Path, *, paths: list[str] | None = None, loaded_sources: dict[str, Any] | None = None, field_mapping: dict[str, str] | None = None, max_cases: int = 200, ) -> dict[str, Any]: sources = _sources_from_args(root, paths, loaded_sources) evidence: list[dict[str, Any]] = [] table_profiles: list[dict[str, Any]] = [] required_questions: list[str] = [] for source in sources: for table in source.get('tables', []): headers = _table_headers(table) header_index = _table_header_index(table) if not headers: continue rows = table.get('rows') if not isinstance(rows, list): continue profile = _profile_headers(headers, rows, field_mapping or {}) profile.update({'source_ref': table.get('source_ref'), 'title': table.get('title', '')}) table_profiles.append(profile) questions = _questions_for_profile(profile) required_questions.extend(questions) if questions: continue evidence.extend( _rows_to_case_evidence( source, table, rows, profile['role_to_column'], header_index=header_index, max_cases=max(0, max_cases - len(evidence)), ) ) if len(evidence) >= max_cases: break if len(evidence) >= max_cases: break return { 'evidence': evidence, 'evidence_count': len(evidence), 'table_profiles': table_profiles, 'required_questions': _dedupe(required_questions), } def render_source_context( root: str | Path, *, paths: list[str] | None = None, loaded_sources: dict[str, Any] | None = None, max_chars: int = 40_000, max_tables_per_source: int = 12, max_rows_per_table: int = 20, focus_keywords: list[str] | None = None, ) -> dict[str, Any]: sources = _sources_from_args(root, paths, loaded_sources) sections: list[str] = [] truncated = False keywords = [keyword for keyword in focus_keywords or [] if isinstance(keyword, str) and keyword.strip()] for source in sources: source_lines = [ f'# Source: {source.get("path", "")}', f'kind: {source.get("kind", "")}', f'summary: {source.get("summary", "")}', ] warnings = source.get('warnings') if warnings: source_lines.append('warnings: ' + '; '.join(str(item) for item in warnings)) for paragraph in source.get('paragraphs', []): text = str(paragraph.get('text') or '').strip() if not text or not _matches_focus(text, keywords): continue source_lines.append('') source_lines.append(f'## Paragraph {paragraph.get("source_ref", "")}') source_lines.append(text) for table in source.get('tables', [])[:max_tables_per_source]: headers = _table_headers(table) rows = table.get('rows') if not isinstance(rows, list): continue rendered_rows = [] for row_index, row in enumerate(rows[:max_rows_per_table], start=1): if not isinstance(row, list): continue row_text = _render_table_row(headers, row) if headers else ' | '.join(str(cell) for cell in row) if not row_text or not _matches_focus(row_text, keywords): continue rendered_rows.append(f'[{table.get("source_ref")}:row{row_index}] {row_text}') if rendered_rows: source_lines.append('') source_lines.append(f'## Table {table.get("title", "")} {table.get("source_ref", "")}') if headers: source_lines.append('columns: ' + ' | '.join(headers)) source_lines.extend(rendered_rows) section = '\n'.join(source_lines).strip() if section: sections.append(section) rendered = '\n\n'.join(sections) if len(rendered) > max_chars: rendered = rendered[: max_chars - 80].rstrip() + '\n\n...[truncated: source context exceeded max_chars]...' truncated = True return { 'context_text': rendered, 'char_count': len(rendered), 'truncated': truncated, 'source_count': len(sources), 'instructions': '请让大模型只基于 context_text 抽取和用户 query 语义、功能点、边界、target/open questions 相关的信息,并保留 source refs。', } def _resolve_input_paths(root: str | Path, paths: list[str], *, max_files: int) -> list[Path]: if not isinstance(paths, list) or not paths: raise DataAgentInputError('paths must be a non-empty array') root_path = Path(root).resolve() resolved: list[Path] = [] for raw in paths: if not isinstance(raw, str) or not raw.strip(): raise DataAgentInputError('paths items must be non-empty strings') path = Path(raw).expanduser() candidate = path if path.is_absolute() else root_path / path candidate = candidate.resolve() if not candidate.exists(): raise DataAgentInputError(f'path not found: {raw}') if candidate.is_dir(): for child in sorted(candidate.rglob('*')): if child.is_file() and child.suffix.lower() in SUPPORTED_SUFFIXES: resolved.append(child.resolve()) elif candidate.is_file(): if candidate.suffix.lower() in SUPPORTED_SUFFIXES: resolved.append(candidate) if len(resolved) >= max_files: break return _dedupe_paths(resolved)[:max_files] def _load_one_source( path: Path, *, root: Path, max_paragraphs: int, max_tables: int, max_rows: int, max_cell_chars: int, ) -> dict[str, Any]: suffix = path.suffix.lower() rel_path = _display_input_path(path, root) source: dict[str, Any] = { 'path': rel_path, 'kind': suffix.lstrip('.'), 'size_bytes': path.stat().st_size, 'paragraphs': [], 'tables': [], 'warnings': [], } try: if suffix == '.xlsx': _load_xlsx(path, source, max_tables=max_tables, max_rows=max_rows, max_cell_chars=max_cell_chars) elif suffix == '.csv': _load_csv(path, source, max_rows=max_rows, max_cell_chars=max_cell_chars) elif suffix == '.docx': _load_docx(path, source, max_paragraphs=max_paragraphs, max_tables=max_tables, max_rows=max_rows, max_cell_chars=max_cell_chars) elif suffix == '.pdf': _load_pdf(path, source, max_paragraphs=max_paragraphs) elif suffix in {'.txt', '.md', '.json', '.jsonl'}: _load_text(path, source, max_paragraphs=max_paragraphs) else: source['warnings'].append(f'暂不支持的文件类型: {suffix}') except Exception as exc: source['warnings'].append(f'解析失败: {type(exc).__name__}: {exc}') source['summary'] = _source_summary(source) return source def _display_input_path(path: Path, root: Path) -> str: try: return path.relative_to(root).as_posix() except ValueError: return path.as_posix() def _load_xlsx(path: Path, source: dict[str, Any], *, max_tables: int, max_rows: int, max_cell_chars: int) -> None: try: import openpyxl # type: ignore[import-not-found] except ImportError as exc: raise DataAgentInputError('解析 xlsx 需要 openpyxl') from exc workbook = openpyxl.load_workbook(path, data_only=True, read_only=True) for sheet_index, sheet in enumerate(workbook.worksheets[:max_tables]): # 有些 xlsx 的维度元数据不完整,read_only 模式会误判成 A1:A1。 # reset_dimensions 后让 openpyxl 从实际行流里重新推断列数。 if hasattr(sheet, 'reset_dimensions'): sheet.reset_dimensions() rows = [] for row in sheet.iter_rows(max_row=max_rows, values_only=True): rows.append([_clean_cell(value, max_cell_chars=max_cell_chars) for value in row]) source['tables'].append( { 'source_ref': f'{source["path"]}#sheet={sheet.title}', 'title': sheet.title, 'row_count': sheet.max_row, 'column_count': sheet.max_column, 'rows': rows, } ) if sheet_index + 1 >= max_tables: break def _load_csv(path: Path, source: dict[str, Any], *, max_rows: int, max_cell_chars: int) -> None: rows: list[list[str]] = [] with path.open('r', encoding='utf-8-sig', errors='replace', newline='') as handle: reader = csv.reader(handle) for index, row in enumerate(reader): if index >= max_rows: break rows.append([_clean_cell(cell, max_cell_chars=max_cell_chars) for cell in row]) source['tables'].append( { 'source_ref': source['path'], 'title': path.name, 'row_count': len(rows), 'column_count': max((len(row) for row in rows), default=0), 'rows': rows, } ) def _load_docx( path: Path, source: dict[str, Any], *, max_paragraphs: int, max_tables: int, max_rows: int, max_cell_chars: int, ) -> None: with zipfile.ZipFile(path) as archive: document_xml = archive.read('word/document.xml') root = ElementTree.fromstring(document_xml) namespace = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'} paragraphs: list[dict[str, str]] = [] tables: list[dict[str, Any]] = [] body = root.find('w:body', namespace) if body is None: return for element in list(body): if element.tag.endswith('}p') and len(paragraphs) < max_paragraphs: text = _docx_text(element, namespace) if text: paragraphs.append({'text': text, 'source_ref': f'{source["path"]}#p{len(paragraphs) + 1}'}) elif element.tag.endswith('}tbl') and len(tables) < max_tables: rows = _docx_table_rows(element, namespace, max_rows=max_rows, max_cell_chars=max_cell_chars) tables.append( { 'source_ref': f'{source["path"]}#table={len(tables)}', 'title': f'table_{len(tables)}', 'row_count': len(rows), 'column_count': max((len(row) for row in rows), default=0), 'rows': rows, } ) source['paragraphs'].extend(paragraphs) source['tables'].extend(tables) def _load_pdf(path: Path, source: dict[str, Any], *, max_paragraphs: int) -> None: try: from pypdf import PdfReader # type: ignore[import-not-found] except ImportError: source['warnings'].append('当前 Python 环境缺少 pypdf,无法抽取 PDF 文本。优先使用同名 docx。') return reader = PdfReader(str(path)) for index, page in enumerate(reader.pages[:max_paragraphs]): text = ' '.join((page.extract_text() or '').split()) if text: source['paragraphs'].append({'text': text, 'source_ref': f'{source["path"]}#page={index + 1}'}) def _load_text(path: Path, source: dict[str, Any], *, max_paragraphs: int) -> None: text = path.read_text(encoding='utf-8', errors='replace') for index, block in enumerate(re.split(r'\n\s*\n', text)): if index >= max_paragraphs: break block = ' '.join(block.split()) if block: source['paragraphs'].append({'text': block, 'source_ref': f'{source["path"]}#block={index + 1}'}) def _docx_text(element: ElementTree.Element, namespace: dict[str, str]) -> str: parts = [node.text or '' for node in element.findall('.//w:t', namespace)] return ' '.join(''.join(parts).split()) def _docx_table_rows( table: ElementTree.Element, namespace: dict[str, str], *, max_rows: int, max_cell_chars: int, ) -> list[list[str]]: rows: list[list[str]] = [] for row in table.findall('./w:tr', namespace): if len(rows) >= max_rows: break cells = [] for cell in row.findall('./w:tc', namespace): cells.append(_clean_cell(_docx_text(cell, namespace), max_cell_chars=max_cell_chars)) rows.append(cells) return rows def _sources_from_args( root: str | Path, paths: list[str] | None, loaded_sources: dict[str, Any] | None, ) -> list[dict[str, Any]]: if loaded_sources is not None: sources = loaded_sources.get('sources') if isinstance(loaded_sources, dict) else None if not isinstance(sources, list): raise DataAgentInputError('loaded_sources.sources must be an array') return [source for source in sources if isinstance(source, dict)] if paths is None: raise DataAgentInputError('paths or loaded_sources is required') return load_input_sources(root, paths)['sources'] def _table_headers(table: dict[str, Any]) -> list[str]: rows = table.get('rows') if not isinstance(rows, list) or not rows: return [] first = rows[_table_header_index(table)] if not isinstance(first, list): return [] headers = [] for index, cell in enumerate(first): text = str(cell).strip() headers.append(text or f'column_{index + 1}') return headers def _table_header_index(table: dict[str, Any]) -> int: rows = table.get('rows') if not isinstance(rows, list): return 0 best_index = 0 best_score = -1 for index, row in enumerate(rows[:12]): if not isinstance(row, list): continue score = 0 for cell in [str(value).strip().lower() for value in row]: if any(token in cell for token in ('query', '用户query', '预期', 'label', 'domain', '类型', '类别', '场景', '意图')): score += 2 if any(token in cell for token in ('备注', 'case问题', '高置信', '低置信', '用户主要诉求')): score += 1 if score > best_score: best_score = score best_index = index return best_index if best_score > 0 else 0 def _profile_headers( headers: list[str], rows: list[Any], field_mapping: dict[str, str], ) -> dict[str, Any]: column_profiles: list[dict[str, Any]] = [] role_to_column: dict[str, str] = {} explicit = {str(role): str(column) for role, column in field_mapping.items()} for header in headers: if not header: continue samples = _sample_values(header, headers, rows) role, confidence = _detect_column_role(header, samples) for explicit_role, explicit_column in explicit.items(): if explicit_column == header: role = explicit_role confidence = 1.0 if role and role not in role_to_column: role_to_column[role] = header column_profiles.append( { 'name': header, 'detected_role': role or 'unknown', 'confidence': confidence, 'sample_values': samples[:5], } ) return {'columns': column_profiles, 'role_to_column': role_to_column} def _detect_column_role(header: str, samples: list[str]) -> tuple[str, float]: name = header.strip().lower() joined = ' '.join(samples[:8]).lower() if any(token in name for token in ('query', 'utterance', '用户query', '请求', '问题')): return QUERY_ROLE, 0.95 if any(token in name for token in ('预期', 'expected', '正确', '人工', 'gold', 'target', 'label')): return EXPECTED_LABEL_ROLE, 0.85 if any(token in name for token in ('pred', '预测', '模型', 'prev-domain', '当前domain', '实际domain', '命中')): return PREDICTED_LABEL_ROLE, 0.8 if any(token in name for token in ('context', '上下文', 'prev_session', 'session')): return CONTEXT_ROLE, 0.85 if any(token in name for token in ('备注', 'note', '原因', '反馈', 'case问题', '说明')): return NOTES_ROLE, 0.8 if any(token in name for token in ('type', '类型', '类别', '场景', '子类')): return CASE_TYPE_ROLE, 0.75 if any(marker in joined for marker in ('agent(', 'function', 'productagent', 'complex_task')): return EXPECTED_LABEL_ROLE, 0.45 if name.startswith('column_') and any(len(sample) >= 10 for sample in samples): return NOTES_ROLE, 0.35 return '', 0.0 def _questions_for_profile(profile: dict[str, Any]) -> list[str]: role_to_column = profile.get('role_to_column') if isinstance(profile.get('role_to_column'), dict) else {} title = profile.get('title') or profile.get('source_ref') or '表格' questions = [] if QUERY_ROLE not in role_to_column: questions.append(f'{title} 没有稳定识别到 query 列,请确认哪一列是用户 query。') if EXPECTED_LABEL_ROLE not in role_to_column: questions.append(f'{title} 没有稳定识别到预期标签列,请确认哪一列是正确 label/target。') return questions def _rows_to_case_evidence( source: dict[str, Any], table: dict[str, Any], rows: list[Any], role_to_column: dict[str, str], *, header_index: int, max_cases: int, ) -> list[dict[str, Any]]: if max_cases <= 0: return [] headers = _table_headers(table) evidence = [] for row_index, row in enumerate(rows[header_index + 1 :], start=header_index + 2): if not isinstance(row, list): continue query = _cell_by_header(row, headers, role_to_column.get(QUERY_ROLE)) expected = _cell_by_header(row, headers, role_to_column.get(EXPECTED_LABEL_ROLE)) if not query: continue evidence.append( { 'query': _clean_query_text(query), 'expected_label': expected, 'predicted_label': _cell_by_header(row, headers, role_to_column.get(PREDICTED_LABEL_ROLE)), 'context': _cell_by_header(row, headers, role_to_column.get(CONTEXT_ROLE)), 'case_type': _cell_by_header(row, headers, role_to_column.get(CASE_TYPE_ROLE)), 'notes': _cell_by_header(row, headers, role_to_column.get(NOTES_ROLE)), 'source_ref': f'{table.get("source_ref") or source.get("path")}:row{row_index}', } ) if len(evidence) >= max_cases: break return evidence def _cell_by_header(row: list[Any], headers: list[str], header: str | None) -> str: if not header: return '' try: index = headers.index(header) except ValueError: return '' if index >= len(row): return '' return str(row[index] or '').strip() def _sample_values(header: str, headers: list[str], rows: list[Any]) -> list[str]: samples = [] start_index = 0 for index, row in enumerate(rows[:12]): if isinstance(row, list) and _looks_like_header_row(row, headers): start_index = index + 1 break for row in rows[start_index : start_index + 12]: if not isinstance(row, list): continue value = _cell_by_header(row, headers, header) if value: samples.append(value[:120]) return samples def _looks_like_header_row(row: list[Any], headers: list[str]) -> bool: return [str(cell).strip() for cell in row] == headers def _clean_query_text(text: str) -> str: text = re.sub(r'^[QA]\s*[::]\s*', '', text.strip()) return ' / '.join(part.strip() for part in re.split(r'\s*/\s*', text) if part.strip()) def _render_table_row(headers: list[str], row: list[Any]) -> str: pairs = [] for index, cell in enumerate(row): text = str(cell or '').strip() if not text: continue header = headers[index] if index < len(headers) else f'column_{index + 1}' pairs.append(f'{header}: {text}') return ' | '.join(pairs) def _matches_focus(text: str, keywords: list[str]) -> bool: if not keywords: return True return any(keyword in text for keyword in keywords) def _clean_cell(value: Any, *, max_cell_chars: int) -> str: if value is None: return '' text = ' / '.join(str(value).split()) if len(text) > max_cell_chars: return text[: max_cell_chars - 3] + '...' return text def _source_summary(source: dict[str, Any]) -> str: return ( f'{source.get("kind")} 文件,' f'{len(source.get("paragraphs", []))} 个段落,' f'{len(source.get("tables", []))} 个表格,' f'{len(source.get("warnings", []))} 个警告' ) def _dedupe(values: list[str]) -> list[str]: seen = set() output = [] for value in values: if value in seen: continue seen.add(value) output.append(value) return output def _dedupe_paths(paths: list[Path]) -> list[Path]: seen = set() output = [] for path in paths: if path in seen: continue seen.add(path) output.append(path) return output def dumps_payload(payload: dict[str, Any]) -> str: return json.dumps(payload, ensure_ascii=False, indent=2)