ragdesk 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.
- ragdesk/__init__.py +3 -0
- ragdesk/__main__.py +4 -0
- ragdesk/answer.py +178 -0
- ragdesk/archive.py +39 -0
- ragdesk/buildenv.py +94 -0
- ragdesk/chunk.py +93 -0
- ragdesk/cli.py +1012 -0
- ragdesk/complete.py +167 -0
- ragdesk/confluence.py +368 -0
- ragdesk/credentials.py +73 -0
- ragdesk/defaults.py +48 -0
- ragdesk/email_source.py +335 -0
- ragdesk/embed.py +343 -0
- ragdesk/envfile.py +63 -0
- ragdesk/evaluate.py +288 -0
- ragdesk/gdrive.py +308 -0
- ragdesk/github.py +257 -0
- ragdesk/gitlab.py +169 -0
- ragdesk/htmlutil.py +29 -0
- ragdesk/index.py +399 -0
- ragdesk/llm.py +370 -0
- ragdesk/mcp.py +248 -0
- ragdesk/msgraph.py +295 -0
- ragdesk/notes.py +117 -0
- ragdesk/notion.py +231 -0
- ragdesk/oauth.py +85 -0
- ragdesk/obsidian.py +79 -0
- ragdesk/office.py +266 -0
- ragdesk/ollama.py +60 -0
- ragdesk/presets.py +54 -0
- ragdesk/rerank.py +225 -0
- ragdesk/s3.py +329 -0
- ragdesk/screen.py +226 -0
- ragdesk/search.py +296 -0
- ragdesk/serve.py +2915 -0
- ragdesk/settings.py +59 -0
- ragdesk/store.py +1108 -0
- ragdesk/symbols.py +239 -0
- ragdesk/topics.py +124 -0
- ragdesk/tui.py +229 -0
- ragdesk/vectors.py +357 -0
- ragdesk/vision.py +182 -0
- ragdesk/web.py +171 -0
- ragdesk-0.1.0.dist-info/METADATA +479 -0
- ragdesk-0.1.0.dist-info/RECORD +48 -0
- ragdesk-0.1.0.dist-info/WHEEL +4 -0
- ragdesk-0.1.0.dist-info/entry_points.txt +2 -0
- ragdesk-0.1.0.dist-info/licenses/LICENSE +201 -0
ragdesk/__init__.py
ADDED
ragdesk/__main__.py
ADDED
ragdesk/answer.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Grounded answer generation over any :mod:`ragdesk.llm` backend.
|
|
2
|
+
|
|
3
|
+
The grounding gate refuses to call the LLM when retrieval is weak — the
|
|
4
|
+
cosine threshold is embedder-specific, so calibrate it per embedder with the
|
|
5
|
+
eval harness instead of trusting the default.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ragdesk.search import Hit
|
|
15
|
+
|
|
16
|
+
DEFAULT_LLM_MODEL = "qwen3.5:4b"
|
|
17
|
+
# Grounded answers are short; the cap also stops small models from looping
|
|
18
|
+
# (a 1k-token ramble holds the model for minutes on laptop hardware).
|
|
19
|
+
# ``num_ctx`` is Ollama-specific and ignored by other backends.
|
|
20
|
+
ANSWER_OPTIONS = {"num_predict": 400, "temperature": 0.2, "num_ctx": 8192}
|
|
21
|
+
ANSWER_LENGTHS = {"short": 200, "medium": 400, "long": 700}
|
|
22
|
+
LENGTH_HINTS = {
|
|
23
|
+
"short": "Keep the answer to two or three sentences.\n",
|
|
24
|
+
"medium": "",
|
|
25
|
+
"long": "Answer thoroughly, but stay under ten sentences.\n",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
REFUSAL = "I could not find this in your indexed sources."
|
|
29
|
+
|
|
30
|
+
PROMPT_TEMPLATE = """You are ragdesk, a retrieval assistant. Answer from the sources below, plus any
|
|
31
|
+
correction the user added — a correction outranks the sources.
|
|
32
|
+
Structure the answer for scanning: a one- or two-sentence summary, then short
|
|
33
|
+
paragraphs; when the sources enumerate facts (names, codes, amounts, dates),
|
|
34
|
+
list them as `- ` bullets on their own lines. No LaTeX, no headings, no tables.
|
|
35
|
+
Cite the sources you used inline as [1], [2] and so on — end each factual
|
|
36
|
+
sentence with its citation marker, but never reply with citations alone.
|
|
37
|
+
If the sources do not contain the answer, say exactly:
|
|
38
|
+
"{refusal}". Never use outside knowledge.
|
|
39
|
+
{history}{memory}{diagram}
|
|
40
|
+
Sources:
|
|
41
|
+
{context}
|
|
42
|
+
{corrections}
|
|
43
|
+
Question: {question}
|
|
44
|
+
Answer:"""
|
|
45
|
+
|
|
46
|
+
HISTORY_HEADER = "Recent conversation (context only — the sources are the only source of truth):\n"
|
|
47
|
+
HISTORY_TURNS = 3
|
|
48
|
+
HISTORY_CHARS = 400
|
|
49
|
+
MEMORY_HEADER = (
|
|
50
|
+
"Durable notes the user asked you to remember (context, still answer from the sources):\n"
|
|
51
|
+
)
|
|
52
|
+
CORRECTION_HEADER = (
|
|
53
|
+
"IMPORTANT — the user fixed an earlier answer to a matching question. Use the "
|
|
54
|
+
"fixed answer even when a source says otherwise, and keep citing sources:\n"
|
|
55
|
+
)
|
|
56
|
+
CORRECTION_QUESTION_CHARS = 200
|
|
57
|
+
CORRECTION_ANSWER_CHARS = 800
|
|
58
|
+
DIAGRAM_NOTE = """The user asked for a diagram. Reply with ONE fenced ```mermaid block and at
|
|
59
|
+
most two sentences of context. Use ONLY this Mermaid vocabulary: `flowchart TD` or
|
|
60
|
+
`flowchart LR`, nodes `id[Label]`, edges `A --> B` or `A -->|label| B`, optional grouping
|
|
61
|
+
`subgraph Name ... end`, plus `classDef accent fill:#2e6b58,color:#f6f7f2,stroke:#2e6b58;`
|
|
62
|
+
and `class NodeId accent;` for the node to notice first. Never invent keywords such as
|
|
63
|
+
`subregion`, `flowchart graph`, `linkStyle`, or HTML labels. At most 12 nodes, short labels
|
|
64
|
+
in the same language as the question.
|
|
65
|
+
"""
|
|
66
|
+
# Words that mean "draw me something" rather than "tell me something".
|
|
67
|
+
DIAGRAM_WORDS = (
|
|
68
|
+
"diagram",
|
|
69
|
+
"flowchart",
|
|
70
|
+
"flow chart",
|
|
71
|
+
"sequence diagram",
|
|
72
|
+
"architecture diagram",
|
|
73
|
+
"draw",
|
|
74
|
+
"chart",
|
|
75
|
+
"graph",
|
|
76
|
+
"sơ đồ",
|
|
77
|
+
"biểu đồ",
|
|
78
|
+
"vẽ",
|
|
79
|
+
)
|
|
80
|
+
_DIAGRAM_RE = re.compile(r"\b(?:" + "|".join(re.escape(word) for word in DIAGRAM_WORDS) + r")\b")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def wants_diagram(question: str) -> bool:
|
|
84
|
+
# Word boundaries: "graph" must not match "paragraph", "draw" not "drawback".
|
|
85
|
+
return bool(_DIAGRAM_RE.search(question.lower()))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _answer_length() -> str:
|
|
89
|
+
from ragdesk import settings # noqa: PLC0415 - avoids an import cycle at load
|
|
90
|
+
|
|
91
|
+
return str(settings.load().get("answer_length") or "medium")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def length_hint() -> str:
|
|
95
|
+
return LENGTH_HINTS.get(_answer_length(), "")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def answer_options() -> dict:
|
|
99
|
+
return {**ANSWER_OPTIONS, "num_predict": ANSWER_LENGTHS.get(_answer_length(), 400)}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_prompt(
|
|
103
|
+
question: str,
|
|
104
|
+
hits: list[Hit],
|
|
105
|
+
history: list[tuple[str, str]] | None = None,
|
|
106
|
+
memory: list[str] | None = None,
|
|
107
|
+
diagram: bool = False,
|
|
108
|
+
corrections: list[dict] | None = None,
|
|
109
|
+
) -> str:
|
|
110
|
+
blocks = [f"[{i}] {hit.path}\n{hit.context}" for i, hit in enumerate(hits, start=1)]
|
|
111
|
+
lines: list[str] = []
|
|
112
|
+
for role, text in (history or [])[-HISTORY_TURNS * 2 :]:
|
|
113
|
+
speaker = "User" if role == "user" else "ragdesk"
|
|
114
|
+
lines.append(f"{speaker}: {text[:HISTORY_CHARS]}")
|
|
115
|
+
notes = [f"- {note}" for note in (memory or [])]
|
|
116
|
+
fixes = [
|
|
117
|
+
f"Fixed answer (to {str(item.get('question', ''))[:CORRECTION_QUESTION_CHARS]!r}): "
|
|
118
|
+
f"{str(item.get('answer', ''))[:CORRECTION_ANSWER_CHARS]}"
|
|
119
|
+
for item in (corrections or [])
|
|
120
|
+
]
|
|
121
|
+
return PROMPT_TEMPLATE.format(
|
|
122
|
+
refusal=REFUSAL,
|
|
123
|
+
history=(HISTORY_HEADER + "\n".join(lines) + "\n\n") if lines else "",
|
|
124
|
+
memory=(MEMORY_HEADER + "\n".join(notes) + "\n\n") if notes else "",
|
|
125
|
+
corrections=(CORRECTION_HEADER + "\n".join(fixes) + "\n\n") if fixes else "",
|
|
126
|
+
diagram=(DIAGRAM_NOTE if diagram else "") + length_hint(),
|
|
127
|
+
context="\n\n".join(blocks),
|
|
128
|
+
question=question,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def answer(
|
|
133
|
+
question: str,
|
|
134
|
+
hits: list[Hit],
|
|
135
|
+
llm: Any,
|
|
136
|
+
*,
|
|
137
|
+
min_cosine: float = 0.0,
|
|
138
|
+
history: list[tuple[str, str]] | None = None,
|
|
139
|
+
memory: list[str] | None = None,
|
|
140
|
+
diagram: bool = False,
|
|
141
|
+
corrections: list[dict] | None = None,
|
|
142
|
+
) -> str:
|
|
143
|
+
best_cosine = max((hit.cosine for hit in hits), default=0.0)
|
|
144
|
+
if not hits or best_cosine < min_cosine:
|
|
145
|
+
return REFUSAL
|
|
146
|
+
prompt = build_prompt(question, hits, history, memory, diagram, corrections)
|
|
147
|
+
# Small models occasionally return an empty completion; one retry.
|
|
148
|
+
for _ in range(2):
|
|
149
|
+
text = str(llm.generate(prompt, answer_options())).strip()
|
|
150
|
+
if text:
|
|
151
|
+
return text
|
|
152
|
+
return REFUSAL
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def answer_stream(
|
|
156
|
+
question: str,
|
|
157
|
+
hits: list[Hit],
|
|
158
|
+
llm: Any,
|
|
159
|
+
*,
|
|
160
|
+
min_cosine: float = 0.0,
|
|
161
|
+
history: list[tuple[str, str]] | None = None,
|
|
162
|
+
memory: list[str] | None = None,
|
|
163
|
+
diagram: bool = False,
|
|
164
|
+
corrections: list[dict] | None = None,
|
|
165
|
+
) -> Iterator[str]:
|
|
166
|
+
"""Same contract as :func:`answer`, but yields text pieces as they arrive."""
|
|
167
|
+
best_cosine = max((hit.cosine for hit in hits), default=0.0)
|
|
168
|
+
if not hits or best_cosine < min_cosine:
|
|
169
|
+
yield REFUSAL
|
|
170
|
+
return
|
|
171
|
+
emitted = False
|
|
172
|
+
prompt = build_prompt(question, hits, history, memory, diagram, corrections)
|
|
173
|
+
for piece in llm.generate_stream(prompt, answer_options()):
|
|
174
|
+
if piece:
|
|
175
|
+
emitted = True
|
|
176
|
+
yield str(piece)
|
|
177
|
+
if not emitted:
|
|
178
|
+
yield REFUSAL
|
ragdesk/archive.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Shared tarball helpers for repository connectors (GitHub, GitLab)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import tarfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ragdesk.index import extract_bytes, is_indexable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def tar_text_files(data: bytes, subdir: str = "") -> list[tuple[str, str]]:
|
|
13
|
+
"""Return ``(relative_path, text)`` for indexable files in a repo tarball.
|
|
14
|
+
|
|
15
|
+
Repository archives wrap everything in one top-level ``<project>-<sha>/``
|
|
16
|
+
directory, which is stripped here.
|
|
17
|
+
"""
|
|
18
|
+
files: list[tuple[str, str]] = []
|
|
19
|
+
prefix = Path(subdir) if subdir else None
|
|
20
|
+
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
|
|
21
|
+
for member in tar.getmembers():
|
|
22
|
+
if not member.isfile():
|
|
23
|
+
continue
|
|
24
|
+
rel = Path(*Path(member.name).parts[1:]) # strip '<owner>-<repo>-<sha>/'
|
|
25
|
+
if not rel.parts:
|
|
26
|
+
continue
|
|
27
|
+
if prefix is not None and rel != prefix and prefix not in rel.parents:
|
|
28
|
+
continue
|
|
29
|
+
if not is_indexable(rel, member.size):
|
|
30
|
+
continue
|
|
31
|
+
extracted = tar.extractfile(member)
|
|
32
|
+
if extracted is None:
|
|
33
|
+
continue
|
|
34
|
+
raw = extracted.read()
|
|
35
|
+
text = extract_bytes(raw, str(rel))
|
|
36
|
+
if text is None or not text.strip():
|
|
37
|
+
continue
|
|
38
|
+
files.append((str(rel), text))
|
|
39
|
+
return files
|
ragdesk/buildenv.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Bake local ``.env`` values into a gitignored module for release builds.
|
|
2
|
+
|
|
3
|
+
python -m ragdesk.buildenv # ./.env -> src/ragdesk/_build_env.py
|
|
4
|
+
uv build # the wheel now carries the values
|
|
5
|
+
|
|
6
|
+
The generated module is gitignored — the repository never contains
|
|
7
|
+
credentials, while distributed artifacts (PyPI wheel, bundled sidecars) can
|
|
8
|
+
still offer one-click connects. Only non-confidential values belong in a
|
|
9
|
+
public build (see SECURITY.md): GitHub client ID, Google Desktop client
|
|
10
|
+
credentials. The Atlassian 3LO secret is a real credential and is refused
|
|
11
|
+
here.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ragdesk.envfile import parse_env_file
|
|
20
|
+
|
|
21
|
+
ENV_TO_CONST = {
|
|
22
|
+
"RAGDESK_GITHUB_CLIENT_ID": "GITHUB_CLIENT_ID",
|
|
23
|
+
"RAGDESK_ATLASSIAN_CLIENT_ID": "ATLASSIAN_CLIENT_ID",
|
|
24
|
+
"RAGDESK_ATLASSIAN_CLIENT_SECRET": "ATLASSIAN_CLIENT_SECRET",
|
|
25
|
+
"GDRIVE_CLIENT_ID": "GOOGLE_CLIENT_ID",
|
|
26
|
+
"GDRIVE_CLIENT_SECRET": "GOOGLE_CLIENT_SECRET",
|
|
27
|
+
"RAGDESK_MS_CLIENT_ID": "MS_CLIENT_ID",
|
|
28
|
+
}
|
|
29
|
+
CONST_ORDER = (
|
|
30
|
+
"GITHUB_CLIENT_ID",
|
|
31
|
+
"ATLASSIAN_CLIENT_ID",
|
|
32
|
+
"ATLASSIAN_CLIENT_SECRET",
|
|
33
|
+
"GOOGLE_CLIENT_ID",
|
|
34
|
+
"GOOGLE_CLIENT_SECRET",
|
|
35
|
+
"MS_CLIENT_ID",
|
|
36
|
+
)
|
|
37
|
+
REFUSED = {"ATLASSIAN_CLIENT_SECRET"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_build_env(values: dict[str, str]) -> str:
|
|
41
|
+
lines = [
|
|
42
|
+
'"""Generated by `python -m ragdesk.buildenv` — do not edit, do not commit."""',
|
|
43
|
+
"",
|
|
44
|
+
]
|
|
45
|
+
for const in CONST_ORDER:
|
|
46
|
+
lines.append(f"{const} = {values.get(const, '')!r}")
|
|
47
|
+
lines.append("")
|
|
48
|
+
return "\n".join(lines)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write_build_env(env_path: Path, out_path: Path) -> dict[str, str]:
|
|
52
|
+
"""Map ``.env`` values to constants; refuses confidential values."""
|
|
53
|
+
raw = parse_env_file(env_path.read_text()) if env_path.is_file() else {}
|
|
54
|
+
mapped = {const: raw.get(env_name, "") for env_name, const in ENV_TO_CONST.items()}
|
|
55
|
+
for const in REFUSED:
|
|
56
|
+
if mapped.get(const):
|
|
57
|
+
mapped[const] = ""
|
|
58
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
out_path.write_text(render_build_env(mapped))
|
|
60
|
+
return mapped
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(argv: list[str] | None = None) -> int:
|
|
64
|
+
parser = argparse.ArgumentParser(
|
|
65
|
+
prog="python -m ragdesk.buildenv",
|
|
66
|
+
description="Bake .env values into src/ragdesk/_build_env.py for release builds.",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument("--env", type=Path, default=Path(".env"))
|
|
69
|
+
parser.add_argument("--out", type=Path, default=Path("src/ragdesk/_build_env.py"))
|
|
70
|
+
args = parser.parse_args(argv)
|
|
71
|
+
|
|
72
|
+
if not args.env.is_file():
|
|
73
|
+
print(f"no {args.env} found — nothing baked (fine for a repo-only build)")
|
|
74
|
+
return 0
|
|
75
|
+
mapped = write_build_env(args.env, args.out)
|
|
76
|
+
baked = [const for const, value in mapped.items() if value]
|
|
77
|
+
refused = [
|
|
78
|
+
const
|
|
79
|
+
for const in REFUSED
|
|
80
|
+
if parse_env_file(args.env.read_text()).get(
|
|
81
|
+
next(env for env, name in ENV_TO_CONST.items() if name == const), ""
|
|
82
|
+
)
|
|
83
|
+
]
|
|
84
|
+
print(f"baked {args.out} from {args.env}: {', '.join(sorted(baked)) or 'no values'}")
|
|
85
|
+
if refused:
|
|
86
|
+
print(
|
|
87
|
+
f"refused to bake confidential values: {', '.join(sorted(refused))} "
|
|
88
|
+
"(Atlassian 3LO secret belongs to the user, see SECURITY.md)"
|
|
89
|
+
)
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
raise SystemExit(main())
|
ragdesk/chunk.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Paragraph-aware chunking with character overlap and line tracking.
|
|
2
|
+
|
|
3
|
+
Symbol-aware chunking was measured three ways and rejected on this corpus:
|
|
4
|
+
per-symbol segmentation (fragmentation + BM25 term density), symbol headers in
|
|
5
|
+
the chunk text (test files out-rank the implementation) and a symbol lane over
|
|
6
|
+
definition sites (loose LIKE matching dilutes RRF). See AGENTS.md for the
|
|
7
|
+
numbers; symbol intelligence belongs in a call-graph index, not in chunking.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
# Lines that open a named block in the languages we index most.
|
|
16
|
+
SYMBOL_RE = re.compile(
|
|
17
|
+
r"^\s*(?:async\s+)?(?:def|class|func|function|impl|struct|interface|enum|"
|
|
18
|
+
r"fn|pub\s+fn)\s+([A-Za-z_][\w]*)"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
DEFAULT_MAX_CHARS = 1000
|
|
22
|
+
DEFAULT_OVERLAP = 150
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Chunk:
|
|
27
|
+
ordinal: int
|
|
28
|
+
text: str
|
|
29
|
+
line_start: int = 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _line_number(text: str, offset: int) -> int:
|
|
33
|
+
return text.count("\n", 0, offset) + 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def chunk_text(
|
|
37
|
+
text: str,
|
|
38
|
+
max_chars: int = DEFAULT_MAX_CHARS,
|
|
39
|
+
overlap: int = DEFAULT_OVERLAP,
|
|
40
|
+
) -> list[Chunk]:
|
|
41
|
+
"""Split text into overlapping, paragraph-aware chunks.
|
|
42
|
+
|
|
43
|
+
Paragraphs are packed until ``max_chars``; oversized paragraphs are
|
|
44
|
+
hard-split. The overlap is taken from the tail of the previous chunk so
|
|
45
|
+
context survives boundaries. Each chunk remembers the source line it starts
|
|
46
|
+
on, which is what a citation's ``:line`` points at.
|
|
47
|
+
"""
|
|
48
|
+
if overlap >= max_chars:
|
|
49
|
+
raise ValueError("overlap must be smaller than max_chars")
|
|
50
|
+
|
|
51
|
+
paragraphs: list[tuple[int, str]] = []
|
|
52
|
+
cursor = 0
|
|
53
|
+
for raw in text.split("\n\n"):
|
|
54
|
+
stripped = raw.strip()
|
|
55
|
+
if stripped:
|
|
56
|
+
offset = cursor + raw.index(stripped)
|
|
57
|
+
paragraphs.append((offset, stripped))
|
|
58
|
+
cursor += len(raw) + 2
|
|
59
|
+
|
|
60
|
+
pieces: list[tuple[int, str]] = []
|
|
61
|
+
for offset, para in paragraphs:
|
|
62
|
+
if len(para) <= max_chars:
|
|
63
|
+
pieces.append((offset, para))
|
|
64
|
+
else:
|
|
65
|
+
step = max_chars - overlap
|
|
66
|
+
pieces.extend((offset + i, para[i : i + max_chars]) for i in range(0, len(para), step))
|
|
67
|
+
|
|
68
|
+
chunks: list[tuple[int, str]] = []
|
|
69
|
+
buf = ""
|
|
70
|
+
buf_offset = 0
|
|
71
|
+
for offset, piece in pieces:
|
|
72
|
+
if not buf:
|
|
73
|
+
buf = piece
|
|
74
|
+
buf_offset = offset
|
|
75
|
+
continue
|
|
76
|
+
candidate = f"{buf}\n\n{piece}"
|
|
77
|
+
if len(candidate) <= max_chars:
|
|
78
|
+
buf = candidate
|
|
79
|
+
continue
|
|
80
|
+
chunks.append((buf_offset, buf))
|
|
81
|
+
tail = buf[-overlap:]
|
|
82
|
+
joined = f"{tail}\n\n{piece}"
|
|
83
|
+
if len(joined) <= max_chars:
|
|
84
|
+
buf = joined
|
|
85
|
+
else:
|
|
86
|
+
buf = piece
|
|
87
|
+
buf_offset = offset
|
|
88
|
+
if buf:
|
|
89
|
+
chunks.append((buf_offset, buf))
|
|
90
|
+
return [
|
|
91
|
+
Chunk(ordinal=i, text=chunk, line_start=_line_number(text, offset))
|
|
92
|
+
for i, (offset, chunk) in enumerate(chunks)
|
|
93
|
+
]
|