Files
zk-data-agent/src/data_agent_router_sessions.py
T
2026-04-30 18:25:13 +08:00

740 lines
26 KiB
Python

from __future__ import annotations
import json
import random
import re
from collections import Counter
from pathlib import Path
from typing import Any, Iterable
class DataAgentRouterSessionError(ValueError):
"""路由 session parquet 工具的输入或运行错误。"""
DEFAULT_ROUTER_SESSION_ROOT = 'router_session_parquet'
def profile_router_sessions(
root: str | Path,
*,
dates: list[str] | None = None,
paths: list[str] | None = None,
max_files: int = 8,
max_rows_per_file: int = 2000,
examples_limit: int = 3,
) -> dict[str, Any]:
"""读取路由 session parquet 的小样本概貌,辅助后续制定挖掘策略。"""
files = _resolve_parquet_files(root, dates=dates, paths=paths, max_files=max_files)
rows_seen = 0
missing_req_id = 0
devices: Counter[str] = Counter()
turn_counts: Counter[str] = Counter()
domains: Counter[str] = Counter()
intents: Counter[str] = Counter()
funcs: Counter[str] = Counter()
action_keys: Counter[str] = Counter()
examples: list[dict[str, Any]] = []
schema = ''
for parquet_file in files:
parquet = _import_pyarrow_parquet()
if not schema:
schema = str(parquet.ParquetFile(parquet_file).schema)
for row in _iter_parquet_rows(parquet_file, max_rows_per_file=max_rows_per_file):
rows_seen += 1
session = _normalize_session(row, parquet_file)
if not session.get('req_id'):
missing_req_id += 1
devices[_counter_key(session.get('device'))] += 1
turn_counts[str(session.get('turn_count', 0))] += 1
for turn in session.get('turns', []):
action = turn.get('action') or {}
for key in action:
action_keys[key] += 1
domains[_counter_key(action.get('domain'))] += 1
intents[_counter_key(action.get('intent'))] += 1
if action.get('func') is not None:
funcs[_counter_key(action.get('func'))] += 1
if len(examples) < examples_limit:
examples.append(_session_example(session))
return {
'ok': True,
'action': 'data_agent_profile_router_sessions',
'source': {
'dates': dates or [],
'paths': [str(item) for item in files],
'file_count': len(files),
'max_rows_per_file': max_rows_per_file,
},
'schema': schema,
'sampled_row_count': rows_seen,
'missing_req_id_count': missing_req_id,
'distributions': {
'device': _top_counts(devices),
'turn_count': _top_counts(turn_counts),
'domain': _top_counts(domains),
'intent': _top_counts(intents),
'func': _top_counts(funcs),
'action_json_keys': _top_counts(action_keys),
},
'examples': examples,
}
def search_router_sessions(
root: str | Path,
*,
dates: list[str] | None = None,
paths: list[str] | None = None,
devices: list[str] | None = None,
domains: list[str] | None = None,
intents: list[str] | None = None,
funcs: list[str] | None = None,
query_keywords: list[str] | None = None,
keyword_match_mode: str = 'any',
query_regex: str = '',
match_scope: str = 'any_turn',
turn_count_min: int | None = None,
turn_count_max: int | None = None,
max_files: int = 20,
max_rows_per_file: int = 5000,
max_candidates: int = 200,
) -> dict[str, Any]:
"""按可 review 的策略召回路由 session 候选,输出 turn 级候选。"""
if match_scope not in {'any_turn', 'last_turn'}:
raise DataAgentRouterSessionError('match_scope must be any_turn or last_turn')
if keyword_match_mode not in {'any', 'all'}:
raise DataAgentRouterSessionError('keyword_match_mode must be any or all')
regex = _compile_regex(query_regex)
files = _resolve_parquet_files(root, dates=dates, paths=paths, max_files=max_files)
candidate_rows: list[dict[str, Any]] = []
scanned_rows = 0
scanned_turns = 0
filters = {
'devices': devices or [],
'domains': domains or [],
'intents': intents or [],
'funcs': funcs or [],
'query_keywords': query_keywords or [],
'keyword_match_mode': keyword_match_mode,
'query_regex': query_regex,
'match_scope': match_scope,
'turn_count_min': turn_count_min,
'turn_count_max': turn_count_max,
}
for parquet_file in files:
for row in _iter_parquet_rows(parquet_file, max_rows_per_file=max_rows_per_file):
scanned_rows += 1
session = _normalize_session(row, parquet_file)
if not _session_matches(session, devices, turn_count_min, turn_count_max):
continue
turn_indexes = _candidate_turn_indexes(session.get('turns', []), match_scope)
for turn_index in turn_indexes:
scanned_turns += 1
turn = session['turns'][turn_index]
if not _turn_matches(
turn,
domains=domains,
intents=intents,
funcs=funcs,
query_keywords=query_keywords,
keyword_match_mode=keyword_match_mode,
regex=regex,
):
continue
candidate_rows.append(_candidate_from_session(session, turn_index))
if len(candidate_rows) >= max_candidates:
return _search_payload(files, filters, scanned_rows, scanned_turns, candidate_rows, truncated=True)
return _search_payload(files, filters, scanned_rows, scanned_turns, candidate_rows, truncated=False)
def sample_router_candidates(
candidates: list[dict[str, Any]] | dict[str, Any] | str,
*,
sample_size: int = 100,
strategy: str = 'random',
seed: int = 20260430,
) -> dict[str, Any]:
"""从候选集中做稳定抽样,并给出用于人工 review 的基础统计。"""
candidate_rows = _extract_candidates(candidates)
if sample_size < 1:
raise DataAgentRouterSessionError('sample_size must be greater than 0')
if strategy not in {'random', 'first', 'stride'}:
raise DataAgentRouterSessionError('strategy must be random, first, or stride')
if strategy == 'first':
sampled = candidate_rows[:sample_size]
elif strategy == 'stride':
sampled = _stride_sample(candidate_rows, sample_size)
else:
sampled = list(candidate_rows)
random.Random(seed).shuffle(sampled)
sampled = sampled[:sample_size]
return {
'ok': True,
'action': 'data_agent_sample_router_candidates',
'total_candidate_count': len(candidate_rows),
'sampled_count': len(sampled),
'strategy': strategy,
'seed': seed if strategy == 'random' else None,
'summary': _candidate_summary(candidate_rows),
'sample_summary': _candidate_summary(sampled),
'candidates': sampled,
}
def convert_router_candidates_to_records(
candidates: list[dict[str, Any]] | dict[str, Any] | str,
*,
dataset_label: str,
default_target: str = '',
review_decisions: list[dict[str, Any]] | None = None,
batch_id: str = 'router',
include_uncertain: bool = False,
) -> dict[str, Any]:
"""把 review 后的线上候选直接转换为 canonical records,不经过数据生成流程。"""
if not dataset_label.strip():
raise DataAgentRouterSessionError('dataset_label is required')
if not batch_id.strip():
raise DataAgentRouterSessionError('batch_id is required')
candidate_rows = _extract_candidates(candidates)
decisions = _normalize_review_decisions(review_decisions or [])
records: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
for index, candidate in enumerate(candidate_rows, 1):
decision = _decision_for_candidate(decisions, candidate)
status = str(decision.get('decision') or 'include').strip().lower()
if status in {'exclude', 'drop', 'reject'}:
skipped.append(_skip_item(candidate, status, decision.get('notes')))
continue
if status in {'uncertain', 'unknown', '不确定'} and not include_uncertain:
skipped.append(_skip_item(candidate, status, decision.get('notes')))
continue
target = str(decision.get('target') or default_target).strip()
if not target:
raise DataAgentRouterSessionError(
f'target is required for candidate {candidate.get("semantic_session_id")}#{candidate.get("matched_turn_index")}'
)
records.append(
_router_candidate_to_record(
candidate,
dataset_label=str(decision.get('dataset_label') or dataset_label).strip(),
target=target,
notes=str(decision.get('notes') or '').strip(),
batch_id=batch_id,
index=len(records) + 1,
)
)
return {
'ok': True,
'action': 'data_agent_convert_router_candidates_to_records',
'input_candidate_count': len(candidate_rows),
'record_count': len(records),
'skipped_count': len(skipped),
'skipped': skipped,
'records': records,
}
def _resolve_parquet_files(
root: str | Path,
*,
dates: list[str] | None,
paths: list[str] | None,
max_files: int,
) -> list[Path]:
if max_files < 1:
raise DataAgentRouterSessionError('max_files must be greater than 0')
root_path = Path(root).resolve()
requested: list[Path] = []
if dates:
for date in dates:
if not re.fullmatch(r'\d{8}', date):
raise DataAgentRouterSessionError(f'invalid date: {date}')
requested.append(root_path / DEFAULT_ROUTER_SESSION_ROOT / f'date={date}')
if paths:
for raw_path in paths:
if not isinstance(raw_path, str) or not raw_path.strip():
raise DataAgentRouterSessionError('paths items must be non-empty strings')
expanded = Path(raw_path).expanduser()
requested.append(expanded if expanded.is_absolute() else root_path / expanded)
if not requested:
raise DataAgentRouterSessionError('dates or paths is required')
files: list[Path] = []
for path in requested:
resolved = path.resolve()
try:
resolved.relative_to(root_path)
except ValueError as exc:
raise DataAgentRouterSessionError(f'path escapes workspace root: {path}') from exc
if not resolved.exists():
raise DataAgentRouterSessionError(f'path not found: {path}')
if resolved.is_dir():
files.extend(sorted(item for item in resolved.glob('*.parquet') if item.is_file()))
elif resolved.suffix == '.parquet':
files.append(resolved)
else:
raise DataAgentRouterSessionError(f'path is not a parquet file or directory: {path}')
deduped = []
seen: set[Path] = set()
for file_path in files:
if file_path in seen:
continue
seen.add(file_path)
deduped.append(file_path)
if not deduped:
raise DataAgentRouterSessionError('no parquet files found')
return deduped[:max_files]
def _iter_parquet_rows(path: Path, *, max_rows_per_file: int) -> Iterable[dict[str, Any]]:
if max_rows_per_file < 1:
raise DataAgentRouterSessionError('max_rows_per_file must be greater than 0')
parquet = _import_pyarrow_parquet()
parquet_file = parquet.ParquetFile(path)
emitted = 0
batch_size = min(1024, max_rows_per_file)
for batch in parquet_file.iter_batches(batch_size=batch_size):
for row in batch.to_pylist():
yield row
emitted += 1
if emitted >= max_rows_per_file:
return
def _import_pyarrow_parquet() -> Any:
try:
import pyarrow.parquet as parquet
except ImportError as exc:
raise DataAgentRouterSessionError('读取 router session parquet 需要安装 pyarrow') from exc
return parquet
def _normalize_session(row: dict[str, Any], source_path: Path) -> dict[str, Any]:
turns = row.get('turns_array') or []
normalized_turns = []
for turn in turns:
if not isinstance(turn, dict):
continue
action, parse_error = _parse_action(turn.get('action_json'))
normalized_turns.append(
{
'timestamp': turn.get('timestamp'),
'query': str(turn.get('query') or ''),
'tts': str(turn.get('tts') or ''),
'action': action,
'action_parse_error': parse_error,
}
)
return {
'semantic_session_id': row.get('semantic_session_id'),
'device': row.get('device'),
'req_id': row.get('req_id'),
'turn_count': int(row.get('turn_count') or len(normalized_turns)),
'turns': normalized_turns,
'source_path': str(source_path),
'date': _date_from_path(source_path),
}
def _parse_action(raw: Any) -> tuple[dict[str, Any], str | None]:
if not raw:
return {}, None
if isinstance(raw, dict):
return raw, None
if not isinstance(raw, str):
return {}, f'unsupported action_json type: {type(raw).__name__}'
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
return {}, str(exc)
if not isinstance(payload, dict):
return {}, 'action_json is not an object'
return payload, None
def _date_from_path(path: Path) -> str:
for part in path.parts:
if part.startswith('date='):
return part.split('=', 1)[1]
return ''
def _session_matches(
session: dict[str, Any],
devices: list[str] | None,
turn_count_min: int | None,
turn_count_max: int | None,
) -> bool:
if devices and session.get('device') not in set(devices):
return False
turn_count = int(session.get('turn_count') or 0)
if turn_count_min is not None and turn_count < turn_count_min:
return False
if turn_count_max is not None and turn_count > turn_count_max:
return False
return True
def _turn_matches(
turn: dict[str, Any],
*,
domains: list[str] | None,
intents: list[str] | None,
funcs: list[str] | None,
query_keywords: list[str] | None,
keyword_match_mode: str,
regex: re.Pattern[str] | None,
) -> bool:
action = turn.get('action') or {}
if domains and action.get('domain') not in set(domains):
return False
if intents and action.get('intent') not in set(intents):
return False
if funcs and action.get('func') not in set(funcs):
return False
query = str(turn.get('query') or '')
keywords = [keyword for keyword in query_keywords or [] if keyword]
if keywords:
hits = [keyword in query for keyword in keywords]
if keyword_match_mode == 'all' and not all(hits):
return False
if keyword_match_mode == 'any' and not any(hits):
return False
if regex and not regex.search(query):
return False
return True
def _compile_regex(pattern: str) -> re.Pattern[str] | None:
if not pattern:
return None
try:
return re.compile(pattern)
except re.error as exc:
raise DataAgentRouterSessionError(f'invalid query_regex: {exc}') from exc
def _candidate_turn_indexes(turns: list[dict[str, Any]], match_scope: str) -> list[int]:
if not turns:
return []
if match_scope == 'last_turn':
return [len(turns) - 1]
return list(range(len(turns)))
def _candidate_from_session(session: dict[str, Any], turn_index: int) -> dict[str, Any]:
turns = session.get('turns') or []
turn = turns[turn_index]
return {
'semantic_session_id': session.get('semantic_session_id'),
'req_id': session.get('req_id'),
'device': session.get('device'),
'turn_count': session.get('turn_count'),
'matched_turn_index': turn_index,
'matched_turn': turn,
'prev_turns': [
{
'timestamp': prev.get('timestamp'),
'query': prev.get('query'),
'tts': prev.get('tts'),
'action': prev.get('action'),
}
for prev in turns[max(0, turn_index - 10) : turn_index]
],
'source_path': session.get('source_path'),
'date': session.get('date'),
}
def _search_payload(
files: list[Path],
filters: dict[str, Any],
scanned_rows: int,
scanned_turns: int,
candidates: list[dict[str, Any]],
*,
truncated: bool,
) -> dict[str, Any]:
return {
'ok': True,
'action': 'data_agent_search_router_sessions',
'source': {
'paths': [str(item) for item in files],
'file_count': len(files),
},
'filters': filters,
'scanned_row_count': scanned_rows,
'scanned_turn_count': scanned_turns,
'candidate_count': len(candidates),
'truncated': truncated,
'summary': _candidate_summary(candidates),
'candidates': candidates,
}
def _extract_candidates(candidates: list[dict[str, Any]] | dict[str, Any] | str) -> list[dict[str, Any]]:
payload = candidates
if isinstance(payload, str):
try:
payload = json.loads(payload)
except json.JSONDecodeError as exc:
raise DataAgentRouterSessionError(f'candidates is not valid JSON: {exc}') from exc
if isinstance(payload, dict):
payload = payload.get('candidates')
if not isinstance(payload, list):
raise DataAgentRouterSessionError('candidates must be an array or an object with candidates')
for index, item in enumerate(payload):
if not isinstance(item, dict):
raise DataAgentRouterSessionError(f'candidates[{index}] must be an object')
return payload
def _normalize_review_decisions(review_decisions: list[dict[str, Any]]) -> dict[tuple[str, str], dict[str, Any]]:
decisions: dict[tuple[str, str], dict[str, Any]] = {}
if not isinstance(review_decisions, list):
raise DataAgentRouterSessionError('review_decisions must be an array')
for index, item in enumerate(review_decisions):
if not isinstance(item, dict):
raise DataAgentRouterSessionError(f'review_decisions[{index}] must be an object')
key = _decision_key_from_item(item)
if key == ('', ''):
raise DataAgentRouterSessionError(
f'review_decisions[{index}] must include semantic_session_id or req_id'
)
decisions[key] = item
return decisions
def _decision_key_from_item(item: dict[str, Any]) -> tuple[str, str]:
session_id = str(item.get('semantic_session_id') or item.get('session_id') or item.get('req_id') or '').strip()
turn_index = item.get('matched_turn_index')
return session_id, '' if turn_index is None else str(turn_index)
def _candidate_key(candidate: dict[str, Any]) -> tuple[str, str]:
session_id = str(candidate.get('semantic_session_id') or candidate.get('req_id') or '').strip()
turn_index = candidate.get('matched_turn_index')
exact_key = (session_id, '' if turn_index is None else str(turn_index))
if exact_key in {('', '')}:
return exact_key
return exact_key
def _decision_for_candidate(
decisions: dict[tuple[str, str], dict[str, Any]],
candidate: dict[str, Any],
) -> dict[str, Any]:
turn_index = candidate.get('matched_turn_index')
turn_key = '' if turn_index is None else str(turn_index)
keys = [
(str(candidate.get('semantic_session_id') or '').strip(), turn_key),
(str(candidate.get('semantic_session_id') or '').strip(), ''),
(str(candidate.get('req_id') or '').strip(), turn_key),
(str(candidate.get('req_id') or '').strip(), ''),
]
for key in keys:
if key in decisions:
return decisions[key]
return {}
def _router_candidate_to_record(
candidate: dict[str, Any],
*,
dataset_label: str,
target: str,
notes: str,
batch_id: str,
index: int,
) -> dict[str, Any]:
matched_turn = candidate.get('matched_turn')
if not isinstance(matched_turn, dict):
raise DataAgentRouterSessionError(f'candidate {index} matched_turn is required')
query = str(matched_turn.get('query') or '').strip()
if not query:
raise DataAgentRouterSessionError(f'candidate {index} matched_turn.query is required')
timestamp = _int_or_zero(matched_turn.get('timestamp'))
if timestamp <= 0:
raise DataAgentRouterSessionError(f'candidate {index} matched_turn.timestamp is required')
request_id = str(candidate.get('req_id') or '').strip()
if not request_id:
raise DataAgentRouterSessionError(f'candidate {index} req_id is required')
action = matched_turn.get('action') if isinstance(matched_turn.get('action'), dict) else {}
prev_session = _prev_turns_to_prev_session(candidate.get('prev_turns'))
return {
'record_id': f'online_{_safe_token(batch_id)}_{_safe_token(request_id)}_{index:06d}',
'source': {
'type': 'online',
'request_id': request_id,
'timestamp': timestamp,
},
'turn': {
'query': query,
'timestamp': timestamp,
},
'prev_session': prev_session,
'context': {
'semantic_session_id': candidate.get('semantic_session_id'),
'device': candidate.get('device'),
'date': candidate.get('date'),
'turn_count': candidate.get('turn_count'),
'matched_turn_index': candidate.get('matched_turn_index'),
'source_path': candidate.get('source_path'),
'domain': action.get('domain'),
'intent': action.get('intent'),
'func': action.get('func'),
'norm_code': action.get('norm_code'),
},
'label': {
'dataset_label': dataset_label,
'target': target,
'target_type': _target_type(target),
},
'meta': {
'case_name': '',
'notes': notes,
},
}
def _prev_turns_to_prev_session(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
prev_session: list[dict[str, Any]] = []
for item in value[-10:]:
if not isinstance(item, dict):
continue
query = str(item.get('query') or '').strip()
if not query:
continue
prev_session.append(
{
'query': query,
'tts': str(item.get('tts') or ''),
'timestamp': _int_or_zero(item.get('timestamp')),
}
)
return prev_session
def _skip_item(candidate: dict[str, Any], decision: str, notes: Any) -> dict[str, Any]:
return {
'semantic_session_id': candidate.get('semantic_session_id'),
'req_id': candidate.get('req_id'),
'matched_turn_index': candidate.get('matched_turn_index'),
'decision': decision,
'notes': str(notes or ''),
}
def _target_type(target: str) -> str:
if target.startswith('Agent('):
return 'agent'
if target:
return 'function'
return 'unknown'
def _safe_token(value: str) -> str:
token = re.sub(r'[^A-Za-z0-9_.-]+', '_', value.strip())
return token.strip('_.-') or 'item'
def _int_or_zero(value: Any) -> int:
if isinstance(value, bool):
return 0
if isinstance(value, int):
return value
if isinstance(value, float) and value.is_integer():
return int(value)
if isinstance(value, str) and value.isdigit():
return int(value)
return 0
def _candidate_summary(candidates: list[dict[str, Any]]) -> dict[str, Any]:
devices: Counter[str] = Counter()
turn_counts: Counter[str] = Counter()
domains: Counter[str] = Counter()
intents: Counter[str] = Counter()
funcs: Counter[str] = Counter()
top_queries: Counter[str] = Counter()
for item in candidates:
devices[_counter_key(item.get('device'))] += 1
turn_counts[str(item.get('turn_count') or 0)] += 1
matched_turn = item.get('matched_turn') or {}
if isinstance(matched_turn, dict):
query = str(matched_turn.get('query') or '')
if query:
top_queries[query] += 1
action = matched_turn.get('action') or {}
if isinstance(action, dict):
domains[_counter_key(action.get('domain'))] += 1
intents[_counter_key(action.get('intent'))] += 1
if action.get('func') is not None:
funcs[_counter_key(action.get('func'))] += 1
return {
'device': _top_counts(devices),
'turn_count': _top_counts(turn_counts),
'domain': _top_counts(domains),
'intent': _top_counts(intents),
'func': _top_counts(funcs),
'top_queries': _top_counts(top_queries, limit=20),
}
def _stride_sample(items: list[dict[str, Any]], sample_size: int) -> list[dict[str, Any]]:
if sample_size >= len(items):
return list(items)
if sample_size == 1:
return [items[0]]
step = (len(items) - 1) / (sample_size - 1)
return [items[round(index * step)] for index in range(sample_size)]
def _session_example(session: dict[str, Any]) -> dict[str, Any]:
return {
'semantic_session_id': session.get('semantic_session_id'),
'req_id': session.get('req_id'),
'device': session.get('device'),
'turn_count': session.get('turn_count'),
'date': session.get('date'),
'turns': [
{
'timestamp': turn.get('timestamp'),
'query': turn.get('query'),
'tts': turn.get('tts'),
'action': turn.get('action'),
}
for turn in session.get('turns', [])[:5]
],
}
def _top_counts(counter: Counter[str], *, limit: int = 20) -> list[dict[str, Any]]:
return [{'value': value, 'count': count} for value, count in counter.most_common(limit)]
def _counter_key(value: Any) -> str:
if value is None or value == '':
return '<empty>'
return str(value)