semdex 1.5.6__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.
Files changed (148) hide show
  1. semdex/__init__.py +20 -0
  2. semdex/__init__conf__.py +90 -0
  3. semdex/__main__.py +14 -0
  4. semdex/adapters/__init__.py +15 -0
  5. semdex/adapters/_http_retry.py +97 -0
  6. semdex/adapters/chunker/__init__.py +20 -0
  7. semdex/adapters/chunker/_base.py +117 -0
  8. semdex/adapters/chunker/chonkie.py +209 -0
  9. semdex/adapters/chunker/semantic_text_splitter.py +67 -0
  10. semdex/adapters/chunker/whitespace.py +37 -0
  11. semdex/adapters/cli/__init__.py +64 -0
  12. semdex/adapters/cli/commands/__init__.py +34 -0
  13. semdex/adapters/cli/commands/config.py +511 -0
  14. semdex/adapters/cli/commands/email/__init__.py +17 -0
  15. semdex/adapters/cli/commands/email/_common.py +374 -0
  16. semdex/adapters/cli/commands/email/send_email.py +148 -0
  17. semdex/adapters/cli/commands/email/send_notification.py +100 -0
  18. semdex/adapters/cli/commands/index_search.py +260 -0
  19. semdex/adapters/cli/commands/info.py +60 -0
  20. semdex/adapters/cli/commands/logging.py +34 -0
  21. semdex/adapters/cli/commands/serve.py +253 -0
  22. semdex/adapters/cli/config_load.py +205 -0
  23. semdex/adapters/cli/constants.py +28 -0
  24. semdex/adapters/cli/context.py +192 -0
  25. semdex/adapters/cli/exit_codes.py +57 -0
  26. semdex/adapters/cli/main.py +123 -0
  27. semdex/adapters/cli/py.typed +0 -0
  28. semdex/adapters/cli/root.py +162 -0
  29. semdex/adapters/cli/safe_console.py +297 -0
  30. semdex/adapters/cli/typed_click.py +76 -0
  31. semdex/adapters/config/__init__.py +25 -0
  32. semdex/adapters/config/_section.py +21 -0
  33. semdex/adapters/config/chunker.py +77 -0
  34. semdex/adapters/config/dataset.py +189 -0
  35. semdex/adapters/config/defaultconfig.d/10-index.toml +126 -0
  36. semdex/adapters/config/defaultconfig.d/15-vectorstore.toml +163 -0
  37. semdex/adapters/config/defaultconfig.d/16-datasets.toml +193 -0
  38. semdex/adapters/config/defaultconfig.d/17-mcp.toml +210 -0
  39. semdex/adapters/config/defaultconfig.d/20-extractor.toml +97 -0
  40. semdex/adapters/config/defaultconfig.d/25-chunker.toml +130 -0
  41. semdex/adapters/config/defaultconfig.d/28-sanitize.toml +59 -0
  42. semdex/adapters/config/defaultconfig.d/30-embedding.toml +227 -0
  43. semdex/adapters/config/defaultconfig.d/31-summary.toml +131 -0
  44. semdex/adapters/config/defaultconfig.d/32-health.toml +105 -0
  45. semdex/adapters/config/defaultconfig.d/35-watch.toml +54 -0
  46. semdex/adapters/config/defaultconfig.d/40-layered-config.toml +78 -0
  47. semdex/adapters/config/defaultconfig.d/50-mail.toml +244 -0
  48. semdex/adapters/config/defaultconfig.d/90-logging.toml +445 -0
  49. semdex/adapters/config/defaultconfig.toml +56 -0
  50. semdex/adapters/config/deploy.py +135 -0
  51. semdex/adapters/config/display.py +60 -0
  52. semdex/adapters/config/embedding.py +95 -0
  53. semdex/adapters/config/extractor.py +72 -0
  54. semdex/adapters/config/health.py +104 -0
  55. semdex/adapters/config/index.py +72 -0
  56. semdex/adapters/config/loader.py +198 -0
  57. semdex/adapters/config/mcp.py +69 -0
  58. semdex/adapters/config/overrides.py +302 -0
  59. semdex/adapters/config/py.typed +0 -0
  60. semdex/adapters/config/sanitize.py +65 -0
  61. semdex/adapters/config/summary.py +87 -0
  62. semdex/adapters/config/vectorstore.py +119 -0
  63. semdex/adapters/config/watch.py +54 -0
  64. semdex/adapters/discovery/__init__.py +15 -0
  65. semdex/adapters/discovery/filesystem.py +108 -0
  66. semdex/adapters/discovery/location.py +62 -0
  67. semdex/adapters/email/__init__.py +28 -0
  68. semdex/adapters/email/config.py +381 -0
  69. semdex/adapters/email/py.typed +0 -0
  70. semdex/adapters/email/sender.py +26 -0
  71. semdex/adapters/email/transport.py +233 -0
  72. semdex/adapters/email/validation.py +65 -0
  73. semdex/adapters/embedding/__init__.py +38 -0
  74. semdex/adapters/embedding/_base.py +150 -0
  75. semdex/adapters/embedding/cohere.py +127 -0
  76. semdex/adapters/embedding/fastembed.py +87 -0
  77. semdex/adapters/embedding/gemini.py +153 -0
  78. semdex/adapters/embedding/model2vec.py +51 -0
  79. semdex/adapters/embedding/ollama.py +130 -0
  80. semdex/adapters/embedding/openai.py +118 -0
  81. semdex/adapters/embedding/sentence_transformers.py +117 -0
  82. semdex/adapters/extractor/__init__.py +37 -0
  83. semdex/adapters/extractor/_http.py +129 -0
  84. semdex/adapters/extractor/docling.py +85 -0
  85. semdex/adapters/extractor/markitdown.py +135 -0
  86. semdex/adapters/extractor/mineru.py +95 -0
  87. semdex/adapters/extractor/router.py +192 -0
  88. semdex/adapters/extractor/text.py +58 -0
  89. semdex/adapters/extractor/vision_ocr.py +186 -0
  90. semdex/adapters/extractor/xberg.py +109 -0
  91. semdex/adapters/health/__init__.py +61 -0
  92. semdex/adapters/health/_loop.py +111 -0
  93. semdex/adapters/health/_probe.py +63 -0
  94. semdex/adapters/health/_restart.py +105 -0
  95. semdex/adapters/health/_warmup.py +55 -0
  96. semdex/adapters/logging/__init__.py +13 -0
  97. semdex/adapters/logging/py.typed +0 -0
  98. semdex/adapters/logging/setup.py +345 -0
  99. semdex/adapters/mcp/__init__.py +1 -0
  100. semdex/adapters/mcp/auth.py +128 -0
  101. semdex/adapters/mcp/schemas.py +146 -0
  102. semdex/adapters/mcp/server.py +275 -0
  103. semdex/adapters/memory/__init__.py +60 -0
  104. semdex/adapters/memory/config.py +66 -0
  105. semdex/adapters/memory/email.py +173 -0
  106. semdex/adapters/memory/index.py +230 -0
  107. semdex/adapters/memory/logging.py +80 -0
  108. semdex/adapters/py.typed +0 -0
  109. semdex/adapters/sanitize/__init__.py +14 -0
  110. semdex/adapters/sanitize/detectors.py +71 -0
  111. semdex/adapters/sanitize/extractor.py +79 -0
  112. semdex/adapters/summarizer/__init__.py +22 -0
  113. semdex/adapters/summarizer/_base.py +116 -0
  114. semdex/adapters/summarizer/ollama.py +122 -0
  115. semdex/adapters/summarizer/openai.py +128 -0
  116. semdex/adapters/vectorstore/__init__.py +22 -0
  117. semdex/adapters/vectorstore/jsonstore.py +141 -0
  118. semdex/adapters/vectorstore/lance.py +273 -0
  119. semdex/adapters/vectorstore/mariadb.py +234 -0
  120. semdex/adapters/vectorstore/pgvector.py +227 -0
  121. semdex/adapters/vectorstore/sqlitevec.py +219 -0
  122. semdex/adapters/watch/__init__.py +13 -0
  123. semdex/adapters/watch/watchfiles_watcher.py +115 -0
  124. semdex/application/__init__.py +51 -0
  125. semdex/application/batching.py +51 -0
  126. semdex/application/ports.py +331 -0
  127. semdex/application/py.typed +0 -0
  128. semdex/application/resilience.py +160 -0
  129. semdex/application/use_cases/__init__.py +31 -0
  130. semdex/application/use_cases/indexing.py +101 -0
  131. semdex/application/use_cases/reconciling.py +77 -0
  132. semdex/application/use_cases/searching.py +121 -0
  133. semdex/application/use_cases/writing.py +67 -0
  134. semdex/composition/__init__.py +1148 -0
  135. semdex/composition/py.typed +0 -0
  136. semdex/domain/__init__.py +50 -0
  137. semdex/domain/ann_tuning.py +102 -0
  138. semdex/domain/enums.py +280 -0
  139. semdex/domain/errors.py +143 -0
  140. semdex/domain/models.py +180 -0
  141. semdex/domain/py.typed +0 -0
  142. semdex/entry.py +35 -0
  143. semdex/py.typed +0 -0
  144. semdex-1.5.6.dist-info/METADATA +549 -0
  145. semdex-1.5.6.dist-info/RECORD +148 -0
  146. semdex-1.5.6.dist-info/WHEEL +4 -0
  147. semdex-1.5.6.dist-info/entry_points.txt +2 -0
  148. semdex-1.5.6.dist-info/licenses/LICENSE +22 -0
