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.
- semdex/__init__.py +20 -0
- semdex/__init__conf__.py +90 -0
- semdex/__main__.py +14 -0
- semdex/adapters/__init__.py +15 -0
- semdex/adapters/_http_retry.py +97 -0
- semdex/adapters/chunker/__init__.py +20 -0
- semdex/adapters/chunker/_base.py +117 -0
- semdex/adapters/chunker/chonkie.py +209 -0
- semdex/adapters/chunker/semantic_text_splitter.py +67 -0
- semdex/adapters/chunker/whitespace.py +37 -0
- semdex/adapters/cli/__init__.py +64 -0
- semdex/adapters/cli/commands/__init__.py +34 -0
- semdex/adapters/cli/commands/config.py +511 -0
- semdex/adapters/cli/commands/email/__init__.py +17 -0
- semdex/adapters/cli/commands/email/_common.py +374 -0
- semdex/adapters/cli/commands/email/send_email.py +148 -0
- semdex/adapters/cli/commands/email/send_notification.py +100 -0
- semdex/adapters/cli/commands/index_search.py +260 -0
- semdex/adapters/cli/commands/info.py +60 -0
- semdex/adapters/cli/commands/logging.py +34 -0
- semdex/adapters/cli/commands/serve.py +253 -0
- semdex/adapters/cli/config_load.py +205 -0
- semdex/adapters/cli/constants.py +28 -0
- semdex/adapters/cli/context.py +192 -0
- semdex/adapters/cli/exit_codes.py +57 -0
- semdex/adapters/cli/main.py +123 -0
- semdex/adapters/cli/py.typed +0 -0
- semdex/adapters/cli/root.py +162 -0
- semdex/adapters/cli/safe_console.py +297 -0
- semdex/adapters/cli/typed_click.py +76 -0
- semdex/adapters/config/__init__.py +25 -0
- semdex/adapters/config/_section.py +21 -0
- semdex/adapters/config/chunker.py +77 -0
- semdex/adapters/config/dataset.py +189 -0
- semdex/adapters/config/defaultconfig.d/10-index.toml +126 -0
- semdex/adapters/config/defaultconfig.d/15-vectorstore.toml +163 -0
- semdex/adapters/config/defaultconfig.d/16-datasets.toml +193 -0
- semdex/adapters/config/defaultconfig.d/17-mcp.toml +210 -0
- semdex/adapters/config/defaultconfig.d/20-extractor.toml +97 -0
- semdex/adapters/config/defaultconfig.d/25-chunker.toml +130 -0
- semdex/adapters/config/defaultconfig.d/28-sanitize.toml +59 -0
- semdex/adapters/config/defaultconfig.d/30-embedding.toml +227 -0
- semdex/adapters/config/defaultconfig.d/31-summary.toml +131 -0
- semdex/adapters/config/defaultconfig.d/32-health.toml +105 -0
- semdex/adapters/config/defaultconfig.d/35-watch.toml +54 -0
- semdex/adapters/config/defaultconfig.d/40-layered-config.toml +78 -0
- semdex/adapters/config/defaultconfig.d/50-mail.toml +244 -0
- semdex/adapters/config/defaultconfig.d/90-logging.toml +445 -0
- semdex/adapters/config/defaultconfig.toml +56 -0
- semdex/adapters/config/deploy.py +135 -0
- semdex/adapters/config/display.py +60 -0
- semdex/adapters/config/embedding.py +95 -0
- semdex/adapters/config/extractor.py +72 -0
- semdex/adapters/config/health.py +104 -0
- semdex/adapters/config/index.py +72 -0
- semdex/adapters/config/loader.py +198 -0
- semdex/adapters/config/mcp.py +69 -0
- semdex/adapters/config/overrides.py +302 -0
- semdex/adapters/config/py.typed +0 -0
- semdex/adapters/config/sanitize.py +65 -0
- semdex/adapters/config/summary.py +87 -0
- semdex/adapters/config/vectorstore.py +119 -0
- semdex/adapters/config/watch.py +54 -0
- semdex/adapters/discovery/__init__.py +15 -0
- semdex/adapters/discovery/filesystem.py +108 -0
- semdex/adapters/discovery/location.py +62 -0
- semdex/adapters/email/__init__.py +28 -0
- semdex/adapters/email/config.py +381 -0
- semdex/adapters/email/py.typed +0 -0
- semdex/adapters/email/sender.py +26 -0
- semdex/adapters/email/transport.py +233 -0
- semdex/adapters/email/validation.py +65 -0
- semdex/adapters/embedding/__init__.py +38 -0
- semdex/adapters/embedding/_base.py +150 -0
- semdex/adapters/embedding/cohere.py +127 -0
- semdex/adapters/embedding/fastembed.py +87 -0
- semdex/adapters/embedding/gemini.py +153 -0
- semdex/adapters/embedding/model2vec.py +51 -0
- semdex/adapters/embedding/ollama.py +130 -0
- semdex/adapters/embedding/openai.py +118 -0
- semdex/adapters/embedding/sentence_transformers.py +117 -0
- semdex/adapters/extractor/__init__.py +37 -0
- semdex/adapters/extractor/_http.py +129 -0
- semdex/adapters/extractor/docling.py +85 -0
- semdex/adapters/extractor/markitdown.py +135 -0
- semdex/adapters/extractor/mineru.py +95 -0
- semdex/adapters/extractor/router.py +192 -0
- semdex/adapters/extractor/text.py +58 -0
- semdex/adapters/extractor/vision_ocr.py +186 -0
- semdex/adapters/extractor/xberg.py +109 -0
- semdex/adapters/health/__init__.py +61 -0
- semdex/adapters/health/_loop.py +111 -0
- semdex/adapters/health/_probe.py +63 -0
- semdex/adapters/health/_restart.py +105 -0
- semdex/adapters/health/_warmup.py +55 -0
- semdex/adapters/logging/__init__.py +13 -0
- semdex/adapters/logging/py.typed +0 -0
- semdex/adapters/logging/setup.py +345 -0
- semdex/adapters/mcp/__init__.py +1 -0
- semdex/adapters/mcp/auth.py +128 -0
- semdex/adapters/mcp/schemas.py +146 -0
- semdex/adapters/mcp/server.py +275 -0
- semdex/adapters/memory/__init__.py +60 -0
- semdex/adapters/memory/config.py +66 -0
- semdex/adapters/memory/email.py +173 -0
- semdex/adapters/memory/index.py +230 -0
- semdex/adapters/memory/logging.py +80 -0
- semdex/adapters/py.typed +0 -0
- semdex/adapters/sanitize/__init__.py +14 -0
- semdex/adapters/sanitize/detectors.py +71 -0
- semdex/adapters/sanitize/extractor.py +79 -0
- semdex/adapters/summarizer/__init__.py +22 -0
- semdex/adapters/summarizer/_base.py +116 -0
- semdex/adapters/summarizer/ollama.py +122 -0
- semdex/adapters/summarizer/openai.py +128 -0
- semdex/adapters/vectorstore/__init__.py +22 -0
- semdex/adapters/vectorstore/jsonstore.py +141 -0
- semdex/adapters/vectorstore/lance.py +273 -0
- semdex/adapters/vectorstore/mariadb.py +234 -0
- semdex/adapters/vectorstore/pgvector.py +227 -0
- semdex/adapters/vectorstore/sqlitevec.py +219 -0
- semdex/adapters/watch/__init__.py +13 -0
- semdex/adapters/watch/watchfiles_watcher.py +115 -0
- semdex/application/__init__.py +51 -0
- semdex/application/batching.py +51 -0
- semdex/application/ports.py +331 -0
- semdex/application/py.typed +0 -0
- semdex/application/resilience.py +160 -0
- semdex/application/use_cases/__init__.py +31 -0
- semdex/application/use_cases/indexing.py +101 -0
- semdex/application/use_cases/reconciling.py +77 -0
- semdex/application/use_cases/searching.py +121 -0
- semdex/application/use_cases/writing.py +67 -0
- semdex/composition/__init__.py +1148 -0
- semdex/composition/py.typed +0 -0
- semdex/domain/__init__.py +50 -0
- semdex/domain/ann_tuning.py +102 -0
- semdex/domain/enums.py +280 -0
- semdex/domain/errors.py +143 -0
- semdex/domain/models.py +180 -0
- semdex/domain/py.typed +0 -0
- semdex/entry.py +35 -0
- semdex/py.typed +0 -0
- semdex-1.5.6.dist-info/METADATA +549 -0
- semdex-1.5.6.dist-info/RECORD +148 -0
- semdex-1.5.6.dist-info/WHEEL +4 -0
- semdex-1.5.6.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|
semdex/__init__conf__.py
ADDED
|
@@ -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
|
+
]
|