ihmt-memory 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ihmt/__init__.py +134 -0
- ihmt/api.py +166 -0
- ihmt/chunkers/__init__.py +55 -0
- ihmt/chunkers/base.py +297 -0
- ihmt/chunkers/code.py +572 -0
- ihmt/chunkers/generic.py +19 -0
- ihmt/chunkers/narrative.py +142 -0
- ihmt/chunkers/process.py +79 -0
- ihmt/chunkers/temporal.py +187 -0
- ihmt/config.py +161 -0
- ihmt/conflict_resolver.py +468 -0
- ihmt/detectors.py +327 -0
- ihmt/models.py +566 -0
- ihmt/project_index.py +440 -0
- ihmt/recursive_summarizer.py +342 -0
- ihmt/semantic_navigator.py +847 -0
- ihmt/storage.py +589 -0
- ihmt/summarizers.py +264 -0
- ihmt/templates/memory-instructions.md +37 -0
- ihmt/textutils.py +273 -0
- ihmt/universal_ingestor.py +373 -0
- ihmt_launcher.py +54 -0
- ihmt_memory-1.2.0.dist-info/METADATA +609 -0
- ihmt_memory-1.2.0.dist-info/RECORD +28 -0
- ihmt_memory-1.2.0.dist-info/WHEEL +4 -0
- ihmt_memory-1.2.0.dist-info/entry_points.txt +2 -0
- ihmt_memory-1.2.0.dist-info/licenses/LICENSE +21 -0
- mcp_server.py +701 -0
ihmt/__init__.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""IHMT — Infinite Hierarchical Memory Tree.
|
|
2
|
+
|
|
3
|
+
A domain-agnostic, dependency-free long-term memory for LLMs. Knowledge is
|
|
4
|
+
stored as a recursive tree on the local filesystem: raw ``.txt`` leaves at
|
|
5
|
+
layer 0, JSON "summaries of summaries" in layers 1..N, and a single
|
|
6
|
+
``root.json`` trunk. Retrieval walks that tree instead of scanning it, so the
|
|
7
|
+
cost of a query grows with the *depth* of the tree, not with its size.
|
|
8
|
+
|
|
9
|
+
Typical use::
|
|
10
|
+
|
|
11
|
+
from ihmt import IHMT
|
|
12
|
+
|
|
13
|
+
memory = IHMT.initialize("./workspace")
|
|
14
|
+
memory.ingest_file("notes/journal.txt")
|
|
15
|
+
memory.flush()
|
|
16
|
+
print(memory.search("vacaciones en Benidorm").best.excerpt)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from .api import IHMT
|
|
22
|
+
from .project_index import CodeHit, FindResult, ProjectIndex, SyncReport
|
|
23
|
+
from .chunkers import (
|
|
24
|
+
Block,
|
|
25
|
+
Chunk,
|
|
26
|
+
Chunker,
|
|
27
|
+
CodeAwareChunker,
|
|
28
|
+
GenericChunker,
|
|
29
|
+
NarrativeChunker,
|
|
30
|
+
ProcessChunker,
|
|
31
|
+
TemporalChunker,
|
|
32
|
+
get_chunker,
|
|
33
|
+
)
|
|
34
|
+
from .config import IHMTConfig
|
|
35
|
+
from .conflict_resolver import ConflictResolver, FactPattern
|
|
36
|
+
from .detectors import Detection, DomainDetector
|
|
37
|
+
from .models import (
|
|
38
|
+
BranchNode,
|
|
39
|
+
ChildRef,
|
|
40
|
+
Contradiction,
|
|
41
|
+
DataType,
|
|
42
|
+
DomainEntry,
|
|
43
|
+
Fact,
|
|
44
|
+
IHMTError,
|
|
45
|
+
LeafStatus,
|
|
46
|
+
MalformedLeafError,
|
|
47
|
+
MemoryLeaf,
|
|
48
|
+
NodeKind,
|
|
49
|
+
NodeNotFoundError,
|
|
50
|
+
RootIndex,
|
|
51
|
+
StoreNotInitializedError,
|
|
52
|
+
)
|
|
53
|
+
from .recursive_summarizer import ConsolidationReport, RecursiveSummarizer, SummarizationEvent
|
|
54
|
+
from .semantic_navigator import (
|
|
55
|
+
ClueOption,
|
|
56
|
+
ClueRequest,
|
|
57
|
+
SearchResponse,
|
|
58
|
+
SearchResult,
|
|
59
|
+
SemanticNavigator,
|
|
60
|
+
)
|
|
61
|
+
from .storage import Catalog, CatalogEntry, MemoryStore
|
|
62
|
+
from .summarizers import (
|
|
63
|
+
AnthropicSummarizer,
|
|
64
|
+
HeuristicSummarizer,
|
|
65
|
+
NodeSummary,
|
|
66
|
+
SummarizerBackend,
|
|
67
|
+
SummaryInput,
|
|
68
|
+
get_summarizer,
|
|
69
|
+
)
|
|
70
|
+
from .universal_ingestor import IngestReport, UniversalIngestor
|
|
71
|
+
|
|
72
|
+
__version__ = "1.1.0"
|
|
73
|
+
|
|
74
|
+
__all__ = [
|
|
75
|
+
"ProjectIndex",
|
|
76
|
+
"SyncReport",
|
|
77
|
+
"CodeHit",
|
|
78
|
+
"FindResult",
|
|
79
|
+
"__version__",
|
|
80
|
+
# facade
|
|
81
|
+
"IHMT",
|
|
82
|
+
"IHMTConfig",
|
|
83
|
+
# storage
|
|
84
|
+
"MemoryStore",
|
|
85
|
+
"Catalog",
|
|
86
|
+
"CatalogEntry",
|
|
87
|
+
# model
|
|
88
|
+
"MemoryLeaf",
|
|
89
|
+
"BranchNode",
|
|
90
|
+
"ChildRef",
|
|
91
|
+
"RootIndex",
|
|
92
|
+
"DomainEntry",
|
|
93
|
+
"Fact",
|
|
94
|
+
"Contradiction",
|
|
95
|
+
"DataType",
|
|
96
|
+
"NodeKind",
|
|
97
|
+
"LeafStatus",
|
|
98
|
+
"IHMTError",
|
|
99
|
+
"NodeNotFoundError",
|
|
100
|
+
"MalformedLeafError",
|
|
101
|
+
"StoreNotInitializedError",
|
|
102
|
+
# pipeline
|
|
103
|
+
"DomainDetector",
|
|
104
|
+
"Detection",
|
|
105
|
+
"UniversalIngestor",
|
|
106
|
+
"IngestReport",
|
|
107
|
+
"RecursiveSummarizer",
|
|
108
|
+
"ConsolidationReport",
|
|
109
|
+
"SummarizationEvent",
|
|
110
|
+
"SemanticNavigator",
|
|
111
|
+
"SearchResponse",
|
|
112
|
+
"SearchResult",
|
|
113
|
+
"ClueRequest",
|
|
114
|
+
"ClueOption",
|
|
115
|
+
"ConflictResolver",
|
|
116
|
+
"FactPattern",
|
|
117
|
+
# chunkers
|
|
118
|
+
"Chunker",
|
|
119
|
+
"Chunk",
|
|
120
|
+
"Block",
|
|
121
|
+
"CodeAwareChunker",
|
|
122
|
+
"NarrativeChunker",
|
|
123
|
+
"TemporalChunker",
|
|
124
|
+
"ProcessChunker",
|
|
125
|
+
"GenericChunker",
|
|
126
|
+
"get_chunker",
|
|
127
|
+
# summarizers
|
|
128
|
+
"SummarizerBackend",
|
|
129
|
+
"SummaryInput",
|
|
130
|
+
"NodeSummary",
|
|
131
|
+
"HeuristicSummarizer",
|
|
132
|
+
"AnthropicSummarizer",
|
|
133
|
+
"get_summarizer",
|
|
134
|
+
]
|
ihmt/api.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""High-level facade wiring the five components together.
|
|
2
|
+
|
|
3
|
+
Most callers — the CLI, a notebook, or an LLM tool layer — only need this::
|
|
4
|
+
|
|
5
|
+
from ihmt import IHMT
|
|
6
|
+
|
|
7
|
+
memory = IHMT.initialize("./workspace") # or IHMT("./workspace")
|
|
8
|
+
memory.ingest_file("InventoryService.java")
|
|
9
|
+
memory.flush() # close the tree
|
|
10
|
+
answer = memory.search("reserveStock concurrency")
|
|
11
|
+
answer = memory.ask("Luis", clue_provider=input_clue)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from .config import IHMTConfig
|
|
20
|
+
from .conflict_resolver import ConflictResolver
|
|
21
|
+
from .detectors import DomainDetector
|
|
22
|
+
from .models import Contradiction, DataType, MemoryLeaf
|
|
23
|
+
from .recursive_summarizer import ConsolidationReport, RecursiveSummarizer
|
|
24
|
+
from .semantic_navigator import ClueRequest, SearchResponse, SemanticNavigator
|
|
25
|
+
from .storage import MemoryStore
|
|
26
|
+
from .summarizers import SummarizerBackend
|
|
27
|
+
from .universal_ingestor import IngestReport, UniversalIngestor
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class IHMT:
|
|
31
|
+
"""One initialized memory tree, with every component pre-wired."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
base_dir: Path | str = ".",
|
|
36
|
+
*,
|
|
37
|
+
backend: Optional[SummarizerBackend] = None,
|
|
38
|
+
auto_consolidate: bool = True,
|
|
39
|
+
require_init: bool = True,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Args:
|
|
43
|
+
base_dir: Directory containing (or to contain) ``ihmt_memory``.
|
|
44
|
+
backend: Summarization backend override.
|
|
45
|
+
auto_consolidate: Fire Summarization Events during ingestion.
|
|
46
|
+
require_init: Raise if the store does not exist yet.
|
|
47
|
+
"""
|
|
48
|
+
self.store = MemoryStore.open(base_dir, require_init=require_init)
|
|
49
|
+
self.config: IHMTConfig = self.store.config
|
|
50
|
+
self.detector = DomainDetector()
|
|
51
|
+
self.resolver = ConflictResolver(self.store)
|
|
52
|
+
self.summarizer = RecursiveSummarizer(self.store, backend=backend)
|
|
53
|
+
self.ingestor = UniversalIngestor(
|
|
54
|
+
self.store,
|
|
55
|
+
detector=self.detector,
|
|
56
|
+
summarizer=self.summarizer,
|
|
57
|
+
conflict_resolver=self.resolver,
|
|
58
|
+
auto_consolidate=auto_consolidate,
|
|
59
|
+
)
|
|
60
|
+
self.navigator = SemanticNavigator(self.store, conflict_resolver=self.resolver)
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------- lifecycle
|
|
63
|
+
@classmethod
|
|
64
|
+
def initialize(
|
|
65
|
+
cls,
|
|
66
|
+
base_dir: Path | str = ".",
|
|
67
|
+
*,
|
|
68
|
+
force: bool = False,
|
|
69
|
+
**overrides: Any,
|
|
70
|
+
) -> "IHMT":
|
|
71
|
+
"""Create the store on disk (idempotent) and return a ready facade.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
base_dir: Where to create ``ihmt_memory``.
|
|
75
|
+
force: Discard all *derived* state — trunk, branch layers, catalog
|
|
76
|
+
and fact timeline — and detach the surviving leaves so the next
|
|
77
|
+
consolidation rebuilds the hierarchy. Leaf content is kept.
|
|
78
|
+
**overrides: Configuration fields, e.g. ``branch_factor=4``.
|
|
79
|
+
"""
|
|
80
|
+
config = IHMTConfig.load(base_dir)
|
|
81
|
+
for key, value in overrides.items():
|
|
82
|
+
if not hasattr(config, key):
|
|
83
|
+
raise ValueError(f"unknown configuration field: {key}")
|
|
84
|
+
setattr(config, key, value)
|
|
85
|
+
config.validate()
|
|
86
|
+
MemoryStore(config).initialize(force=force)
|
|
87
|
+
return cls(base_dir)
|
|
88
|
+
|
|
89
|
+
# -------------------------------------------------------------- ingestion
|
|
90
|
+
def ingest_text(self, text: str, **kwargs: Any) -> IngestReport:
|
|
91
|
+
"""Ingest an in-memory document. See :meth:`UniversalIngestor.ingest_text`."""
|
|
92
|
+
return self.ingestor.ingest_text(text, **kwargs)
|
|
93
|
+
|
|
94
|
+
def ingest_file(self, path: Path | str, **kwargs: Any) -> IngestReport:
|
|
95
|
+
"""Ingest a single file."""
|
|
96
|
+
return self.ingestor.ingest_file(path, **kwargs)
|
|
97
|
+
|
|
98
|
+
def ingest_directory(self, path: Path | str, **kwargs: Any) -> List[IngestReport]:
|
|
99
|
+
"""Ingest every text file under a directory."""
|
|
100
|
+
return self.ingestor.ingest_directory(path, **kwargs)
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------- consolidation
|
|
103
|
+
def consolidate(self, *, force: bool = False) -> ConsolidationReport:
|
|
104
|
+
"""Run pending Summarization Events and rewrite the trunk."""
|
|
105
|
+
return self.summarizer.consolidate(force=force)
|
|
106
|
+
|
|
107
|
+
def flush(self) -> ConsolidationReport:
|
|
108
|
+
"""Close the tree completely, promoting partial groups as well."""
|
|
109
|
+
return self.summarizer.flush()
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------- retrieval
|
|
112
|
+
def search(self, query: str, **kwargs: Any) -> SearchResponse:
|
|
113
|
+
"""Walk the tree for ``query``."""
|
|
114
|
+
return self.navigator.search(query, **kwargs)
|
|
115
|
+
|
|
116
|
+
def ask(
|
|
117
|
+
self,
|
|
118
|
+
query: str,
|
|
119
|
+
clue_provider: Callable[[ClueRequest], Optional[str]],
|
|
120
|
+
**kwargs: Any,
|
|
121
|
+
) -> SearchResponse:
|
|
122
|
+
"""Search with the interactive clue loop enabled."""
|
|
123
|
+
return self.navigator.search_interactive(query, clue_provider, **kwargs)
|
|
124
|
+
|
|
125
|
+
def get_leaf(self, leaf_id: str) -> MemoryLeaf:
|
|
126
|
+
"""Fetch one leaf by identifier."""
|
|
127
|
+
return self.store.load_leaf(leaf_id)
|
|
128
|
+
|
|
129
|
+
def outline(self, **kwargs: Any) -> List[str]:
|
|
130
|
+
"""Readable outline of the tree."""
|
|
131
|
+
return self.navigator.outline(**kwargs)
|
|
132
|
+
|
|
133
|
+
# -------------------------------------------------------------- timeline
|
|
134
|
+
def record_fact(self, subject: str, attribute: str, value: str, **kwargs: Any):
|
|
135
|
+
"""Add an assertion to the timeline."""
|
|
136
|
+
return self.resolver.record_fact(subject, attribute, value, **kwargs)
|
|
137
|
+
|
|
138
|
+
def conflicts(self, **kwargs: Any) -> List[Contradiction]:
|
|
139
|
+
"""Every attribute whose value changed over time."""
|
|
140
|
+
return self.resolver.detect_conflicts(**kwargs)
|
|
141
|
+
|
|
142
|
+
def supersede(self, old_leaf_id: str, new_leaf_id: str) -> str:
|
|
143
|
+
"""Record that one memory corrects another; return the user-facing notice."""
|
|
144
|
+
return self.resolver.supersede_leaf(old_leaf_id, new_leaf_id)
|
|
145
|
+
|
|
146
|
+
def notices(self, **kwargs: Any) -> List[str]:
|
|
147
|
+
"""User-facing contradiction notices."""
|
|
148
|
+
return [c.render_notice() for c in self.resolver.detect_conflicts(**kwargs)]
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------ inspection
|
|
151
|
+
def stats(self) -> Dict[str, Any]:
|
|
152
|
+
"""Counters describing the store."""
|
|
153
|
+
stats = self.store.stats()
|
|
154
|
+
stats["facts"] = len(self.resolver.facts)
|
|
155
|
+
stats["conflicts"] = len(self.resolver.detect_conflicts())
|
|
156
|
+
return stats
|
|
157
|
+
|
|
158
|
+
def rebuild(self) -> Dict[str, Any]:
|
|
159
|
+
"""Rebuild the catalog, the timeline and the trunk from the files."""
|
|
160
|
+
self.store.rebuild_catalog()
|
|
161
|
+
facts = self.resolver.rebuild()
|
|
162
|
+
self.summarizer.rebuild_root()
|
|
163
|
+
return {"catalog_entries": len(self.store.catalog.entries), "facts": facts}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
__all__ = ["IHMT"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Type-aware splitters and the factory that selects one.
|
|
2
|
+
|
|
3
|
+
Adding support for a new material type means adding a :class:`~ihmt.chunkers.base.Chunker`
|
|
4
|
+
subclass and one entry in :func:`get_chunker` — nothing else in the framework
|
|
5
|
+
changes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from ..config import IHMTConfig
|
|
13
|
+
from ..models import DataType
|
|
14
|
+
from .base import Block, Chunk, Chunker
|
|
15
|
+
from .code import BraceScanner, CodeAwareChunker
|
|
16
|
+
from .generic import GenericChunker
|
|
17
|
+
from .narrative import NarrativeChunker
|
|
18
|
+
from .process import ProcessChunker
|
|
19
|
+
from .temporal import TemporalChunker, extract_date, leading_date
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Block",
|
|
23
|
+
"Chunk",
|
|
24
|
+
"Chunker",
|
|
25
|
+
"BraceScanner",
|
|
26
|
+
"CodeAwareChunker",
|
|
27
|
+
"GenericChunker",
|
|
28
|
+
"NarrativeChunker",
|
|
29
|
+
"ProcessChunker",
|
|
30
|
+
"TemporalChunker",
|
|
31
|
+
"extract_date",
|
|
32
|
+
"get_chunker",
|
|
33
|
+
"leading_date",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_chunker(data_type: DataType, config: IHMTConfig, *, source: str = "") -> Chunker:
|
|
38
|
+
"""Return the splitter responsible for ``data_type``.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
data_type: Type decided by :class:`~ihmt.detectors.DomainDetector`.
|
|
42
|
+
config: Store configuration (token budgets).
|
|
43
|
+
source: Optional filename, used to pin the programming language.
|
|
44
|
+
"""
|
|
45
|
+
if data_type is DataType.CODE:
|
|
46
|
+
return CodeAwareChunker(config, language=CodeAwareChunker.language_for(source))
|
|
47
|
+
if data_type is DataType.NARRATIVE:
|
|
48
|
+
return NarrativeChunker(config)
|
|
49
|
+
if data_type is DataType.CLINICAL:
|
|
50
|
+
return TemporalChunker(config, clinical=True)
|
|
51
|
+
if data_type is DataType.PERSONAL:
|
|
52
|
+
return TemporalChunker(config, clinical=False)
|
|
53
|
+
if data_type is DataType.PROCESS:
|
|
54
|
+
return ProcessChunker(config)
|
|
55
|
+
return GenericChunker(config)
|
ihmt/chunkers/base.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Chunker interface and the block-packing algorithm shared by all splitters.
|
|
2
|
+
|
|
3
|
+
Every splitter works in two phases:
|
|
4
|
+
|
|
5
|
+
1. **Block detection** — cut the document only at boundaries that are
|
|
6
|
+
*meaningful for its type*: a syntactic block for code, a paragraph or scene
|
|
7
|
+
for narrative, a dated entry for a journal or clinical note. A block is the
|
|
8
|
+
atomic unit: IHMT never cuts inside one.
|
|
9
|
+
2. **Packing** (:meth:`Chunker.pack`) — greedily merge consecutive blocks up to
|
|
10
|
+
``target_tokens``. If a single block exceeds ``max_tokens`` it is emitted
|
|
11
|
+
whole and flagged ``oversized``: block integrity outranks the token budget.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
19
|
+
|
|
20
|
+
from ..config import IHMTConfig
|
|
21
|
+
from ..textutils import estimate_tokens, truncate
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Block:
|
|
26
|
+
"""An indivisible span of a source document.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
text: Exact source text, byte-for-byte, including trailing newlines.
|
|
30
|
+
title: Human-readable label (``"class InventoryService"``, a scene
|
|
31
|
+
heading, a journal date...).
|
|
32
|
+
start_line: 1-based inclusive first line in the source.
|
|
33
|
+
end_line: 1-based inclusive last line in the source.
|
|
34
|
+
kind: Block flavour, e.g. ``"class"``, ``"method"``, ``"paragraph"``.
|
|
35
|
+
tags: Structural tags propagated to the resulting leaf.
|
|
36
|
+
timestamp: Date extracted from the content, when the type carries one.
|
|
37
|
+
hard_boundary: If true, this block never merges with the previous one
|
|
38
|
+
(a new scene, a new clinical encounter, a new journal date).
|
|
39
|
+
context: Enclosing context that is *not* part of the text, such as the
|
|
40
|
+
class header of an extracted method. Stored in leaf metadata so the
|
|
41
|
+
LLM sees it without corrupting the byte-exact content.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
text: str
|
|
45
|
+
title: str = ""
|
|
46
|
+
start_line: int = 1
|
|
47
|
+
end_line: int = 1
|
|
48
|
+
kind: str = "block"
|
|
49
|
+
tags: List[str] = field(default_factory=list)
|
|
50
|
+
timestamp: Optional[str] = None
|
|
51
|
+
hard_boundary: bool = False
|
|
52
|
+
context: str = ""
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def tokens(self) -> int:
|
|
56
|
+
return estimate_tokens(self.text)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def is_blank(self) -> bool:
|
|
60
|
+
return not self.text.strip()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Chunk:
|
|
65
|
+
"""A packed group of blocks, ready to become a :class:`~ihmt.models.MemoryLeaf`."""
|
|
66
|
+
|
|
67
|
+
text: str
|
|
68
|
+
title: str = ""
|
|
69
|
+
start_line: int = 1
|
|
70
|
+
end_line: int = 1
|
|
71
|
+
kind: str = "chunk"
|
|
72
|
+
tags: List[str] = field(default_factory=list)
|
|
73
|
+
timestamp: Optional[str] = None
|
|
74
|
+
oversized: bool = False
|
|
75
|
+
context: str = ""
|
|
76
|
+
meta: Dict[str, Any] = field(default_factory=dict)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def tokens(self) -> int:
|
|
80
|
+
return estimate_tokens(self.text)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def split_lines(text: str) -> List[str]:
|
|
84
|
+
"""Split into lines while keeping the line terminators (exact round-trip)."""
|
|
85
|
+
return text.splitlines(keepends=True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def join_lines(lines: Sequence[str], start_line: int, end_line: int) -> str:
|
|
89
|
+
"""Join 1-based inclusive line range ``[start_line, end_line]``."""
|
|
90
|
+
return "".join(lines[start_line - 1 : end_line])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Chunker(ABC):
|
|
94
|
+
"""Base class for every splitter.
|
|
95
|
+
|
|
96
|
+
Subclasses implement :meth:`blocks`; :meth:`chunk` packs those blocks into
|
|
97
|
+
leaf-sized units. Overriding :meth:`chunk` directly is allowed when a type
|
|
98
|
+
needs custom grouping (see the temporal splitter).
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
#: Short identifier recorded in leaf metadata.
|
|
102
|
+
name: str = "base"
|
|
103
|
+
|
|
104
|
+
def __init__(self, config: IHMTConfig) -> None:
|
|
105
|
+
self.config = config
|
|
106
|
+
|
|
107
|
+
@abstractmethod
|
|
108
|
+
def blocks(self, text: str, *, source: str = "") -> List[Block]:
|
|
109
|
+
"""Detect the indivisible blocks of ``text``."""
|
|
110
|
+
|
|
111
|
+
def chunk(self, text: str, *, source: str = "") -> List[Chunk]:
|
|
112
|
+
"""Split ``text`` into leaf-sized chunks. The public entry point."""
|
|
113
|
+
if not text.strip():
|
|
114
|
+
return []
|
|
115
|
+
return self.pack(self.blocks(text, source=source))
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------ packing
|
|
118
|
+
def pack(self, blocks: Sequence[Block]) -> List[Chunk]:
|
|
119
|
+
"""Merge consecutive blocks into chunks of roughly ``target_tokens``.
|
|
120
|
+
|
|
121
|
+
The packing rules, in priority order:
|
|
122
|
+
|
|
123
|
+
1. A block larger than ``max_tokens`` is emitted alone and flagged
|
|
124
|
+
``oversized`` — it is never cut.
|
|
125
|
+
2. A block marked ``hard_boundary`` always starts a new chunk.
|
|
126
|
+
3. Otherwise blocks accumulate until adding one would exceed
|
|
127
|
+
``target_tokens``, provided the current chunk already carries at
|
|
128
|
+
least ``min_tokens`` (this avoids emitting slivers).
|
|
129
|
+
"""
|
|
130
|
+
target = self.config.target_tokens
|
|
131
|
+
maximum = self.config.max_tokens
|
|
132
|
+
minimum = self.config.min_tokens
|
|
133
|
+
|
|
134
|
+
chunks: List[Chunk] = []
|
|
135
|
+
current: List[Block] = []
|
|
136
|
+
current_tokens = 0
|
|
137
|
+
|
|
138
|
+
def flush() -> None:
|
|
139
|
+
nonlocal current, current_tokens
|
|
140
|
+
if current:
|
|
141
|
+
chunks.append(self._merge(current))
|
|
142
|
+
current = []
|
|
143
|
+
current_tokens = 0
|
|
144
|
+
|
|
145
|
+
for block in blocks:
|
|
146
|
+
if block.is_blank:
|
|
147
|
+
# Whitespace-only runs ride along with the preceding block so
|
|
148
|
+
# that concatenating chunks reproduces the source exactly.
|
|
149
|
+
if current:
|
|
150
|
+
current.append(block)
|
|
151
|
+
else:
|
|
152
|
+
current = [block]
|
|
153
|
+
current_tokens = 0
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
tokens = block.tokens
|
|
157
|
+
if tokens > maximum:
|
|
158
|
+
flush()
|
|
159
|
+
chunk = self._merge([block])
|
|
160
|
+
chunk.oversized = True
|
|
161
|
+
chunk.meta["oversized_reason"] = (
|
|
162
|
+
f"indivisible {block.kind} of ~{tokens} tokens exceeds max_tokens={maximum}"
|
|
163
|
+
)
|
|
164
|
+
chunks.append(chunk)
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
if current and (block.hard_boundary or (current_tokens + tokens > target and current_tokens >= minimum)):
|
|
168
|
+
flush()
|
|
169
|
+
|
|
170
|
+
current.append(block)
|
|
171
|
+
current_tokens += tokens
|
|
172
|
+
|
|
173
|
+
flush()
|
|
174
|
+
return chunks
|
|
175
|
+
|
|
176
|
+
def pack_each(self, blocks: Sequence[Block]) -> List[Chunk]:
|
|
177
|
+
"""One chunk per block (symbol granularity).
|
|
178
|
+
|
|
179
|
+
Whitespace-only blocks ride along with the preceding block, exactly as
|
|
180
|
+
in :meth:`pack`, so concatenating the chunks reproduces the source.
|
|
181
|
+
Runs of *small* blocks of the same kind — import lines, fields, trivial
|
|
182
|
+
accessors — are grouped up to ``target_tokens``, so a lookup never has
|
|
183
|
+
to wade through a dozen one-line leaves.
|
|
184
|
+
"""
|
|
185
|
+
small = max(24, self.config.target_tokens // 4)
|
|
186
|
+
|
|
187
|
+
def solid(group: List[Block]) -> List[Block]:
|
|
188
|
+
return [b for b in group if not b.is_blank]
|
|
189
|
+
|
|
190
|
+
groups: List[List[Block]] = []
|
|
191
|
+
leading: List[Block] = []
|
|
192
|
+
for block in blocks:
|
|
193
|
+
if block.is_blank:
|
|
194
|
+
if groups:
|
|
195
|
+
groups[-1].append(block)
|
|
196
|
+
else:
|
|
197
|
+
leading.append(block)
|
|
198
|
+
continue
|
|
199
|
+
if groups and not leading and block.tokens < small:
|
|
200
|
+
previous = solid(groups[-1])
|
|
201
|
+
if (
|
|
202
|
+
previous
|
|
203
|
+
and previous[-1].tokens < small
|
|
204
|
+
and previous[-1].kind == block.kind
|
|
205
|
+
and sum(b.tokens for b in previous) + block.tokens <= self.config.target_tokens
|
|
206
|
+
):
|
|
207
|
+
groups[-1].append(block)
|
|
208
|
+
continue
|
|
209
|
+
groups.append(leading + [block])
|
|
210
|
+
leading = []
|
|
211
|
+
if leading:
|
|
212
|
+
groups.append(leading)
|
|
213
|
+
|
|
214
|
+
chunks: List[Chunk] = []
|
|
215
|
+
for group in groups:
|
|
216
|
+
chunk = self._merge(group)
|
|
217
|
+
big = [b for b in group if not b.is_blank and b.tokens > self.config.max_tokens]
|
|
218
|
+
if big:
|
|
219
|
+
chunk.oversized = True
|
|
220
|
+
chunk.meta["oversized_reason"] = (
|
|
221
|
+
f"indivisible {big[0].kind} of ~{big[0].tokens} tokens exceeds max_tokens={self.config.max_tokens}"
|
|
222
|
+
)
|
|
223
|
+
chunks.append(chunk)
|
|
224
|
+
return chunks
|
|
225
|
+
|
|
226
|
+
def _merge(self, blocks: Sequence[Block]) -> Chunk:
|
|
227
|
+
"""Fuse a run of blocks into a single chunk, preserving exact text."""
|
|
228
|
+
named = [b for b in blocks if b.title and not b.is_blank] or list(blocks)
|
|
229
|
+
if len(named) == 1:
|
|
230
|
+
title = named[0].title
|
|
231
|
+
else:
|
|
232
|
+
title = truncate(f"{named[0].title} → {named[-1].title}", 90)
|
|
233
|
+
|
|
234
|
+
tags: List[str] = []
|
|
235
|
+
for block in blocks:
|
|
236
|
+
for tag in block.tags:
|
|
237
|
+
if tag not in tags:
|
|
238
|
+
tags.append(tag)
|
|
239
|
+
|
|
240
|
+
timestamps = [b.timestamp for b in blocks if b.timestamp]
|
|
241
|
+
contexts = [b.context for b in blocks if b.context]
|
|
242
|
+
kinds = {b.kind for b in blocks if not b.is_blank}
|
|
243
|
+
|
|
244
|
+
return Chunk(
|
|
245
|
+
text="".join(b.text for b in blocks),
|
|
246
|
+
title=title or "untitled",
|
|
247
|
+
start_line=blocks[0].start_line,
|
|
248
|
+
end_line=blocks[-1].end_line,
|
|
249
|
+
kind=kinds.pop() if len(kinds) == 1 else "mixed",
|
|
250
|
+
tags=tags,
|
|
251
|
+
timestamp=timestamps[0] if timestamps else None,
|
|
252
|
+
context=contexts[0] if contexts else "",
|
|
253
|
+
meta={"blocks": len(blocks), "chunker": self.name},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# ------------------------------------------------------------------ helpers
|
|
257
|
+
def _fallback_blocks(self, text: str) -> List[Block]:
|
|
258
|
+
"""Line-based blocks used when no structure can be detected.
|
|
259
|
+
|
|
260
|
+
Cuts only at blank lines, so even the degenerate path never splits a
|
|
261
|
+
line in half.
|
|
262
|
+
"""
|
|
263
|
+
lines = split_lines(text)
|
|
264
|
+
blocks: List[Block] = []
|
|
265
|
+
start = 1
|
|
266
|
+
for index, line in enumerate(lines, start=1):
|
|
267
|
+
if not line.strip() and index > start:
|
|
268
|
+
blocks.append(
|
|
269
|
+
Block(
|
|
270
|
+
text=join_lines(lines, start, index),
|
|
271
|
+
title=self._first_meaningful_line(lines[start - 1 : index]),
|
|
272
|
+
start_line=start,
|
|
273
|
+
end_line=index,
|
|
274
|
+
kind="segment",
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
start = index + 1
|
|
278
|
+
if start <= len(lines):
|
|
279
|
+
blocks.append(
|
|
280
|
+
Block(
|
|
281
|
+
text=join_lines(lines, start, len(lines)),
|
|
282
|
+
title=self._first_meaningful_line(lines[start - 1 :]),
|
|
283
|
+
start_line=start,
|
|
284
|
+
end_line=len(lines),
|
|
285
|
+
kind="segment",
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
return blocks
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def _first_meaningful_line(lines: Sequence[str], limit: int = 70) -> str:
|
|
292
|
+
"""Title heuristic: the first non-empty line, trimmed."""
|
|
293
|
+
for line in lines:
|
|
294
|
+
stripped = line.strip()
|
|
295
|
+
if stripped:
|
|
296
|
+
return truncate(stripped, limit)
|
|
297
|
+
return ""
|