Files
zk-data-agent/skills/online-mining-v2/scripts/build_online_records.py
T
2026-05-14 17:42:08 +08:00

413 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
"""把线上 ELK case 直接转换为 product-data canonical records。
这个脚本承接 online-mining-v2 和 product-data
1. 按 request_id 拉主 NLP 表和前处理表。
2. 优先从前处理表 promptModel 的 [对话历史] 抽取模型真实输入 session。
3. 从用户指定 target 或线上 planning/code 结果推断标签。
4. 直接产出 canonical records,并可同步导出流通表格、训练 jsonl、评测 csv。
"""
import json
import re
import sys
from pathlib import Path
from typing import Any
from online_mining_common import (
OnlineMiningError,
compact_text,
emit_error,
emit_success,
extract_case,
fetch_one_by_request_id,
load_json_payload,
resolve_portable_path,
string_values,
)
PRODUCT_DATA_SCRIPT_DIR = Path(__file__).resolve().parents[1].parent / "product-data" / "scripts"
sys.path.insert(0, str(PRODUCT_DATA_SCRIPT_DIR))
from product_data_portable import ( # noqa: E402
canonical_target,
export_dataset_records,
export_planning_eval_csv,
export_training_jsonl,
record_id,
target_type,
validate_dataset_records,
)
def load_cases(payload: dict[str, Any]) -> list[dict[str, Any]]:
if isinstance(payload.get("cases"), list):
return [item for item in payload["cases"] if isinstance(item, dict)]
cases_path = payload.get("cases_path")
if cases_path:
rows = []
text = resolve_portable_path(str(cases_path)).read_text(encoding="utf-8")
for line in text.splitlines():
if not line.strip():
continue
item = json.loads(line)
if isinstance(item, dict):
rows.append(item)
return rows
return []
def merge_case(request_id: str, main_case: dict[str, Any] | None, pre_case: dict[str, Any] | None) -> dict[str, Any]:
return {
"request_id": request_id,
"query": (pre_case or {}).get("query") or (main_case or {}).get("query"),
"timestamp": (main_case or {}).get("timestamp") or (pre_case or {}).get("timestamp"),
"session_id": (main_case or {}).get("session_id"),
"device_id": (main_case or {}).get("device_id"),
"device": (main_case or {}).get("device"),
"tts": (main_case or {}).get("text"),
"main": main_case,
"pre_processing": pre_case,
}
def fetch_cases_by_request_ids(
request_ids: list[str],
*,
date: str | None,
date_from: str | None = None,
date_to: str | None = None,
lookback_days: int | None = None,
) -> list[dict[str, Any]]:
rows = []
for request_id in request_ids:
main_doc = fetch_one_by_request_id(
"main",
request_id,
date,
date_from=date_from,
date_to=date_to,
lookback_days=lookback_days,
)
pre_doc = fetch_one_by_request_id(
"pre_processing",
request_id,
date,
date_from=date_from,
date_to=date_to,
lookback_days=lookback_days,
)
main_case = extract_case("main", main_doc) if main_doc else None
pre_case = extract_case("pre_processing", pre_doc) if pre_doc else None
rows.append(merge_case(request_id, main_case, pre_case))
return rows
def case_value(case: dict[str, Any], key: str) -> Any:
if key in case:
return case.get(key)
main = case.get("main") if isinstance(case.get("main"), dict) else {}
pre = case.get("pre_processing") if isinstance(case.get("pre_processing"), dict) else {}
return case.get(key) or pre.get(key) or main.get(key)
def enrich_prev_session(case: dict[str, Any], *, limit: int) -> list[dict[str, Any]]:
explicit = case.get("prev_session")
if isinstance(explicit, list):
return normalize_prev_session(explicit)[-limit:]
timestamp = optional_int(case_value(case, "timestamp"))
# 前处理 promptModel 是中控 planning 模型真实看到的输入,里面的 [对话历史]
# 才是训练/评测应复现的 session。主表 session_id 不是同一概念,不默认使用。
prompt_history = parse_prompt_history(str(case_value(case, "prompt_model") or ""))
if prompt_history and timestamp is not None:
step_ms = 60_000
start_ts = timestamp - len(prompt_history) * step_ms
for index, item in enumerate(prompt_history):
item["timestamp"] = start_ts + index * step_ms
return prompt_history[-limit:]
def parse_prompt_history(prompt: str) -> list[dict[str, Any]]:
if not prompt:
return []
history_marker = "[对话历史]"
current_marker = "[当前query]"
history_start = prompt.rfind(history_marker)
if history_start < 0:
return []
current_start = prompt.find(current_marker, history_start)
if current_start < 0:
return []
block = prompt[history_start + len(history_marker) : current_start].strip()
if not block:
return []
rows: list[dict[str, Any]] = []
pending_user: str | None = None
for raw_line in block.splitlines():
line = raw_line.strip()
if not line:
continue
user_match = re.match(r"^用户\s*[:]\s*(.*)$", line)
if user_match:
if pending_user is not None:
rows.append({"query": pending_user, "tts": "", "timestamp": 0})
pending_user = user_match.group(1).strip()
continue
assistant_match = re.match(r"^小爱\s*[:]\s*(.*)$", line)
if assistant_match and pending_user is not None:
rows.append({"query": pending_user, "tts": assistant_match.group(1).strip(), "timestamp": 0})
pending_user = None
if pending_user is not None:
rows.append({"query": pending_user, "tts": "", "timestamp": 0})
return [row for row in rows if row["query"]]
def normalize_prev_session(items: list[Any]) -> list[dict[str, Any]]:
rows = []
for item in items:
if not isinstance(item, dict):
continue
query = str(item.get("query") or "").strip()
if not query:
continue
rows.append(
{
"query": query,
"tts": str(item.get("tts") or ""),
"timestamp": optional_int(item.get("timestamp")) or 0,
}
)
rows.sort(key=lambda item: int(item.get("timestamp") or 0))
return rows
def infer_target(case: dict[str, Any], explicit_target: str) -> tuple[str, str]:
if explicit_target.strip():
return canonical_target(explicit_target.strip()), "input.target"
case_target = str(case_value(case, "target") or "").strip()
if case_target:
return canonical_target(case_target), "case.target"
pre = case.get("pre_processing") if isinstance(case.get("pre_processing"), dict) else {}
main = case.get("main") if isinstance(case.get("main"), dict) else {}
for key in ("code", "planning_result"):
value = str(pre.get(key) or "").strip()
if value:
return canonical_target(value), f"pre_processing.{key}"
llm_agent_info = main.get("llm_agent_info")
if isinstance(llm_agent_info, dict):
agent_type = str(llm_agent_info.get("agentType") or "").strip()
if agent_type:
return f'Agent(tag="{agent_type}")', "main.intention.intent_arbitrator_info.llm_agent_info.agentType"
domain = str(main.get("domain") or case_value(case, "domain") or "").strip()
if domain:
if re.fullmatch(r"[A-Z][A-Za-z0-9_]*", domain):
return f"{domain}()", "main.domain"
return f'Agent(tag="{domain}")', "main.domain"
raise OnlineMiningError("target is required because online logs do not contain code/planning_result/domain")
def case_to_record(
case: dict[str, Any],
*,
dataset_label: str,
target: str,
complex_value: bool,
index: int,
session_limit: int,
) -> tuple[dict[str, Any], dict[str, Any]]:
query = str(case_value(case, "query") or "").strip()
if not query:
raise OnlineMiningError(f"case {index} has no query")
request_id = str(case_value(case, "request_id") or "").strip()
if not request_id:
raise OnlineMiningError(f"case {index} has no request_id")
timestamp = optional_int(case_value(case, "timestamp"))
if timestamp is None:
raise OnlineMiningError(f"case {index} has no timestamp")
final_target, target_source = infer_target(case, target)
prev_session = enrich_prev_session(case, limit=session_limit)
main = case.get("main") if isinstance(case.get("main"), dict) else {}
pre = case.get("pre_processing") if isinstance(case.get("pre_processing"), dict) else {}
record = {
"record_id": record_id(source_type="online", batch_id="online", request_id=request_id, index=index),
"source": {
"type": "online",
"request_id": request_id,
"timestamp": timestamp,
},
"turn": {
"query": query,
"timestamp": timestamp,
},
"prev_session": prev_session,
"context": {},
"label": {
"dataset_label": dataset_label,
"target": final_target,
"target_type": target_type(final_target),
},
"dimensions": {
"complex": parse_complex(case_value(case, "complex"), default=complex_value),
},
"meta": {
"session_id": str(case_value(case, "session_id") or ""),
"device_id": str(case_value(case, "device_id") or ""),
"device": compact_text(case_value(case, "device"), 500),
"online_tts": str(case_value(case, "tts") or ""),
"main_domain": str(main.get("domain") or ""),
"main_func": str(main.get("func") or ""),
"planning_result": str(pre.get("planning_result") or ""),
"planning_code": str(pre.get("code") or ""),
"hit_rules": pre.get("hit_rules") or main.get("hit_rules") or [],
"candidate_domains": pre.get("candidate_domains") or [],
"target_source": target_source,
},
}
summary = {
"request_id": request_id,
"query": query,
"target": final_target,
"target_source": target_source,
"prev_session_count": len(prev_session),
"main_domain": record["meta"]["main_domain"],
"planning_result": record["meta"]["planning_result"],
}
return record, summary
def optional_int(value: Any) -> int | None:
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def parse_complex(value: Any, *, default: bool) -> bool:
if value is None or value == "":
return default
if isinstance(value, bool):
return value
text = str(value).strip().lower()
if text in {"true", "1", "yes", "y", "", "复杂", "complex"}:
return True
if text in {"false", "0", "no", "n", "", "不复杂", "简单", "simple"}:
return False
raise OnlineMiningError("complex must be true/false")
def write_records_jsonl(records: list[dict[str, Any]], path: str) -> str:
output = resolve_portable_path(path)
output.parent.mkdir(parents=True, exist_ok=True)
with output.open("w", encoding="utf-8") as fh:
for record in records:
fh.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
return str(output)
def main() -> int:
try:
payload = load_json_payload()
dataset_label = str(payload.get("dataset_label") or "").strip()
if not dataset_label:
raise OnlineMiningError("dataset_label is required")
target = str(payload.get("target") or "").strip()
complex_value = parse_complex(payload.get("complex"), default=False)
date = str(payload.get("date") or "").strip() or None
date_from = str(payload.get("date_from") or "").strip() or None
date_to = str(payload.get("date_to") or "").strip() or None
lookback_days = payload.get("lookback_days")
lookback_days = int(lookback_days) if lookback_days not in (None, "") else None
session_limit = int(payload.get("session_limit") or 10)
if session_limit < 0 or session_limit > 10:
raise OnlineMiningError("session_limit must be between 0 and 10")
request_ids = string_values(payload.get("request_ids"))
cases = load_cases(payload)
if request_ids:
cases = fetch_cases_by_request_ids(
request_ids,
date=date,
date_from=date_from,
date_to=date_to,
lookback_days=lookback_days,
)
if not cases:
raise OnlineMiningError("request_ids, cases or cases_path is required")
records = []
summaries = []
skipped = []
for index, case in enumerate(cases, start=1):
try:
record, summary = case_to_record(
case,
dataset_label=dataset_label,
target=target,
complex_value=complex_value,
index=index,
session_limit=session_limit,
)
except Exception as exc: # noqa: BLE001
skipped.append({"index": index, "request_id": case.get("request_id"), "error": str(exc)})
continue
records.append(record)
summaries.append(summary)
if not records:
raise OnlineMiningError(f"no records built; skipped={skipped}")
records_path = write_records_jsonl(records, str(payload.get("records_output_path") or "scratchpad/online_records.jsonl"))
validation = validate_dataset_records(records)
output_dir = str(payload.get("output_dir") or "output")
exports: dict[str, Any] = {}
if bool(payload.get("export_records", True)):
exports["records"] = export_dataset_records(
records,
output_path=str(resolve_portable_path(output_dir) / "records.jsonl"),
output_format="jsonl",
require_validation_ok=bool(payload.get("require_validation_ok", True)),
overwrite=bool(payload.get("overwrite", True)),
export_table=bool(payload.get("export_table", True)),
)
if bool(payload.get("export_training", False)):
exports["training"] = export_training_jsonl(
records,
output_path=str(resolve_portable_path(output_dir) / "training.jsonl"),
require_validation_ok=bool(payload.get("require_validation_ok", True)),
overwrite=bool(payload.get("overwrite", True)),
)
if bool(payload.get("export_eval", False)):
exports["eval"] = export_planning_eval_csv(
records,
output_path=str(resolve_portable_path(output_dir) / "eval_planning.csv"),
require_validation_ok=bool(payload.get("require_validation_ok", True)),
overwrite=bool(payload.get("overwrite", True)),
)
emit_success(
{
"date": date or (f"{date_from}..{date_to}" if date_from and date_to else f"past-{lookback_days}d" if lookback_days else "past-48h"),
"record_count": len(records),
"records_path": records_path,
"validation": validation,
"exports": exports,
"summaries": summaries,
"skipped": skipped,
}
)
return 0
except Exception as exc: # noqa: BLE001
emit_error(exc)
return 1
if __name__ == "__main__":
raise SystemExit(main())