semdex/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Public package surface exposing metadata and configuration.
2
+
3
+ This module provides the stable public API for the package, routing imports
4
+ through the proper architectural layers:
5
+ - Composition exports: Wired adapter services (configuration)
6
+ - Metadata: Package information
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ # Metadata
12
+ from .__init__conf__ import print_info
13
+
14
+ # Composition exports (wired adapters)
15
+ from .composition import get_config
16
+
17
+ __all__ = [
18
+ "get_config",
19
+ "print_info",
20
+ ]
@@ -0,0 +1,90 @@
1
+ """Static package metadata surfaced to CLI commands and documentation.
2
+
3
+ Purpose
4
+ -------
5
+ Expose the current project metadata as simple constants. These values are kept
6
+ in sync with ``pyproject.toml`` by development automation (tests, push
7
+ pipelines), so runtime code does not query packaging metadata.
8
+
9
+ Contents
10
+ --------
11
+ * Module-level constants describing the published package.
12
+ * :func:`print_info` rendering the constants for the CLI ``info`` command.
13
+
14
+ System Role
15
+ -----------
16
+ Lives in the adapters/platform layer; CLI transports import these constants to
17
+ present authoritative project information without invoking packaging APIs.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import sys
23
+
24
+ __all__ = [
25
+ "LAYEREDCONF_APP",
26
+ "LAYEREDCONF_SLUG",
27
+ "LAYEREDCONF_VENDOR",
28
+ "author",
29
+ "author_email",
30
+ "homepage",
31
+ "name",
32
+ "print_info",
33
+ "shell_command",
34
+ "title",
35
+ "version",
36
+ ]
37
+
38
+ #: Distribution name declared in ``pyproject.toml``.
39
+ name = "semdex"
40
+ #: Human-readable summary shown in CLI help output.
41
+ title = "Read-only, file-watching semantic-search MCP server and CLI over local files"
42
+ #: Current release version pulled from ``pyproject.toml`` by automation.
43
+ version = "1.5.6"
44
+ #: Repository homepage presented to users.
45
+ homepage = "https://github.com/bitranox/semdex"
46
+ #: Author attribution surfaced in CLI output.
47
+ author = "bitranox"
48
+ #: Contact email surfaced in CLI output.
49
+ author_email = "bitranox@gmail.com"
50
+ #: Console-script name published by the package.
51
+ shell_command = "semdex"
52
+
53
+ #: Vendor identifier for lib_layered_config paths (macOS/Windows)
54
+ LAYEREDCONF_VENDOR: str = "bitranox"
55
+ #: Application display name for lib_layered_config paths (macOS/Windows)
56
+ LAYEREDCONF_APP: str = "semdex"
57
+ #: Configuration slug for lib_layered_config Linux paths and environment variables
58
+ LAYEREDCONF_SLUG: str = "semdex"
59
+
60
+
61
+ def print_info() -> None:
62
+ """Print the summarised metadata block used by the CLI ``info`` command.
63
+
64
+ Why
65
+ Provides a single, auditable rendering function so documentation and
66
+ CLI output always match the system design reference.
67
+
68
+ Side Effects
69
+ Writes to ``stdout``.
70
+
71
+ Examples
72
+ --------
73
+ >>> print_info() # doctest: +ELLIPSIS
74
+ Info for semdex:
75
+ ...
76
+ """
77
+
78
+ fields = [
79
+ ("name", name),
80
+ ("title", title),
81
+ ("version", version),
82
+ ("homepage", homepage),
83
+ ("author", author),
84
+ ("author_email", author_email),
85
+ ("shell_command", shell_command),
86
+ ]
87
+ pad = max(len(label) for label, _ in fields)
88
+ lines = [f"Info for {name}:", ""]
89
+ lines.extend(f" {label.ljust(pad)} = {value}" for label, value in fields)
90
+ sys.stdout.write("\n".join(lines) + "\n")
semdex/__main__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Module entry point for ``python -m semdex``.
2
+
3
+ Delegates to the same production wiring as the console script (``entry.main``)
4
+ so ``python -m semdex`` and the installed ``semdex`` command behave identically -
5
+ in particular both serve the index/search commands (the ``Bootstrap`` carries the
6
+ index services factory, not just the AppServices factory).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .entry import main
12
+
13
+ if __name__ == "__main__":
14
+ raise SystemExit(main())
@@ -0,0 +1,15 @@
1
+ """Adapters layer - infrastructure and framework integrations.
2
+
3
+ Contains adapter implementations that connect the application to external
4
+ systems and frameworks (CLI, configuration, email, logging).
5
+
6
+ Contents:
7
+ * :mod:`.config` - Configuration loading, deployment, and display
8
+ * :mod:`.email` - Email sending via SMTP
9
+ * :mod:`.logging` - Logging setup with lib_log_rich
10
+ * :mod:`.cli` - Click CLI framework integration
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ __all__: list[str] = []
@@ -0,0 +1,97 @@
1
+ """Shared retry-with-backoff for the adapters' HTTP transports.
2
+
3
+ The embedding, summarizer, and extractor transports all POST to a model or
4
+ document server that can drop a connection or return a transient 5xx/429
5
+ mid-run - a single blip would otherwise kill an hours-long embed pass.
6
+ ``retry_http`` wraps ONLY the "post + raise_for_status" step of each transport
7
+ in exponential backoff: a transient failure is retried, anything else re-raised
8
+ at once, so each transport keeps its own response parsing and error mapping
9
+ OUTSIDE the retry (a schema/JSON failure is a real error, never retried). httpx
10
+ is imported lazily so this module stays importable without the optional HTTP
11
+ extras installed.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from typing import TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable
21
+
22
+ import httpx
23
+
24
+ # Total attempts for one request (not "retries after the first"): with the
25
+ # default 4, a request is tried up to 4 times before its last error propagates.
26
+ DEFAULT_RETRIES = 4
27
+ # First backoff pause in seconds; each subsequent pause doubles it (1, 2, 4, ...).
28
+ DEFAULT_BASE_DELAY = 1.0
29
+ # Upper bound (seconds) on any single backoff pause, so a long run of failures
30
+ # does not grow the wait without limit.
31
+ DEFAULT_MAX_DELAY = 30.0
32
+
33
+ # The smallest HTTP status treated as a server-side (transient) failure. A 429
34
+ # (rate limit / "retry later") is transient too; every other 4xx is the caller's
35
+ # fault, where retrying never helps.
36
+ _SERVER_ERROR_FLOOR = 500
37
+ _TOO_MANY_REQUESTS = 429
38
+
39
+
40
+ def is_transient(exc: BaseException) -> bool:
41
+ """Whether ``exc`` is a blip worth retrying rather than a permanent failure.
42
+
43
+ True for an httpx transport error (connect/read/write/pool timeout, a dropped
44
+ connection, a protocol error) and for an HTTP 5xx or 429 status. False for a
45
+ 4xx status other than 429, a pydantic ``ValidationError``, a JSON decode
46
+ error, or any other exception - retrying those never helps.
47
+ """
48
+ import httpx # noqa: PLC0415 - optional dependency httpx (in every HTTP-backed provider and extractor extra), loaded only when one is selected
49
+
50
+ if isinstance(exc, httpx.TransportError):
51
+ return True
52
+ if isinstance(exc, httpx.HTTPStatusError):
53
+ status = exc.response.status_code
54
+ return status == _TOO_MANY_REQUESTS or status >= _SERVER_ERROR_FLOOR
55
+ return False
56
+
57
+
58
+ def retry_http(
59
+ do_request: Callable[[], httpx.Response],
60
+ *,
61
+ tries: int = DEFAULT_RETRIES,
62
+ base_delay: float = DEFAULT_BASE_DELAY,
63
+ max_delay: float = DEFAULT_MAX_DELAY,
64
+ sleep: Callable[[float], None] = time.sleep,
65
+ ) -> httpx.Response:
66
+ """Call ``do_request`` with exponential backoff on a transient failure.
67
+
68
+ ``do_request`` must perform the POST and ``raise_for_status``; its ``Response``
69
+ is returned. A transient failure (see :func:`is_transient`) is retried up to
70
+ ``tries`` attempts, pausing ``base_delay`` seconds and doubling each time
71
+ (capped at ``max_delay``); once the attempts are spent the last exception
72
+ propagates. A non-transient failure propagates immediately, so the caller's
73
+ own parsing and error mapping run on it unretried. ``sleep`` is injected so
74
+ tests exercise the backoff without real delay.
75
+ """
76
+ import httpx # noqa: PLC0415 - optional dependency httpx (in every HTTP-backed provider and extractor extra), loaded only when one is selected
77
+
78
+ attempt = 0
79
+ delay = base_delay
80
+ while True:
81
+ try:
82
+ return do_request()
83
+ except httpx.HTTPError as exc:
84
+ attempt += 1
85
+ if attempt >= tries or not is_transient(exc):
86
+ raise
87
+ sleep(min(delay, max_delay))
88
+ delay *= 2
89
+
90
+
91
+ __all__ = [
92
+ "DEFAULT_BASE_DELAY",
93
+ "DEFAULT_MAX_DELAY",
94
+ "DEFAULT_RETRIES",
95
+ "is_transient",
96
+ "retry_http",
97
+ ]
@@ -0,0 +1,20 @@
1
+ """Chunker adapters, each behind the ``ChunkText`` port.
2
+
3
+ * :class:`.whitespace.WhitespaceChunker` - zero-dependency fixed-window fallback
4
+ * :class:`.chonkie.ChonkieChunker` - recursive (default), semantic (SDPM), late
5
+ * :class:`.semantic_text_splitter.StsChunker` - markdown / fast (CommonMark, Rust)
6
+
7
+ Selected by ``[chunker].strategy`` via ``composition.build_chunker``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .chonkie import ChonkieChunker
13
+ from .semantic_text_splitter import StsChunker
14
+ from .whitespace import WhitespaceChunker
15
+
16
+ __all__ = [
17
+ "ChonkieChunker",
18
+ "StsChunker",
19
+ "WhitespaceChunker",
20
+ ]
@@ -0,0 +1,117 @@
1
+ """Shared helpers for the chunker adapters.
2
+
3
+ Turns a library's output into ordered domain :class:`~semdex.domain.models.Chunk`
4
+ objects (dropping empties, numbering ordinals), provides the fixed-window
5
+ splitter used by the whitespace fallback, and resolves a token-count callable
6
+ from chonkie's tokenizer (offline for ``gpt2``/``character``) so the
7
+ semantic-text-splitter strategies count tokens the same way the chonkie ones do.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ from ...domain.errors import ChunkingError
15
+ from ...domain.models import Chunk
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Callable, Iterable
19
+
20
+ from ...domain.models import ExtractedDocument, SourceRef
21
+
22
+
23
+ def window_chunks(document: ExtractedDocument, *, max_tokens: int) -> list[Chunk]:
24
+ """Split a document into ordered, whitespace-token-bounded windows."""
25
+ tokens = document.text.split()
26
+ return [
27
+ Chunk(
28
+ text=" ".join(tokens[start : start + max_tokens]),
29
+ source=document.source,
30
+ ordinal=ordinal,
31
+ token_count=len(tokens[start : start + max_tokens]),
32
+ )
33
+ for ordinal, start in enumerate(range(0, len(tokens), max_tokens))
34
+ ]
35
+
36
+
37
+ def split_oversized(
38
+ text: str,
39
+ token_count: int,
40
+ *,
41
+ max_tokens: int,
42
+ offsets_of: Callable[[str], list[tuple[int, int]]],
43
+ ) -> list[tuple[str, int]]:
44
+ """Split a chunk that exceeds ``max_tokens`` into <=max_tokens pieces, LOSSLESSLY.
45
+
46
+ The size-guard for the embedding-driven strategies (semantic/late), whose sentence-level
47
+ grouping can emit a chunk far over the target when a single "sentence" is un-splittable (a
48
+ minified-JS/webpack blob with no boundaries). Without this the embedder truncates the chunk to
49
+ its max_seq at embed time and silently drops the overflow; splitting here embeds ALL of it.
50
+
51
+ ``offsets_of(text)`` returns one ``(start_char, end_char)`` per token. The pieces TILE the
52
+ original text at token boundaries, so ``"".join(piece_texts) == text`` exactly (each piece is a
53
+ substring) and every piece holds at most ``max_tokens`` tokens. A within-cap chunk is returned
54
+ unchanged.
55
+
56
+ ``token_count`` and ``offsets_of`` need not agree on how many tokens the text holds, and for
57
+ the semantic and late strategies they systematically do not: chonkie's ``SemanticChunker``
58
+ accepts no tokenizer argument and counts in its embedding model's tokenizer, while the offsets
59
+ come from ``[chunker].tokenizer``. Measured divergence on the cached corpora is 6 to 22 percent
60
+ over byte-identical text. This used to return the chunk unchanged whenever OUR tokenizer said
61
+ it fit, which let a chunk recorded at 782 tokens through a 256 cap - and the embedder then
62
+ truncated it and dropped the overflow silently, the exact loss this guard exists to prevent.
63
+ So the RECORDED count decides how many pieces are needed, because that is the count the
64
+ embedder will act on, and the offsets decide only where the cuts fall.
65
+ """
66
+ if token_count <= max_tokens:
67
+ return [(text, token_count)]
68
+ offsets = offsets_of(text)
69
+ n = len(offsets)
70
+ if n <= 1: # a single token, or a tokenizer that yielded nothing: there is no cut to make
71
+ return [(text, token_count)]
72
+ window = max_tokens
73
+ if n <= max_tokens:
74
+ # Our tokenizer says it fits and the recorded count says it does not. Split into as many
75
+ # EVEN pieces as the recorded count needs, so each piece lands under the cap in the terms
76
+ # that matter, rather than skipping the split entirely.
77
+ pieces_needed = -(-token_count // max_tokens) # ceil
78
+ window = max(1, -(-n // pieces_needed))
79
+ pieces: list[tuple[str, int]] = []
80
+ for i in range(0, n, window):
81
+ start = 0 if i == 0 else offsets[i][0]
82
+ end = len(text) if i + window >= n else offsets[i + window][0]
83
+ pieces.append((text[start:end], len(offsets[i : i + window])))
84
+ return pieces
85
+
86
+
87
+ def chunks_from_pairs(pairs: Iterable[tuple[str, int]], *, source: SourceRef) -> list[Chunk]:
88
+ """Build ordered domain chunks from ``(text, token_count)`` pairs, dropping empties."""
89
+ result: list[Chunk] = []
90
+ for text, token_count in pairs:
91
+ if not text.strip():
92
+ continue
93
+ result.append(Chunk(text=text, source=source, ordinal=len(result), token_count=int(token_count)))
94
+ return result
95
+
96
+
97
+ def token_counter(tokenizer: str) -> Callable[[str], int]:
98
+ """Return a token-count callable from chonkie's tokenizer resolution.
99
+
100
+ chonkie is always present with the semantic-text-splitter strategies (both in
101
+ ``semdex[chunk]``), so reusing its tokenizer keeps token counts consistent
102
+ across strategies. Offline for ``gpt2``/``character``.
103
+ """
104
+ try:
105
+ from chonkie import RecursiveChunker # noqa: PLC0415 - optional dependency chonkie (extra chunk), loaded only when a semantic-text-splitter chunker needs its tokenizer
106
+ except ImportError as exc: # pragma: no cover - only without semdex[chunk]
107
+ raise ChunkingError("chonkie is not installed; install semdex[chunk]") from exc
108
+ counter: Callable[[str], int] = RecursiveChunker(tokenizer=tokenizer, chunk_size=2048).tokenizer.count_tokens
109
+ return counter
110
+
111
+
112
+ __all__ = [
113
+ "chunks_from_pairs",
114
+ "split_oversized",
115
+ "token_counter",
116
+ "window_chunks",
117
+ ]
@@ -0,0 +1,209 @@
1
+ """chonkie chunker adapter: recursive (default), semantic (SDPM), and late.
2
+
3
+ ``RECURSIVE`` uses ``RecursiveChunker`` with the markdown recipe (token-accurate,
4
+ no embedding spend) plus ``OverlapRefinery`` when overlap>0; the recipe falls
5
+ back to chonkie's default rules if it cannot be fetched (offline). ``SEMANTIC``
6
+ uses ``SemanticChunker`` with a skip window (the SDPM double-pass merge) and
7
+ ``LATE`` uses ``LateChunker``; both need ``semdex[chunk-semantic]`` and only beat
8
+ recursive with a real embedding provider. The underlying chunker is built once
9
+ and rebuilt only if ``max_tokens`` changes.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib
15
+ import logging
16
+ from functools import lru_cache
17
+ from typing import TYPE_CHECKING, Any, cast
18
+
19
+ from ...domain.enums import ChunkStrategy
20
+ from ...domain.errors import ChunkingError
21
+ from ._base import chunks_from_pairs, split_oversized
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Iterable, Iterator
25
+
26
+ from ...domain.models import Chunk, ExtractedDocument
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _DEFAULT_MAX_TOKENS = 256
31
+
32
+
33
+ def _whitespace_offsets(text: str) -> list[tuple[int, int]]:
34
+ """Fallback token offsets (one per whitespace word) when the tokenizer is a bare string."""
35
+ offsets: list[tuple[int, int]] = []
36
+ cursor = 0
37
+ for word in text.split(" "):
38
+ start = text.index(word, cursor) if word else cursor
39
+ offsets.append((start, start + len(word)))
40
+ cursor = start + len(word)
41
+ return offsets
42
+
43
+
44
+ # Tokenizer names chonkie tokenizes locally (no model, no network) - pass them through as-is.
45
+ _CHONKIE_BUILTIN_TOKENIZERS = frozenset({"character", "word", "byte", "row"})
46
+ # Bare HuggingFace ids whose local cache lives under the canonical org-qualified repo, so the
47
+ # bare id is not found offline. Map to the cached repo id. Extend as more tokenizers are used.
48
+ _TOKENIZER_ALIASES = {"gpt2": "openai-community/gpt2"}
49
+
50
+
51
+ @lru_cache(maxsize=16)
52
+ def resolve_tokenizer(name: str) -> Any:
53
+ """Resolve a tokenizer NAME to a concrete ``tokenizers.Tokenizer`` object once (cached).
54
+
55
+ Handing chonkie a tokenizer *string* makes it resolve through its ``tokie`` backend, whose
56
+ ``from_pretrained`` issues a blocking hub request that ignores ``HF_HUB_OFFLINE`` and stalls
57
+ for minutes on a slow HuggingFace CDN (it only bites the overlap path, ``OverlapRefinery``,
58
+ so it is invisible until overlap>0). Passing a pre-built ``Tokenizer`` object skips that path
59
+ entirely. Builtin names (``character``/``word``/...) tokenize locally, so they pass through;
60
+ an unresolvable name also falls back to the string so chonkie can try its own resolution.
61
+
62
+ Upstream issue: https://github.com/feyninc/chonkie/issues/631
63
+ """
64
+ if name in _CHONKIE_BUILTIN_TOKENIZERS:
65
+ return name
66
+ try:
67
+ tokenizers: Any = importlib.import_module("tokenizers")
68
+ except ImportError:
69
+ return name
70
+ for repo_id in (name, _TOKENIZER_ALIASES.get(name)):
71
+ if not repo_id:
72
+ continue
73
+ try:
74
+ return tokenizers.Tokenizer.from_pretrained(repo_id)
75
+ except Exception as exc: # any load failure (offline miss, bad id) falls back to the string
76
+ logger.debug("tokenizer %r not loadable as %r (%s)", name, repo_id, exc)
77
+ return name
78
+
79
+
80
+ class ChonkieChunker:
81
+ """Chunk a document with chonkie's recursive/semantic/late chunkers."""
82
+
83
+ def __init__( # noqa: PLR0913 - config-holding constructor; each arg is a distinct chunk setting
84
+ self,
85
+ strategy: ChunkStrategy,
86
+ *,
87
+ recipe: str = "markdown",
88
+ overlap: int = 0,
89
+ tokenizer: str = "gpt2",
90
+ semantic_model: str | Any | None = None,
91
+ enforce_max_tokens: bool = True,
92
+ ) -> None:
93
+ self._strategy = strategy
94
+ self._recipe = recipe
95
+ self._overlap = overlap
96
+ self._tokenizer = tokenizer
97
+ # Lossless size-guard: split any chunk over max_tokens into token windows. The semantic/late
98
+ # strategies group at sentence granularity, so an un-splittable "sentence" (a minified-JS blob)
99
+ # can overflow far past the target; without this the embedder truncates it and drops the rest.
100
+ self._enforce_max_tokens = enforce_max_tokens
101
+ # The embedding model SemanticChunker/LateChunker cut boundaries on. None keeps chonkie's
102
+ # own default (potion-base-32M), which is English-distilled - set an explicit multilingual
103
+ # model for non-English corpora, where the default finds boundaries on text it cannot read.
104
+ # A str is resolved by chonkie locally (model2vec/sentence-transformers); a pre-built chonkie
105
+ # BaseEmbeddings object is passed through as-is (e.g. an endpoint-backed breakpoint model).
106
+ self._semantic_model = semantic_model
107
+ self._cached: tuple[int, Any] | None = None
108
+
109
+ def __call__(self, document: ExtractedDocument, *, max_tokens: int = _DEFAULT_MAX_TOKENS) -> list[Chunk]:
110
+ chunker = self._resolve(max_tokens)
111
+ raw = chunker(document.text)
112
+ if self._strategy is ChunkStrategy.RECURSIVE and self._overlap > 0:
113
+ raw = self._overlapped(raw)
114
+ pairs: Iterable[tuple[str, int]] = ((chunk.text, chunk.token_count) for chunk in raw)
115
+ if self._strategy is ChunkStrategy.LATE:
116
+ # chonkie's LateChunker reports token_count 1 for every chunk of a long document (a
117
+ # 30,000-character court decision: 60 of 60 chunks) while counting a short one
118
+ # correctly. A count of 1 satisfies any cap, so the size-guard below would pass an
119
+ # oversized late chunk through to be clipped by the embedder. The count is therefore
120
+ # never taken from chonkie for late chunks: it is re-measured in the configured
121
+ # tokenizer, the unit recursive chunks are counted in.
122
+ pairs = ((chunk.text, len(self._offsets_of(chunk.text))) for chunk in raw)
123
+ # Only the embedding-driven strategies overflow (sentence-granularity grouping of an
124
+ # un-splittable blob). recursive caps already, and its overlap intentionally pushes a chunk
125
+ # slightly over max_tokens - guarding it would wrongly re-split those overlap chunks.
126
+ if self._enforce_max_tokens and self._strategy in (ChunkStrategy.SEMANTIC, ChunkStrategy.LATE):
127
+ pairs = self._guard(pairs, max_tokens)
128
+ return chunks_from_pairs(pairs, source=document.source)
129
+
130
+ def _guard(self, pairs: Iterable[tuple[str, int]], max_tokens: int) -> Iterator[tuple[str, int]]:
131
+ """Split any oversized (text, token_count) pair into <=max_tokens pieces, losslessly."""
132
+ for text, count in pairs:
133
+ yield from split_oversized(text, count, max_tokens=max_tokens, offsets_of=self._offsets_of)
134
+
135
+ def _offsets_of(self, text: str) -> list[tuple[int, int]]:
136
+ """Per-token (start, end) char offsets from the resolved tokenizer; whitespace fallback.
137
+
138
+ ``resolve_tokenizer`` returns either a bare string (builtin/unresolvable name) or a
139
+ ``tokenizers.Tokenizer`` object; the latter's ``encode(text).offsets`` is read at this
140
+ optional-dependency boundary via ``getattr`` and coerced to a concrete ``list[tuple[int, int]]``.
141
+ """
142
+ tok: Any = resolve_tokenizer(self._tokenizer)
143
+ offsets: list[tuple[int, int]] = []
144
+ if callable(getattr(tok, "encode", None)):
145
+ try:
146
+ # boundary read of the optional-dep Tokenizer's Encoding.offsets
147
+ offsets = cast("list[tuple[int, int]]", tok.encode(text).offsets)
148
+ except Exception:
149
+ # a bare-string tokenizer, or an encode quirk of whichever tokenizers
150
+ # build is installed: leave offsets empty and fall back to whitespace.
151
+ offsets = []
152
+ return list(offsets) if offsets else _whitespace_offsets(text)
153
+
154
+ def _resolve(self, max_tokens: int) -> Any:
155
+ if self._cached is not None and self._cached[0] == max_tokens:
156
+ return self._cached[1]
157
+ chunker = self._build(max_tokens)
158
+ self._cached = (max_tokens, chunker)
159
+ return chunker
160
+
161
+ def _build(self, max_tokens: int) -> Any:
162
+ try:
163
+ import chonkie # noqa: PLC0415 - optional dependency chonkie (extra chunk), loaded only when the chonkie chunker builds
164
+ except ImportError as exc:
165
+ raise ChunkingError("chonkie is not installed; install semdex[chunk]") from exc
166
+ if self._strategy is ChunkStrategy.RECURSIVE:
167
+ return self._build_recursive(chonkie, max_tokens)
168
+ # Pass embedding_model only when set (explicit calls, not a **spread, so the typed
169
+ # chonkie kwargs stay checked), so an unset model keeps chonkie's own default.
170
+ model = self._semantic_model
171
+ if self._strategy is ChunkStrategy.SEMANTIC:
172
+ # A non-zero skip window makes SemanticChunker do the SDPM double-pass merge.
173
+ if model:
174
+ return chonkie.SemanticChunker(chunk_size=max_tokens, skip_window=1, embedding_model=model)
175
+ return chonkie.SemanticChunker(chunk_size=max_tokens, skip_window=1)
176
+ if model:
177
+ return chonkie.LateChunker(chunk_size=max_tokens, embedding_model=model)
178
+ return chonkie.LateChunker(chunk_size=max_tokens)
179
+
180
+ def _build_recursive(self, chonkie: Any, max_tokens: int) -> Any:
181
+ tokenizer = resolve_tokenizer(self._tokenizer)
182
+ if self._recipe:
183
+ try:
184
+ return chonkie.RecursiveChunker.from_recipe(
185
+ self._recipe, lang="en", tokenizer=tokenizer, chunk_size=max_tokens
186
+ )
187
+ except Exception as exc:
188
+ logger.warning(
189
+ "chonkie recipe %r unavailable (%s); using the default recursive rules", self._recipe, exc
190
+ )
191
+ return chonkie.RecursiveChunker(tokenizer=tokenizer, chunk_size=max_tokens)
192
+
193
+ def _overlapped(self, raw: Any) -> Any:
194
+ import chonkie # noqa: PLC0415 - optional dependency chonkie (extra chunk), loaded only when the chonkie chunker applies overlap
195
+
196
+ return chonkie.OverlapRefinery(tokenizer=resolve_tokenizer(self._tokenizer), context_size=self._overlap)(raw)
197
+
198
+
199
+ # Static conformance assertion -- pyright verifies ChonkieChunker satisfies ChunkText.
200
+ if TYPE_CHECKING:
201
+ from ...application.ports import ChunkText
202
+
203
+ _assert_chunk: ChunkText = ChonkieChunker(ChunkStrategy.RECURSIVE)
204
+
205
+
206
+ __all__ = [
207
+ "ChonkieChunker",
208
+ "resolve_tokenizer",
209
+ ]
@@ -0,0 +1,67 @@
1
+ """semantic-text-splitter chunker adapter: markdown and fast recursive.
2
+
3
+ ``MARKDOWN`` uses ``MarkdownSplitter`` (true CommonMark parsing, so it never
4
+ splits inside a code block or table); ``FAST`` uses ``TextSplitter`` (plain fast
5
+ recursive). Both are Rust-backed with native overlap. Token-accurate sizing uses
6
+ chonkie's tokenizer as the length callback (see :func:`._base.token_counter`), so
7
+ chunk sizes match the chonkie strategies. Needs ``semdex[chunk]``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from ...domain.enums import ChunkStrategy
15
+ from ...domain.errors import ChunkingError
16
+ from ._base import chunks_from_pairs, token_counter
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Callable
20
+
21
+ from ...domain.models import Chunk, ExtractedDocument
22
+
23
+ _DEFAULT_MAX_TOKENS = 256
24
+
25
+
26
+ class StsChunker:
27
+ """Chunk a document with semantic-text-splitter's Markdown/Text splitter."""
28
+
29
+ def __init__(self, strategy: ChunkStrategy, *, overlap: int = 0, tokenizer: str = "gpt2") -> None:
30
+ self._strategy = strategy
31
+ self._overlap = overlap
32
+ self._count = token_counter(tokenizer)
33
+ self._cached: tuple[int, Any] | None = None
34
+
35
+ def __call__(self, document: ExtractedDocument, *, max_tokens: int = _DEFAULT_MAX_TOKENS) -> list[Chunk]:
36
+ splitter = self._resolve(max_tokens)
37
+ texts = splitter.chunks(document.text)
38
+ return chunks_from_pairs(((text, self._count(text)) for text in texts), source=document.source)
39
+
40
+ def _resolve(self, max_tokens: int) -> Any:
41
+ if self._cached is not None and self._cached[0] == max_tokens:
42
+ return self._cached[1]
43
+ splitter = self._build(max_tokens)
44
+ self._cached = (max_tokens, splitter)
45
+ return splitter
46
+
47
+ def _build(self, max_tokens: int) -> Any:
48
+ try:
49
+ from semantic_text_splitter import MarkdownSplitter, TextSplitter # noqa: PLC0415 - optional dependency semantic-text-splitter (extra chunk), loaded only when the sts chunker builds
50
+ except ImportError as exc:
51
+ raise ChunkingError("semantic-text-splitter is not installed; install semdex[chunk]") from exc
52
+ counter: Callable[[str], int] = self._count
53
+ if self._strategy is ChunkStrategy.MARKDOWN:
54
+ return MarkdownSplitter.from_callback(counter, max_tokens, overlap=self._overlap)
55
+ return TextSplitter.from_callback(counter, max_tokens, overlap=self._overlap)
56
+
57
+
58
+ # Static conformance assertion -- pyright verifies StsChunker satisfies ChunkText.
59
+ if TYPE_CHECKING:
60
+ from ...application.ports import ChunkText
61
+
62
+ _assert_chunk: ChunkText = StsChunker(ChunkStrategy.MARKDOWN)
63
+
64
+
65
+ __all__ = [
66
+ "StsChunker",
67
+ ]