Stabilize product data export flow
This commit is contained in:
+48
-3
@@ -832,8 +832,15 @@ class LocalCodingAgent:
|
||||
return result
|
||||
# fall through to the normal tool-call branch below
|
||||
# normal error path if not recovered
|
||||
final_output = str(exc)
|
||||
session.append_assistant(
|
||||
final_output,
|
||||
message_id=f'assistant_{len(session.messages)}',
|
||||
stop_reason='backend_error',
|
||||
)
|
||||
_append_final_text_stream_events(stream_events, final_output)
|
||||
result = AgentRunResult(
|
||||
final_output=str(exc),
|
||||
final_output=final_output,
|
||||
turns=max(turn_index - 1, 0),
|
||||
tool_calls=tool_calls,
|
||||
transcript=session.transcript(),
|
||||
@@ -1535,15 +1542,42 @@ class LocalCodingAgent:
|
||||
usage=usage,
|
||||
)
|
||||
assistant_message = session.messages[assistant_index]
|
||||
try:
|
||||
parsed_tool_calls = self._tool_calls_from_message(assistant_message.tool_calls)
|
||||
except OpenAICompatError:
|
||||
self._scrub_malformed_assistant_tool_calls(session, assistant_index)
|
||||
raise
|
||||
turn = AssistantTurn(
|
||||
content=assistant_message.content,
|
||||
tool_calls=self._tool_calls_from_message(assistant_message.tool_calls),
|
||||
tool_calls=parsed_tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
raw_message=assistant_message.to_openai_message(),
|
||||
usage=usage,
|
||||
)
|
||||
return turn, tuple(events)
|
||||
|
||||
def _scrub_malformed_assistant_tool_calls(
|
||||
self,
|
||||
session: AgentSessionState,
|
||||
assistant_index: int,
|
||||
) -> None:
|
||||
message = session.messages[assistant_index]
|
||||
filtered_blocks = tuple(
|
||||
block
|
||||
for block in message.blocks
|
||||
if block.get('type') not in {'tool_use', 'tool_call'}
|
||||
)
|
||||
session.messages[assistant_index] = replace(
|
||||
message,
|
||||
tool_calls=(),
|
||||
blocks=filtered_blocks,
|
||||
stop_reason='malformed_tool_arguments',
|
||||
metadata={
|
||||
**message.metadata,
|
||||
'malformed_tool_arguments': True,
|
||||
},
|
||||
)
|
||||
|
||||
def _tool_calls_from_message(
|
||||
self,
|
||||
tool_calls: tuple[dict[str, object], ...],
|
||||
@@ -1558,7 +1592,18 @@ class LocalCodingAgent:
|
||||
continue
|
||||
raw_arguments = function_block.get('arguments', '')
|
||||
if isinstance(raw_arguments, str) and raw_arguments.strip():
|
||||
arguments = json.loads(raw_arguments)
|
||||
try:
|
||||
arguments = json.loads(raw_arguments)
|
||||
except json.JSONDecodeError as exc:
|
||||
preview = raw_arguments[:240].replace('\n', '\\n')
|
||||
raise OpenAICompatError(
|
||||
'模型返回的工具参数不是合法 JSON,已停止本轮以避免执行错误工具。'
|
||||
f'工具:{name};位置:line {exc.lineno} column {exc.colno};'
|
||||
f'原因:{exc.msg};参数片段:{preview!r}。'
|
||||
'请回复“继续”,我会要求模型重新生成合法 JSON 参数;'
|
||||
'对于大段 dataset draft,应分批或使用 draft_path/records_path,'
|
||||
'不要把大量带引号的文本一次性塞进工具参数。'
|
||||
) from exc
|
||||
if not isinstance(arguments, dict):
|
||||
raise OpenAICompatError(
|
||||
f'Tool arguments must decode to an object, got {type(arguments).__name__}'
|
||||
|
||||
@@ -429,7 +429,8 @@ def build_data_agent_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentToo
|
||||
description=(
|
||||
'Parse dataset draft text written with 用户/小爱/target blocks and build canonical '
|
||||
'data-agent records with generated record IDs, timestamps, source metadata, context, and labels. '
|
||||
'Generated data requires a confirmed_plan_id from data_agent_confirm_generation_plan.'
|
||||
'Generated data requires a confirmed_plan_id from data_agent_confirm_generation_plan. '
|
||||
'Use draft_path instead of draft_text when the draft is large or contains many quoted targets.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
@@ -438,6 +439,10 @@ def build_data_agent_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentToo
|
||||
'type': 'string',
|
||||
'description': 'Dataset draft text in dataset draft text v1 format.',
|
||||
},
|
||||
'draft_path': {
|
||||
'type': 'string',
|
||||
'description': 'Path to a UTF-8 dataset draft text file. Prefer this for large drafts.',
|
||||
},
|
||||
'batch_id': {
|
||||
'type': 'string',
|
||||
'description': 'Batch identifier used in generated record_id values.',
|
||||
@@ -464,7 +469,10 @@ def build_data_agent_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentToo
|
||||
'description': 'Required for generated data; returned by data_agent_confirm_generation_plan.',
|
||||
},
|
||||
},
|
||||
'required': ['draft_text'],
|
||||
'anyOf': [
|
||||
{'required': ['draft_text']},
|
||||
{'required': ['draft_path']},
|
||||
],
|
||||
},
|
||||
handler=resolve_handler(handlers, 'data_agent_normalize_dataset_draft', 'data-agent'),
|
||||
),
|
||||
@@ -484,8 +492,15 @@ def build_data_agent_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentToo
|
||||
],
|
||||
'description': 'Canonical records as an array, or a JSON string containing the array.',
|
||||
},
|
||||
'records_path': {
|
||||
'type': 'string',
|
||||
'description': 'Path to records JSON/JSONL. Prefer this when records are large.',
|
||||
},
|
||||
},
|
||||
'required': ['records'],
|
||||
'anyOf': [
|
||||
{'required': ['records']},
|
||||
{'required': ['records_path']},
|
||||
],
|
||||
},
|
||||
handler=resolve_handler(handlers, 'data_agent_validate_dataset_records', 'data-agent'),
|
||||
),
|
||||
@@ -493,7 +508,8 @@ def build_data_agent_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentToo
|
||||
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.'
|
||||
'Defaults to compact JSONL, one canonical record per line, and also writes records.csv '
|
||||
'in the same output directory for human sharing.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
@@ -505,6 +521,10 @@ def build_data_agent_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentToo
|
||||
],
|
||||
'description': 'Canonical records as an array, or a JSON string containing the array.',
|
||||
},
|
||||
'records_path': {
|
||||
'type': 'string',
|
||||
'description': 'Path to records JSON/JSONL. Prefer this when records are large.',
|
||||
},
|
||||
'output_path': {
|
||||
'type': 'string',
|
||||
'description': (
|
||||
@@ -526,8 +546,16 @@ def build_data_agent_tools(handlers: Mapping[str, ToolHandler]) -> list[AgentToo
|
||||
'type': 'boolean',
|
||||
'description': 'When false, refuse to overwrite an existing file.',
|
||||
},
|
||||
'export_table': {
|
||||
'type': 'boolean',
|
||||
'description': 'Defaults to true. Also write records.csv with the shared spreadsheet columns.',
|
||||
},
|
||||
},
|
||||
'required': ['records', 'output_path'],
|
||||
'required': ['output_path'],
|
||||
'anyOf': [
|
||||
{'required': ['records']},
|
||||
{'required': ['records_path']},
|
||||
],
|
||||
},
|
||||
handler=resolve_handler(handlers, 'data_agent_export_dataset_records', 'data-agent'),
|
||||
),
|
||||
|
||||
+61
-7
@@ -1474,7 +1474,7 @@ def _data_agent_confirm_generation_plan_tool(arguments: dict[str, Any], context:
|
||||
|
||||
|
||||
def _normalize_dataset_draft_tool(arguments: dict[str, Any], _context: ToolExecutionContext) -> str:
|
||||
draft_text = _require_string(arguments, 'draft_text')
|
||||
draft_text = _draft_text_from_arguments(arguments, _context)
|
||||
batch_id = arguments.get('batch_id', 'aabbccdd')
|
||||
if not isinstance(batch_id, str) or not batch_id.strip():
|
||||
raise ToolExecutionError('batch_id must be a non-empty string')
|
||||
@@ -1508,10 +1508,8 @@ def _normalize_dataset_draft_tool(arguments: dict[str, Any], _context: ToolExecu
|
||||
|
||||
|
||||
def _validate_dataset_records_tool(arguments: dict[str, Any], _context: ToolExecutionContext) -> str:
|
||||
if 'records' not in arguments:
|
||||
raise ToolExecutionError('records is required')
|
||||
try:
|
||||
records = records_from_tool_argument(arguments['records'])
|
||||
records = _records_from_arguments(arguments, _context)
|
||||
payload = validate_dataset_records(records)
|
||||
except (DataRecordError, json.JSONDecodeError) as exc:
|
||||
raise ToolExecutionError(str(exc)) from exc
|
||||
@@ -1519,8 +1517,6 @@ def _validate_dataset_records_tool(arguments: dict[str, Any], _context: ToolExec
|
||||
|
||||
|
||||
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')
|
||||
@@ -1530,8 +1526,11 @@ def _export_dataset_records_tool(arguments: dict[str, Any], context: ToolExecuti
|
||||
overwrite = arguments.get('overwrite', True)
|
||||
if not isinstance(overwrite, bool):
|
||||
raise ToolExecutionError('overwrite must be a boolean')
|
||||
export_table = arguments.get('export_table', True)
|
||||
if not isinstance(export_table, bool):
|
||||
raise ToolExecutionError('export_table must be a boolean')
|
||||
try:
|
||||
records = records_from_tool_argument(arguments['records'])
|
||||
records = _records_from_arguments(arguments, context)
|
||||
canonical_filename = 'records.json' if output_format == 'json' else 'records.jsonl'
|
||||
payload = export_dataset_records(
|
||||
records,
|
||||
@@ -1544,12 +1543,67 @@ def _export_dataset_records_tool(arguments: dict[str, Any], context: ToolExecuti
|
||||
output_format=output_format,
|
||||
require_validation_ok=require_validation_ok,
|
||||
overwrite=overwrite,
|
||||
export_table=export_table,
|
||||
)
|
||||
except (DataRecordError, json.JSONDecodeError) as exc:
|
||||
raise ToolExecutionError(str(exc)) from exc
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _draft_text_from_arguments(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
draft_text = arguments.get('draft_text')
|
||||
draft_path = arguments.get('draft_path')
|
||||
has_text = isinstance(draft_text, str) and bool(draft_text.strip())
|
||||
has_path = isinstance(draft_path, str) and bool(draft_path.strip())
|
||||
if has_text and has_path:
|
||||
raise ToolExecutionError('provide only one of draft_text or draft_path')
|
||||
if has_text:
|
||||
return draft_text
|
||||
if not has_path:
|
||||
raise ToolExecutionError('draft_text or draft_path is required')
|
||||
path = _resolve_path(draft_path, context, allow_missing=False, allow_outside_root=True)
|
||||
if not path.is_file():
|
||||
raise ToolExecutionError(f'draft_path is not a file: {path}')
|
||||
return path.read_text(encoding='utf-8', errors='replace')
|
||||
|
||||
|
||||
def _records_from_arguments(arguments: dict[str, Any], context: ToolExecutionContext) -> list[dict[str, Any]]:
|
||||
has_records = 'records' in arguments and arguments.get('records') is not None
|
||||
records_path = arguments.get('records_path')
|
||||
has_path = isinstance(records_path, str) and bool(records_path.strip())
|
||||
if has_records and has_path:
|
||||
raise ToolExecutionError('provide only one of records or records_path')
|
||||
if has_records:
|
||||
return records_from_tool_argument(arguments['records'])
|
||||
if not has_path:
|
||||
raise ToolExecutionError('records or records_path is required')
|
||||
path = _resolve_path(records_path, context, allow_missing=False, allow_outside_root=True)
|
||||
if not path.is_file():
|
||||
raise ToolExecutionError(f'records_path is not a file: {path}')
|
||||
return _records_from_file(path)
|
||||
|
||||
|
||||
def _records_from_file(path: Path) -> list[dict[str, Any]]:
|
||||
text = path.read_text(encoding='utf-8', errors='replace').strip()
|
||||
if not text:
|
||||
raise DataRecordError('records_path is empty')
|
||||
if path.suffix == '.jsonl':
|
||||
records: list[dict[str, Any]] = []
|
||||
for line_number, line in enumerate(text.splitlines(), start=1):
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
item = json.loads(stripped)
|
||||
if not isinstance(item, dict):
|
||||
raise DataRecordError(f'records_path line {line_number} must decode to an object')
|
||||
records.append(item)
|
||||
return records_from_tool_argument(records)
|
||||
payload = json.loads(text)
|
||||
if isinstance(payload, dict) and isinstance(payload.get('records'), list):
|
||||
payload = payload['records']
|
||||
return records_from_tool_argument(payload)
|
||||
|
||||
|
||||
def _data_agent_output_path(
|
||||
output_path: str,
|
||||
context: ToolExecutionContext,
|
||||
|
||||
@@ -7,9 +7,11 @@ Skill 应该先让模型生成紧凑、便于人工 review 的 dataset draft tex
|
||||
再由这里的逻辑解析并补齐为 canonical records,供后续校验和导出使用。
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -461,6 +463,7 @@ def export_dataset_records(
|
||||
output_format: str = 'jsonl',
|
||||
require_validation_ok: bool = True,
|
||||
overwrite: bool = True,
|
||||
export_table: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""校验并导出 canonical records。
|
||||
|
||||
@@ -480,8 +483,11 @@ def export_dataset_records(
|
||||
)
|
||||
|
||||
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':
|
||||
@@ -494,13 +500,83 @@ def export_dataset_records(
|
||||
|
||||
path.write_text(content, encoding='utf-8')
|
||||
rel_path = str(path.relative_to(Path(root).resolve()))
|
||||
return {
|
||||
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]:
|
||||
@@ -797,7 +873,7 @@ def _case_to_record(
|
||||
if not current_query:
|
||||
raise DataRecordError(f'case {index} current 用户 line must be non-empty')
|
||||
|
||||
target = str(case.get('target') or '').strip()
|
||||
target = _canonical_target(str(case.get('target') or '').strip())
|
||||
if not target:
|
||||
raise DataRecordError(f'case {index} target is required')
|
||||
|
||||
@@ -866,6 +942,17 @@ def _target_type(target: str) -> str:
|
||||
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}'
|
||||
|
||||
@@ -75,8 +75,11 @@ def _parse_tool_arguments(raw_arguments: Any) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = json.loads(raw_arguments)
|
||||
except json.JSONDecodeError as exc:
|
||||
preview = raw_arguments[:240].replace('\n', '\\n')
|
||||
raise OpenAICompatError(
|
||||
f'Invalid tool arguments returned by model: {raw_arguments!r}'
|
||||
'Invalid tool arguments returned by model: '
|
||||
f'line {exc.lineno} column {exc.colno}: {exc.msg}; '
|
||||
f'preview={preview!r}'
|
||||
) from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise OpenAICompatError(
|
||||
|
||||
Reference in New Issue
Block a user