arborist/arborist/document.py
russell@unturf.com 2c9d00ac95
modified: CLAUDE.md
modified:   Makefile
	modified:   README.md
	deleted:    aborist/search/__init__.py
	renamed:    aborist/__init__.py -> arborist/__init__.py
	renamed:    aborist/cli.py -> arborist/cli.py
	renamed:    aborist/compress.py -> arborist/compress.py
	renamed:    aborist/concepts/__init__.py -> arborist/concepts/__init__.py
	renamed:    aborist/concepts/extract.py -> arborist/concepts/extract.py
	renamed:    aborist/concepts/query.py -> arborist/concepts/query.py
	renamed:    aborist/concepts/seed.py -> arborist/concepts/seed.py
	renamed:    aborist/concepts/store.py -> arborist/concepts/store.py
	renamed:    aborist/distill/__init__.py -> arborist/distill/__init__.py
	renamed:    aborist/distill/base.py -> arborist/distill/base.py
	renamed:    aborist/distill/first_sentence.py -> arborist/distill/first_sentence.py
	renamed:    aborist/distill/runner.py -> arborist/distill/runner.py
	renamed:    aborist/distill/tfidf.py -> arborist/distill/tfidf.py
	renamed:    aborist/document.py -> arborist/document.py
	renamed:    aborist/evict.py -> arborist/evict.py
	renamed:    aborist/ingest.py -> arborist/ingest.py
	renamed:    aborist/journal.py -> arborist/journal.py
	renamed:    aborist/merkle.py -> arborist/merkle.py
	renamed:    aborist/mesh/__init__.py -> arborist/mesh/__init__.py
	renamed:    aborist/mesh/crypto.py -> arborist/mesh/crypto.py
	renamed:    aborist/mesh/members.py -> arborist/mesh/members.py
	renamed:    aborist/mesh/state.py -> arborist/mesh/state.py
	renamed:    aborist/mesh/wire.py -> arborist/mesh/wire.py
	renamed:    aborist/progress.py -> arborist/progress.py
	renamed:    aborist/qa/__init__.py -> arborist/qa/__init__.py
	renamed:    aborist/qa/client.py -> arborist/qa/client.py
	renamed:    aborist/qa/concepts.py -> arborist/qa/concepts.py
	renamed:    aborist/qa/dag.py -> arborist/qa/dag.py
	renamed:    aborist/qa/evidence.py -> arborist/qa/evidence.py
	renamed:    aborist/qa/frame.py -> arborist/qa/frame.py
	renamed:    aborist/qa/inspect.py -> arborist/qa/inspect.py
	renamed:    aborist/qa/keys.py -> arborist/qa/keys.py
	renamed:    aborist/qa/metacognition.py -> arborist/qa/metacognition.py
	renamed:    aborist/qa/model_profiles.py -> arborist/qa/model_profiles.py
	renamed:    aborist/qa/parse_claims.py -> arborist/qa/parse_claims.py
	renamed:    aborist/qa/prompts.py -> arborist/qa/prompts.py
	renamed:    aborist/qa/quantifier.py -> arborist/qa/quantifier.py
	renamed:    aborist/qa/quantifier_reminder.py -> arborist/qa/quantifier_reminder.py
	renamed:    aborist/qa/query.py -> arborist/qa/query.py
	renamed:    aborist/qa/repair.py -> arborist/qa/repair.py
	renamed:    aborist/qa/retrieval_plan.py -> arborist/qa/retrieval_plan.py
	renamed:    aborist/qa/runner.py -> arborist/qa/runner.py
	renamed:    aborist/qa/soft_preflight.py -> arborist/qa/soft_preflight.py
	renamed:    aborist/qa/verify.py -> arborist/qa/verify.py
	renamed:    aborist/qa/warrant.py -> arborist/qa/warrant.py
	new file:   arborist/search/__init__.py
	renamed:    aborist/search/base.py -> arborist/search/base.py
	renamed:    aborist/search/fts5.py -> arborist/search/fts5.py
	renamed:    aborist/snapshot.py -> arborist/snapshot.py
	renamed:    aborist/source.py -> arborist/source.py
	renamed:    aborist/sources/__init__.py -> arborist/sources/__init__.py
	renamed:    aborist/sources/crawler/__init__.py -> arborist/sources/crawler/__init__.py
	renamed:    aborist/sources/crawler/async_web_fetcher.py -> arborist/sources/crawler/async_web_fetcher.py
	renamed:    aborist/sources/crawler/bridge.py -> arborist/sources/crawler/bridge.py
	renamed:    aborist/sources/crawler/web_fetch.py -> arborist/sources/crawler/web_fetch.py
	renamed:    aborist/sources/grok.py -> arborist/sources/grok.py
	renamed:    aborist/sources/html_page.py -> arborist/sources/html_page.py
	renamed:    aborist/sources/providence.py -> arborist/sources/providence.py
	renamed:    aborist/sources/vcs.py -> arborist/sources/vcs.py
	renamed:    aborist/sources/wikipedia.py -> arborist/sources/wikipedia.py
	renamed:    aborist/sources/wikipedia_xml.py -> arborist/sources/wikipedia_xml.py
	renamed:    aborist/store.py -> arborist/store.py
	renamed:    aborist/wikitext.py -> arborist/wikitext.py
	modified:   pyproject.toml
