File size: 5,325 Bytes
f8b48da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Resolve golden dataset chunk references against the real chunker output.



The golden dataset was authored with *approximate* chunk indices. This script:



1. Loads every corpus markdown document.

2. Runs the real paragraph-aware chunker (``app.ingestion.chunker``).

3. Rebuilds ``relevant_chunk_ids`` per question by matching the expected

   answer's key phrases against the chunk texts (best-match chunk).

4. Rewrites ``evals/dataset/golden.jsonl`` in place.



This keeps the dataset truthful: every referenced chunk actually contains

the evidence that answers the question.



Usage:

    python scripts/resolve_relevant_chunks.py [--write]

"""

from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

from app.ingestion.chunker import Chunker, ChunkingStrategy  # noqa: E402

QUESTION_CHUNK_OVERRIDES: dict[str, list[str]] = {}


def _tokenize(text: str) -> set[str]:
    return set(re.findall(r"[a-z0-9_]+", text.lower()))


def _ngram_keywords(text: str, n: int = 3) -> list[str]:
    """Extract salient n-gram phrases for matching."""
    tokens = _tokenize(text)
    # Remove very common filler words for matching robustness.
    stop = {
        "the", "a", "an", "of", "for", "to", "and", "or", "in", "on", "is",
        "are", "what", "which", "how", "does", "do", "whats", "what's", "your",
    }
    tokens = tokens - stop
    return list(tokens)


def _match_score(chunk_text: str, keywords: list[str]) -> float:
    """Jaccard-like overlap between chunk tokens and question keywords."""
    chunk_tokens = _tokenize(chunk_text)
    if not keywords or not chunk_tokens:
        return 0.0
    hit = sum(1 for k in keywords if k in chunk_tokens)
    return hit / len(keywords)


def resolve_for_document(

    doc_id: str,

    chunks: list,

    question: str,

    expected_answer: str,

) -> str | None:
    """Return the chunk id whose text best matches the expected answer."""
    answer_keywords = _ngram_keywords(expected_answer or question)

    candidates = chunks
    # Prefer chunks that contain answer-specific token matches.
    scored = [
        (chunk, _match_score(chunk.text, answer_keywords) + _match_score(chunk.text, _ngram_keywords(question)))
        for chunk in candidates
    ]
    scored.sort(key=lambda pair: pair[1], reverse=True)
    best_chunk, best_score = scored[0]
    if best_score <= 0.0:
        return None
    return best_chunk.chunk_id


def main() -> int:
    parser = argparse.ArgumentParser(description="Resolve golden dataset chunk references.")
    parser.add_argument("--write", action="store_true", help="Rewrite golden.jsonl in place")
    args = parser.parse_args()

    corpus_dir = ROOT / "data" / "corpus" / "markdown"
    dataset_path = ROOT / "evals" / "dataset" / "golden.jsonl"

    chunker = Chunker(strategy=ChunkingStrategy.PARAGRAPH, chunk_size=512, chunk_overlap=64)

    # Chunk every markdown document once.
    doc_chunks: dict[str, list] = {}
    for md_path in sorted(corpus_dir.glob("*.md")):
        doc_id = md_path.stem
        text = md_path.read_text(encoding="utf-8")
        chunks = chunker.chunk_text(
            text=text,
            document_id=doc_id,
            document_name=doc_id,
            source=f"markdown/{md_path.name}",
        )
        doc_chunks[doc_id] = chunks

    lines = dataset_path.read_text(encoding="utf-8").splitlines()
    updated: list[str] = []
    changes = 0
    unresolved: list[str] = []

    for line in lines:
        if not line.strip():
            continue
        record = json.loads(line)
        if record.get("difficulty") == "unanswerable":
            updated.append(line)
            continue
        new_refs: list[str] = []
        for ref in record.get("relevant_chunk_ids", []):
            doc_id, _, _ = ref.rpartition(":")
            chunks = doc_chunks.get(doc_id)
            if chunks is None:
                unresolved.append(f"{record['id']}: unknown doc '{doc_id}'")
                continue
            matched = resolve_for_document(
                doc_id, chunks, record["question"], record.get("expected_answer", "")
            )
            if matched is None:
                unresolved.append(f"{record['id']}: no match in '{doc_id}'")
                continue
            if matched not in new_refs:
                new_refs.append(matched)
        if new_refs != record.get("relevant_chunk_ids", []):
            changes += 1
            record["relevant_chunk_ids"] = new_refs
        updated.append(json.dumps(record, ensure_ascii=False))

    # Report chunk inventory per document for verification.
    print(f"Processed {len(updated)} records; {changes} changed; {len(unresolved)} unresolved.")
    if unresolved:
        for u in unresolved:
            print(f"  UNRESOLVED: {u}")
        return 1
    if args.write:
        dataset_path.write_text("\n".join(updated) + "\n", encoding="utf-8")
        print(f"Wrote {dataset_path}")
    else:
        print("Dry-run: no file written (use --write to commit).")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())