Spaces:
Runtime error
Runtime error
sourabh gupta
feat(pipeline): RAG quality gate, live coverage, streaming API, recursion limit
c24f0b6 | # File Objective : Tag every indexed chunk with the topics whose string appears in its article. | |
| # Scope : Dev script β payload-only update to an already-populated Qdrant collection. | |
| # What it does : 1. Loads the topic vocabulary (Gartner + GDELT) from topics.csv. | |
| # 2. Loads all HuffPost articles into a DataFrame (doc_id, text). | |
| # 3. Runs ONE word-boundary regex per topic across the whole column, | |
| # collecting which doc_ids mention each topic. | |
| # 4. Inverts that into doc_id β [topics] and writes it to each chunk's | |
| # `topics` payload field in Qdrant β vectors are never touched. | |
| # What it does not: 1. Re-embed, re-chunk, or re-ingest (the slow 2-hour path). | |
| # 2. Do semantic / fuzzy matching β a topic is "present" only if its | |
| # exact string occurs in the article (case-insensitive, word-bounded). | |
| # 3. Create payload indexes or compute gap scores. | |
| # | |
| # Live logging : prints a banner, then per-phase progress, then PER TOPIC the number of | |
| # docs matched, and for every 50th topic dumps the raw matched documents. | |
| # | |
| # Usage : PYTHONPATH=packages python scripts/tag_topics.py # full run | |
| # LIMIT=10 PYTHONPATH=packages python scripts/tag_topics.py # 10-doc dry test | |
| from __future__ import annotations | |
| import csv | |
| import json | |
| import os | |
| import re | |
| import time | |
| import uuid | |
| from pathlib import Path | |
| import pandas as pd | |
| from qdrant_client import models as qm | |
| from vantage_core.adapters.vector_qdrant import QdrantVectorRepo | |
| from vantage_core.constants import QDRANT_COLLECTION_NAME | |
| # ββ Configuration ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| TOPICS_CSV = Path("packages/vantage_core/seeds/topics.csv") | |
| DATA_PATH = Path("data/news_category.json") | |
| SCROLL_BATCH = 512 # points fetched per Qdrant scroll page | |
| WRITE_BATCH = 256 # set-payload ops per batch_update_points request | |
| DUMP_EVERY = 50 # print raw matched docs on every Nth topic | |
| DUMP_SAMPLE = 5 # how many raw docs to show in that dump | |
| LIMIT = int(os.environ["LIMIT"]) if os.environ.get("LIMIT") else None | |
| # ββ Logging helper βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def log(msg: str = "") -> None: | |
| """Print immediately (unbuffered) so progress is visible live.""" | |
| print(msg, flush=True) | |
| def banner(title: str) -> None: | |
| log(f"\nββ {title} " + "β" * max(0, 56 - len(title))) | |
| # ββ Phase 1: load inputs βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def load_topics() -> list[str]: | |
| """Return the topic vocabulary from topics.csv (the `keyword` column).""" | |
| with TOPICS_CSV.open() as f: | |
| return [row["keyword"].strip() for row in csv.DictReader(f) if row["keyword"].strip()] | |
| def load_articles(limit: int | None) -> pd.DataFrame: | |
| """Stream HuffPost JSONL into a DataFrame of (doc_id, text). | |
| `text` mirrors exactly what was ingested as chunk_text: headline + short_description. | |
| `doc_id` is the row's yield-index as a string β identical to the loader/ingest scheme. | |
| """ | |
| rows: list[dict[str, str]] = [] | |
| doc_id = 0 | |
| with DATA_PATH.open(encoding="utf-8") as fh: | |
| for line in fh: | |
| row = json.loads(line) | |
| headline = row.get("headline", "").strip() | |
| desc = row.get("short_description", "").strip() | |
| text = f"{headline}. {desc}" if desc else headline | |
| if len(text) < 20: # skip noise rows (matches loader.py) | |
| continue | |
| rows.append({"doc_id": str(doc_id), "text": text}) | |
| doc_id += 1 | |
| if limit is not None and doc_id >= limit: | |
| break | |
| return pd.DataFrame(rows) | |
| # ββ Phase 2: match topics β documents ββββββββββββββββββββββββββββββββββββββββ | |
| def match_topics_to_docs(df: pd.DataFrame, topics: list[str]) -> dict[str, list[str]]: | |
| """For each topic, find the docs whose text contains it; invert to doc_id β [topics]. | |
| One vectorized regex scan per topic across the whole column (pandas str.contains in C). | |
| Prints the match count for every topic, and the raw matched docs every DUMP_EVERY topics. | |
| """ | |
| doc_topics: dict[str, list[str]] = {doc_id: [] for doc_id in df["doc_id"]} | |
| t0 = time.time() | |
| for i, topic in enumerate(topics, start=1): | |
| pattern = rf"\b{re.escape(topic)}\b" # word-bounded so 'ai' β 'maintain' | |
| hit_mask = df["text"].str.contains(pattern, case=False, regex=True, na=False) | |
| matched = df.loc[hit_mask] | |
| for doc_id in matched["doc_id"]: | |
| doc_topics[doc_id].append(topic) | |
| log(f" [{i:>3}/{len(topics)}] {topic[:45]:<45} β {len(matched):>6} docs") | |
| if i % DUMP_EVERY == 0 and not matched.empty: | |
| log(f" ββ raw matches for '{topic}' (showing {DUMP_SAMPLE}) ββ") | |
| for _, r in matched.head(DUMP_SAMPLE).iterrows(): | |
| log(f" doc {r['doc_id']:>6} | {r['text'][:90]}") | |
| log(f"\n matched {len(topics)} topics over {len(df)} docs in {time.time()-t0:.1f}s") | |
| return doc_topics | |
| # ββ Phase 3a: write topicβdoc mapping to CSV βββββββββββββββββββββββββββββββββ | |
| COVERAGE_CSV = Path("data/topic_coverage.csv") | |
| def write_coverage_csv(doc_topics: dict[str, list[str]]) -> None: | |
| """Write (topic, doc_id) pairs to CSV β one row per match.""" | |
| rows = [ | |
| {"topic": topic, "doc_id": doc_id} | |
| for doc_id, topics in doc_topics.items() | |
| for topic in topics | |
| ] | |
| COVERAGE_CSV.parent.mkdir(parents=True, exist_ok=True) | |
| with COVERAGE_CSV.open("w", newline="", encoding="utf-8") as f: | |
| writer = csv.DictWriter(f, fieldnames=["topic", "doc_id"]) | |
| writer.writeheader() | |
| writer.writerows(rows) | |
| log(f" [csv] wrote {len(rows)} rows β {COVERAGE_CSV}") | |
| # ββ Phase 3b: write topics into Qdrant payloads (commented out) βββββββββββββββ | |
| def point_id(chunk_id: str) -> str: | |
| """Stable Qdrant point UUID from chunk_id β mirrors Chunk.qdrant_uuid().""" | |
| return str(uuid.uuid5(uuid.NAMESPACE_DNS, chunk_id)) | |
| def write_matched_only(doc_topics: dict[str, list[str]]) -> None: | |
| """Full run: only write to docs that actually matched β₯1 topic. | |
| Computes point IDs directly from doc_id (no collection scroll needed). | |
| Docs with zero topic matches are left untouched β no empty writes. | |
| Note: uses {doc_id}-0 (covers 99.87% of docs; multi-chunk docs get chunk-0 updated). | |
| """ | |
| client = QdrantVectorRepo()._client | |
| matched = {doc_id: topics for doc_id, topics in doc_topics.items() if topics} | |
| ops: list[qm.SetPayloadOperation] = [] | |
| written = 0 | |
| t0 = time.time() | |
| def flush() -> None: | |
| nonlocal written | |
| if ops: | |
| client.batch_update_points(collection_name=QDRANT_COLLECTION_NAME, update_operations=ops) | |
| written += len(ops) | |
| ops.clear() | |
| for doc_id, topics in matched.items(): | |
| ops.append(qm.SetPayloadOperation(set_payload=qm.SetPayload( | |
| payload={"topics": topics}, points=[point_id(f"{doc_id}-0")], | |
| ))) | |
| if len(ops) >= WRITE_BATCH: | |
| flush() | |
| log(f" [qdrant] written={written:>6}/{len(matched)} elapsed={time.time()-t0:.0f}s") | |
| flush() | |
| log(f" [qdrant] done β wrote to {written} matched docs (skipped {len(doc_topics)-written} with no topics) elapsed={time.time()-t0:.1f}s") | |
| def write_targeted(doc_topics: dict[str, list[str]]) -> list[str]: | |
| """LIMIT/dry-run mode: write directly to computed point IDs ({doc_id}-0). | |
| No filter, no index, no full scroll β touches only the docs under test. | |
| """ | |
| client = QdrantVectorRepo()._client | |
| ops = [ | |
| qm.SetPayloadOperation(set_payload=qm.SetPayload( | |
| payload={"topics": topics}, points=[point_id(f"{doc_id}-0")], | |
| )) | |
| for doc_id, topics in doc_topics.items() | |
| ] | |
| client.batch_update_points(collection_name=QDRANT_COLLECTION_NAME, update_operations=ops) | |
| log(f" [qdrant] wrote topics to {len(ops)} points (doc_id-0)") | |
| return [point_id(f"{doc_id}-0") for doc_id in doc_topics] | |
| def read_back(ids: list[str]) -> None: | |
| """Re-fetch the written points from Qdrant to confirm the payload persisted.""" | |
| client = QdrantVectorRepo()._client | |
| points = client.retrieve( | |
| collection_name=QDRANT_COLLECTION_NAME, ids=ids, | |
| with_payload=["doc_id", "title", "topics"], with_vectors=False, | |
| ) | |
| banner("Read-back from Qdrant") | |
| for p in sorted(points, key=lambda x: int(x.payload["doc_id"])): | |
| pl = p.payload | |
| log(f" doc {pl['doc_id']:>3} | topics={pl.get('topics')}") | |
| log(f" {pl['title'][:80]}") | |
| # ββ Orchestration ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main() -> None: | |
| topics = load_topics() | |
| dry_run = LIMIT is not None | |
| banner(f"Tag Topics{' [LIMIT=' + str(LIMIT) + ']' if dry_run else ''}") | |
| log(f" topics={len(topics)} collection={QDRANT_COLLECTION_NAME} source={DATA_PATH}") | |
| banner("Phase 1 β load articles") | |
| df = load_articles(LIMIT) | |
| log(f" loaded {len(df)} articles") | |
| banner("Phase 2 β match topics β documents") | |
| doc_topics = match_topics_to_docs(df, topics) | |
| matched_docs = sum(1 for tps in doc_topics.values() if tps) | |
| log(f" docs with β₯1 topic: {matched_docs}/{len(doc_topics)}") | |
| banner("Phase 3a β write coverage CSV") | |
| write_coverage_csv(doc_topics) | |
| # banner("Phase 3b β write topics to Qdrant") | |
| # if dry_run: | |
| # ids = write_targeted(doc_topics) | |
| # read_back(ids) | |
| # else: | |
| # write_matched_only(doc_topics) | |
| banner("Done") | |
| log("") | |
| if __name__ == "__main__": | |
| main() | |