Add data agent input and export workflow
This commit is contained in:
+366
-3
@@ -17,14 +17,24 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator, Union
|
||||
from .agent_types import AgentPermissions, AgentRuntimeConfig, ToolExecutionResult
|
||||
from .data_agent_records import (
|
||||
DataRecordError,
|
||||
confirm_generation_goal,
|
||||
confirm_generation_plan,
|
||||
export_dataset_records,
|
||||
get_generation_plan,
|
||||
normalize_dataset_draft,
|
||||
prepare_generation_goal,
|
||||
prepare_generation_plan,
|
||||
records_from_tool_argument,
|
||||
update_generation_plan,
|
||||
validate_dataset_records,
|
||||
)
|
||||
from .data_agent_inputs import (
|
||||
DataAgentInputError,
|
||||
dumps_payload as data_agent_dumps_payload,
|
||||
extract_case_evidence,
|
||||
load_input_sources,
|
||||
render_source_context,
|
||||
)
|
||||
from .session_env_vars import get_session_env_vars
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -1252,14 +1262,92 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
||||
handler=_execute_skill,
|
||||
),
|
||||
AgentTool(
|
||||
name='data_agent_prepare_generation_plan',
|
||||
name='data_agent_prepare_generation_goal',
|
||||
description=(
|
||||
'Create a pending data-agent generation plan. Use this before generating dataset draft text; '
|
||||
'show the returned plan to the user and wait for confirmation.'
|
||||
'Create a pending data-agent generation goal from source evidence or user rules. '
|
||||
'Use this before data_agent_prepare_generation_plan; show the returned goal to the user and wait for confirmation.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'dataset_label': {'type': 'string'},
|
||||
'goal_summary': {'type': 'string'},
|
||||
'target': {
|
||||
'type': 'string',
|
||||
'description': 'Single target label. For multi-target boundary tasks, use target_definitions.',
|
||||
},
|
||||
'target_definitions': {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'name': {'type': 'string'},
|
||||
'target': {'type': 'string'},
|
||||
'rule': {'type': 'string'},
|
||||
},
|
||||
'required': ['target'],
|
||||
},
|
||||
},
|
||||
'plan_hint': {
|
||||
'type': 'string',
|
||||
'description': (
|
||||
'Optional plain-language hint for the later plan, such as '
|
||||
'"建议先生成 50 条单轮,输出到 tasks/.../records.jsonl". '
|
||||
'Structured total_count, turn_mix, and output_path belong to data_agent_prepare_generation_plan.'
|
||||
),
|
||||
},
|
||||
'coverage': {'type': 'string'},
|
||||
'exclusions': {'type': 'string'},
|
||||
'open_questions': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
},
|
||||
'source_refs': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
},
|
||||
'notes': {'type': 'string'},
|
||||
},
|
||||
'required': ['dataset_label', 'goal_summary', 'coverage', 'exclusions'],
|
||||
},
|
||||
handler=_data_agent_prepare_generation_goal_tool,
|
||||
),
|
||||
AgentTool(
|
||||
name='data_agent_confirm_generation_goal',
|
||||
description=(
|
||||
'Mark a pending data-agent generation goal as confirmed after the user explicitly approves it. '
|
||||
'The returned confirmed_goal_id is required by data_agent_prepare_generation_plan.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'goal_id': {'type': 'string'},
|
||||
'confirmation': {
|
||||
'type': 'string',
|
||||
'description': 'The user approval text, such as 确认, 开始生成, or approve.',
|
||||
},
|
||||
'reviewed_revision': {
|
||||
'type': 'integer',
|
||||
'description': 'The goal revision shown to the user before confirmation.',
|
||||
},
|
||||
},
|
||||
'required': ['goal_id', 'confirmation'],
|
||||
},
|
||||
handler=_data_agent_confirm_generation_goal_tool,
|
||||
),
|
||||
AgentTool(
|
||||
name='data_agent_prepare_generation_plan',
|
||||
description=(
|
||||
'Create a pending data-agent generation plan. Use this before generating dataset draft text; '
|
||||
'requires confirmed_goal_id from data_agent_confirm_generation_goal; show the returned plan to the user and wait for confirmation.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'confirmed_goal_id': {
|
||||
'type': 'string',
|
||||
'description': 'Required; returned by data_agent_confirm_generation_goal after the user reviews the generation goal.',
|
||||
},
|
||||
'dataset_label': {'type': 'string'},
|
||||
'target': {
|
||||
'type': 'string',
|
||||
@@ -1286,6 +1374,7 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
||||
'notes': {'type': 'string'},
|
||||
},
|
||||
'required': [
|
||||
'confirmed_goal_id',
|
||||
'dataset_label',
|
||||
'total_count',
|
||||
'turn_mix',
|
||||
@@ -1296,6 +1385,87 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
||||
},
|
||||
handler=_data_agent_prepare_generation_plan_tool,
|
||||
),
|
||||
AgentTool(
|
||||
name='data_agent_load_input_sources',
|
||||
description=(
|
||||
'Load data-agent input files or directories and extract structured paragraphs and table previews '
|
||||
'from xlsx, csv, docx, pdf, txt, md, json, or jsonl sources.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'paths': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'description': 'Workspace-relative files or directories to load.',
|
||||
},
|
||||
'max_files': {'type': 'integer', 'minimum': 1, 'maximum': 100},
|
||||
'max_paragraphs_per_file': {'type': 'integer', 'minimum': 1, 'maximum': 300},
|
||||
'max_tables_per_file': {'type': 'integer', 'minimum': 1, 'maximum': 100},
|
||||
'max_rows_per_table': {'type': 'integer', 'minimum': 1, 'maximum': 200},
|
||||
'max_cell_chars': {'type': 'integer', 'minimum': 20, 'maximum': 2000},
|
||||
},
|
||||
'required': ['paths'],
|
||||
},
|
||||
handler=_data_agent_load_input_sources_tool,
|
||||
),
|
||||
AgentTool(
|
||||
name='data_agent_extract_case_evidence',
|
||||
description=(
|
||||
'Extract query/badcase evidence from loaded input sources or source paths. '
|
||||
'Profiles table columns first and returns required questions when query or expected label columns are ambiguous.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'paths': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'description': 'Workspace-relative files or directories. Omit when loaded_sources is provided.',
|
||||
},
|
||||
'loaded_sources': {
|
||||
'type': 'object',
|
||||
'description': 'Output from data_agent_load_input_sources.',
|
||||
},
|
||||
'field_mapping': {
|
||||
'type': 'object',
|
||||
'description': 'Optional role-to-column mapping, e.g. {"query":"query","expected_label":"预期domain"}.',
|
||||
},
|
||||
'max_cases': {'type': 'integer', 'minimum': 1, 'maximum': 1000},
|
||||
},
|
||||
},
|
||||
handler=_data_agent_extract_case_evidence_tool,
|
||||
),
|
||||
AgentTool(
|
||||
name='data_agent_render_source_context',
|
||||
description=(
|
||||
'Render loaded data-agent sources into LLM-readable evidence text with source refs. '
|
||||
'Use this after data_agent_load_input_sources before asking the model to structure product semantics.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'paths': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'description': 'Workspace-relative files or directories. Omit when loaded_sources is provided.',
|
||||
},
|
||||
'loaded_sources': {
|
||||
'type': 'object',
|
||||
'description': 'Output from data_agent_load_input_sources.',
|
||||
},
|
||||
'max_chars': {'type': 'integer', 'minimum': 1000, 'maximum': 200000},
|
||||
'max_tables_per_source': {'type': 'integer', 'minimum': 1, 'maximum': 100},
|
||||
'max_rows_per_table': {'type': 'integer', 'minimum': 1, 'maximum': 200},
|
||||
'focus_keywords': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'description': 'Optional keywords used to keep only matching paragraphs/table rows.',
|
||||
},
|
||||
},
|
||||
},
|
||||
handler=_data_agent_render_source_context_tool,
|
||||
),
|
||||
AgentTool(
|
||||
name='data_agent_show_generation_plan',
|
||||
description='Show a data-agent generation plan for human review.',
|
||||
@@ -1417,6 +1587,44 @@ def default_tool_registry() -> dict[str, AgentTool]:
|
||||
},
|
||||
handler=_validate_dataset_records_tool,
|
||||
),
|
||||
AgentTool(
|
||||
name='data_agent_export_dataset_records',
|
||||
description=(
|
||||
'Validate canonical data-agent records and write them to a workspace file. '
|
||||
'Defaults to compact JSONL, one canonical record per line, so the model does not hand-write JSON output.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'records': {
|
||||
'oneOf': [
|
||||
{'type': 'array'},
|
||||
{'type': 'string'},
|
||||
],
|
||||
'description': 'Canonical records as an array, or a JSON string containing the array.',
|
||||
},
|
||||
'output_path': {
|
||||
'type': 'string',
|
||||
'description': 'Workspace-relative path to write, usually ending in .jsonl.',
|
||||
},
|
||||
'output_format': {
|
||||
'type': 'string',
|
||||
'enum': ['jsonl', 'json'],
|
||||
'description': 'Defaults to jsonl. json writes a compact JSON array.',
|
||||
},
|
||||
'require_validation_ok': {
|
||||
'type': 'boolean',
|
||||
'description': 'When true, refuse to write records with validation errors.',
|
||||
},
|
||||
'overwrite': {
|
||||
'type': 'boolean',
|
||||
'description': 'When false, refuse to overwrite an existing file.',
|
||||
},
|
||||
},
|
||||
'required': ['records', 'output_path'],
|
||||
},
|
||||
handler=_export_dataset_records_tool,
|
||||
),
|
||||
]
|
||||
return {tool.name: tool for tool in tools}
|
||||
|
||||
@@ -1488,6 +1696,64 @@ def _coerce_float(arguments: dict[str, Any], key: str, default: float) -> float:
|
||||
return float(value)
|
||||
|
||||
|
||||
def _optional_string_list(arguments: dict[str, Any], key: str) -> list[str] | None:
|
||||
value = arguments.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
||||
raise ToolExecutionError(f'{key} must be an array of strings')
|
||||
return value
|
||||
|
||||
|
||||
def _data_agent_prepare_generation_goal_tool(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
notes = arguments.get('notes', '')
|
||||
if not isinstance(notes, str):
|
||||
raise ToolExecutionError('notes must be a string')
|
||||
try:
|
||||
payload = prepare_generation_goal(
|
||||
root=str(context.root),
|
||||
dataset_label=_require_string(arguments, 'dataset_label'),
|
||||
goal_summary=_require_string(arguments, 'goal_summary'),
|
||||
target=_optional_string(arguments, 'target'),
|
||||
target_definitions=_optional_target_definitions(arguments.get('target_definitions')),
|
||||
plan_hint=_optional_string(arguments, 'plan_hint'),
|
||||
coverage=_require_string(arguments, 'coverage'),
|
||||
exclusions=_require_string(arguments, 'exclusions'),
|
||||
open_questions=_optional_string_list(arguments, 'open_questions'),
|
||||
source_refs=_optional_string_list(arguments, 'source_refs'),
|
||||
notes=notes,
|
||||
)
|
||||
except DataRecordError as exc:
|
||||
raise ToolExecutionError(str(exc)) from exc
|
||||
return (
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
{
|
||||
'action': 'data_agent_prepare_generation_goal',
|
||||
'requires_user_review': True,
|
||||
'goal_id': payload.get('goal', {}).get('goal_id'),
|
||||
'goal_revision': payload.get('goal', {}).get('revision'),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _data_agent_confirm_generation_goal_tool(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
reviewed_revision = arguments.get('reviewed_revision')
|
||||
if reviewed_revision is not None and (
|
||||
isinstance(reviewed_revision, bool) or not isinstance(reviewed_revision, int)
|
||||
):
|
||||
raise ToolExecutionError('reviewed_revision must be an integer')
|
||||
try:
|
||||
payload = confirm_generation_goal(
|
||||
root=str(context.root),
|
||||
goal_id=_require_string(arguments, 'goal_id'),
|
||||
confirmation=_require_string(arguments, 'confirmation'),
|
||||
reviewed_revision=reviewed_revision,
|
||||
)
|
||||
except DataRecordError as exc:
|
||||
raise ToolExecutionError(str(exc)) from exc
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _data_agent_prepare_generation_plan_tool(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
total_count = _coerce_int(arguments, 'total_count', 0)
|
||||
notes = arguments.get('notes', '')
|
||||
@@ -1505,6 +1771,7 @@ def _data_agent_prepare_generation_plan_tool(arguments: dict[str, Any], context:
|
||||
output_path=_require_string(arguments, 'output_path'),
|
||||
target_definitions=_optional_target_definitions(arguments.get('target_definitions')),
|
||||
notes=notes,
|
||||
confirmed_goal_id=_require_string(arguments, 'confirmed_goal_id'),
|
||||
)
|
||||
except DataRecordError as exc:
|
||||
raise ToolExecutionError(str(exc)) from exc
|
||||
@@ -1519,6 +1786,75 @@ def _data_agent_prepare_generation_plan_tool(arguments: dict[str, Any], context:
|
||||
)
|
||||
|
||||
|
||||
def _data_agent_load_input_sources_tool(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
paths = arguments.get('paths')
|
||||
if not isinstance(paths, list) or not all(isinstance(item, str) for item in paths):
|
||||
raise ToolExecutionError('paths must be an array of strings')
|
||||
try:
|
||||
payload = load_input_sources(
|
||||
context.root,
|
||||
paths,
|
||||
max_files=_coerce_int(arguments, 'max_files', 20),
|
||||
max_paragraphs_per_file=_coerce_int(arguments, 'max_paragraphs_per_file', 80),
|
||||
max_tables_per_file=_coerce_int(arguments, 'max_tables_per_file', 20),
|
||||
max_rows_per_table=_coerce_int(arguments, 'max_rows_per_table', 30),
|
||||
max_cell_chars=_coerce_int(arguments, 'max_cell_chars', 240),
|
||||
)
|
||||
except DataAgentInputError as exc:
|
||||
raise ToolExecutionError(str(exc)) from exc
|
||||
return data_agent_dumps_payload(payload)
|
||||
|
||||
|
||||
def _data_agent_extract_case_evidence_tool(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
paths = arguments.get('paths')
|
||||
if paths is not None and (not isinstance(paths, list) or not all(isinstance(item, str) for item in paths)):
|
||||
raise ToolExecutionError('paths must be an array of strings')
|
||||
loaded_sources = arguments.get('loaded_sources')
|
||||
if loaded_sources is not None and not isinstance(loaded_sources, dict):
|
||||
raise ToolExecutionError('loaded_sources must be an object')
|
||||
field_mapping = arguments.get('field_mapping')
|
||||
if field_mapping is not None and not isinstance(field_mapping, dict):
|
||||
raise ToolExecutionError('field_mapping must be an object')
|
||||
try:
|
||||
payload = extract_case_evidence(
|
||||
context.root,
|
||||
paths=paths,
|
||||
loaded_sources=loaded_sources,
|
||||
field_mapping=field_mapping,
|
||||
max_cases=_coerce_int(arguments, 'max_cases', 200),
|
||||
)
|
||||
except DataAgentInputError as exc:
|
||||
raise ToolExecutionError(str(exc)) from exc
|
||||
return data_agent_dumps_payload(payload)
|
||||
|
||||
|
||||
def _data_agent_render_source_context_tool(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
paths = arguments.get('paths')
|
||||
if paths is not None and (not isinstance(paths, list) or not all(isinstance(item, str) for item in paths)):
|
||||
raise ToolExecutionError('paths must be an array of strings')
|
||||
loaded_sources = arguments.get('loaded_sources')
|
||||
if loaded_sources is not None and not isinstance(loaded_sources, dict):
|
||||
raise ToolExecutionError('loaded_sources must be an object')
|
||||
focus_keywords = arguments.get('focus_keywords')
|
||||
if focus_keywords is not None and (
|
||||
not isinstance(focus_keywords, list) or not all(isinstance(item, str) for item in focus_keywords)
|
||||
):
|
||||
raise ToolExecutionError('focus_keywords must be an array of strings')
|
||||
try:
|
||||
payload = render_source_context(
|
||||
context.root,
|
||||
paths=paths,
|
||||
loaded_sources=loaded_sources,
|
||||
max_chars=_coerce_int(arguments, 'max_chars', 40_000),
|
||||
max_tables_per_source=_coerce_int(arguments, 'max_tables_per_source', 12),
|
||||
max_rows_per_table=_coerce_int(arguments, 'max_rows_per_table', 20),
|
||||
focus_keywords=focus_keywords,
|
||||
)
|
||||
except DataAgentInputError as exc:
|
||||
raise ToolExecutionError(str(exc)) from exc
|
||||
return data_agent_dumps_payload(payload)
|
||||
|
||||
|
||||
def _data_agent_show_generation_plan_tool(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
try:
|
||||
payload = get_generation_plan(
|
||||
@@ -1609,6 +1945,33 @@ def _validate_dataset_records_tool(arguments: dict[str, Any], _context: ToolExec
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _export_dataset_records_tool(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
if 'records' not in arguments:
|
||||
raise ToolExecutionError('records is required')
|
||||
output_format = arguments.get('output_format', 'jsonl')
|
||||
if not isinstance(output_format, str):
|
||||
raise ToolExecutionError('output_format must be a string')
|
||||
require_validation_ok = arguments.get('require_validation_ok', True)
|
||||
if not isinstance(require_validation_ok, bool):
|
||||
raise ToolExecutionError('require_validation_ok must be a boolean')
|
||||
overwrite = arguments.get('overwrite', True)
|
||||
if not isinstance(overwrite, bool):
|
||||
raise ToolExecutionError('overwrite must be a boolean')
|
||||
try:
|
||||
records = records_from_tool_argument(arguments['records'])
|
||||
payload = export_dataset_records(
|
||||
records,
|
||||
root=str(context.root),
|
||||
output_path=_require_string(arguments, 'output_path'),
|
||||
output_format=output_format,
|
||||
require_validation_ok=require_validation_ok,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
except (DataRecordError, json.JSONDecodeError) as exc:
|
||||
raise ToolExecutionError(str(exc)) from exc
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _resolve_path(raw_path: str, context: ToolExecutionContext, *, allow_missing: bool = True) -> Path:
|
||||
expanded = Path(raw_path).expanduser()
|
||||
candidate = expanded if expanded.is_absolute() else context.root / expanded
|
||||
|
||||
@@ -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)
|
||||
@@ -10,18 +10,109 @@ Skill 应该先让模型生成紧凑、便于人工 review 的 dataset draft tex
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_REQUEST_ID = 'aabbccdd'
|
||||
DEFAULT_TIMESTAMP_STEP_MS = 60_000
|
||||
DEFAULT_PLAN_STATE_FILE = '.port_sessions/data_agent_generation_plans.json'
|
||||
DEFAULT_GOAL_STATE_FILE = '.port_sessions/data_agent_generation_goals.json'
|
||||
|
||||
|
||||
class DataRecordError(ValueError):
|
||||
"""Raised when dataset draft text or records are structurally invalid."""
|
||||
|
||||
|
||||
def prepare_generation_goal(
|
||||
*,
|
||||
root: str,
|
||||
dataset_label: str,
|
||||
goal_summary: str,
|
||||
coverage: str,
|
||||
exclusions: str,
|
||||
target: str = '',
|
||||
target_definitions: list[dict[str, Any]] | None = None,
|
||||
plan_hint: str = '',
|
||||
open_questions: list[str] | None = None,
|
||||
source_refs: list[str] | None = None,
|
||||
notes: str = '',
|
||||
) -> dict[str, Any]:
|
||||
missing = _missing_plan_fields(
|
||||
{
|
||||
'dataset_label': dataset_label,
|
||||
'goal_summary': goal_summary,
|
||||
'coverage': coverage,
|
||||
'exclusions': exclusions,
|
||||
}
|
||||
)
|
||||
if missing:
|
||||
raise DataRecordError('missing required goal fields: ' + ', '.join(missing))
|
||||
normalized_targets = _normalize_target_definitions(target_definitions)
|
||||
state = _load_goal_state(root)
|
||||
sequence = int(state.get('next_sequence') or 1)
|
||||
goal_id = f'data_goal_{sequence:06d}'
|
||||
goal = {
|
||||
'goal_id': goal_id,
|
||||
'status': 'pending_confirmation',
|
||||
'dataset_label': dataset_label.strip(),
|
||||
'goal_summary': goal_summary.strip(),
|
||||
'target': target.strip(),
|
||||
'target_definitions': normalized_targets,
|
||||
'plan_hint': plan_hint.strip(),
|
||||
'coverage': coverage.strip(),
|
||||
'exclusions': exclusions.strip(),
|
||||
'open_questions': _normalize_string_list(open_questions, 'open_questions'),
|
||||
'source_refs': _normalize_string_list(source_refs, 'source_refs'),
|
||||
'notes': notes.strip(),
|
||||
'revision': 1,
|
||||
'created_at_ms': int(time.time() * 1000),
|
||||
}
|
||||
goals = state.setdefault('goals', {})
|
||||
if not isinstance(goals, dict):
|
||||
goals = {}
|
||||
state['goals'] = goals
|
||||
goals[goal_id] = goal
|
||||
state['next_sequence'] = sequence + 1
|
||||
_save_goal_state(root, state)
|
||||
return {
|
||||
'goal': goal,
|
||||
'message': 'generation goal 已创建,状态为 pending_confirmation。请向用户展示 goal,并等待用户确认后再创建 generation plan。',
|
||||
}
|
||||
|
||||
|
||||
def confirm_generation_goal(
|
||||
*,
|
||||
root: str,
|
||||
goal_id: str,
|
||||
confirmation: str,
|
||||
reviewed_revision: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not goal_id.strip():
|
||||
raise DataRecordError('goal_id is required')
|
||||
if not _is_confirmation_text(confirmation):
|
||||
raise DataRecordError('confirmation must clearly approve the generation goal')
|
||||
state = _load_goal_state(root)
|
||||
goal = _get_goal(state, goal_id)
|
||||
current_revision = int(goal.get('revision') or 1)
|
||||
if reviewed_revision is not None and reviewed_revision != current_revision:
|
||||
raise DataRecordError(
|
||||
f'reviewed_revision must match current goal revision {current_revision}'
|
||||
)
|
||||
if not _allowed_targets_for_goal(goal):
|
||||
raise DataRecordError('confirmed generation goal must include target or target_definitions')
|
||||
goal['status'] = 'confirmed'
|
||||
goal['confirmed_at_ms'] = int(time.time() * 1000)
|
||||
goal['confirmation'] = confirmation.strip()
|
||||
goal['confirmed_revision'] = current_revision
|
||||
_save_goal_state(root, state)
|
||||
return {
|
||||
'goal': goal,
|
||||
'confirmed_goal_id': goal_id,
|
||||
'message': 'generation goal 已确认,可以把 confirmed_goal_id 传给 data_agent_prepare_generation_plan。',
|
||||
}
|
||||
|
||||
|
||||
def normalize_dataset_draft(
|
||||
draft_text: str,
|
||||
*,
|
||||
@@ -80,7 +171,11 @@ def prepare_generation_plan(
|
||||
output_path: str,
|
||||
target_definitions: list[dict[str, Any]] | None = None,
|
||||
notes: str = '',
|
||||
confirmed_goal_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
confirmed_goal: dict[str, Any] | None = None
|
||||
if confirmed_goal_id is not None:
|
||||
confirmed_goal = _require_confirmed_generation_goal(root, confirmed_goal_id)
|
||||
missing = _missing_plan_fields(
|
||||
{
|
||||
'dataset_label': dataset_label,
|
||||
@@ -97,6 +192,13 @@ def prepare_generation_plan(
|
||||
raise DataRecordError('missing required plan fields: ' + ', '.join(missing))
|
||||
if total_count <= 0:
|
||||
raise DataRecordError('total_count must be greater than 0')
|
||||
if confirmed_goal is not None:
|
||||
_validate_plan_against_goal(
|
||||
dataset_label=dataset_label,
|
||||
target=target,
|
||||
target_definitions=normalized_targets,
|
||||
goal=confirmed_goal,
|
||||
)
|
||||
|
||||
state = _load_plan_state(root)
|
||||
sequence = int(state.get('next_sequence') or 1)
|
||||
@@ -115,6 +217,7 @@ def prepare_generation_plan(
|
||||
'output_path': resolved_output_path,
|
||||
'requested_output_path': output_path.strip(),
|
||||
'notes': notes.strip(),
|
||||
'confirmed_goal_id': confirmed_goal_id or '',
|
||||
'revision': 1,
|
||||
'review_history': [],
|
||||
'created_at_ms': int(time.time() * 1000),
|
||||
@@ -342,6 +445,56 @@ def validate_dataset_records(records: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def export_dataset_records(
|
||||
records: list[dict[str, Any]],
|
||||
*,
|
||||
root: str,
|
||||
output_path: str,
|
||||
output_format: str = 'jsonl',
|
||||
require_validation_ok: bool = True,
|
||||
overwrite: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""校验并导出 canonical records。
|
||||
|
||||
这个函数用于收口数据智能体的最终落盘动作,避免模型手写 JSON/JSONL。
|
||||
默认导出紧凑 JSONL:一行一个 canonical record,方便 diff、抽样和增量处理。
|
||||
"""
|
||||
|
||||
if output_format not in {'jsonl', 'json'}:
|
||||
raise DataRecordError('output_format must be jsonl or json')
|
||||
if not output_path.strip():
|
||||
raise DataRecordError('output_path is required')
|
||||
|
||||
validation = validate_dataset_records(records)
|
||||
if require_validation_ok and not validation['ok']:
|
||||
raise DataRecordError(
|
||||
f'records failed validation with {validation["error_count"]} errors'
|
||||
)
|
||||
|
||||
path = _resolve_output_path(root, output_path)
|
||||
if path.exists() and not overwrite:
|
||||
raise DataRecordError(f'output_path already exists: {output_path}')
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if output_format == 'jsonl':
|
||||
content = ''.join(
|
||||
json.dumps(record, ensure_ascii=False, separators=(',', ':')) + '\n'
|
||||
for record in records
|
||||
)
|
||||
else:
|
||||
content = json.dumps(records, ensure_ascii=False, separators=(',', ':')) + '\n'
|
||||
|
||||
path.write_text(content, encoding='utf-8')
|
||||
rel_path = str(path.relative_to(Path(root).resolve()))
|
||||
return {
|
||||
'output_path': rel_path,
|
||||
'output_format': output_format,
|
||||
'record_count': len(records),
|
||||
'bytes_written': len(content.encode('utf-8')),
|
||||
'validation': validation,
|
||||
}
|
||||
|
||||
|
||||
def _parse_draft_text(draft_text: str) -> dict[str, Any]:
|
||||
dataset_label = ''
|
||||
cases: list[dict[str, Any]] = []
|
||||
@@ -418,6 +571,25 @@ def _normalize_target_definitions(value: list[dict[str, Any]] | None) -> list[di
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_string_list(value: list[str] | None, field_name: str) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise DataRecordError(f'{field_name} must be a list')
|
||||
normalized: list[str] = []
|
||||
for index, item in enumerate(value):
|
||||
if not isinstance(item, str):
|
||||
raise DataRecordError(f'{field_name}[{index}] must be a string')
|
||||
text = item.strip()
|
||||
if text:
|
||||
normalized.append(text)
|
||||
return normalized
|
||||
|
||||
|
||||
def _allowed_targets_for_goal(goal: dict[str, Any]) -> set[str]:
|
||||
return _allowed_targets_for_plan(goal)
|
||||
|
||||
|
||||
def _allowed_targets_for_plan(plan: dict[str, Any]) -> set[str]:
|
||||
target_definitions = plan.get('target_definitions')
|
||||
allowed: set[str] = set()
|
||||
@@ -431,6 +603,33 @@ def _allowed_targets_for_plan(plan: dict[str, Any]) -> set[str]:
|
||||
return allowed
|
||||
|
||||
|
||||
def _validate_plan_against_goal(
|
||||
*,
|
||||
dataset_label: str,
|
||||
target: str,
|
||||
target_definitions: list[dict[str, str]],
|
||||
goal: dict[str, Any],
|
||||
) -> None:
|
||||
goal_label = str(goal.get('dataset_label') or '').strip()
|
||||
if goal_label and dataset_label.strip() != goal_label:
|
||||
raise DataRecordError(
|
||||
f'plan dataset_label must match confirmed generation goal: {goal_label}'
|
||||
)
|
||||
goal_targets = _allowed_targets_for_goal(goal)
|
||||
plan_targets = set()
|
||||
if target.strip():
|
||||
plan_targets.add(target.strip())
|
||||
for item in target_definitions:
|
||||
item_target = item.get('target', '').strip()
|
||||
if item_target:
|
||||
plan_targets.add(item_target)
|
||||
unexpected = sorted(plan_targets - goal_targets)
|
||||
if unexpected:
|
||||
raise DataRecordError(
|
||||
'plan targets must match confirmed generation goal: ' + ', '.join(unexpected)
|
||||
)
|
||||
|
||||
|
||||
def _validate_records_against_plan(records: list[dict[str, Any]], plan: dict[str, Any]) -> None:
|
||||
allowed_targets = _allowed_targets_for_plan(plan)
|
||||
if not allowed_targets:
|
||||
@@ -475,6 +674,12 @@ def _plan_state_path(root: str):
|
||||
return Path(root).resolve() / DEFAULT_PLAN_STATE_FILE
|
||||
|
||||
|
||||
def _goal_state_path(root: str):
|
||||
from pathlib import Path
|
||||
|
||||
return Path(root).resolve() / DEFAULT_GOAL_STATE_FILE
|
||||
|
||||
|
||||
def _load_plan_state(root: str) -> dict[str, Any]:
|
||||
path = _plan_state_path(root)
|
||||
if not path.exists():
|
||||
@@ -492,12 +697,35 @@ def _load_plan_state(root: str) -> dict[str, Any]:
|
||||
return payload
|
||||
|
||||
|
||||
def _load_goal_state(root: str) -> dict[str, Any]:
|
||||
path = _goal_state_path(root)
|
||||
if not path.exists():
|
||||
return {'next_sequence': 1, 'goals': {}}
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise DataRecordError(f'invalid generation goal state file: {path}') from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise DataRecordError(f'invalid generation goal state file: {path}')
|
||||
if not isinstance(payload.get('goals'), dict):
|
||||
payload['goals'] = {}
|
||||
if not isinstance(payload.get('next_sequence'), int):
|
||||
payload['next_sequence'] = 1
|
||||
return payload
|
||||
|
||||
|
||||
def _save_plan_state(root: str, state: dict[str, Any]) -> None:
|
||||
path = _plan_state_path(root)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
|
||||
def _save_goal_state(root: str, state: dict[str, Any]) -> None:
|
||||
path = _goal_state_path(root)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
|
||||
def _get_plan(state: dict[str, Any], plan_id: str) -> dict[str, Any]:
|
||||
plans = state.get('plans')
|
||||
if not isinstance(plans, dict):
|
||||
@@ -508,6 +736,16 @@ def _get_plan(state: dict[str, Any], plan_id: str) -> dict[str, Any]:
|
||||
return plan
|
||||
|
||||
|
||||
def _get_goal(state: dict[str, Any], goal_id: str) -> dict[str, Any]:
|
||||
goals = state.get('goals')
|
||||
if not isinstance(goals, dict):
|
||||
raise DataRecordError('generation goal state is invalid')
|
||||
goal = goals.get(goal_id)
|
||||
if not isinstance(goal, dict):
|
||||
raise DataRecordError(f'generation goal not found: {goal_id}')
|
||||
return goal
|
||||
|
||||
|
||||
def _require_confirmed_generation_plan(root: str, plan_id: str) -> dict[str, Any]:
|
||||
state = _load_plan_state(root)
|
||||
plan = _get_plan(state, plan_id)
|
||||
@@ -516,6 +754,14 @@ def _require_confirmed_generation_plan(root: str, plan_id: str) -> dict[str, Any
|
||||
return plan
|
||||
|
||||
|
||||
def _require_confirmed_generation_goal(root: str, goal_id: str) -> dict[str, Any]:
|
||||
state = _load_goal_state(root)
|
||||
goal = _get_goal(state, goal_id)
|
||||
if goal.get('status') != 'confirmed':
|
||||
raise DataRecordError(f'generation goal is not confirmed: {goal_id}')
|
||||
return goal
|
||||
|
||||
|
||||
def _case_to_record(
|
||||
case: dict[str, Any],
|
||||
*,
|
||||
@@ -622,6 +868,18 @@ def _safe_token(value: str) -> str:
|
||||
return token.strip('_') or 'unknown'
|
||||
|
||||
|
||||
def _resolve_output_path(root: str, output_path: str) -> Path:
|
||||
root_path = Path(root).resolve()
|
||||
candidate = Path(output_path).expanduser()
|
||||
path = candidate if candidate.is_absolute() else root_path / candidate
|
||||
resolved = path.resolve(strict=False)
|
||||
try:
|
||||
resolved.relative_to(root_path)
|
||||
except ValueError as exc:
|
||||
raise DataRecordError(f'output_path escapes workspace root: {output_path}') from exc
|
||||
return resolved
|
||||
|
||||
|
||||
def _optional_int(value: Any) -> int | None:
|
||||
if value is None or value == '':
|
||||
return None
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
---
|
||||
name: data-record-format
|
||||
description: 定义、检查、校验并准备标准数据记录,作为导出前的统一中间格式。
|
||||
when_to_use: 当用户想讨论、创建、校验、归一化或转换标准数据记录格式时使用。
|
||||
aliases: canonical-record, record-format
|
||||
allowed_tools: read_file, write_file, edit_file, grep_search, glob_search, ask_user_question, data_agent_prepare_generation_plan, data_agent_show_generation_plan, data_agent_update_generation_plan, data_agent_confirm_generation_plan, data_agent_normalize_dataset_draft, data_agent_validate_dataset_records
|
||||
---
|
||||
|
||||
使用这个 skill 处理共享的数据格式层。目标是让模型先输出便于人工 review 的 dataset draft text,再通过工具进入统一 canonical record,最后再导出为训练、评测、prompt 或展示格式。
|
||||
|
||||
## 交互门禁
|
||||
|
||||
默认不要一步到位生成数据。除非用户已经明确说“开始生成”“确认计划”“按这个计划生成”或同义表达,否则只能做目标对齐、计划草案和问题确认。
|
||||
|
||||
开始生成前必须确认这些信息:
|
||||
|
||||
- `dataset_label`:数据集或专题名称。
|
||||
- `target` / `target_definitions`:最终监督标签。单标签任务用 `target`,多标签边界任务必须用 `target_definitions` 列出每个标签和判定规则。
|
||||
- 生成数量:总条数,以及单轮/多轮数量或比例。
|
||||
- 覆盖范围:需要覆盖哪些 query 类型、意图边界或错误类型。
|
||||
- 负例/排除项:哪些表达不要生成,或哪些边界容易误判。
|
||||
- 落盘路径:draft、canonical records、validation result 写到哪里。
|
||||
|
||||
如果任一信息缺失,不要生成数据,不要调用 `data_agent_normalize_dataset_draft`,不要调用 `data_agent_validate_dataset_records`,只向用户提出需要确认的问题。
|
||||
|
||||
信息完整后,先调用 `data_agent_prepare_generation_plan` 创建 pending 计划,并把返回的计划展示给用户。此时必须停止,等待用户 review。
|
||||
|
||||
在真正生成前,必须先给用户展示一份简短生成计划,包含:
|
||||
|
||||
```text
|
||||
dataset_label:
|
||||
target 或 target_definitions:
|
||||
生成数量:
|
||||
单轮/多轮:
|
||||
覆盖范围:
|
||||
负例/排除项:
|
||||
落盘路径:
|
||||
```
|
||||
|
||||
展示计划后停下来等用户 review。
|
||||
|
||||
- 如果用户提出修改意见,调用 `data_agent_update_generation_plan` 更新计划,然后重新展示计划并继续等待 review。
|
||||
- 如果用户要求查看当前计划,调用 `data_agent_show_generation_plan`。
|
||||
- 只有用户明确确认当前计划版本后,才能调用 `data_agent_confirm_generation_plan`。确认时传入刚展示给用户的 `reviewed_revision`。
|
||||
|
||||
用户确认后,拿到 `confirmed_plan_id`,才能生成 dataset draft text,并继续调用工具。
|
||||
|
||||
`data_agent_normalize_dataset_draft` 对生成数据有代码级门禁:没有 `confirmed_plan_id`,或者计划未确认,会拒绝执行。
|
||||
|
||||
多标签边界数据不要拆成多个互不相关的单标签计划。应该创建一个计划,并在 `target_definitions` 中列出所有候选标签。例如:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "餐饮服务",
|
||||
"target": "Agent(tag=\"餐饮服务\")",
|
||||
"rule": "找附近的美食、奶茶、餐厅等,没有明确要求导航。"
|
||||
},
|
||||
{
|
||||
"name": "地图导航",
|
||||
"target": "Agent(tag=\"地图导航\")",
|
||||
"rule": "明确出现导航去某地点、带我去某地点、路线规划等。"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
生成 draft text 时,每条 case 的 `target:` 必须从已确认计划的 `target` 或 `target_definitions[*].target` 中选择。不要临时发明新 target。
|
||||
|
||||
## 数据生成输出格式
|
||||
|
||||
当需要生成数据样本时,默认使用 dataset draft text v1,不要直接输出 JSON、JSONL、CSV 或最终表格格式。除非用户明确要求机器可读格式,否则优先输出便于人工 review 的文本格式。
|
||||
|
||||
### 格式
|
||||
|
||||
```text
|
||||
# dataset_label: 数据集或专题名称
|
||||
|
||||
### case: case名称
|
||||
用户: 本轮 query
|
||||
target: Agent(tag="xxx")
|
||||
notes: 可选,说明覆盖的问题或边界
|
||||
|
||||
### case: 多轮 case 名称
|
||||
用户: 前一轮 query
|
||||
小爱: 前一轮 tts
|
||||
用户: 本轮 query
|
||||
target: Agent(tag="xxx")
|
||||
notes: 可选,说明覆盖的问题或边界
|
||||
```
|
||||
|
||||
### 规则
|
||||
|
||||
- 每条数据用一个 `### case:` 开始。
|
||||
- `用户:` 表示用户 query。
|
||||
- `小爱:` 表示小爱回复 tts。
|
||||
- 最后一个 `用户:` 是本轮 query。
|
||||
- `target:` 是本轮 query 对应的监督标签,必须存在。
|
||||
- 单轮数据只需要写一行 `用户:`,然后写 `target:`。
|
||||
- 多轮数据需要按照时间顺序写多组 `用户:` / `小爱:`。
|
||||
- 多轮数据的最后一轮只写 `用户:` 和 `target:`,不要写最后一轮 `小爱:`,因为本轮 query 不包含 tts。
|
||||
- 如果 `target` 无法确定,不要编造,必须向用户确认。
|
||||
- 不要手写 `record_id`、`request_id`、`timestamp`、`context`。
|
||||
- 线上挖掘数据如有真实 `request_id` 和 `timestamp`,可以附加在 case 中;没有则不写。
|
||||
|
||||
## Canonical Record
|
||||
|
||||
`data_agent_normalize_dataset_draft` 会把 dataset draft text 转成 canonical records,并统一补齐 `record_id`、`source`、`timestamp`、`context`、`target_type` 等机械字段。
|
||||
|
||||
当前 canonical record v1 工作格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"record_id": "gen_aabbccdd_000001",
|
||||
"source": {
|
||||
"type": "generated",
|
||||
"request_id": "aabbccdd",
|
||||
"timestamp": 1755567930500
|
||||
},
|
||||
"turn": {
|
||||
"query": "本轮 query",
|
||||
"timestamp": 1755567930500
|
||||
},
|
||||
"prev_session": [
|
||||
{
|
||||
"query": "前一轮 query",
|
||||
"tts": "前一轮 tts",
|
||||
"timestamp": 1755567870500
|
||||
}
|
||||
],
|
||||
"context": {},
|
||||
"label": {
|
||||
"dataset_label": "数据集或专题名称",
|
||||
"target": "Agent(tag=\"xxx\")",
|
||||
"target_type": "agent"
|
||||
},
|
||||
"meta": {
|
||||
"case_name": "case名称",
|
||||
"notes": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 必要工作循环
|
||||
|
||||
1. 先判断用户讨论的是模型生成草稿、canonical record 层,还是下游导出格式层。
|
||||
2. 如果用户提供了文件,先检查文件结构,再提出转换或校验方案。
|
||||
3. 如果生成目标没有确认完整,按“交互门禁”提问,不要继续生成。
|
||||
4. 信息完整后,调用 `data_agent_prepare_generation_plan` 创建 pending 计划,展示计划并等待用户 review。
|
||||
5. 用户提出修改意见时,调用 `data_agent_update_generation_plan`,然后重新展示计划。
|
||||
6. 用户明确确认当前计划版本后,调用 `data_agent_confirm_generation_plan` 获取 `confirmed_plan_id`。
|
||||
7. 让模型优先生成 dataset draft text,而不是直接生成 canonical JSON。
|
||||
8. 调用 `data_agent_normalize_dataset_draft`,并传入 `confirmed_plan_id`,转成 canonical records。
|
||||
9. 调用 `data_agent_validate_dataset_records` 校验字段、标签、时间戳和多轮结构。
|
||||
10. 明确记录必填字段中的不确定点,尤其是 `target`、标签类型、多轮结构和来源元数据。
|
||||
11. 如果当前 schema 信息不足以继续,提出简短问题,或把问题写入任务笔记。
|
||||
12. 在 canonical records 存在之前,不要直接创建最终训练、评测或展示格式。
|
||||
|
||||
## 工具使用建议
|
||||
|
||||
- 用 `read_file` 读取用户提供的样例或 schema 笔记。
|
||||
- 用 `write_file` 或 `edit_file` 起草 schema 笔记、样例记录或 open questions。
|
||||
- 用 `grep_search` 和 `glob_search` 查找已有 data-agent 文档和样例。
|
||||
- 用 `data_agent_prepare_generation_plan` 创建待确认生成计划。
|
||||
- 用 `data_agent_show_generation_plan` 查看当前计划。
|
||||
- 用 `data_agent_update_generation_plan` 根据用户 review 意见修改计划。
|
||||
- 用 `data_agent_confirm_generation_plan` 记录用户确认,并获取 `confirmed_plan_id`。
|
||||
- 用 `data_agent_normalize_dataset_draft` 把 dataset draft text 转成 canonical records,生成数据必须传入 `confirmed_plan_id`。
|
||||
- 用 `data_agent_validate_dataset_records` 校验 canonical records。
|
||||
- 后续导出阶段再使用 `convert_dataset_format` 或 `export_dataset`。
|
||||
|
||||
## 推荐产物
|
||||
|
||||
优先把过程沉淀到这些路径:
|
||||
|
||||
```text
|
||||
tasks/{task_id}/memory/open_questions.md
|
||||
tasks/{task_id}/artifacts/canonical_record_notes.md
|
||||
tasks/{task_id}/artifacts/sample_records.jsonl
|
||||
```
|
||||
|
||||
最终回复保持简短,并引用创建或更新过的文件路径。
|
||||
@@ -1,70 +1,293 @@
|
||||
---
|
||||
name: product-definition-data-generation
|
||||
description: 从产品或标签定义文档中提取边界,并生成边界感知的 canonical data records。
|
||||
when_to_use: 当用户提供产品定义、标签规则或路由边界文档,并希望生成训练/评测数据时使用。
|
||||
description: 从产品/标签定义、手写边界规则或示例 query 中提取标签边界,并生成可 review 的数据计划、dataset draft text 和 canonical metadata records。
|
||||
when_to_use: 当用户提供产品定义、标签规则、路由边界文档、示例 query、手写标签边界,并希望生成训练/评测/专项数据时使用。
|
||||
aliases: definition-data-generation, label-definition-generation
|
||||
allowed_tools: read_file, write_file, edit_file, grep_search, glob_search, ask_user_question
|
||||
allowed_tools: read_file, write_file, edit_file, grep_search, glob_search, ask_user_question, data_agent_load_input_sources, data_agent_render_source_context, data_agent_extract_case_evidence, data_agent_prepare_generation_goal, data_agent_confirm_generation_goal, data_agent_prepare_generation_plan, data_agent_show_generation_plan, data_agent_update_generation_plan, data_agent_confirm_generation_plan, data_agent_normalize_dataset_draft, data_agent_validate_dataset_records, data_agent_export_dataset_records
|
||||
---
|
||||
|
||||
使用这个 skill 处理“产品/标签定义 -> 边界总结 -> review 后的数据生成计划 -> canonical records -> 导出”的工作流。
|
||||
使用这个 skill 作为“产品/标签定义/手写规则/示例 query -> 输入文本化 -> generation goal 草案 -> 人工 review -> 生成计划 review -> dataset draft text -> canonical metadata records”的统一入口。
|
||||
|
||||
本 skill 内置数据记录生成协议。其他数据开发 skill 如果需要生成或整理标准数据,可以复用这里的“交互门禁、dataset draft text v1、canonical record v1”规则。
|
||||
|
||||
## 输入假设
|
||||
|
||||
用户可能会提供:
|
||||
|
||||
- 产品定义文档
|
||||
- 标签定义文档
|
||||
- 正例和反例
|
||||
- 路由、agent 或 function 选择规则
|
||||
- 已知边界冲突
|
||||
- 产品定义文档、标签定义文档、路由规则文档。
|
||||
- 表格、Markdown、JSON、CSV 或普通文本里的标签定义。
|
||||
- 手写的标签边界规则。
|
||||
- 一组 example query、badcase、正例或反例。
|
||||
- 已知边界冲突,例如某类 query 应该进入哪个 Agent/function。
|
||||
|
||||
文档可能不完整或存在歧义。保留不确定性,不要自行发明隐藏规则。
|
||||
输入可能不完整或存在歧义。保留不确定性,不要自行发明隐藏规则。
|
||||
|
||||
## 必要工作流
|
||||
## 任务定位
|
||||
|
||||
1. 读取用户提供的定义文档;如果没有路径,先询问。
|
||||
2. 总结目标标签/类型及其预期边界。
|
||||
3. 提取:
|
||||
- 正向规则
|
||||
- 负向规则
|
||||
- 模糊边界
|
||||
- 示例和反例
|
||||
- 缺失假设
|
||||
4. 写出可 review 的边界总结。
|
||||
5. 对重要的模糊边界,先请用户确认,再进行大规模生成。
|
||||
6. 创建生成计划,覆盖:
|
||||
- 直接正例
|
||||
- hard negative
|
||||
- 边界样例
|
||||
- 必要时包含单轮和多轮样例
|
||||
7. 只有在边界总结和计划清楚后,才生成 canonical records。
|
||||
8. TODO:工具实现后,校验 records、生成预览并导出目标格式。
|
||||
先判断用户输入属于哪一类:
|
||||
|
||||
## 当前工具状态
|
||||
1. **文件定义型**:用户提供文件路径、文档、表格或粘贴的大段定义内容。
|
||||
2. **手写规则型**:用户直接描述标签边界,例如“找附近美食是餐饮服务,导航去某地是地图导航”。
|
||||
3. **示例归纳型**:用户只给 query/example/badcase,需要先归纳边界和标签倾向。
|
||||
|
||||
当前先使用已有工具完成文件检查和产物写入:
|
||||
|
||||
- `read_file`
|
||||
- `write_file`
|
||||
- `edit_file`
|
||||
- `grep_search`
|
||||
- `glob_search`
|
||||
- `ask_user_question`
|
||||
|
||||
规划中的定义类工具,例如 `extract_label_definition` 和 `validate_label_coverage`,不一定已经实现。
|
||||
|
||||
## 推荐产物
|
||||
三类输入最后都要统一产出:
|
||||
|
||||
```text
|
||||
tasks/{task_id}/context/product_definition.*
|
||||
tasks/{task_id}/artifacts/label_boundary_summary.md
|
||||
tasks/{task_id}/artifacts/generation_plan.md
|
||||
tasks/{task_id}/artifacts/generated_candidates.jsonl
|
||||
tasks/{task_id}/memory/open_questions.md
|
||||
dataset_label:
|
||||
target 或 target_definitions:
|
||||
plan_hint:
|
||||
coverage:
|
||||
exclusions:
|
||||
open_questions:
|
||||
source_refs:
|
||||
```
|
||||
|
||||
这一步称为 `generation_goal`。它是模型基于输入资料整理出的数据生成目标草案,不是最终生成计划。
|
||||
|
||||
`generation_goal` 必须先展示给用户 review。只有用户确认 generation goal 后,才能进入本 skill 内置的 generation plan review 流程。
|
||||
|
||||
## 交互门禁
|
||||
|
||||
默认不要一步到位生成数据。除非用户已经明确说“开始生成”“确认计划”“按这个计划生成”或同义表达,否则只能做目标对齐、计划草案和问题确认。
|
||||
|
||||
开始生成前必须确认这些信息:
|
||||
|
||||
- `dataset_label`:数据集或专题名称。
|
||||
- `target` / `target_definitions`:最终监督标签。单标签任务用 `target`,多标签边界任务必须用 `target_definitions` 列出每个标签和判定规则。
|
||||
- 生成数量:总条数,以及单轮/多轮数量或比例。
|
||||
- 覆盖范围:需要覆盖哪些 query 类型、意图边界或错误类型。
|
||||
- 负例/排除项:哪些表达不要生成,或哪些边界容易误判。
|
||||
- 落盘路径:draft、canonical records、validation result 写到哪里。
|
||||
|
||||
如果任一信息缺失,不要生成数据,不要调用 `data_agent_prepare_generation_plan`,不要调用 `data_agent_normalize_dataset_draft`,不要调用 `data_agent_validate_dataset_records`,只向用户提出需要确认的问题。
|
||||
|
||||
信息完整后,调用 `data_agent_prepare_generation_goal` 创建 pending goal。这个工具会暂停本轮,必须把返回的 `generation_goal` 展示给用户 review。用户确认 goal 之后,调用 `data_agent_confirm_generation_goal` 获取 `confirmed_goal_id`,再调用 `data_agent_prepare_generation_plan` 创建 pending plan。创建 plan 后也会暂停本轮,必须等待用户 review。
|
||||
|
||||
用户确认后,拿到 `confirmed_plan_id`,才能生成 dataset draft text,并继续调用工具。
|
||||
|
||||
`data_agent_normalize_dataset_draft` 对生成数据有代码级门禁:没有 `confirmed_plan_id`,或者计划未确认,会拒绝执行。
|
||||
|
||||
多标签边界数据不要拆成多个互不相关的单标签计划。应该创建一个计划,并在 `target_definitions` 中列出所有候选标签。例如:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "餐饮服务",
|
||||
"target": "Agent(tag=\"餐饮服务\")",
|
||||
"rule": "找附近的美食、奶茶、餐厅等,没有明确要求导航。"
|
||||
},
|
||||
{
|
||||
"name": "地图导航",
|
||||
"target": "Agent(tag=\"地图导航\")",
|
||||
"rule": "明确出现导航去某地点、带我去某地点、路线规划等。"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
生成 draft text 时,每条 case 的 `target:` 必须从已确认计划的 `target` 或 `target_definitions[*].target` 中选择。不要临时发明新 target。
|
||||
|
||||
## 必须遵守的数据生成协议
|
||||
|
||||
不要直接生成 canonical JSON,不要直接导出最终训练/评测格式,不要绕过人类 review。
|
||||
|
||||
如果需要生成数据,必须按顺序执行:
|
||||
|
||||
1. 用 `data_agent_load_input_sources` 读取文件。
|
||||
2. 用 `data_agent_render_source_context` 把输入渲染成大模型可读 evidence text。
|
||||
3. 大模型只基于 evidence text 抽取 `generation_goal`,包括 `dataset_label`、`target_definitions`、`plan_hint`、`coverage`、`exclusions`、`open_questions`、`source_refs`。
|
||||
4. 如果目标标签、边界或字段含义不清楚,先用普通回复向用户提问并停止。
|
||||
5. 信息足够时,调用 `data_agent_prepare_generation_goal` 创建 pending goal,并停止等待用户 review。
|
||||
6. 用户确认 generation goal 后,调用 `data_agent_confirm_generation_goal` 获取 `confirmed_goal_id`。
|
||||
7. 调用 `data_agent_prepare_generation_plan` 创建 pending 计划,必须传入 `confirmed_goal_id`。
|
||||
8. 展示计划后停止本轮,等待用户 review。
|
||||
9. 用户提出修改意见时,调用 `data_agent_update_generation_plan`,再展示计划。
|
||||
10. 用户明确确认当前计划版本后,调用 `data_agent_confirm_generation_plan`。
|
||||
11. 生成 dataset draft text v1。
|
||||
12. 调用 `data_agent_normalize_dataset_draft`,必须传入 `confirmed_plan_id`。
|
||||
13. 调用 `data_agent_validate_dataset_records`。
|
||||
14. 如果用户要求落盘 canonical records,调用 `data_agent_export_dataset_records`,默认导出紧凑 JSONL,不要用 `write_file` 手写 JSON。
|
||||
15. 本阶段默认只推进到 canonical metadata records;除非用户另行要求,不做最终训练/评测格式导出。
|
||||
|
||||
## 数据生成输出格式
|
||||
|
||||
当需要生成数据样本时,默认使用 dataset draft text v1,不要直接输出 JSON、JSONL、CSV 或最终表格格式。除非用户明确要求机器可读格式,否则优先输出便于人工 review 的文本格式。
|
||||
|
||||
### 格式
|
||||
|
||||
```text
|
||||
# dataset_label: 数据集或专题名称
|
||||
|
||||
### case: case名称
|
||||
用户: 本轮 query
|
||||
target: Agent(tag="xxx")
|
||||
notes: 可选,说明覆盖的问题或边界
|
||||
|
||||
### case: 多轮 case 名称
|
||||
用户: 前一轮 query
|
||||
小爱: 前一轮 tts
|
||||
用户: 本轮 query
|
||||
target: Agent(tag="xxx")
|
||||
notes: 可选,说明覆盖的问题或边界
|
||||
```
|
||||
|
||||
### 规则
|
||||
|
||||
- 每条数据用一个 `### case:` 开始。
|
||||
- `用户:` 表示用户 query。
|
||||
- `小爱:` 表示小爱回复 tts。
|
||||
- 最后一个 `用户:` 是本轮 query。
|
||||
- `target:` 是本轮 query 对应的监督标签,必须存在。
|
||||
- 单轮数据只需要写一行 `用户:`,然后写 `target:`。
|
||||
- 多轮数据需要按照时间顺序写多组 `用户:` / `小爱:`。
|
||||
- 多轮数据的最后一轮只写 `用户:` 和 `target:`,不要写最后一轮 `小爱:`,因为本轮 query 不包含 tts。
|
||||
- 如果 `target` 无法确定,不要编造,必须向用户确认。
|
||||
- 不要手写 `record_id`、`request_id`、`timestamp`、`context`。
|
||||
- 线上挖掘数据如有真实 `request_id` 和 `timestamp`,可以附加在 case 中;没有则不写。
|
||||
|
||||
## Canonical Record
|
||||
|
||||
`data_agent_normalize_dataset_draft` 会把 dataset draft text 转成 canonical records,并统一补齐 `record_id`、`source`、`timestamp`、`context`、`target_type` 等机械字段。
|
||||
|
||||
canonical records 落盘必须使用 `data_agent_export_dataset_records`,默认格式是紧凑 JSONL:一行一个 canonical record,不带外层数组,不手写缩进 JSON。只有用户明确要求兼容旧文件时,才使用 `output_format="json"` 导出紧凑 JSON 数组。
|
||||
|
||||
当前 canonical record v1 工作格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"record_id": "gen_aabbccdd_000001",
|
||||
"source": {
|
||||
"type": "generated",
|
||||
"request_id": "aabbccdd",
|
||||
"timestamp": 1755567930500
|
||||
},
|
||||
"turn": {
|
||||
"query": "本轮 query",
|
||||
"timestamp": 1755567930500
|
||||
},
|
||||
"prev_session": [
|
||||
{
|
||||
"query": "前一轮 query",
|
||||
"tts": "前一轮 tts",
|
||||
"timestamp": 1755567870500
|
||||
}
|
||||
],
|
||||
"context": {},
|
||||
"label": {
|
||||
"dataset_label": "数据集或专题名称",
|
||||
"target": "Agent(tag=\"xxx\")",
|
||||
"target_type": "agent"
|
||||
},
|
||||
"meta": {
|
||||
"case_name": "case名称",
|
||||
"notes": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 场景工作流
|
||||
|
||||
### 文件定义型
|
||||
|
||||
1. 使用 `data_agent_load_input_sources` 读取用户提供的文件。
|
||||
2. 如果用户只描述了文件名或主题但没给路径,先询问路径,不要猜。
|
||||
3. 使用 `data_agent_render_source_context` 文本化输入资料,必要时用 `focus_keywords` 缩小到 query、功能点、标签、badcase 相关内容。
|
||||
4. 大模型基于 evidence text 总结 query 语义、功能点、边界、正例、反例、冲突点和缺失假设。
|
||||
5. 把标签边界整理为 `generation_goal.target_definitions`。
|
||||
6. 如果文档里没有明确 target 格式,向用户确认,例如 `Agent(tag="xxx")` 还是 function 调用格式。
|
||||
7. 调用 `data_agent_prepare_generation_goal`,由工具暂停等待用户 review;用户确认后再调用 `data_agent_confirm_generation_goal` 和 `data_agent_prepare_generation_plan`。
|
||||
|
||||
### 手写规则型
|
||||
|
||||
1. 直接从用户描述里抽取标签边界。
|
||||
2. 多标签边界必须使用 `target_definitions`,不要拆成多个无关单标签计划。
|
||||
3. 识别规则中的冲突词、优先级和反例。
|
||||
4. 整理为 `generation_goal` 并调用 `data_agent_prepare_generation_goal`。
|
||||
5. 对不明确的 target、数量、单轮/多轮比例、输出路径提出问题。
|
||||
6. 用户确认 generation goal 后,调用 `data_agent_confirm_generation_goal`,再调用 `data_agent_prepare_generation_plan`。
|
||||
|
||||
### 示例归纳型
|
||||
|
||||
1. 先把 example query / badcase 按意图和可能标签分组。
|
||||
2. 输出边界归纳和不确定点,不要马上生成数据。
|
||||
3. 如果 query 没有明确正确标签,必须向用户确认标签或允许的 target 集合。
|
||||
4. 用户确认后,整理为 `generation_goal.target_definitions` 和 `generation_goal.coverage`。
|
||||
5. 调用 `data_agent_prepare_generation_goal` 展示 `generation_goal` 给用户 review;用户确认后再调用 `data_agent_confirm_generation_goal` 和 `data_agent_prepare_generation_plan`。
|
||||
|
||||
## Generation Goal 草案格式
|
||||
|
||||
大模型完成产品定义或 badcase 分析后,先输出下面的草案给用户 review:
|
||||
|
||||
```json
|
||||
{
|
||||
"dataset_label": "数据集或专题名称",
|
||||
"goal_summary": "这批数据要解决什么问题",
|
||||
"target_definitions": [
|
||||
{
|
||||
"name": "标签名称",
|
||||
"target": "Agent(tag=\"xxx\") 或 function 调用",
|
||||
"rule": "哪些 query 应该进入这个标签",
|
||||
"positive_examples": [],
|
||||
"negative_examples": [],
|
||||
"boundary_notes": [],
|
||||
"source_refs": []
|
||||
}
|
||||
],
|
||||
"plan_hint": "建议先生成 50 条单轮,输出到 tasks/<数据集名称>/records.jsonl;具体数量、轮次和路径在 generation plan 中确认。",
|
||||
"coverage": "需要覆盖的 query 语义、功能点、错误类型",
|
||||
"exclusions": "不要生成或需要排除的表达",
|
||||
"open_questions": [],
|
||||
"source_refs": []
|
||||
}
|
||||
```
|
||||
|
||||
`generation_goal` 只确认“做什么数据、为什么做、标签边界是什么”。不要在 goal 中维护结构化的 `total_count`、`turn_mix` 或 `output_path`;这些字段属于后续 `generation_plan`。如果需要在 goal review 阶段提示执行方向,只写一句 `plan_hint`,例如“建议先生成 50 条单轮,输出到 tasks/.../records.jsonl;具体数量、轮次和路径在 generation plan 中确认”。
|
||||
|
||||
如果未来增加 `data_agent_validate_generation_goal`,它只做结构校验和缺失字段提示,不做语义判断,不替代用户 review,也不替代 `data_agent_prepare_generation_plan`。
|
||||
|
||||
现在已经有代码级 goal review 门禁:不要手写 goal 后直接进入 plan,必须先调用 `data_agent_prepare_generation_goal`,并在用户确认后用 `data_agent_confirm_generation_goal` 取得 `confirmed_goal_id`。
|
||||
|
||||
## 计划中的专用工具
|
||||
|
||||
当前前链路只保留三个工具。不要再假设有 `data_agent_load_definition_source`、`data_agent_extract_target_definitions` 这类更细工具。
|
||||
|
||||
### `data_agent_load_input_sources`
|
||||
|
||||
读取目录或文件,统一抽取 `xlsx/csv/docx/pdf/txt/md/json/jsonl` 的段落、表格预览、行数据样例和 source refs。
|
||||
|
||||
### `data_agent_render_source_context`
|
||||
|
||||
把 `data_agent_load_input_sources` 的结构化结果渲染成大模型可读的 evidence text。产品定义/PRD/走查文档的语义理解应该基于这个文本由大模型完成,不要依赖程序规则直接抽语义。
|
||||
|
||||
### `data_agent_extract_case_evidence`
|
||||
|
||||
从 badcase、评测表、走查表里识别 query、上下文、预期标签、模型预测、类型和备注。字段不明确时,它会返回 `required_questions`,此时必须向用户确认字段含义。
|
||||
|
||||
## 当前可用工具
|
||||
|
||||
- `read_file`:读取用户提供的产品定义、标签定义、样例 query 文件。
|
||||
- `write_file`:在用户确认后落盘 draft、校验结果或说明文档;不要用它手写 canonical records 文件。
|
||||
- `edit_file`:修改已有的计划、说明文档或生成结果文件。
|
||||
- `grep_search`:在项目中搜索已有标签定义、历史数据样例或相关文档。
|
||||
- `glob_search`:按路径模式查找定义文件、样例文件或历史产物。
|
||||
- `ask_user_question`:需要用户明确选择或补充关键信息时使用;如果不可用,就用普通回复提问并停止。
|
||||
- `data_agent_load_input_sources`:读取用户给的目录或文件,把 docx/xlsx/pdf 等输入统一抽成段落、表格和 source refs。
|
||||
- `data_agent_render_source_context`:把结构化输入渲染成大模型可读文本,支持 `max_chars`、表格行数和关键词过滤,用于后续模型语义抽取。
|
||||
- `data_agent_extract_case_evidence`:从 badcase/评测/走查表中抽取 query、预期标签、模型预测、上下文和备注;字段歧义会返回需要确认的问题。
|
||||
- `data_agent_prepare_generation_goal`:在已经整理出 `dataset_label`、`target_definitions`、`plan_hint`、`coverage`、`exclusions`、`source_refs` 后,创建待 review 的 generation goal;调用后本轮会暂停等待用户 review。
|
||||
- `data_agent_confirm_generation_goal`:用户明确确认 generation goal 后使用,获取 `confirmed_goal_id`。
|
||||
- `data_agent_prepare_generation_plan`:在 generation goal 已确认后创建待 review 的生成计划;必须传入 `confirmed_goal_id`,调用后本轮会暂停等待用户 review。
|
||||
- `data_agent_show_generation_plan`:用户要求查看当前计划,或继续上下文时需要恢复计划详情时使用。
|
||||
- `data_agent_update_generation_plan`:用户对计划提出修改意见后使用,更新计划并重新展示。
|
||||
- `data_agent_confirm_generation_plan`:用户明确确认当前计划版本后使用,获取 `confirmed_plan_id`。
|
||||
- `data_agent_normalize_dataset_draft`:用户确认计划后,把 dataset draft text v1 转成 canonical records;必须传入 `confirmed_plan_id`。
|
||||
- `data_agent_validate_dataset_records`:对 canonical records 做结构、标签、时间戳和多轮上下文校验。
|
||||
- `data_agent_export_dataset_records`:校验 canonical records 并落盘;默认写紧凑 JSONL,一行一条,不要再用 `write_file` 手写 records 文件。
|
||||
|
||||
## 约束
|
||||
|
||||
- 不要静默解决产品或标签歧义。
|
||||
- 如果 `ask_user_question` 不可用,使用普通回复向用户提问并停止,不要自己替用户确认。
|
||||
- canonical records 通过校验前,不要生成最终导出格式。
|
||||
- canonical records 需要落盘时,必须用 `data_agent_export_dataset_records`;不要自己拼接 JSON/JSONL。
|
||||
- 除非用户明确要求,否则不要把“修改标签定义”和“生成数据”混在一起做。
|
||||
- 不要因为用户说“生成一些数据”就跳过边界总结和 generation plan review。
|
||||
|
||||
Reference in New Issue
Block a user