"""Validate and ingest Karthik's EmpathRAG Core dataset delivery. This script is intentionally conservative: it validates the delivery folder, copies the single-turn labels into the supervised router dataset, copies the multi-turn scenarios into the eval harness, and writes a small report. Resource profile additions are validated and reported, but not auto-merged into the runtime registry because contact details need a human provenance review. """ from __future__ import annotations import argparse import csv import json from pathlib import Path import shutil import sys from typing import Any ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) from pipeline.v2_schema import SafetyTier, SupportRoute # noqa: E402 DEFAULT_DELIVERY = ROOT / "Data_Karthik" / "empathrag_core_dataset_v2" DEFAULT_SINGLE_OUTPUT = ROOT / "eval" / "empathrag_core_supervised.csv" DEFAULT_MULTITURN_OUTPUT = ROOT / "eval" / "multiturn_scenarios.jsonl" DEFAULT_REPORT_JSON = ROOT / "eval" / "core_dataset_v2_ingest_report.json" DEFAULT_REPORT_MD = ROOT / "eval" / "core_dataset_v2_ingest_report.md" REQUIRED_FILES = { "README_dataset_notes.md", "single_turn_labeled.csv", "multi_turn_scenarios.jsonl", "source_target_map.csv", "risky_ambiguous_cases.csv", "resource_profile_additions.csv", } SINGLE_TURN_COLUMNS = [ "query_id", "query_text", "audience_mode", "route_label", "safety_tier", "should_intercept", "expected_usage_modes", "preferred_source_names", "avoid_source_names", "preferred_topics", "expected_response_action", "tricky_flags", "split", "notes", ] RESOURCE_ADDITION_COLUMNS = [ "resource_name", "resource_type", "official_url", "source_authority", "route_labels", "safety_tiers", "usage_modes", "audience", "contact_mode", "contact_value", "hours", "location", "confidentiality_status", "last_verified", "notes", ] VALID_ROUTES = {item.value for item in SupportRoute} VALID_TIERS = {item.value for item in SafetyTier} VALID_AUDIENCE = {"student", "helping_friend"} VALID_SPLITS = {"train", "dev", "test"} VALID_USAGE_MODES = {"retrieval", "wellbeing_only", "crisis_only"} def read_csv(path: Path) -> list[dict[str, str]]: with path.open("r", encoding="utf-8-sig", newline="") as handle: return list(csv.DictReader(handle)) def write_csv(path: Path, rows: list[dict[str, str]], fieldnames: list[str]) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8", newline="") as handle: writer = csv.DictWriter(handle, fieldnames=fieldnames, extrasaction="ignore") writer.writeheader() writer.writerows(rows) def normalize_bool(value: str) -> str: normalized = str(value).strip().lower() if normalized in {"true", "yes", "1", "y"}: return "true" if normalized in {"false", "no", "0", "n"}: return "false" return normalized def split_tokens(value: str) -> list[str]: return [token.strip() for token in str(value).replace(";", ",").split(",") if token.strip()] def validate_delivery(delivery_dir: Path) -> dict[str, Any]: report: dict[str, Any] = { "delivery_dir": str(delivery_dir), "status": "pass", "errors": [], "warnings": [], "counts": {}, "label_distribution": {"route_label": {}, "safety_tier": {}, "split": {}}, "next_steps": [], } if not delivery_dir.exists(): report["errors"].append(f"Delivery directory not found: {delivery_dir}") report["status"] = "fail" return report missing = sorted(name for name in REQUIRED_FILES if not (delivery_dir / name).exists()) if missing: report["errors"].append(f"Missing required files: {', '.join(missing)}") single_path = delivery_dir / "single_turn_labeled.csv" if single_path.exists(): rows = read_csv(single_path) report["counts"]["single_turn_rows"] = len(rows) _validate_single_turn_rows(rows, report) multiturn_path = delivery_dir / "multi_turn_scenarios.jsonl" if multiturn_path.exists(): scenarios = _read_jsonl(multiturn_path, report) report["counts"]["multi_turn_scenarios"] = len(scenarios) _validate_multiturn_scenarios(scenarios, report) risky_path = delivery_dir / "risky_ambiguous_cases.csv" if risky_path.exists(): report["counts"]["risky_ambiguous_rows"] = len(read_csv(risky_path)) resource_path = delivery_dir / "resource_profile_additions.csv" if resource_path.exists(): additions = read_csv(resource_path) report["counts"]["resource_profile_additions"] = len(additions) _validate_resource_additions(additions, report) if report["errors"]: report["status"] = "fail" elif report["warnings"]: report["status"] = "pass_with_warnings" report["next_steps"] = _next_steps(report) return report def _validate_single_turn_rows(rows: list[dict[str, str]], report: dict[str, Any]) -> None: if not rows: report["errors"].append("single_turn_labeled.csv has no rows.") return columns = set(rows[0].keys()) missing_columns = [col for col in SINGLE_TURN_COLUMNS if col not in columns] if missing_columns: report["errors"].append(f"single_turn_labeled.csv missing columns: {', '.join(missing_columns)}") seen_ids: set[str] = set() for idx, row in enumerate(rows, start=2): query_id = row.get("query_id", "").strip() if not query_id: report["errors"].append(f"Row {idx}: query_id is blank.") elif query_id in seen_ids: report["errors"].append(f"Row {idx}: duplicate query_id {query_id}.") seen_ids.add(query_id) if not row.get("query_text", "").strip(): report["errors"].append(f"Row {idx}: query_text is blank.") _validate_choice(row, "audience_mode", VALID_AUDIENCE, idx, report) _validate_choice(row, "route_label", VALID_ROUTES, idx, report) _validate_choice(row, "safety_tier", VALID_TIERS, idx, report) _validate_choice(row, "split", VALID_SPLITS, idx, report) should_intercept = normalize_bool(row.get("should_intercept", "")) if should_intercept not in {"true", "false"}: report["errors"].append(f"Row {idx}: should_intercept must be true/false/yes/no.") row["should_intercept"] = should_intercept for usage_mode in split_tokens(row.get("expected_usage_modes", "")): if usage_mode not in VALID_USAGE_MODES: report["warnings"].append(f"Row {idx}: unknown expected_usage_modes value '{usage_mode}'.") if row.get("safety_tier") == SafetyTier.IMMINENT_SAFETY.value and should_intercept != "true": report["warnings"].append(f"Row {idx}: imminent_safety row is not marked should_intercept=true.") if should_intercept == "true" and row.get("safety_tier") != SafetyTier.IMMINENT_SAFETY.value: report["warnings"].append(f"Row {idx}: intercept row should usually use imminent_safety tier.") for field in ("route_label", "safety_tier", "split"): value = row.get(field, "").strip() bucket = report["label_distribution"][field] bucket[value] = bucket.get(value, 0) + 1 def _validate_multiturn_scenarios(scenarios: list[dict[str, Any]], report: dict[str, Any]) -> None: case_ids: set[str] = set() for idx, scenario in enumerate(scenarios, start=1): case_id = str(scenario.get("case_id", "")).strip() if not case_id: report["errors"].append(f"Scenario line {idx}: case_id is blank.") elif case_id in case_ids: report["errors"].append(f"Scenario line {idx}: duplicate case_id {case_id}.") case_ids.add(case_id) audience_mode = str(scenario.get("audience_mode", "student")).strip() if audience_mode not in VALID_AUDIENCE: report["errors"].append(f"Scenario {case_id or idx}: invalid audience_mode '{audience_mode}'.") turns = scenario.get("turns") if not isinstance(turns, list) or not turns: report["errors"].append(f"Scenario {case_id or idx}: turns must be a non-empty list.") continue if len(turns) < 3: report["warnings"].append(f"Scenario {case_id or idx}: fewer than 3 turns limits trajectory evaluation.") for turn_idx, turn in enumerate(turns, start=1): prefix = f"Scenario {case_id or idx} turn {turn_idx}" if not str(turn.get("user", "")).strip(): report["errors"].append(f"{prefix}: user text is blank.") route = str(turn.get("expected_route", "")).strip() tier = str(turn.get("expected_safety_tier", "")).strip() if route not in VALID_ROUTES: report["errors"].append(f"{prefix}: invalid expected_route '{route}'.") if tier not in VALID_TIERS: report["errors"].append(f"{prefix}: invalid expected_safety_tier '{tier}'.") intercept = normalize_bool(str(turn.get("should_intercept", ""))) if intercept not in {"true", "false"}: report["errors"].append(f"{prefix}: should_intercept must be true/false.") def _validate_resource_additions(rows: list[dict[str, str]], report: dict[str, Any]) -> None: if not rows: report["warnings"].append("resource_profile_additions.csv is empty.") return columns = set(rows[0].keys()) missing_columns = [col for col in RESOURCE_ADDITION_COLUMNS if col not in columns] if missing_columns: report["errors"].append(f"resource_profile_additions.csv missing columns: {', '.join(missing_columns)}") for idx, row in enumerate(rows, start=2): if not row.get("resource_name", "").strip(): report["errors"].append(f"Resource row {idx}: resource_name is blank.") official_url = row.get("official_url", "").strip() if not official_url.startswith(("https://", "http://", "internal://")): report["errors"].append(f"Resource row {idx}: official_url must be a URL or internal:// provenance.") for route in split_tokens(row.get("route_labels", "")): if route not in VALID_ROUTES: report["warnings"].append(f"Resource row {idx}: unknown route_labels value '{route}'.") for tier in split_tokens(row.get("safety_tiers", "")): if tier not in VALID_TIERS: report["warnings"].append(f"Resource row {idx}: unknown safety_tiers value '{tier}'.") for field in ("contact_value", "hours", "location"): if not row.get(field, "").strip(): report["warnings"].append(f"Resource row {idx}: {field} is blank; use 'unknown' if not verified.") def _validate_choice( row: dict[str, str], field: str, valid_values: set[str], row_number: int, report: dict[str, Any], ) -> None: value = row.get(field, "").strip() if value not in valid_values: report["errors"].append(f"Row {row_number}: invalid {field} '{value}'.") def _read_jsonl(path: Path, report: dict[str, Any]) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] with path.open("r", encoding="utf-8") as handle: for line_no, line in enumerate(handle, start=1): if not line.strip(): continue try: value = json.loads(line) except json.JSONDecodeError as exc: report["errors"].append(f"{path.name} line {line_no}: invalid JSON: {exc}") continue if not isinstance(value, dict): report["errors"].append(f"{path.name} line {line_no}: expected JSON object.") continue rows.append(value) return rows def _next_steps(report: dict[str, Any]) -> list[str]: if report["status"] == "fail": return [ "Send the report errors back to Karthik before training.", "Do not overwrite eval/empathrag_core_supervised.csv with a failed delivery.", ] return [ "Run eval/train_ml_router.py to train local TF-IDF route and tier classifiers.", "Run eval/run_router_eval.py for Eval A single-turn router metrics.", "Run eval/run_multiturn_eval.py for Eval B trajectory metrics.", "Manually review resource_profile_additions.csv before merging new services into data/curated/service_graph.jsonl.", ] def write_report(report: dict[str, Any], json_path: Path, md_path: Path) -> None: json_path.parent.mkdir(parents=True, exist_ok=True) json_path.write_text(json.dumps(report, indent=2), encoding="utf-8") lines = [ "# EmpathRAG Core Dataset V2 Ingest Report", "", f"- Status: `{report['status']}`", f"- Delivery directory: `{report['delivery_dir']}`", "", "## Counts", ] for key, value in report.get("counts", {}).items(): lines.append(f"- {key}: {value}") lines.extend(["", "## Label Distribution"]) for field, buckets in report.get("label_distribution", {}).items(): lines.append(f"### {field}") if buckets: for key, value in sorted(buckets.items()): lines.append(f"- `{key}`: {value}") else: lines.append("- No rows counted.") lines.extend(["", "## Errors"]) lines.extend([f"- {item}" for item in report["errors"]] or ["- None"]) lines.extend(["", "## Warnings"]) lines.extend([f"- {item}" for item in report["warnings"]] or ["- None"]) lines.extend(["", "## Next Steps"]) lines.extend([f"- {item}" for item in report["next_steps"]]) md_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def ingest(args: argparse.Namespace) -> dict[str, Any]: report = validate_delivery(args.delivery_dir) write_report(report, args.report_json, args.report_md) if report["status"] == "fail": raise SystemExit(f"Dataset delivery failed validation. See {args.report_md}") single_rows = read_csv(args.delivery_dir / "single_turn_labeled.csv") for row in single_rows: row["should_intercept"] = normalize_bool(row.get("should_intercept", "")) write_csv(args.output, single_rows, SINGLE_TURN_COLUMNS) if not args.skip_multiturn_copy: args.multiturn_output.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(args.delivery_dir / "multi_turn_scenarios.jsonl", args.multiturn_output) return report def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--delivery-dir", type=Path, default=DEFAULT_DELIVERY) parser.add_argument("--output", type=Path, default=DEFAULT_SINGLE_OUTPUT) parser.add_argument("--multiturn-output", type=Path, default=DEFAULT_MULTITURN_OUTPUT) parser.add_argument("--report-json", type=Path, default=DEFAULT_REPORT_JSON) parser.add_argument("--report-md", type=Path, default=DEFAULT_REPORT_MD) parser.add_argument("--skip-multiturn-copy", action="store_true") args = parser.parse_args() report = ingest(args) print(json.dumps({"status": report["status"], "counts": report["counts"]}, indent=2)) print(f"Wrote supervised labels to {args.output}") if not args.skip_multiturn_copy: print(f"Wrote multi-turn scenarios to {args.multiturn_output}") print(f"Wrote ingest report to {args.report_md}") if __name__ == "__main__": main()