ragx-cli 0.1.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.
ragx/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
ragx/cli/__init__.py ADDED
File without changes
ragx/cli/app.py ADDED
@@ -0,0 +1,105 @@
1
+ """ragx CLI shell: init, status, config. index/query are wired in a later pass."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from ragx.cli.output import emit_json, fail
12
+ from ragx.core.config import Config, RAGX_DIR, db_path, require_root, write_default_config
13
+ from ragx.core.errors import RagxError
14
+
15
+ app = typer.Typer(add_completion=False, no_args_is_help=True)
16
+ config_app = typer.Typer(add_completion=False, no_args_is_help=True)
17
+ app.add_typer(config_app, name="config")
18
+
19
+ from ragx.cli import eval_cmd, inspect_cmd, pipeline # noqa: E402 (needs `app` defined above)
20
+
21
+ pipeline.register(app)
22
+ inspect_cmd.register(app)
23
+ eval_cmd.register(app)
24
+
25
+
26
+ def _require_root() -> Path:
27
+ try:
28
+ return require_root()
29
+ except RagxError as exc:
30
+ fail(str(exc))
31
+
32
+
33
+ @app.command()
34
+ def init(path: Path = typer.Argument(Path("."))) -> None:
35
+ """Create .ragx/ with a default config at PATH (default: cwd)."""
36
+ root = path.resolve()
37
+ if (root / RAGX_DIR).exists():
38
+ fail(f"{root / RAGX_DIR} already exists")
39
+ cfg_path = write_default_config(root)
40
+ typer.echo(f"created {cfg_path}")
41
+
42
+
43
+ def _counts(root: Path) -> tuple[int, int, int]:
44
+ db = db_path(root)
45
+ if not db.exists():
46
+ return 0, 0, 0
47
+ try:
48
+ from ragx.core.store import Store
49
+ except ImportError:
50
+ return 0, 0, 0
51
+ with Store(db) as store:
52
+ return store.file_count(), store.chunk_count(), store.edge_count()
53
+
54
+
55
+ @app.command()
56
+ def status(json_out: bool = typer.Option(False, "--json")) -> None:
57
+ """Show corpus root, embedding config, and index counts."""
58
+ root = _require_root()
59
+ cfg = Config.load(root)
60
+ files, chunks, edges = _counts(root)
61
+ doc = {
62
+ "schema": "ragx.status.v1",
63
+ "root": str(root),
64
+ "embedding_provider": cfg.get("embeddings.provider"),
65
+ "embedding_model": cfg.get("embeddings.model"),
66
+ "files": files,
67
+ "chunks": chunks,
68
+ "edges": edges,
69
+ }
70
+ if json_out:
71
+ emit_json(doc)
72
+ else:
73
+ typer.echo(f"root: {doc['root']}")
74
+ typer.echo(f"embedding: {doc['embedding_provider']}/{doc['embedding_model']}")
75
+ typer.echo(f"files: {files} chunks: {chunks} edges: {edges}")
76
+ if files == 0 and chunks == 0 and edges == 0:
77
+ raise typer.Exit(code=1)
78
+
79
+
80
+ @config_app.command("get")
81
+ def config_get(key: str) -> None:
82
+ root = _require_root()
83
+ cfg = Config.load(root)
84
+ try:
85
+ value = cfg.get(key)
86
+ except RagxError as exc:
87
+ fail(str(exc))
88
+ typer.echo(str(value))
89
+
90
+
91
+ @config_app.command("set")
92
+ def config_set(key: str, value: str) -> None:
93
+ root = _require_root()
94
+ cfg = Config.load(root)
95
+ try:
96
+ cfg.set(key, value)
97
+ except RagxError as exc:
98
+ fail(str(exc))
99
+ cfg.save(root)
100
+ typer.echo(f"{key} = {cfg.get(key)}")
101
+
102
+
103
+ def main() -> None:
104
+ logging.basicConfig(level=logging.INFO, stream=sys.stderr, format="%(levelname)s: %(message)s")
105
+ app()
ragx/cli/eval_cmd.py ADDED
@@ -0,0 +1,74 @@
1
+ """`ragx eval` command. Registered onto the main app by app.py (integration-owned)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from ragx.cli.output import emit_json, fail
11
+ from ragx.core.config import Config, require_root
12
+ from ragx.core.errors import RagxError
13
+ from ragx.core.eval import evaluate, load_queries
14
+ from ragx.core.query import QueryOptions, run_query
15
+ from ragx.providers.registry import make_embedder, make_generator, make_reranker
16
+
17
+ _BUILTIN_CONFIGS: dict[str, QueryOptions] = {
18
+ "baseline": QueryOptions(expand=False, graph=False, rerank=False),
19
+ "graph": QueryOptions(expand=False, graph=True, rerank=False),
20
+ "rerank": QueryOptions(expand=False, graph=True, rerank=True),
21
+ "full": QueryOptions(expand=True, graph=True, rerank=True),
22
+ }
23
+
24
+
25
+ def register(app: typer.Typer) -> None:
26
+ app.command("eval")(eval_cmd)
27
+
28
+
29
+ def eval_cmd(
30
+ queries_file: Path = typer.Argument(..., help="path to a queries.jsonl file"),
31
+ json_out: bool = typer.Option(False, "--json"),
32
+ top: int = typer.Option(10, "--top"),
33
+ configs: str = typer.Option("baseline,graph,full", "--configs"),
34
+ ) -> None:
35
+ """Evaluate retrieval quality (recall@5/@10, MRR) against labeled queries."""
36
+ try:
37
+ queries = load_queries(queries_file)
38
+ except RagxError as exc:
39
+ fail(str(exc))
40
+
41
+ names = [n.strip() for n in configs.split(",") if n.strip()]
42
+ unknown = [n for n in names if n not in _BUILTIN_CONFIGS]
43
+ if unknown:
44
+ fail(f"unknown config(s): {', '.join(unknown)}")
45
+
46
+ try:
47
+ root = require_root()
48
+ cfg = Config.load(root)
49
+ embedder = make_embedder(cfg)
50
+ generator = make_generator(cfg) if any(_BUILTIN_CONFIGS[n].expand for n in names) else None
51
+ reranker = make_reranker(cfg) if any(_BUILTIN_CONFIGS[n].rerank for n in names) else None
52
+ except RagxError as exc:
53
+ fail(str(exc))
54
+
55
+ def query_fn(text: str, opts: QueryOptions):
56
+ return run_query(
57
+ root, cfg, embedder, text, opts,
58
+ generator=generator if opts.expand else None,
59
+ reranker=reranker if opts.rerank else None,
60
+ )
61
+
62
+ # retrieve chunks deep enough that file-level ranking has >= `top` files to rank
63
+ selected = [
64
+ (name, replace(_BUILTIN_CONFIGS[name], top=top * 3)) for name in names
65
+ ]
66
+ result = evaluate(queries, selected, query_fn, top=top)
67
+
68
+ if json_out:
69
+ emit_json(result)
70
+ return
71
+ header = f"{'config':<12}{'recall@5':>10}{'recall@10':>10}{'mrr':>10}"
72
+ typer.echo(header)
73
+ for c in result["configs"]:
74
+ typer.echo(f"{c['name']:<12}{c['recall_at_5']:>10.4f}{c['recall_at_10']:>10.4f}{c['mrr']:>10.4f}")
@@ -0,0 +1,122 @@
1
+ """ragx inspect: read-only introspection into the store (chunks, files, neighbors).
2
+
3
+ No embeddings or vector index needed — everything here reads Store directly.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import typer
9
+
10
+ from ragx.cli.output import emit_json, fail
11
+ from ragx.core.config import db_path, require_root
12
+ from ragx.core.errors import RagxError
13
+ from ragx.core.store import Store
14
+
15
+ inspect_app = typer.Typer(add_completion=False, no_args_is_help=True)
16
+
17
+
18
+ def register(app: typer.Typer) -> None:
19
+ app.add_typer(inspect_app, name="inspect")
20
+
21
+
22
+ def _open_store() -> Store:
23
+ try:
24
+ root = require_root()
25
+ except RagxError as exc:
26
+ fail(str(exc))
27
+ return Store(db_path(root))
28
+
29
+
30
+ def _neighbor_info(store: Store, chunk_id: int) -> list[dict]:
31
+ raw = store.neighbors(chunk_id)
32
+ others = {c.id: c for c in store.get_chunks([other_id for other_id, _ in raw])}
33
+ return [
34
+ {
35
+ "id": other_id,
36
+ "weight": round(weight, 6),
37
+ "file": others[other_id].file_path if other_id in others else None,
38
+ "line_start": others[other_id].line_start if other_id in others else None,
39
+ "line_end": others[other_id].line_end if other_id in others else None,
40
+ }
41
+ for other_id, weight in raw
42
+ ]
43
+
44
+
45
+ @inspect_app.command("chunk")
46
+ def inspect_chunk(chunk_id: int, json_out: bool = typer.Option(False, "--json")) -> None:
47
+ """Show a chunk's text, location, and edges."""
48
+ with _open_store() as store:
49
+ chunks = store.get_chunks([chunk_id])
50
+ if not chunks:
51
+ fail(f"unknown chunk id: {chunk_id}")
52
+ chunk = chunks[0]
53
+ edges = _neighbor_info(store, chunk_id)
54
+ doc = {
55
+ "schema": "ragx.inspect.chunk.v1",
56
+ "id": chunk.id,
57
+ "file": chunk.file_path,
58
+ "byte_start": chunk.byte_start,
59
+ "byte_end": chunk.byte_end,
60
+ "line_start": chunk.line_start,
61
+ "line_end": chunk.line_end,
62
+ "text": chunk.text,
63
+ "edges": edges,
64
+ }
65
+ if json_out:
66
+ emit_json(doc)
67
+ else:
68
+ typer.echo(f"chunk {chunk.id} {chunk.file_path}:{chunk.line_start}-{chunk.line_end}")
69
+ typer.echo(chunk.text)
70
+ typer.echo(f"edges ({len(edges)}):")
71
+ for e in edges:
72
+ typer.echo(f" {e['id']} {e['weight']:.4f} {e['file']}")
73
+ if not edges:
74
+ raise typer.Exit(code=1)
75
+
76
+
77
+ @inspect_app.command("file")
78
+ def inspect_file(path: str, json_out: bool = typer.Option(False, "--json")) -> None:
79
+ """Show a file's record and its chunks."""
80
+ with _open_store() as store:
81
+ hashes = store.get_file_hashes()
82
+ if path not in hashes:
83
+ fail(f"unknown file: {path}")
84
+ ids = store.chunk_ids_for_file(path)
85
+ chunks = store.get_chunks(ids)
86
+ chunk_docs = [
87
+ {"id": c.id, "line_start": c.line_start, "line_end": c.line_end, "preview": c.text[:80]}
88
+ for c in chunks
89
+ ]
90
+ doc = {
91
+ "schema": "ragx.inspect.file.v1",
92
+ "path": path,
93
+ "content_hash": hashes[path],
94
+ "chunk_count": len(chunk_docs),
95
+ "chunks": chunk_docs,
96
+ }
97
+ if json_out:
98
+ emit_json(doc)
99
+ else:
100
+ typer.echo(f"file {path} ({doc['chunk_count']} chunks) hash={doc['content_hash']}")
101
+ for c in chunk_docs:
102
+ typer.echo(f" {c['id']} {c['line_start']}-{c['line_end']} {c['preview']}")
103
+ if not chunk_docs:
104
+ raise typer.Exit(code=1)
105
+
106
+
107
+ @inspect_app.command("neighbors")
108
+ def inspect_neighbors(chunk_id: int, json_out: bool = typer.Option(False, "--json")) -> None:
109
+ """Show a chunk's neighbors enriched with file/line info."""
110
+ with _open_store() as store:
111
+ if not store.get_chunks([chunk_id]):
112
+ fail(f"unknown chunk id: {chunk_id}")
113
+ neighbors = _neighbor_info(store, chunk_id)
114
+ doc = {"schema": "ragx.inspect.neighbors.v1", "id": chunk_id, "neighbors": neighbors}
115
+ if json_out:
116
+ emit_json(doc)
117
+ else:
118
+ typer.echo(f"neighbors of {chunk_id}:")
119
+ for n in neighbors:
120
+ typer.echo(f" {n['id']} {n['weight']:.4f} {n['file']}:{n['line_start']}-{n['line_end']}")
121
+ if not neighbors:
122
+ raise typer.Exit(code=1)
ragx/cli/output.py ADDED
@@ -0,0 +1,19 @@
1
+ """Stdout/exit conventions for the CLI. stdout is reserved for result payloads;
2
+ everything diagnostic goes to stderr (configured in app.main())."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import sys
8
+ from typing import NoReturn
9
+
10
+
11
+ def emit_json(doc: dict) -> None:
12
+ """Write exactly one JSON document to stdout, nothing else on stdout."""
13
+ sys.stdout.write(json.dumps(doc) + "\n")
14
+
15
+
16
+ def fail(msg: str, code: int = 2) -> NoReturn:
17
+ """Print an error message to stderr and exit with `code`."""
18
+ print(msg, file=sys.stderr)
19
+ raise SystemExit(code)
ragx/cli/pipeline.py ADDED
@@ -0,0 +1,93 @@
1
+ """`index` and `query` commands. Registered onto the main app by app.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from ragx.cli.output import emit_json, fail
11
+ from ragx.core.config import Config, require_root
12
+ from ragx.core.errors import RagxError
13
+ from ragx.core.indexer import run_index
14
+ from ragx.core.query import QueryOptions, run_query, to_files_json, to_query_json
15
+ from ragx.providers.registry import make_embedder, make_generator, make_reranker
16
+
17
+
18
+ def register(app: typer.Typer) -> None:
19
+ app.command()(index)
20
+ app.command()(query)
21
+
22
+
23
+ def index(
24
+ path: Path | None = typer.Argument(None),
25
+ changed: bool = typer.Option(False, "--changed", help="only re-process new/modified/deleted files"),
26
+ json_out: bool = typer.Option(False, "--json"),
27
+ ) -> None:
28
+ """Chunk, embed, and index the corpus (full rebuild unless --changed)."""
29
+ try:
30
+ root = require_root(path)
31
+ cfg = Config.load(root)
32
+ stats = run_index(root, cfg, make_embedder(cfg), changed_only=changed)
33
+ except RagxError as exc:
34
+ fail(str(exc))
35
+ doc = {"schema": "ragx.index.v1", **stats.__dict__}
36
+ if json_out:
37
+ emit_json(doc)
38
+ else:
39
+ typer.echo(
40
+ f"indexed {stats.files_indexed} files ({stats.chunks_added} chunks), "
41
+ f"deleted {stats.files_deleted} ({stats.chunks_deleted} chunks), "
42
+ f"unchanged {stats.files_unchanged}"
43
+ )
44
+
45
+
46
+ def query(
47
+ text: str = typer.Argument(..., help="query text, or '-' to read from stdin"),
48
+ top: int | None = typer.Option(None, "--top"),
49
+ json_out: bool = typer.Option(False, "--json"),
50
+ files_only: bool = typer.Option(False, "--files-only"),
51
+ no_expand: bool = typer.Option(False, "--no-expand"),
52
+ no_graph: bool = typer.Option(False, "--no-graph"),
53
+ no_rerank: bool = typer.Option(False, "--no-rerank"),
54
+ hops: int | None = typer.Option(None, "--hops"),
55
+ explain: bool = typer.Option(False, "--explain"),
56
+ ) -> None:
57
+ """Search the index; ranked chunks (or files with --files-only)."""
58
+ if text == "-":
59
+ text = sys.stdin.read().strip()
60
+ try:
61
+ root = require_root()
62
+ cfg = Config.load(root)
63
+ opts = QueryOptions(
64
+ top=top if top is not None else cfg.get("query.top"),
65
+ files_only=files_only,
66
+ expand=not no_expand,
67
+ graph=not no_graph,
68
+ rerank=not no_rerank,
69
+ hops=hops,
70
+ explain=explain,
71
+ )
72
+ out = run_query(
73
+ root, cfg, make_embedder(cfg), text, opts,
74
+ generator=make_generator(cfg) if opts.expand else None,
75
+ reranker=make_reranker(cfg) if opts.rerank else None,
76
+ )
77
+ except RagxError as exc:
78
+ fail(str(exc))
79
+ if json_out:
80
+ if files_only:
81
+ emit_json(to_files_json(out))
82
+ else:
83
+ emit_json(to_query_json(out, max_chunk_chars=cfg.get("query.max_chunk_chars")))
84
+ elif files_only:
85
+ for f in to_files_json(out)["files"]:
86
+ typer.echo(f"{f['score']:.4f} {f['file']}")
87
+ else:
88
+ for r in out.results:
89
+ loc = f"{r.chunk.file_path}:{r.chunk.line_start}-{r.chunk.line_end}"
90
+ first_line = r.chunk.text.strip().splitlines()[0][:100] if r.chunk.text.strip() else ""
91
+ typer.echo(f"{r.score:.4f} {loc} {first_line}")
92
+ if not out.results:
93
+ raise typer.Exit(code=1)
ragx/core/__init__.py ADDED
File without changes
ragx/core/chunking.py ADDED
@@ -0,0 +1,183 @@
1
+ """Text chunking: markdown/code/recursive splitters producing byte-exact ChunkDraft slices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from bisect import bisect_right
7
+ from pathlib import Path
8
+
9
+ from ragx.core.models import ChunkDraft
10
+
11
+ MARKDOWN_EXTS = {".md", ".markdown"}
12
+ CODE_EXTS = {".py", ".ts", ".js", ".tsx", ".jsx", ".go", ".rs", ".java", ".rb"}
13
+ SEPARATORS = ["\n\n", "\n", ". ", " "]
14
+ MIN_CHUNK_CHARS = 100 # fragments below this merge into a neighbor (may nudge it past hard max)
15
+
16
+ _HEADING_RE = re.compile(r"^#{1,6} .*$", re.MULTILINE)
17
+ _CODE_BOUNDARY_RE = re.compile(r"^(?:def|class|function|fn|func)\b", re.MULTILINE)
18
+
19
+
20
+ def _find_boundary(s: str, start: int, hard_end: int) -> int:
21
+ region = s[start:hard_end]
22
+ for sep in SEPARATORS:
23
+ idx = region.rfind(sep)
24
+ if idx != -1:
25
+ candidate = start + idx + len(sep)
26
+ if candidate > start:
27
+ return candidate
28
+ return hard_end
29
+
30
+
31
+ def _recursive_split_range(s: str, target_chars: int, hard_max_chars: int, overlap: float) -> list[tuple[int, int]]:
32
+ n = len(s)
33
+ if n == 0:
34
+ return []
35
+ ranges: list[tuple[int, int]] = []
36
+ pos = 0
37
+ while pos < n:
38
+ remaining = n - pos
39
+ if remaining <= hard_max_chars:
40
+ end = n
41
+ else:
42
+ hard_end = min(pos + hard_max_chars, n)
43
+ end = _find_boundary(s, pos, hard_end)
44
+ ranges.append((pos, end))
45
+ if end >= n:
46
+ break
47
+ chunk_len = end - pos
48
+ overlap_chars = int(round(chunk_len * overlap))
49
+ next_pos = end - overlap_chars
50
+ if next_pos <= pos:
51
+ next_pos = end
52
+ pos = next_pos
53
+ return ranges
54
+
55
+
56
+ def _recursive_split(
57
+ text: str, start: int, end: int, target_chars: int, hard_max_chars: int, overlap: float
58
+ ) -> list[tuple[int, int]]:
59
+ rel = _recursive_split_range(text[start:end], target_chars, hard_max_chars, overlap)
60
+ return [(start + a, start + b) for a, b in rel]
61
+
62
+
63
+ def _sections_from_boundaries(text: str, starts: list[int]) -> list[tuple[int, int]]:
64
+ sections: list[tuple[int, int]] = []
65
+ if not starts:
66
+ return [(0, len(text))]
67
+ if starts[0] > 0:
68
+ sections.append((0, starts[0]))
69
+ for i, s in enumerate(starts):
70
+ e = starts[i + 1] if i + 1 < len(starts) else len(text)
71
+ sections.append((s, e))
72
+ return sections
73
+
74
+
75
+ def _markdown_split(text: str, target_chars: int, hard_max_chars: int, overlap: float) -> list[tuple[int, int]]:
76
+ starts = [m.start() for m in _HEADING_RE.finditer(text)]
77
+ sections = _sections_from_boundaries(text, starts)
78
+
79
+ ranges: list[tuple[int, int]] = []
80
+ buffer: tuple[int, int] | None = None
81
+ for s, e in sections:
82
+ size = e - s
83
+ if size > hard_max_chars:
84
+ if buffer is not None:
85
+ ranges.append(buffer)
86
+ buffer = None
87
+ ranges.extend(_recursive_split(text, s, e, target_chars, hard_max_chars, overlap))
88
+ continue
89
+ if buffer is None:
90
+ buffer = (s, e)
91
+ elif (buffer[1] - buffer[0]) + size <= target_chars:
92
+ buffer = (buffer[0], e)
93
+ else:
94
+ ranges.append(buffer)
95
+ buffer = (s, e)
96
+ if buffer is not None:
97
+ ranges.append(buffer)
98
+ return ranges
99
+
100
+
101
+ def _code_split(text: str, target_chars: int, hard_max_chars: int, overlap: float) -> list[tuple[int, int]]:
102
+ starts = [m.start() for m in _CODE_BOUNDARY_RE.finditer(text)]
103
+ sections = _sections_from_boundaries(text, starts)
104
+
105
+ ranges: list[tuple[int, int]] = []
106
+ for s, e in sections:
107
+ if (e - s) > hard_max_chars:
108
+ ranges.extend(_recursive_split(text, s, e, target_chars, hard_max_chars, overlap))
109
+ else:
110
+ ranges.append((s, e))
111
+ return ranges
112
+
113
+
114
+ def _char_byte_offsets(text: str) -> list[int]:
115
+ offsets = [0] * (len(text) + 1)
116
+ total = 0
117
+ for i, ch in enumerate(text):
118
+ offsets[i] = total
119
+ total += len(ch.encode("utf-8"))
120
+ offsets[len(text)] = total
121
+ return offsets
122
+
123
+
124
+ def _line_starts(text: str) -> list[int]:
125
+ starts = [0]
126
+ for i, ch in enumerate(text):
127
+ if ch == "\n":
128
+ starts.append(i + 1)
129
+ return starts
130
+
131
+
132
+ def _merge_tiny_ranges(text: str, ranges: list[tuple[int, int]], min_chars: int) -> list[tuple[int, int]]:
133
+ """Absorb ranges with < min_chars of stripped text into the previous (or next) range."""
134
+ out: list[tuple[int, int]] = []
135
+ for start, end in ranges:
136
+ if out and len(text[start:end].strip()) < min_chars:
137
+ out[-1] = (out[-1][0], max(out[-1][1], end))
138
+ else:
139
+ out.append((start, end))
140
+ if len(out) >= 2 and len(text[out[0][0] : out[0][1]].strip()) < min_chars:
141
+ out[1] = (out[0][0], out[1][1])
142
+ out.pop(0)
143
+ return out
144
+
145
+
146
+ def chunk_text(text: str, path: str, size_tokens: int = 800, overlap: float = 0.15) -> list[ChunkDraft]:
147
+ """Split `text` into byte-exact ChunkDrafts, dispatching on `path`'s extension."""
148
+ if not text.strip():
149
+ return []
150
+
151
+ target_chars = size_tokens * 4
152
+ hard_max_chars = int(round(target_chars * 1.5))
153
+
154
+ ext = Path(path).suffix.lower()
155
+ if ext in MARKDOWN_EXTS:
156
+ ranges = _markdown_split(text, target_chars, hard_max_chars, overlap)
157
+ elif ext in CODE_EXTS:
158
+ ranges = _code_split(text, target_chars, hard_max_chars, overlap)
159
+ else:
160
+ ranges = _recursive_split(text, 0, len(text), target_chars, hard_max_chars, overlap)
161
+ min_chars = min(MIN_CHUNK_CHARS, max(1, target_chars // 8))
162
+ ranges = _merge_tiny_ranges(text, ranges, min_chars)
163
+
164
+ char_to_byte = _char_byte_offsets(text)
165
+ line_starts = _line_starts(text)
166
+
167
+ drafts: list[ChunkDraft] = []
168
+ for start, end in ranges:
169
+ if end <= start:
170
+ continue
171
+ chunk_str = text[start:end]
172
+ if not chunk_str.strip():
173
+ continue
174
+ drafts.append(
175
+ ChunkDraft(
176
+ text=chunk_str,
177
+ byte_start=char_to_byte[start],
178
+ byte_end=char_to_byte[end],
179
+ line_start=bisect_right(line_starts, start),
180
+ line_end=bisect_right(line_starts, end - 1),
181
+ )
182
+ )
183
+ return drafts