Add data agent input and export workflow

This commit is contained in:
武阳
2026-04-30 15:11:37 +08:00
parent e3664f8843
commit 5bdc0ffb01
9 changed files with 1787 additions and 237 deletions
+615
View File
@@ -0,0 +1,615 @@
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()
try:
candidate.relative_to(root_path)
except ValueError as exc:
raise DataAgentInputError(f'path escapes workspace root: {raw}') from exc
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 = path.relative_to(root).as_posix()
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 _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)