Spaces:
Running on Zero
Running on Zero
Download src/graphtrace/chunking.py from ARslan-Ahamd/graphtrace-bench: direct link, hf CLI and curl.
- Browser
- Download file 6.69 kB
-
https://huggingface.co/spaces/ARslan-Ahamd/graphtrace-bench/resolve/main/src/graphtrace/chunking.py
- Command line
-
hf download hf://spaces/ARslan-Ahamd/graphtrace-bench/src/graphtrace/chunking.py
-
curl -L -o chunking.py https://huggingface.co/spaces/ARslan-Ahamd/graphtrace-bench/resolve/main/src/graphtrace/chunking.py
6.69 kB
| """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 | |