File size: 6,689 Bytes
465b477
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Chunking with stable provenance IDs.

Paragraphs are packed greedily, in document order, up to ``target_tokens`` (hard cap
``max_tokens``). Section boundaries are preferred break points: a chunk ends at a
section boundary once it holds at least ``min_tokens``, so short adjacent sections can
share a chunk while long sections are split. A paragraph longer than the cap is split
on sentence boundaries. Each section start inside a chunk is marked by a heading line
(``§ Heading``) so the model sees the structure.

When a chunk starts in the middle of a section it is prefixed with up to
``overlap_tokens`` of trailing sentences from the previous chunk; that prefix is
recorded in ``overlap_chars`` and is not owned evidence.

``spans`` records exactly which (section, first paragraph, last paragraph) each chunk
owns. IDs look like ``doc_1912_01214_sec_04_chunk_01``: the section the chunk starts in
and a running index of chunks starting in that section.
"""

from __future__ import annotations

import re
from collections.abc import Iterable

from pydantic import BaseModel

from .data.qasper import normalize_paragraph
from .schemas import Chunk, Document, Question
from .tokens import count_tokens

_SENT = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9\[(])")


class ChunkingConfig(BaseModel):
    target_tokens: int = 550
    max_tokens: int = 700
    min_tokens: int = 300
    overlap_tokens: int = 50


def split_sentences(text: str) -> list[str]:
    return [s for s in _SENT.split(text) if s.strip()]


def _split_long(paragraph: str, max_tokens: int) -> list[str]:
    pieces, cur = [], []
    for sent in split_sentences(paragraph):
        if cur and count_tokens(" ".join(cur + [sent])) > max_tokens:
            pieces.append(" ".join(cur))
            cur = [sent]
        else:
            cur.append(sent)
    if cur:
        pieces.append(" ".join(cur))
    # A single sentence longer than max_tokens stays whole: we never cut inside a sentence.
    return pieces


def _tail(text: str, overlap_tokens: int) -> str:
    if overlap_tokens <= 0:
        return ""
    out: list[str] = []
    for sent in reversed(split_sentences(text)):
        if count_tokens(" ".join([sent] + out)) > overlap_tokens:
            break
        out.insert(0, sent)
    return " ".join(out)


def heading_line(heading: str) -> str:
    return f"§ {heading}"


def chunk_document(doc: Document, cfg: ChunkingConfig, id_prefix: str | None = None) -> list[Chunk]:
    prefix = id_prefix or doc.slug
    # unit = (section idx, paragraph idx, text, first unit of its section?)
    units: list[tuple[int, int, str, bool]] = []
    for sec in doc.sections:
        first = True
        for p_idx, para in enumerate(sec.paragraphs):
            pieces = _split_long(para, cfg.max_tokens) if count_tokens(para) > cfg.max_tokens else [para]
            for piece in pieces:
                units.append((sec.idx, p_idx, piece, first))
                first = False
    headings = {s.idx: s.heading for s in doc.sections}

    groups: list[list[tuple[int, int, str, bool]]] = []
    cur: list[tuple[int, int, str, bool]] = []
    cur_tokens = 0
    for u in units:
        t = count_tokens(u[2]) + (count_tokens(heading_line(headings[u[0]])) if u[3] else 0)
        boundary_break = u[3] and cur_tokens >= cfg.min_tokens
        if cur and (boundary_break or cur_tokens + t > cfg.target_tokens):
            groups.append(cur)
            cur, cur_tokens = [], 0
        cur.append(u)
        cur_tokens += t
    if cur:
        groups.append(cur)

    chunks: list[Chunk] = []
    per_section_count: dict[int, int] = {}
    prev_body = ""
    for group in groups:
        start_sec = group[0][0]
        lines: list[str] = []
        for i, (s, _, text, first) in enumerate(group):
            # Always show the heading at the top of a chunk, and at each new section inside it.
            if first or i == 0:
                lines.append(heading_line(headings[s]))
            lines.append(text)
        body = "\n\n".join(lines)
        overlap = "" if group[0][3] else _tail(prev_body, cfg.overlap_tokens)
        if overlap:
            # The heading stays first; the carried-over sentences follow it.
            head, rest = body.split("\n\n", 1)
            text = f"{head}\n\n{overlap}\n\n{rest}"
        else:
            text = body
        spans: list[tuple[int, int, int]] = []
        for s, p, _, _ in group:
            if spans and spans[-1][0] == s:
                spans[-1] = (s, spans[-1][1], p)
            else:
                spans.append((s, p, p))
        c_idx = per_section_count.get(start_sec, 0)
        per_section_count[start_sec] = c_idx + 1
        chunks.append(
            Chunk(
                chunk_id=f"{prefix}_sec_{start_sec:02d}_chunk_{c_idx:02d}",
                doc_id=doc.doc_id,
                section_idx=start_sec,
                section_heading=headings[start_sec],
                chunk_idx=c_idx,
                text=text,
                spans=spans,
                overlap_chars=len(overlap) + 2 if overlap else 0,
                n_tokens=count_tokens(text),
            )
        )
        prev_body = "\n\n".join(u[2] for u in group)
    return chunks


def gold_chunk_ids(question: Question, doc: Document, chunks: Iterable[Chunk]) -> tuple[list[set[str]], int, int]:
    """Map each annotator's evidence paragraphs to the chunks that own them.

    Returns one set of chunk IDs per reference, plus (#evidence strings matched,
    #evidence strings total) so coverage of the mapping can be reported.
    """
    owner: dict[tuple[int, int], str] = {}
    for c in chunks:
        if c.is_distractor:
            continue
        for s, p0, p1 in c.spans:
            for p in range(p0, p1 + 1):
                owner.setdefault((s, p), c.chunk_id)
    index: dict[str, tuple[int, int]] = {}
    for sec in doc.sections:
        for p_idx, para in enumerate(sec.paragraphs):
            index.setdefault(normalize_paragraph(para), (sec.idx, p_idx))

    per_ref: list[set[str]] = []
    matched = total = 0
    for ref in question.references:
        ids: set[str] = set()
        for ev in ref.evidence:
            total += 1
            key = normalize_paragraph(ev)
            loc = index.get(key)
            if loc is None:
                # Evidence sometimes differs from full_text by truncation; accept containment of long strings.
                loc = next((v for k, v in index.items() if min(len(k), len(key)) > 40 and (key in k or k in key)), None)
            if loc is not None and loc in owner:
                ids.add(owner[loc])
                matched += 1
        per_ref.append(ids)
    return per_ref, matched, total