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
101 lines
3.2 KiB
Python
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}")
|