1012 lines
37 KiB
Python
1012 lines
37 KiB
Python
from __future__ import annotations
|
||
|
||
"""数据智能体记录辅助逻辑。
|
||
|
||
这个模块把数据智能体专属的记录处理逻辑从通用工具注册表里拆出来。
|
||
Skill 应该先让模型生成紧凑、便于人工 review 的 dataset draft text,
|
||
再由这里的逻辑解析并补齐为 canonical records,供后续校验和导出使用。
|
||
"""
|
||
|
||
import csv
|
||
import json
|
||
import re
|
||
import time
|
||
from io import StringIO
|
||
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)
|
||
if not target.strip() and not normalized_targets:
|
||
raise DataRecordError('generation goal must include target or 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,
|
||
*,
|
||
batch_id: str = DEFAULT_REQUEST_ID,
|
||
source_type: str = 'generated',
|
||
base_timestamp: int | None = None,
|
||
timestamp_step_ms: int = DEFAULT_TIMESTAMP_STEP_MS,
|
||
default_request_id: str = DEFAULT_REQUEST_ID,
|
||
confirmed_plan_id: str | None = None,
|
||
plan_state_root: str | None = None,
|
||
) -> dict[str, Any]:
|
||
if not draft_text.strip():
|
||
raise DataRecordError('draft_text must be non-empty')
|
||
if timestamp_step_ms <= 0:
|
||
raise DataRecordError('timestamp_step_ms must be greater than 0')
|
||
confirmed_plan: dict[str, Any] | None = None
|
||
if plan_state_root is not None:
|
||
if not confirmed_plan_id:
|
||
raise DataRecordError('confirmed_plan_id is required before normalizing generated data')
|
||
confirmed_plan = _require_confirmed_generation_plan(plan_state_root, confirmed_plan_id)
|
||
|
||
base = int(time.time() * 1000) if base_timestamp is None else base_timestamp
|
||
parsed = _parse_draft_text(draft_text)
|
||
records: list[dict[str, Any]] = []
|
||
warnings: list[str] = []
|
||
|
||
for index, case in enumerate(parsed['cases'], start=1):
|
||
record, case_warnings = _case_to_record(
|
||
case,
|
||
global_dataset_label=parsed.get('dataset_label') or '',
|
||
index=index,
|
||
batch_id=batch_id,
|
||
source_type=source_type,
|
||
base_timestamp=base,
|
||
timestamp_step_ms=timestamp_step_ms,
|
||
default_request_id=default_request_id,
|
||
)
|
||
records.append(record)
|
||
warnings.extend(case_warnings)
|
||
|
||
if confirmed_plan is not None:
|
||
_validate_records_against_plan(records, confirmed_plan)
|
||
|
||
return {'records': records, 'warnings': warnings}
|
||
|
||
|
||
def prepare_generation_plan(
|
||
*,
|
||
root: str,
|
||
dataset_label: str,
|
||
target: str,
|
||
total_count: int,
|
||
turn_mix: str,
|
||
coverage: str,
|
||
exclusions: str,
|
||
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,
|
||
'turn_mix': turn_mix,
|
||
'coverage': coverage,
|
||
'exclusions': exclusions,
|
||
'output_path': output_path,
|
||
}
|
||
)
|
||
normalized_targets = _normalize_target_definitions(target_definitions)
|
||
if confirmed_goal is not None and not target.strip() and not normalized_targets:
|
||
normalized_targets = _normalize_target_definitions(
|
||
confirmed_goal.get('target_definitions') if isinstance(confirmed_goal, dict) else None
|
||
)
|
||
if not normalized_targets:
|
||
target = str(confirmed_goal.get('target') or '').strip()
|
||
if not target.strip() and not normalized_targets:
|
||
missing.append('target')
|
||
if missing:
|
||
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)
|
||
plan_id = f'data_plan_{sequence:06d}'
|
||
resolved_output_path = _dedupe_output_path(root, output_path.strip(), plan_id)
|
||
plan = {
|
||
'plan_id': plan_id,
|
||
'status': 'pending_confirmation',
|
||
'dataset_label': dataset_label.strip(),
|
||
'target': target.strip(),
|
||
'target_definitions': normalized_targets,
|
||
'total_count': total_count,
|
||
'turn_mix': turn_mix.strip(),
|
||
'coverage': coverage.strip(),
|
||
'exclusions': exclusions.strip(),
|
||
'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),
|
||
}
|
||
plans = state.setdefault('plans', {})
|
||
if not isinstance(plans, dict):
|
||
plans = {}
|
||
state['plans'] = plans
|
||
plans[plan_id] = plan
|
||
state['next_sequence'] = sequence + 1
|
||
_save_plan_state(root, state)
|
||
return {
|
||
'plan': plan,
|
||
'message': '计划已创建,状态为 pending_confirmation。请向用户展示计划,并等待用户确认后再调用确认工具。',
|
||
}
|
||
|
||
|
||
def get_generation_plan(*, root: str, plan_id: str) -> dict[str, Any]:
|
||
state = _load_plan_state(root)
|
||
plan = _get_plan(state, plan_id)
|
||
return {
|
||
'plan': plan,
|
||
'message': '请把计划展示给用户 review。用户有意见时调用 update;用户明确确认后再调用 confirm。',
|
||
}
|
||
|
||
|
||
def update_generation_plan(
|
||
*,
|
||
root: str,
|
||
plan_id: str,
|
||
review_feedback: str,
|
||
updates: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
if not review_feedback.strip():
|
||
raise DataRecordError('review_feedback is required')
|
||
if not isinstance(updates, dict) or not updates:
|
||
raise DataRecordError('updates must be a non-empty object')
|
||
state = _load_plan_state(root)
|
||
plan = _get_plan(state, plan_id)
|
||
if plan.get('status') == 'confirmed':
|
||
raise DataRecordError(f'generation plan is already confirmed: {plan_id}')
|
||
|
||
allowed_keys = {
|
||
'dataset_label',
|
||
'target',
|
||
'target_definitions',
|
||
'total_count',
|
||
'turn_mix',
|
||
'coverage',
|
||
'exclusions',
|
||
'output_path',
|
||
'notes',
|
||
}
|
||
unknown = sorted(key for key in updates if key not in allowed_keys)
|
||
if unknown:
|
||
raise DataRecordError('unsupported update fields: ' + ', '.join(unknown))
|
||
|
||
if 'target_definitions' in updates:
|
||
updates = dict(updates)
|
||
updates['target_definitions'] = _normalize_target_definitions(updates['target_definitions'])
|
||
if 'total_count' in updates:
|
||
value = updates['total_count']
|
||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
||
raise DataRecordError('total_count must be a positive integer')
|
||
for key, value in updates.items():
|
||
if key == 'target_definitions' or key == 'total_count':
|
||
continue
|
||
if not isinstance(value, str):
|
||
raise DataRecordError(f'{key} must be a string')
|
||
updates[key] = value.strip()
|
||
|
||
plan.update(updates)
|
||
plan['status'] = 'pending_confirmation'
|
||
plan['revision'] = int(plan.get('revision') or 1) + 1
|
||
history = plan.get('review_history')
|
||
if not isinstance(history, list):
|
||
history = []
|
||
plan['review_history'] = history
|
||
history.append(
|
||
{
|
||
'revision': plan['revision'],
|
||
'feedback': review_feedback.strip(),
|
||
'updates': updates,
|
||
'updated_at_ms': int(time.time() * 1000),
|
||
}
|
||
)
|
||
_save_plan_state(root, state)
|
||
return {
|
||
'plan': plan,
|
||
'message': '计划已根据用户反馈更新。请重新展示给用户 review,确认前不要生成数据。',
|
||
}
|
||
|
||
|
||
def confirm_generation_plan(
|
||
*,
|
||
root: str,
|
||
plan_id: str,
|
||
confirmation: str,
|
||
reviewed_revision: int | None = None,
|
||
) -> dict[str, Any]:
|
||
if not plan_id.strip():
|
||
raise DataRecordError('plan_id is required')
|
||
if not _is_confirmation_text(confirmation):
|
||
raise DataRecordError('confirmation must clearly approve the plan')
|
||
state = _load_plan_state(root)
|
||
plan = _get_plan(state, plan_id)
|
||
current_revision = int(plan.get('revision') or 1)
|
||
if reviewed_revision is not None and reviewed_revision != current_revision:
|
||
raise DataRecordError(
|
||
f'reviewed_revision must match current plan revision {current_revision}'
|
||
)
|
||
plan['status'] = 'confirmed'
|
||
plan['confirmed_at_ms'] = int(time.time() * 1000)
|
||
plan['confirmation'] = confirmation.strip()
|
||
plan['confirmed_revision'] = current_revision
|
||
_save_plan_state(root, state)
|
||
return {
|
||
'plan': plan,
|
||
'confirmed_plan_id': plan_id,
|
||
'message': '计划已确认,可以把 confirmed_plan_id 传给 data_agent_normalize_dataset_draft。',
|
||
}
|
||
|
||
|
||
def validate_dataset_records(records: list[dict[str, Any]]) -> dict[str, Any]:
|
||
errors: list[dict[str, Any]] = []
|
||
warnings: list[dict[str, Any]] = []
|
||
|
||
if not isinstance(records, list):
|
||
raise DataRecordError('records must be a list')
|
||
|
||
seen_ids: set[str] = set()
|
||
for index, record in enumerate(records):
|
||
prefix = f'records[{index}]'
|
||
if not isinstance(record, dict):
|
||
errors.append(_issue(prefix, 'record must be an object'))
|
||
continue
|
||
|
||
record_id = record.get('record_id')
|
||
if not _non_empty_str(record_id):
|
||
errors.append(_issue(f'{prefix}.record_id', 'record_id is required'))
|
||
elif record_id in seen_ids:
|
||
errors.append(_issue(f'{prefix}.record_id', 'record_id must be unique'))
|
||
else:
|
||
seen_ids.add(record_id)
|
||
|
||
source = record.get('source')
|
||
if not isinstance(source, dict):
|
||
errors.append(_issue(f'{prefix}.source', 'source is required'))
|
||
else:
|
||
if source.get('type') not in {'generated', 'online', 'manual', 'mixed'}:
|
||
errors.append(_issue(f'{prefix}.source.type', 'source.type is invalid'))
|
||
if not _non_empty_str(source.get('request_id')):
|
||
errors.append(_issue(f'{prefix}.source.request_id', 'source.request_id is required'))
|
||
if not _int_like(source.get('timestamp')):
|
||
errors.append(_issue(f'{prefix}.source.timestamp', 'source.timestamp must be an integer'))
|
||
|
||
turn = record.get('turn')
|
||
if not isinstance(turn, dict):
|
||
errors.append(_issue(f'{prefix}.turn', 'turn is required'))
|
||
turn_timestamp = None
|
||
else:
|
||
if not _non_empty_str(turn.get('query')):
|
||
errors.append(_issue(f'{prefix}.turn.query', 'turn.query is required'))
|
||
if 'tts' in turn:
|
||
errors.append(_issue(f'{prefix}.turn.tts', 'current turn must not contain tts'))
|
||
turn_timestamp = turn.get('timestamp')
|
||
if not _int_like(turn_timestamp):
|
||
errors.append(_issue(f'{prefix}.turn.timestamp', 'turn.timestamp must be an integer'))
|
||
|
||
prev_session = record.get('prev_session')
|
||
if not isinstance(prev_session, list):
|
||
errors.append(_issue(f'{prefix}.prev_session', 'prev_session must be a list'))
|
||
prev_timestamps: list[int] = []
|
||
else:
|
||
if len(prev_session) > 10:
|
||
errors.append(_issue(f'{prefix}.prev_session', 'prev_session must contain at most 10 turns'))
|
||
prev_timestamps = []
|
||
for prev_index, item in enumerate(prev_session):
|
||
item_prefix = f'{prefix}.prev_session[{prev_index}]'
|
||
if not isinstance(item, dict):
|
||
errors.append(_issue(item_prefix, 'prev_session item must be an object'))
|
||
continue
|
||
if not _non_empty_str(item.get('query')):
|
||
errors.append(_issue(f'{item_prefix}.query', 'query is required'))
|
||
if 'tts' not in item or not isinstance(item.get('tts'), str):
|
||
errors.append(_issue(f'{item_prefix}.tts', 'tts must be a string'))
|
||
timestamp = item.get('timestamp')
|
||
if not _int_like(timestamp):
|
||
errors.append(_issue(f'{item_prefix}.timestamp', 'timestamp must be an integer'))
|
||
else:
|
||
prev_timestamps.append(int(timestamp))
|
||
|
||
if prev_timestamps != sorted(prev_timestamps):
|
||
errors.append(_issue(f'{prefix}.prev_session', 'timestamps must be sorted from early to late'))
|
||
if _int_like(turn_timestamp):
|
||
all_timestamps = [*prev_timestamps, int(turn_timestamp)]
|
||
for left, right in zip(all_timestamps, all_timestamps[1:]):
|
||
if right <= left:
|
||
errors.append(_issue(f'{prefix}.timestamp', 'turn timestamps must be strictly increasing'))
|
||
break
|
||
if right - left > 300_000:
|
||
warnings.append(_issue(f'{prefix}.timestamp', 'adjacent turns are more than 5 minutes apart'))
|
||
|
||
context = record.get('context')
|
||
if not isinstance(context, dict):
|
||
errors.append(_issue(f'{prefix}.context', 'context must be an object'))
|
||
|
||
label = record.get('label')
|
||
if not isinstance(label, dict):
|
||
errors.append(_issue(f'{prefix}.label', 'label is required'))
|
||
else:
|
||
if not _non_empty_str(label.get('dataset_label')):
|
||
warnings.append(_issue(f'{prefix}.label.dataset_label', 'dataset_label is empty'))
|
||
if not _non_empty_str(label.get('target')):
|
||
errors.append(_issue(f'{prefix}.label.target', 'label.target is required'))
|
||
if label.get('target_type') not in {'agent', 'function', 'unknown'}:
|
||
errors.append(_issue(f'{prefix}.label.target_type', 'label.target_type is invalid'))
|
||
|
||
return {
|
||
'ok': not errors,
|
||
'error_count': len(errors),
|
||
'warning_count': len(warnings),
|
||
'errors': errors,
|
||
'warnings': warnings,
|
||
}
|
||
|
||
|
||
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,
|
||
export_table: 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)
|
||
table_path = path.with_name('records.csv')
|
||
if path.exists() and not overwrite:
|
||
raise DataRecordError(f'output_path already exists: {output_path}')
|
||
if export_table and table_path.exists() and not overwrite:
|
||
raise DataRecordError('table output_path already exists: records.csv')
|
||
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()))
|
||
payload = {
|
||
'output_path': rel_path,
|
||
'output_format': output_format,
|
||
'record_count': len(records),
|
||
'bytes_written': len(content.encode('utf-8')),
|
||
'validation': validation,
|
||
}
|
||
if export_table:
|
||
table_content = render_dataset_records_table_csv(records)
|
||
table_path.write_text(table_content, encoding='utf-8-sig')
|
||
payload.update(
|
||
{
|
||
'table_output_path': str(table_path.relative_to(Path(root).resolve())),
|
||
'table_output_format': 'csv',
|
||
'table_bytes_written': len(table_content.encode('utf-8-sig')),
|
||
}
|
||
)
|
||
return payload
|
||
|
||
|
||
def render_dataset_records_table_csv(records: list[dict[str, Any]]) -> str:
|
||
"""把 canonical records 转成同事流转用的表格 CSV。"""
|
||
|
||
output = StringIO()
|
||
writer = csv.DictWriter(output, fieldnames=_dataset_table_columns(), lineterminator='\n')
|
||
writer.writeheader()
|
||
for record in records:
|
||
writer.writerow(_dataset_record_table_row(record))
|
||
return output.getvalue()
|
||
|
||
|
||
def _dataset_table_columns() -> list[str]:
|
||
return [
|
||
'request_id',
|
||
'timestamp',
|
||
'query',
|
||
'prev_session',
|
||
'context',
|
||
'label',
|
||
'是否迁移Function',
|
||
'function',
|
||
]
|
||
|
||
|
||
def _dataset_record_table_row(record: dict[str, Any]) -> dict[str, str]:
|
||
source = record.get('source') if isinstance(record.get('source'), dict) else {}
|
||
turn = record.get('turn') if isinstance(record.get('turn'), dict) else {}
|
||
label = record.get('label') if isinstance(record.get('label'), dict) else {}
|
||
context = record.get('context') if isinstance(record.get('context'), dict) else {}
|
||
prev_session = record.get('prev_session')
|
||
if not isinstance(prev_session, list):
|
||
prev_session = []
|
||
return {
|
||
'request_id': str(source.get('request_id') or ''),
|
||
'timestamp': str(source.get('timestamp') or turn.get('timestamp') or ''),
|
||
'query': str(turn.get('query') or ''),
|
||
'prev_session': json.dumps(_table_prev_session(prev_session), ensure_ascii=False, separators=(',', ':')),
|
||
'context': json.dumps(context, ensure_ascii=False, separators=(',', ':')),
|
||
'label': str(label.get('dataset_label') or ''),
|
||
'是否迁移Function': '',
|
||
'function': str(label.get('target') or ''),
|
||
}
|
||
|
||
|
||
def _table_prev_session(prev_session: list[Any]) -> list[dict[str, str]]:
|
||
rows: list[dict[str, str]] = []
|
||
for item in prev_session[-10:]:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
rows.append(
|
||
{
|
||
'query': str(item.get('query') or ''),
|
||
'tts': str(item.get('tts') or ''),
|
||
'timestamp': str(item.get('timestamp') or ''),
|
||
}
|
||
)
|
||
return rows
|
||
|
||
|
||
def _parse_draft_text(draft_text: str) -> dict[str, Any]:
|
||
dataset_label = ''
|
||
cases: list[dict[str, Any]] = []
|
||
current: dict[str, Any] | None = None
|
||
|
||
for raw_line in draft_text.splitlines():
|
||
line = raw_line.strip()
|
||
if not line:
|
||
continue
|
||
dataset_match = re.match(r'^#\s*dataset_label\s*[::]\s*(.+)$', line, re.IGNORECASE)
|
||
if dataset_match:
|
||
dataset_label = dataset_match.group(1).strip()
|
||
continue
|
||
case_match = re.match(r'^#{3,}\s*case\s*[::]\s*(.+)$', line, re.IGNORECASE)
|
||
if case_match:
|
||
current = {'case_name': case_match.group(1).strip(), 'turns': []}
|
||
cases.append(current)
|
||
continue
|
||
if current is None:
|
||
continue
|
||
|
||
field_match = re.match(r'^(用户|小爱|target|notes|dataset_label|request_id|timestamp)\s*[::]\s*(.*)$', line, re.IGNORECASE)
|
||
if not field_match:
|
||
continue
|
||
key = field_match.group(1).lower()
|
||
value = field_match.group(2).strip()
|
||
if key == '用户':
|
||
current['turns'].append({'role': 'user', 'text': value})
|
||
elif key == '小爱':
|
||
current['turns'].append({'role': 'assistant', 'text': value})
|
||
else:
|
||
current[key] = value
|
||
|
||
if not cases:
|
||
raise DataRecordError('draft_text must contain at least one "### case:" block')
|
||
return {'dataset_label': dataset_label, 'cases': cases}
|
||
|
||
|
||
def _missing_plan_fields(fields: dict[str, str]) -> list[str]:
|
||
return [name for name, value in fields.items() if not isinstance(value, str) or not value.strip()]
|
||
|
||
|
||
def _dedupe_output_path(root: str, output_path: str, plan_id: str) -> str:
|
||
from pathlib import Path
|
||
|
||
path = Path(output_path)
|
||
# 会话 output 下的标准 records 文件名需要保持稳定;导出工具默认允许覆盖。
|
||
if path.name in {'records.jsonl', 'records.json'} and 'output' in path.parts:
|
||
return output_path
|
||
resolved = path if path.is_absolute() else Path(root).resolve() / path
|
||
if not resolved.exists():
|
||
return output_path
|
||
parts = path.parts
|
||
if len(parts) >= 2 and parts[-1] == 'artifacts':
|
||
task_dir = Path(*parts[:-1])
|
||
deduped = Path(f'{task_dir}-{plan_id}') / 'artifacts'
|
||
else:
|
||
deduped = Path(f'{output_path.rstrip("/")}-{plan_id}')
|
||
return deduped.as_posix()
|
||
|
||
|
||
def _normalize_target_definitions(value: list[dict[str, Any]] | None) -> list[dict[str, str]]:
|
||
if value is None:
|
||
return []
|
||
if not isinstance(value, list):
|
||
raise DataRecordError('target_definitions must be a list')
|
||
normalized: list[dict[str, str]] = []
|
||
for index, item in enumerate(value):
|
||
if not isinstance(item, dict):
|
||
raise DataRecordError(f'target_definitions[{index}] must be an object')
|
||
name = str(item.get('name') or '').strip()
|
||
target = str(item.get('target') or '').strip()
|
||
rule = str(item.get('rule') or '').strip()
|
||
if not target:
|
||
raise DataRecordError(f'target_definitions[{index}].target is required')
|
||
normalized.append({'name': name, 'target': target, 'rule': rule})
|
||
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()
|
||
if isinstance(target_definitions, list):
|
||
for item in target_definitions:
|
||
if isinstance(item, dict) and isinstance(item.get('target'), str) and item['target'].strip():
|
||
allowed.add(item['target'].strip())
|
||
target = plan.get('target')
|
||
if isinstance(target, str) and target.strip():
|
||
allowed.add(target.strip())
|
||
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:
|
||
return
|
||
unexpected: list[str] = []
|
||
for index, record in enumerate(records):
|
||
label = record.get('label')
|
||
target = label.get('target') if isinstance(label, dict) else None
|
||
if isinstance(target, str) and target.strip() in allowed_targets:
|
||
continue
|
||
unexpected.append(f'records[{index}].label.target={target!r}')
|
||
if unexpected:
|
||
raise DataRecordError(
|
||
'record targets must match the confirmed generation plan: '
|
||
+ '; '.join(unexpected)
|
||
)
|
||
|
||
|
||
def _is_confirmation_text(text: str) -> bool:
|
||
if not isinstance(text, str):
|
||
return False
|
||
normalized = text.strip().lower()
|
||
if not normalized:
|
||
return False
|
||
approval_words = (
|
||
'确认',
|
||
'同意',
|
||
'开始生成',
|
||
'按这个计划',
|
||
'批准',
|
||
'approve',
|
||
'approved',
|
||
'confirmed',
|
||
'yes',
|
||
)
|
||
return any(word in normalized for word in approval_words)
|
||
|
||
|
||
def _plan_state_path(root: str):
|
||
from pathlib import Path
|
||
|
||
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():
|
||
return {'next_sequence': 1, 'plans': {}}
|
||
try:
|
||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||
except json.JSONDecodeError as exc:
|
||
raise DataRecordError(f'invalid generation plan state file: {path}') from exc
|
||
if not isinstance(payload, dict):
|
||
raise DataRecordError(f'invalid generation plan state file: {path}')
|
||
if not isinstance(payload.get('plans'), dict):
|
||
payload['plans'] = {}
|
||
if not isinstance(payload.get('next_sequence'), int):
|
||
payload['next_sequence'] = 1
|
||
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):
|
||
raise DataRecordError('generation plan state is invalid')
|
||
plan = plans.get(plan_id)
|
||
if not isinstance(plan, dict):
|
||
raise DataRecordError(f'generation plan not found: {plan_id}')
|
||
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)
|
||
if plan.get('status') != 'confirmed':
|
||
raise DataRecordError(f'generation plan is not confirmed: {plan_id}')
|
||
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],
|
||
*,
|
||
global_dataset_label: str,
|
||
index: int,
|
||
batch_id: str,
|
||
source_type: str,
|
||
base_timestamp: int,
|
||
timestamp_step_ms: int,
|
||
default_request_id: str,
|
||
) -> tuple[dict[str, Any], list[str]]:
|
||
warnings: list[str] = []
|
||
turns = case.get('turns')
|
||
if not isinstance(turns, list):
|
||
raise DataRecordError(f'case {index} has invalid turns')
|
||
|
||
user_indexes = [turn_index for turn_index, turn in enumerate(turns) if turn.get('role') == 'user']
|
||
if not user_indexes:
|
||
raise DataRecordError(f'case {index} must contain at least one 用户 line')
|
||
final_user_index = user_indexes[-1]
|
||
current_query = str(turns[final_user_index].get('text') or '').strip()
|
||
if not current_query:
|
||
raise DataRecordError(f'case {index} current 用户 line must be non-empty')
|
||
|
||
target = _canonical_target(str(case.get('target') or '').strip())
|
||
if not target:
|
||
raise DataRecordError(f'case {index} target is required')
|
||
|
||
prev_session = _build_prev_session(turns[:final_user_index], case_index=index)
|
||
if len(prev_session) > 10:
|
||
warnings.append(f'case {index} prev_session has more than 10 turns; keeping the latest 10')
|
||
prev_session = prev_session[-10:]
|
||
|
||
request_id = str(case.get('request_id') or default_request_id).strip()
|
||
case_timestamp = _optional_int(case.get('timestamp'))
|
||
turn_timestamp = case_timestamp if case_timestamp is not None else base_timestamp + (index - 1) * timestamp_step_ms * 20
|
||
first_prev_timestamp = turn_timestamp - len(prev_session) * timestamp_step_ms
|
||
for prev_index, item in enumerate(prev_session):
|
||
item['timestamp'] = first_prev_timestamp + prev_index * timestamp_step_ms
|
||
|
||
dataset_label = str(case.get('dataset_label') or global_dataset_label or '').strip()
|
||
record = {
|
||
'record_id': _record_id(source_type=source_type, batch_id=batch_id, request_id=request_id, index=index),
|
||
'source': {
|
||
'type': source_type,
|
||
'request_id': request_id,
|
||
'timestamp': turn_timestamp,
|
||
},
|
||
'turn': {
|
||
'query': current_query,
|
||
'timestamp': turn_timestamp,
|
||
},
|
||
'prev_session': prev_session,
|
||
'context': {},
|
||
'label': {
|
||
'dataset_label': dataset_label,
|
||
'target': target,
|
||
'target_type': _target_type(target),
|
||
},
|
||
'meta': {
|
||
'case_name': str(case.get('case_name') or '').strip(),
|
||
'notes': str(case.get('notes') or '').strip(),
|
||
},
|
||
}
|
||
return record, warnings
|
||
|
||
|
||
def _build_prev_session(turns: list[dict[str, Any]], *, case_index: int) -> list[dict[str, Any]]:
|
||
prev_session: list[dict[str, Any]] = []
|
||
index = 0
|
||
while index < len(turns):
|
||
turn = turns[index]
|
||
if turn.get('role') != 'user':
|
||
raise DataRecordError(f'case {case_index} prev_session must start with 用户 before 小爱')
|
||
if index + 1 >= len(turns) or turns[index + 1].get('role') != 'assistant':
|
||
raise DataRecordError(f'case {case_index} each previous 用户 line must be followed by 小爱')
|
||
query = str(turn.get('text') or '').strip()
|
||
tts = str(turns[index + 1].get('text') or '').strip()
|
||
if not query or not tts:
|
||
raise DataRecordError(f'case {case_index} previous 用户/小爱 lines must be non-empty')
|
||
prev_session.append({'query': query, 'tts': tts})
|
||
index += 2
|
||
return prev_session
|
||
|
||
|
||
def _target_type(target: str) -> str:
|
||
if re.match(r'^Agent\s*\(\s*tag\s*=', target):
|
||
return 'agent'
|
||
if target:
|
||
return 'function'
|
||
return 'unknown'
|
||
|
||
|
||
def _canonical_target(target: str) -> str:
|
||
"""规范常见 Agent 标签写法,允许草稿里用单引号降低 JSON 转义风险。"""
|
||
|
||
match = re.fullmatch(r'Agent\s*\(\s*tag\s*=\s*([\'"])(.+?)\1\s*\)', target)
|
||
if not match:
|
||
return target
|
||
tag = match.group(2).strip()
|
||
escaped = tag.replace('\\', '\\\\').replace('"', '\\"')
|
||
return f'Agent(tag="{escaped}")'
|
||
|
||
|
||
def _record_id(*, source_type: str, batch_id: str, request_id: str, index: int) -> str:
|
||
if source_type == 'generated':
|
||
return f'gen_{_safe_token(batch_id)}_{index:06d}'
|
||
if source_type == 'online':
|
||
return f'online_{_safe_token(request_id)}_{index:06d}'
|
||
return f'{_safe_token(source_type)}_{_safe_token(batch_id)}_{index:06d}'
|
||
|
||
|
||
def _safe_token(value: str) -> str:
|
||
token = re.sub(r'[^A-Za-z0-9_.-]+', '_', value.strip())
|
||
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
|
||
if isinstance(value, bool):
|
||
raise DataRecordError('timestamp must be an integer')
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError) as exc:
|
||
raise DataRecordError('timestamp must be an integer') from exc
|
||
|
||
|
||
def _non_empty_str(value: Any) -> bool:
|
||
return isinstance(value, str) and bool(value.strip())
|
||
|
||
|
||
def _int_like(value: Any) -> bool:
|
||
return isinstance(value, int) and not isinstance(value, bool)
|
||
|
||
|
||
def _issue(path: str, message: str) -> dict[str, str]:
|
||
return {'path': path, 'message': message}
|
||
|
||
|
||
def records_from_tool_argument(value: Any) -> list[dict[str, Any]]:
|
||
if isinstance(value, str):
|
||
decoded = json.loads(value)
|
||
else:
|
||
decoded = value
|
||
if not isinstance(decoded, list):
|
||
raise DataRecordError('records must be a JSON array')
|
||
return decoded
|