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 ADDED
@@ -0,0 +1,3 @@
1
+ """ragdesk — personal, local-first RAG over your own sources."""
2
+
3
+ __version__ = "0.1.0"
ragdesk/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from ragdesk.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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
+ ]