auralynq-rag / scripts /download_data.py
MHamdan's picture
Deploy Auralynq RAG (Llama-3.3-70B via HF Inference Providers)
8c1b9fe verified
Raw
History Blame Contribute Delete
11.4 kB
#!/usr/bin/env python3
"""Dataset downloader for Auralynq.
Downloads small evaluation/demo corpora via Hugging Face ``datasets`` when
available, with ``--sample`` (default) and ``--full`` modes, caching, and
graceful skips. **It never requires paid keys** and ALWAYS produces a usable
local corpus: if ``datasets`` is unavailable or offline, it writes a curated
synthetic corpus + golden QA set + a synthetic audio sample, so ``make data``,
``make demo``, ``make eval`` and ``make bench`` work at $0 with no network.
Outputs:
data/corpus/ ingestible text + audio sources
data/golden/ golden_qa.json (frozen eval set), asr_refs.json
data/manifests/ dataset_manifest.json (provenance + checksums)
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import struct
import wave
from pathlib import Path
from typing import Any
from auralynq.config import get_settings
from auralynq.telemetry import configure_logging, get_logger
_log = get_logger("auralynq.data")
# Text multi-hop QA datasets (HF ids). Each entry is best-effort; failures skip.
TEXT_DATASETS = [
{"name": "hotpotqa", "hf": "hotpot_qa", "config": "distractor", "split": "validation"},
{"name": "musique", "hf": "dgslibisey/MuSiQue", "config": None, "split": "validation"},
{
"name": "2wikimultihopqa",
"hf": "scholarly-shadows-syndicate/2wikimultihopqa_with_q_gpt35",
"config": None,
"split": "train",
},
]
VOICE_DATASETS = [
{
"name": "librispeech",
"hf": "openslr/librispeech_asr",
"config": "clean",
"split": "validation",
},
]
def _dirs() -> dict[str, Path]:
s = get_settings()
base = s.data_dir
d = {
"corpus": base / "corpus",
"corpus_hf": base / "corpus_hf",
"golden": base / "golden",
"manifests": base / "manifests",
}
for p in d.values():
p.mkdir(parents=True, exist_ok=True)
return d
def _sha256(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
# --------------------------------------------------------------- synthetic ----
_SYNTHETIC_DOCS = {
"pathrag.md": (
"# PathRAG\n\nPathRAG is a graph-based retrieval-augmented generation method. "
"It performs node retrieval to find seed entities, then relational path "
"expansion across the knowledge graph, then flow-based pruning that allocates "
"a resource budget from the seeds and keeps only high-flow paths. Each path is "
"scored by reliability and rendered to text with golden-region ordering.\n\n"
"## Hybrid retrieval\n\nAuralynq fuses dense and sparse vectors with reciprocal "
"rank fusion, reranks with a cross-encoder, and applies maximal marginal "
"relevance to remove redundancy before answering.\n"
),
"geography.md": (
"# Geography\n\nParis is the capital of France. France is a country in Europe. "
"The Seine river flows through Paris. Berlin is the capital of Germany, which is "
"also in Europe. The Rhine flows through Germany.\n"
),
"auralynq.md": (
"# Auralynq\n\nAuralynq is a local-first agentic voice RAG platform. It ingests "
"documents and audio, builds a hybrid vector index in Qdrant and a relational "
"knowledge graph, and answers questions through an adaptive agent that routes "
"simple queries to hybrid retrieval and multi-hop queries to PathRAG. Answers "
"include citations with source spans, speaker labels and timestamps.\n"
),
}
_SYNTHETIC_GOLDEN = [
{
"id": "q1",
"question": "What is the capital of France?",
"answer": "Paris",
"type": "single",
"supporting": ["geography.md"],
},
{
"id": "q2",
"question": "Which river flows through the capital of France?",
"answer": "The Seine",
"type": "multi",
"supporting": ["geography.md"],
},
{
"id": "q3",
"question": "How does PathRAG prune relational paths?",
"answer": "With a flow-based resource-allocation algorithm that keeps high-flow paths.",
"type": "single",
"supporting": ["pathrag.md"],
},
{
"id": "q4",
"question": "What does Auralynq use to remove redundant retrieved chunks?",
"answer": "Maximal marginal relevance.",
"type": "single",
"supporting": ["auralynq.md"],
},
{
"id": "q5",
"question": "Which continent are France and Germany both in?",
"answer": "Europe",
"type": "multi",
"supporting": ["geography.md"],
},
]
def _write_synthetic_audio(corpus: Path) -> dict[str, Any]:
"""Write a real (silent) WAV plus a sidecar transcript for the voice path."""
audio = corpus / "lecture_pathrag.wav"
sr = 16_000
duration = 6.0
with wave.open(str(audio), "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sr)
frames = bytearray()
for i in range(int(sr * duration)):
val = int(1500 * math.sin(2 * math.pi * 220 * i / sr) * (0.3 if i % 32000 else 1))
frames += struct.pack("<h", val)
wf.writeframes(bytes(frames))
transcript = {
"language": "en",
"segments": [
{
"start_s": 0.0,
"end_s": 3.2,
"speaker": "SPEAKER_00",
"text": "PathRAG performs flow based pruning of relational paths in the "
"knowledge graph.",
},
{
"start_s": 3.6,
"end_s": 6.0,
"speaker": "SPEAKER_01",
"text": "Auralynq cites answers with speaker labels and timestamps.",
},
],
}
(corpus / "lecture_pathrag.transcript.json").write_text(
json.dumps(transcript, indent=2), encoding="utf-8"
)
return {"audio": str(audio), "segments": len(transcript["segments"])}
def _write_synthetic(dirs: dict[str, Path]) -> dict[str, Any]:
for name, text in _SYNTHETIC_DOCS.items():
(dirs["corpus"] / name).write_text(text, encoding="utf-8")
(dirs["golden"] / "golden_qa.json").write_text(
json.dumps({"version": 1, "frozen": True, "items": _SYNTHETIC_GOLDEN}, indent=2),
encoding="utf-8",
)
audio_info = _write_synthetic_audio(dirs["corpus"])
(dirs["golden"] / "asr_refs.json").write_text(
json.dumps(
{
"items": [
{
"audio": "lecture_pathrag.wav",
"reference": "PathRAG performs flow based pruning of relational paths "
"in the knowledge graph. Auralynq cites answers with "
"speaker labels and timestamps.",
}
],
},
indent=2,
),
encoding="utf-8",
)
return {"docs": list(_SYNTHETIC_DOCS), "golden_items": len(_SYNTHETIC_GOLDEN), **audio_info}
# ------------------------------------------------------------------ HF -------
def _try_hf_text(spec: dict, n: int, dirs: Path) -> dict[str, Any] | None:
try:
from datasets import load_dataset
except ImportError:
_log.info("data.datasets_missing", note="install auralynq[eval] for HF datasets")
return None
try: # pragma: no cover - network path
ds = load_dataset(spec["hf"], spec["config"], split=f"{spec['split']}[:{n}]")
except Exception as exc: # pragma: no cover
_log.warning("data.skip", dataset=spec["name"], error=str(exc))
return None
written, golden = 0, [] # pragma: no cover
for i, row in enumerate(ds): # pragma: no cover
q = row.get("question") or row.get("query") or ""
ans = row.get("answer") or (row.get("answers") or {})
context = _flatten_context(row)
if not q or not context:
continue
doc = dirs / f"{spec['name']}_{i}.md"
doc.write_text(f"# {spec['name']} {i}\n\n{context}\n", encoding="utf-8")
written += 1
golden.append(
{
"id": f"{spec['name']}_{i}",
"question": q,
"answer": ans if isinstance(ans, str) else json.dumps(ans),
"type": "multi",
"supporting": [doc.name],
}
)
return {"written": written, "golden": golden} # pragma: no cover
def _flatten_context(row: dict) -> str: # pragma: no cover - network path
ctx = row.get("context")
if isinstance(ctx, dict):
sentences = ctx.get("sentences") or []
return " ".join(" ".join(s) if isinstance(s, list) else str(s) for s in sentences)
if isinstance(ctx, str):
return ctx
paras = row.get("paragraphs")
if isinstance(paras, list):
return " ".join(
p.get("paragraph_text", "") if isinstance(p, dict) else str(p) for p in paras
)
return ""
def download(sample: bool = True, full: bool = False) -> dict[str, Any]:
configure_logging()
dirs = _dirs()
n = 200 if full else 20
manifest: dict[str, Any] = {"mode": "full" if full else "sample", "datasets": {}}
# Always lay down the curated synthetic corpus first (guarantees $0 offline).
synth = _write_synthetic(dirs)
manifest["datasets"]["synthetic"] = synth
_log.info("data.synthetic_written", **{k: v for k, v in synth.items() if k != "docs"})
# Best-effort HF augmentation. HF docs land in a *separate* corpus dir so the
# default index/eval run on the curated synthetic corpus (clean, matches the
# frozen golden set). Point AURALYNQ at data/corpus_hf for larger experiments.
extra_golden: list[dict] = []
for spec in TEXT_DATASETS:
info = _try_hf_text(spec, n, dirs["corpus_hf"])
if info: # pragma: no cover - network path
manifest["datasets"][spec["name"]] = {"written": info["written"]}
extra_golden.extend(info["golden"][:5])
# Keep the *frozen* golden set (golden_qa.json) curated and reproducible
# (ADR-0010). HF-derived QA is written to a separate, optional file so the
# drift baseline is stable regardless of upstream dataset availability.
if extra_golden: # pragma: no cover - network path
(dirs["golden"] / "golden_qa_hf.json").write_text(
json.dumps({"version": 1, "frozen": False, "items": extra_golden}, indent=2),
encoding="utf-8",
)
manifest["checksums"] = {
p.name: _sha256(p.read_text(encoding="utf-8", errors="ignore"))
for p in sorted(dirs["corpus"].glob("*.md"))
}
(dirs["manifests"] / "dataset_manifest.json").write_text(
json.dumps(manifest, indent=2), encoding="utf-8"
)
_log.info("data.done", corpus=str(dirs["corpus"]), docs=len(list(dirs["corpus"].glob("*"))))
return manifest
def main() -> None:
ap = argparse.ArgumentParser(description="Download Auralynq datasets.")
ap.add_argument("--sample", action="store_true", default=True, help="small sample (default)")
ap.add_argument("--full", action="store_true", help="larger subsets")
args = ap.parse_args()
download(sample=not args.full, full=args.full)
if __name__ == "__main__":
main()