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()