feat(kb): hierarchical heading_path for statutes (חלק>פרק>סימן>סעיף)

Before: chunk_statute produced heading_path = section_ref = "ס' 195".
After: heading_path walks the four Israeli-statute levels (part, chapter,
sub-chapter, section) and renders them as "חלק ט׳ — ביטוח נכות > פרק ב׳
— ועדות רפואיות > סימן א׳ > ס' 195". section_ref stays the leaf so
citations stay precise.

- New _RE_PART / _RE_CHAPTER / _RE_SUBCHAPTER regexes with Hebrew ordinal
  support including multi-letter gershayim ("י״א", "ט״ז").
- _collect_markers emits all markers ordered by position; chunk_statute
  walks them tracking active_part/chapter/subchapter and attaches the
  full path to each section chunk.

Refs Task Master #2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 15:56:18 +00:00
parent 3948c387b5
commit 6424bfce5c
+99 -18
View File
@@ -19,11 +19,31 @@ _TOKENS_PER_CHAR = 0.55
# and a batch is capped at 120K tokens. Keep chunks small so batches fit.
_MAX_TOKENS_PER_CHUNK = 1500
# A "section" in Israeli statutes: "סעיף 12", "תקנה 5", "5א.", "12(ב)"
_SECTION_RE = re.compile(
r"(?m)^\s*(?:סעיף\s+|תקנה\s+|§\s*)?(\d+[א-ת]?\.?)\s*(?:\(([א-ת\d]+)\))?\s*[\.\:]",
# Hierarchy markers in Israeli statutes. Each regex captures the full heading line
# and is ordered from largest container to smallest. The section-level match
# (_RE_SECTION) also captures the section number and optional sub-letter.
#
# Hebrew ordinal numbers: "א", "ב", … "ט׳", "י״א", "י״ב", … "ט״ז", etc.
# Pattern: one or two Hebrew letters with optional gershayim/geresh between them,
# and optionally followed by a trailing geresh.
_HEB_ORD = r"[א-ת](?:[\"״][א-ת])?[׳'\"]?"
_RE_PART = re.compile(rf"(?m)^\s*חלק\s+({_HEB_ORD})\s*[:\-–—]?\s*(.*?)\s*$")
_RE_CHAPTER = re.compile(rf"(?m)^\s*פרק\s+({_HEB_ORD})\s*[:\-–—]?\s*(.*?)\s*$")
_RE_SUBCHAPTER = re.compile(rf"(?m)^\s*סימן\s+({_HEB_ORD})\s*[:\-–—]?\s*(.*?)\s*$")
# Section header: "סעיף 12.", "12א.", "תקנה 5.", at start of a line, followed by
# optional sub-letter "(ב)" and then a dot / colon / dash or newline.
_RE_SECTION = re.compile(
r"(?m)^\s*(?:סעיף\s+|תקנה\s+|§\s*)?(\d+[א-ת]?)\s*(?:\(([א-ת\d]+)\))?\s*[\.:\-–—]"
)
_STATUTE_MARKERS = [
("part", _RE_PART),
("chapter", _RE_CHAPTER),
("subchapter", _RE_SUBCHAPTER),
("section", _RE_SECTION),
]
# Header in circulars — markdown-ish headings, or numbered "נושא: ..." / "מס' N. ..."
_HEADER_RE = re.compile(
r"(?m)^\s*(?:#{1,4}\s+|מס['׳]\s*\d+\s*[\.\:]\s*|[A-Zא-ת][^\n]{0,80}\:\s*$)",
@@ -83,34 +103,95 @@ def _split_oversized(chunks: list[Chunk]) -> list[Chunk]:
return result
def _collect_markers(text: str) -> list[dict]:
"""Collect all hierarchy markers from the text, sorted by position.
Each marker carries: {level, start, end, number, title, heading}.
level ∈ {'chapter', 'subchapter', 'section'}.
"""
markers: list[dict] = []
for level, rx in _STATUTE_MARKERS:
for m in rx.finditer(text):
g1 = m.group(1) if m.groups() else ""
if level == "section":
number = g1
sub = m.group(2) if m.lastindex and m.lastindex >= 2 else None
ref = f"ס' {number}" + (f"({sub})" if sub else "")
heading = ref
title = ""
else:
prefix = {"part": "חלק", "chapter": "פרק", "subchapter": "סימן"}[level]
title = m.group(2).strip() if m.lastindex and m.lastindex >= 2 else ""
number = g1
ref = f"{prefix} {number}"
heading = f"{ref}{('' + title) if title else ''}"
markers.append({
"level": level,
"start": m.start(),
"end": m.end(),
"number": number,
"ref": ref,
"heading": heading,
"title": title,
})
markers.sort(key=lambda x: x["start"])
return markers
def chunk_statute(text: str) -> list[dict]:
"""Law / regulation: one chunk per section. Preserves section numbering."""
"""Law / regulation: one chunk per section, with hierarchical heading_path.
heading_path format: "חלק ט' — ביטוח נכות > פרק ב' > סימן א' > ס' 195(א)".
section_ref stays the leaf ("ס' 195(א)") for precise citation.
"""
text = text.strip()
if not text:
return []
matches = list(_SECTION_RE.finditer(text))
if not matches:
# Fallback: treat the whole thing as one chunk.
return _as_dicts(
[Chunk(0, "", "", text, _count_tokens(text))]
)
markers = _collect_markers(text)
section_markers = [m for m in markers if m["level"] == "section"]
if not section_markers:
return _as_dicts([Chunk(0, "", "", text, _count_tokens(text))])
# Walk markers in order, tracking the active part/chapter/subchapter.
active_part: dict | None = None
active_chapter: dict | None = None
active_subchapter: dict | None = None
chunks: list[Chunk] = []
for i, m in enumerate(matches):
start = m.start()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
for i, m in enumerate(markers):
if m["level"] == "part":
active_part = m
active_chapter = None
active_subchapter = None
continue
if m["level"] == "chapter":
active_chapter = m
active_subchapter = None
continue
if m["level"] == "subchapter":
active_subchapter = m
continue
# section marker
start = m["start"]
end = markers[i + 1]["start"] if i + 1 < len(markers) else len(text)
content = text[start:end].strip()
if not content:
continue
number = m.group(1).rstrip(".")
sub = m.group(2)
section_ref = f"ס' {number}" + (f"({sub})" if sub else "")
parts: list[str] = []
if active_part:
parts.append(active_part["heading"])
if active_chapter:
parts.append(active_chapter["heading"])
if active_subchapter:
parts.append(active_subchapter["heading"])
parts.append(m["ref"])
heading_path = " > ".join(parts)
chunks.append(
Chunk(
chunk_index=len(chunks),
heading_path=section_ref,
section_ref=section_ref,
heading_path=heading_path,
section_ref=m["ref"],
content=content,
token_count=_count_tokens(content),
)