Spaces:
Runtime error
Runtime error
File size: 10,496 Bytes
c24f0b6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 | # 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()
|