2026-05-07 09:24:46 -04:00

101 lines
3.2 KiB
Python

"""Document and Chunk dataclasses + canonical chunkers.
Every Document carries a URI (identification, backtrack, cross-link) and content.
Chunkers split content into byte-determined chunks before Merkle hashing. The
chunker's name is committed in chunking_version — changing chunker invalidates
all prior cache records under v9.8 admissibility.
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass, field
from typing import Protocol
@dataclass(frozen=True)
class Edge:
"""A cross-link from this document to another."""
edge_type: str # wikilink | citation | derived_from | ...
dst_uri: str # always present
dst_root: str | None = None # filled in later if/when target is ingested
anchor: str | None = None # optional fragment / chunk index
@dataclass
class Document:
"""An ingestable document: URI + content + outbound edges."""
uri: str
content: str # normalized text
source_type: str # wikipedia_xml | html | git | ...
title: str | None = None
edges: list[Edge] = field(default_factory=list)
extra: dict = field(default_factory=dict) # source-specific metadata
def canonicalize(text: str) -> str:
"""Stable text normalization. Bumping this requires CANONICALIZATION_VERSION bump."""
# NFC unicode, normalize whitespace runs to single spaces, strip ends.
text = unicodedata.normalize("NFC", text)
text = re.sub(r"[\r\n\t\f\v]+", "\n", text)
text = re.sub(r"[ ]{2,}", " ", text)
return text.strip()
class Chunker(Protocol):
"""A chunker splits canonicalized text into ordered chunks."""
name: str
def split(self, text: str) -> list[str]: ...
class TokenChunker:
"""512-token chunker (whitespace-tokenized, byte-deterministic).
"Token" here means whitespace-separated unit, NOT a model BPE token. This
avoids tokenizer-version drift in the chunking_version.
"""
name = "tok-512-v1"
def __init__(self, tokens_per_chunk: int = 512):
self.tokens_per_chunk = tokens_per_chunk
def split(self, text: str) -> list[str]:
if not text:
return []
tokens = text.split()
if not tokens:
return []
chunks: list[str] = []
for start in range(0, len(tokens), self.tokens_per_chunk):
chunks.append(" ".join(tokens[start : start + self.tokens_per_chunk]))
return chunks
class SentenceChunker:
"""Sentence-aligned chunker (better for short docs like 2003 Wikipedia)."""
name = "sent-v1"
_split_re = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9])")
def split(self, text: str) -> list[str]:
if not text:
return []
# Naive but deterministic.
sentences = [s.strip() for s in self._split_re.split(text) if s.strip()]
return sentences or ([text] if text else [])
def get_chunker(name: str | None = None) -> Chunker:
"""Lookup chunker by name. Default = TokenChunker."""
if name is None or name == TokenChunker.name:
return TokenChunker()
if name == SentenceChunker.name:
return SentenceChunker()
raise ValueError(f"unknown chunker: {name}")