omarsol commited on
Commit
f2e8e50
·
1 Parent(s): 9db7240

Enhance knowledge base and API functionality

Browse files

- Updated .gitignore to include local environment files for better configuration management.
- Enhanced the build_chat_request function to handle explicit empty source selections, ensuring that the knowledge base is correctly disabled when no sources are selected.
- Improved the UIMessageStreamEncoder to include additional metadata for source matches, enhancing the information available to the frontend.
- Introduced reranking logic in the rerank_results function to score matched chunks instead of entire documents, optimizing relevance and performance.
- Added comprehensive tests to validate the new functionalities, ensuring robustness in knowledge base interactions and API responses.

.gitignore CHANGED
@@ -121,6 +121,8 @@ celerybeat.pid
121
 
122
  # Environments
123
  .env
 
 
124
  .venv
125
  env/
126
  venv/
 
121
 
122
  # Environments
123
  .env
124
+ .env.local
125
+ .env.*.local
126
  .venv
127
  env/
128
  venv/
app/api.py CHANGED
@@ -33,6 +33,7 @@ from .config import (
33
  DEFAULT_MODEL_NAME,
34
  DEFAULT_SELECTED_SOURCE_KEYS,
35
  DEFAULT_SELECTED_SOURCES_UI,
 
36
  SOURCE_UI_TO_KEY,
37
  )
38
 
@@ -210,15 +211,25 @@ def build_chat_request(payload: ApiChatRequest) -> ChatRequest:
210
  )
211
 
212
  allowed_source_keys = set(AVAILABLE_SOURCES)
213
- requested_source_keys = payload.sourceKeys or list(DEFAULT_SELECTED_SOURCE_KEYS)
214
- source_keys = (
215
- tuple(
216
- dict.fromkeys(
217
- key for key in requested_source_keys if key in allowed_source_keys
 
 
 
 
 
 
 
 
 
 
 
218
  )
 
219
  )
220
- or DEFAULT_SELECTED_SOURCE_KEYS
221
- )
222
  model_name = (payload.model or DEFAULT_MODEL_NAME).strip() or DEFAULT_MODEL_NAME
223
  if model_name not in {model["id"] for model in AVAILABLE_MODELS}:
224
  raise HTTPException(status_code=422, detail="Unknown model")
@@ -292,6 +303,10 @@ class UIMessageStreamEncoder:
292
  "sourceLabel": str(data.get("source_label", "")),
293
  "score": float(data.get("score", 0.0)),
294
  "group": str(data.get("group", "")),
 
 
 
 
295
  }
296
 
297
  def encode(self, event: ChatEvent) -> list[dict[str, Any]]:
@@ -303,6 +318,11 @@ class UIMessageStreamEncoder:
303
  {
304
  "type": "data-thread",
305
  "data": {"threadId": self.thread_id},
 
 
 
 
 
306
  }
307
  )
308
  return parts
@@ -550,8 +570,15 @@ def _source_entries() -> list[dict[str, Any]]:
550
  for label in AVAILABLE_SOURCES_UI:
551
  key = SOURCE_UI_TO_KEY[label]
552
  group = "courses" if key in COURSE_SOURCE_KEYS else "docs"
 
553
  entry: dict[str, Any] = {
554
  "label": label,
 
 
 
 
 
 
555
  "key": key,
556
  "group": group,
557
  "selectedByDefault": label in defaults,
 
33
  DEFAULT_MODEL_NAME,
34
  DEFAULT_SELECTED_SOURCE_KEYS,
35
  DEFAULT_SELECTED_SOURCES_UI,
36
+ SOURCE_DISPLAY_INFO,
37
  SOURCE_UI_TO_KEY,
38
  )
39
 
 
211
  )
212
 
213
  allowed_source_keys = set(AVAILABLE_SOURCES)
214
+ if payload.sourceKeys is not None and not payload.sourceKeys:
215
+ # An explicit empty selection is the user turning the knowledge base
216
+ # off; it must not silently coerce to the defaults. Only an *omitted*
217
+ # field means "use the defaults".
218
+ source_keys: tuple[str, ...] = ()
219
+ else:
220
+ requested_source_keys = (
221
+ payload.sourceKeys
222
+ if payload.sourceKeys is not None
223
+ else list(DEFAULT_SELECTED_SOURCE_KEYS)
224
+ )
225
+ source_keys = (
226
+ tuple(
227
+ dict.fromkeys(
228
+ key for key in requested_source_keys if key in allowed_source_keys
229
+ )
230
  )
231
+ or DEFAULT_SELECTED_SOURCE_KEYS
232
  )
 
 
233
  model_name = (payload.model or DEFAULT_MODEL_NAME).strip() or DEFAULT_MODEL_NAME
234
  if model_name not in {model["id"] for model in AVAILABLE_MODELS}:
235
  raise HTTPException(status_code=422, detail="Unknown model")
 
303
  "sourceLabel": str(data.get("source_label", "")),
304
  "score": float(data.get("score", 0.0)),
305
  "group": str(data.get("group", "")),
306
+ # KB-root-relative path ("raw/docs/...") when the source is a KB
307
+ # file; the client uses it to resolve inline `raw/...` citations
308
+ # to this source's real URL.
309
+ "path": str(data.get("path", "")),
310
  }
311
 
312
  def encode(self, event: ChatEvent) -> list[dict[str, Any]]:
 
318
  {
319
  "type": "data-thread",
320
  "data": {"threadId": self.thread_id},
321
+ # Delivered to onData only, never stored in message.parts:
322
+ # nothing renders it, and a non-transient part emitted
323
+ # before "start" relies on undocumented AI SDK ordering
324
+ # behavior (the in-flight message is stored by reference).
325
+ "transient": True,
326
  }
327
  )
328
  return parts
 
570
  for label in AVAILABLE_SOURCES_UI:
571
  key = SOURCE_UI_TO_KEY[label]
572
  group = "courses" if key in COURSE_SOURCE_KEYS else "docs"
573
+ display = SOURCE_DISPLAY_INFO.get(key, {})
574
  entry: dict[str, Any] = {
575
  "label": label,
576
+ # Display metadata is registry-owned (single source of truth);
577
+ # the frontend renders these verbatim instead of keeping its own
578
+ # per-source maps or reshaping the label.
579
+ "shortLabel": display.get("ui_label") or label,
580
+ "description": display.get("description"),
581
+ "infoUrl": display.get("url"),
582
  "key": key,
583
  "group": group,
584
  "selectedByDefault": label in defaults,
app/chat_service.py CHANGED
@@ -818,15 +818,17 @@ def build_agent(
818
  enabled_tools: tuple[str, ...] = (),
819
  include_thoughts: bool = False,
820
  kb_agents_instructions: str | None = None,
 
821
  ):
822
  # kb_agents_instructions is part of the cache key on purpose: an agent
823
  # built before data/kb/AGENTS.md existed must not pin its degraded
824
  # system prompt for the process lifetime.
825
  model = build_chat_model(model_name, include_thoughts=include_thoughts)
826
- tools: list[Any] = [
827
- retrieve_tutor_context,
828
- run_kb_command,
829
- ]
 
830
  middleware: list[AgentMiddleware] = [
831
  ContextEditingMiddleware(
832
  edits=[
@@ -876,7 +878,10 @@ def build_agent(
876
  model=model,
877
  tools=tools,
878
  system_prompt=build_system_prompt(
879
- model_name, enabled_tools, kb_agents_instructions
 
 
 
880
  ),
881
  context_schema=AppContext,
882
  checkpointer=CHECKPOINTER,
@@ -893,8 +898,9 @@ def model_provider_and_name(model_name: str) -> tuple[str, str]:
893
  def effective_tool_names(
894
  model_name: str,
895
  enabled_tools: tuple[str, ...],
 
896
  ) -> tuple[str, ...]:
897
- names = ["retrieve_tutor_context", "run_kb_command"]
898
  enabled = set(enabled_tools)
899
  if is_google_genai_model(model_name):
900
  if "web_search" in enabled:
@@ -950,7 +956,11 @@ def agent_run_config(
950
  message_id: str,
951
  ) -> dict[str, Any]:
952
  provider, actual_model = model_provider_and_name(request.model_name)
953
- tools = effective_tool_names(request.model_name, request.enabled_tools)
 
 
 
 
954
  source_labels = [
955
  SOURCE_KEY_TO_LABEL.get(source_key, source_key)
956
  for source_key in request.source_keys
@@ -1003,7 +1013,12 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
1003
  # decide whether consecutive deltas need a paragraph break between them.
1004
  reasoning_deltas_are_blocks = is_google_genai_model(request.model_name)
1005
  google_search = GoogleSearchActivity(message_id, web_evidence)
1006
- effective_tools = effective_tool_names(request.model_name, request.enabled_tools)
 
 
 
 
 
1007
  if "url_context" in effective_tools:
1008
  _record_evidence(web_evidence, url_context_evidence(request.query))
1009
 
@@ -1017,7 +1032,10 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
1017
  request.model_name,
1018
  enabled_tools=tuple(request.enabled_tools),
1019
  include_thoughts=include_reasoning,
1020
- kb_agents_instructions=ensure_kb_agents_instructions(),
 
 
 
1021
  )
1022
 
1023
  agent = await asyncio.to_thread(_build_agent_for_request)
 
818
  enabled_tools: tuple[str, ...] = (),
819
  include_thoughts: bool = False,
820
  kb_agents_instructions: str | None = None,
821
+ include_local_tools: bool = True,
822
  ):
823
  # kb_agents_instructions is part of the cache key on purpose: an agent
824
  # built before data/kb/AGENTS.md existed must not pin its degraded
825
  # system prompt for the process lifetime.
826
  model = build_chat_model(model_name, include_thoughts=include_thoughts)
827
+ # An explicit empty source selection turns the knowledge base off: no
828
+ # retrieval, no KB browsing, and a system prompt that says so.
829
+ tools: list[Any] = (
830
+ [retrieve_tutor_context, run_kb_command] if include_local_tools else []
831
+ )
832
  middleware: list[AgentMiddleware] = [
833
  ContextEditingMiddleware(
834
  edits=[
 
878
  model=model,
879
  tools=tools,
880
  system_prompt=build_system_prompt(
881
+ model_name,
882
+ enabled_tools,
883
+ kb_agents_instructions,
884
+ include_local_tools=include_local_tools,
885
  ),
886
  context_schema=AppContext,
887
  checkpointer=CHECKPOINTER,
 
898
  def effective_tool_names(
899
  model_name: str,
900
  enabled_tools: tuple[str, ...],
901
+ include_local_tools: bool = True,
902
  ) -> tuple[str, ...]:
903
+ names = ["retrieve_tutor_context", "run_kb_command"] if include_local_tools else []
904
  enabled = set(enabled_tools)
905
  if is_google_genai_model(model_name):
906
  if "web_search" in enabled:
 
956
  message_id: str,
957
  ) -> dict[str, Any]:
958
  provider, actual_model = model_provider_and_name(request.model_name)
959
+ tools = effective_tool_names(
960
+ request.model_name,
961
+ request.enabled_tools,
962
+ include_local_tools=bool(request.source_keys),
963
+ )
964
  source_labels = [
965
  SOURCE_KEY_TO_LABEL.get(source_key, source_key)
966
  for source_key in request.source_keys
 
1013
  # decide whether consecutive deltas need a paragraph break between them.
1014
  reasoning_deltas_are_blocks = is_google_genai_model(request.model_name)
1015
  google_search = GoogleSearchActivity(message_id, web_evidence)
1016
+ include_local_tools = bool(request.source_keys)
1017
+ effective_tools = effective_tool_names(
1018
+ request.model_name,
1019
+ request.enabled_tools,
1020
+ include_local_tools=include_local_tools,
1021
+ )
1022
  if "url_context" in effective_tools:
1023
  _record_evidence(web_evidence, url_context_evidence(request.query))
1024
 
 
1032
  request.model_name,
1033
  enabled_tools=tuple(request.enabled_tools),
1034
  include_thoughts=include_reasoning,
1035
+ kb_agents_instructions=(
1036
+ ensure_kb_agents_instructions() if include_local_tools else None
1037
+ ),
1038
+ include_local_tools=include_local_tools,
1039
  )
1040
 
1041
  agent = await asyncio.to_thread(_build_agent_for_request)
app/chat_types.py CHANGED
@@ -19,6 +19,9 @@ class SourceMatch:
19
  source_label: str
20
  score: float
21
  group: str = ""
 
 
 
22
 
23
 
24
  @dataclass(frozen=True, slots=True)
 
19
  source_label: str
20
  score: float
21
  group: str = ""
22
+ # KB-root-relative file path ("raw/docs/...") for manifest-backed matches;
23
+ # lets the client map inline `raw/...` citations to this source's real URL.
24
+ path: str = ""
25
 
26
 
27
  @dataclass(frozen=True, slots=True)
app/chroma_rag.py CHANGED
@@ -964,6 +964,20 @@ def embed_texts(
964
  return vectors
965
 
966
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
967
  def rerank_results(
968
  client: cohere.ClientV2,
969
  query: str,
@@ -977,7 +991,7 @@ def rerank_results(
977
  response = client.rerank(
978
  model=model,
979
  query=query,
980
- documents=[result.content for result in results],
981
  top_n=min(top_n, len(results)),
982
  )
983
 
 
964
  return vectors
965
 
966
 
967
+ def _rerank_document(result: SearchResult) -> str:
968
+ """Text the reranker scores for a result.
969
+
970
+ For ``retrieve_doc`` results ``content`` is the *entire* document, which
971
+ both dilutes the relevance signal toward the document's average (instead of
972
+ the chunk that actually matched) and can overflow Cohere's per-document
973
+ token limit when several full docs are reranked together. Score the matched
974
+ chunk instead; the full document is still what gets returned for the answer.
975
+ """
976
+ if result.retrieve_doc and result.chunk_content:
977
+ return result.chunk_content
978
+ return result.content
979
+
980
+
981
  def rerank_results(
982
  client: cohere.ClientV2,
983
  query: str,
 
991
  response = client.rerank(
992
  model=model,
993
  query=query,
994
+ documents=[_rerank_document(result) for result in results],
995
  top_n=min(top_n, len(results)),
996
  )
997
 
app/config.py CHANGED
@@ -11,6 +11,7 @@ from data.scraping_scripts.source_registry import (
11
  COURSE_SOURCE_KEYS,
12
  DEFAULT_SELECTED_SOURCE_KEYS,
13
  DEFAULT_SELECTED_SOURCES_UI,
 
14
  SOURCE_KEY_TO_LABEL,
15
  SOURCE_UI_TO_KEY,
16
  )
@@ -151,6 +152,7 @@ __all__ = [
151
  "KB_DIR",
152
  "KB_INDEX_PATH",
153
  "KB_MANIFEST_PATH",
 
154
  "SOURCE_KEY_TO_LABEL",
155
  "SOURCE_UI_TO_KEY",
156
  "VECTOR_COLLECTION_NAME",
 
11
  COURSE_SOURCE_KEYS,
12
  DEFAULT_SELECTED_SOURCE_KEYS,
13
  DEFAULT_SELECTED_SOURCES_UI,
14
+ SOURCE_DISPLAY_INFO,
15
  SOURCE_KEY_TO_LABEL,
16
  SOURCE_UI_TO_KEY,
17
  )
 
152
  "KB_DIR",
153
  "KB_INDEX_PATH",
154
  "KB_MANIFEST_PATH",
155
+ "SOURCE_DISPLAY_INFO",
156
  "SOURCE_KEY_TO_LABEL",
157
  "SOURCE_UI_TO_KEY",
158
  "VECTOR_COLLECTION_NAME",
app/kb_manifest.py CHANGED
@@ -1,15 +1,17 @@
1
  from __future__ import annotations
2
 
3
  import json
 
4
  import re
5
  from dataclasses import dataclass
6
- from functools import lru_cache
7
  from pathlib import Path
8
  from typing import Any
9
 
10
  from .chat_types import SourceMatch
11
  from .config import COURSE_SOURCE_KEYS, KB_DIR, SOURCE_KEY_TO_LABEL
12
 
 
 
13
  KB_DOC_SCHEME_RE = re.compile(r"^kb://doc/(?P<doc_id>[^)\]\s]+)$")
14
  RAW_PATH_RE = re.compile(r"(?:data/kb/)?raw/[^\s)\]>,:]+?\.(?:mdx|md)")
15
 
@@ -35,18 +37,57 @@ def _normalize_path(value: str) -> str:
35
  return value
36
 
37
 
38
- @lru_cache(maxsize=4)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  def load_manifest_entries(kb_dir: str = KB_DIR) -> tuple[KbManifestEntry, ...]:
 
 
 
 
40
  manifest_path = Path(kb_dir) / "generated" / "corpus_manifest.jsonl"
41
  if not manifest_path.exists():
 
 
 
 
42
  return ()
43
 
44
  entries: list[KbManifestEntry] = []
45
  with manifest_path.open("r", encoding="utf-8") as handle:
46
- for line in handle:
47
  if not line.strip():
48
  continue
49
- row = json.loads(line)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  entries.append(
51
  KbManifestEntry(
52
  doc_id=str(row.get("doc_id") or ""),
@@ -57,7 +98,9 @@ def load_manifest_entries(kb_dir: str = KB_DIR) -> tuple[KbManifestEntry, ...]:
57
  path=str(row.get("path") or ""),
58
  )
59
  )
60
- return tuple(entries)
 
 
61
 
62
 
63
  def manifest_indexes(
@@ -72,6 +115,11 @@ def manifest_indexes(
72
  by_url: dict[str, KbManifestEntry] = {}
73
  by_path: dict[str, KbManifestEntry] = {}
74
  by_title: dict[str, KbManifestEntry] = {}
 
 
 
 
 
75
  for entry in load_manifest_entries(kb_dir):
76
  if entry.doc_id:
77
  by_doc_id[entry.doc_id] = entry
@@ -82,7 +130,15 @@ def manifest_indexes(
82
  if entry.path.startswith(f"{KB_DIR}/"):
83
  by_path[entry.path[len(KB_DIR) + 1 :]] = entry
84
  if entry.title:
85
- by_title[entry.title.strip().lower()] = entry
 
 
 
 
 
 
 
 
86
  return by_doc_id, by_url, by_path, by_title
87
 
88
 
@@ -99,6 +155,7 @@ def source_match_from_manifest(
99
  group="courses"
100
  if entry.source in COURSE_SOURCE_KEYS
101
  else entry.source_group or "docs",
 
102
  )
103
 
104
 
@@ -188,6 +245,7 @@ def source_match_payload(
188
  "source_label": match.source_label,
189
  "score": match.score,
190
  "group": match.group,
 
191
  }
192
  if call_id:
193
  payload["call_id"] = call_id
 
1
  from __future__ import annotations
2
 
3
  import json
4
+ import logging
5
  import re
6
  from dataclasses import dataclass
 
7
  from pathlib import Path
8
  from typing import Any
9
 
10
  from .chat_types import SourceMatch
11
  from .config import COURSE_SOURCE_KEYS, KB_DIR, SOURCE_KEY_TO_LABEL
12
 
13
+ logger = logging.getLogger(__name__)
14
+
15
  KB_DOC_SCHEME_RE = re.compile(r"^kb://doc/(?P<doc_id>[^)\]\s]+)$")
16
  RAW_PATH_RE = re.compile(r"(?:data/kb/)?raw/[^\s)\]>,:]+?\.(?:mdx|md)")
17
 
 
37
  return value
38
 
39
 
40
+ def kb_root_path(value: str) -> str:
41
+ """KB-root-relative form of a manifest path ("raw/docs/...") — the shape
42
+ the model is instructed to cite and the client matches against."""
43
+ value = value.strip()
44
+ if value.startswith("./"):
45
+ value = value[2:]
46
+ if value.startswith(f"{KB_DIR}/"):
47
+ value = value[len(KB_DIR) + 1 :]
48
+ return value
49
+
50
+
51
+ # Cache parsed manifests per kb_dir, but only once the file actually exists, so
52
+ # a lookup during the first-start download window does not pin an empty result.
53
+ _MANIFEST_CACHE: dict[str, tuple[KbManifestEntry, ...]] = {}
54
+
55
+
56
  def load_manifest_entries(kb_dir: str = KB_DIR) -> tuple[KbManifestEntry, ...]:
57
+ cached = _MANIFEST_CACHE.get(kb_dir)
58
+ if cached is not None:
59
+ return cached
60
+
61
  manifest_path = Path(kb_dir) / "generated" / "corpus_manifest.jsonl"
62
  if not manifest_path.exists():
63
+ # Do not cache the missing-file case: the KB bundle may still be
64
+ # downloading on first start, so retry on the next call instead of
65
+ # pinning an empty manifest (and thus never resolving citations) for
66
+ # the process lifetime.
67
  return ()
68
 
69
  entries: list[KbManifestEntry] = []
70
  with manifest_path.open("r", encoding="utf-8") as handle:
71
+ for line_number, line in enumerate(handle, start=1):
72
  if not line.strip():
73
  continue
74
+ try:
75
+ row = json.loads(line)
76
+ except json.JSONDecodeError as exc:
77
+ logger.warning(
78
+ "Skipping malformed manifest line %d in %s: %s",
79
+ line_number,
80
+ manifest_path,
81
+ exc,
82
+ )
83
+ continue
84
+ if not isinstance(row, dict):
85
+ logger.warning(
86
+ "Skipping non-object manifest line %d in %s",
87
+ line_number,
88
+ manifest_path,
89
+ )
90
+ continue
91
  entries.append(
92
  KbManifestEntry(
93
  doc_id=str(row.get("doc_id") or ""),
 
98
  path=str(row.get("path") or ""),
99
  )
100
  )
101
+ result = tuple(entries)
102
+ _MANIFEST_CACHE[kb_dir] = result
103
+ return result
104
 
105
 
106
  def manifest_indexes(
 
115
  by_url: dict[str, KbManifestEntry] = {}
116
  by_path: dict[str, KbManifestEntry] = {}
117
  by_title: dict[str, KbManifestEntry] = {}
118
+ # Titles like "Introduction"/"Quickstart" recur across docs; a plain dict
119
+ # would resolve a bare-label citation to whichever doc was ingested last and
120
+ # surface a misleading source card. Track collisions and refuse to resolve
121
+ # an ambiguous title to any single doc.
122
+ ambiguous_titles: set[str] = set()
123
  for entry in load_manifest_entries(kb_dir):
124
  if entry.doc_id:
125
  by_doc_id[entry.doc_id] = entry
 
130
  if entry.path.startswith(f"{KB_DIR}/"):
131
  by_path[entry.path[len(KB_DIR) + 1 :]] = entry
132
  if entry.title:
133
+ title_key = entry.title.strip().lower()
134
+ if title_key in ambiguous_titles:
135
+ continue
136
+ existing = by_title.get(title_key)
137
+ if existing is not None and existing.doc_id != entry.doc_id:
138
+ ambiguous_titles.add(title_key)
139
+ del by_title[title_key]
140
+ else:
141
+ by_title[title_key] = entry
142
  return by_doc_id, by_url, by_path, by_title
143
 
144
 
 
155
  group="courses"
156
  if entry.source in COURSE_SOURCE_KEYS
157
  else entry.source_group or "docs",
158
+ path=kb_root_path(entry.path),
159
  )
160
 
161
 
 
245
  "source_label": match.source_label,
246
  "score": match.score,
247
  "group": match.group,
248
+ "path": match.path,
249
  }
250
  if call_id:
251
  payload["call_id"] = call_id
app/kb_shell.py CHANGED
@@ -5,8 +5,10 @@ import re
5
  import shlex
6
  import shutil
7
  import subprocess
 
8
  from dataclasses import dataclass
9
  from pathlib import Path
 
10
 
11
  DEFAULT_KB_DIR = Path(os.getenv("AI_TUTOR_KB_DIR", "data/kb"))
12
  DEFAULT_TIMEOUT_SECONDS = 8
@@ -44,6 +46,7 @@ RG_FLAG_OPTIONS = frozenset(
44
  GREP_FLAG_OPTIONS = frozenset(
45
  {"-i", "--ignore-case", "-w", "--word-regexp", "-F", "-E"}
46
  )
 
47
  LS_FLAG_OPTIONS = frozenset({"-1", "-a", "-l", "-la", "-al"})
48
  WC_FLAG_OPTIONS = frozenset({"-l", "-w", "-c", "-m"})
49
 
@@ -170,12 +173,16 @@ def _build_rg(tokens: list[str], root: Path) -> list[str]:
170
  pattern = positionals[0]
171
  paths = [_relative_path_arg(value, root) for value in positionals[1:]] or ["."]
172
  _reject_unbounded_raw_search("rg", paths, has_max_count)
 
 
 
173
  return [
174
  "rg",
175
  "--color=never",
176
  "--line-number",
177
  "--no-heading",
178
  *options,
 
179
  pattern,
180
  *paths,
181
  ]
@@ -184,6 +191,7 @@ def _build_rg(tokens: list[str], root: Path) -> list[str]:
184
  def _build_grep(tokens: list[str], root: Path) -> list[str]:
185
  options: list[str] = []
186
  positionals: list[str] = []
 
187
  idx = 1
188
  parsing_options = True
189
  while idx < len(tokens):
@@ -196,6 +204,13 @@ def _build_grep(tokens: list[str], root: Path) -> list[str]:
196
  options.append(token)
197
  idx += 1
198
  continue
 
 
 
 
 
 
 
199
  positionals.append(token)
200
  parsing_options = False
201
  idx += 1
@@ -203,7 +218,14 @@ def _build_grep(tokens: list[str], root: Path) -> list[str]:
203
  raise KbCommandError("`grep` requires a search pattern")
204
  pattern = positionals[0]
205
  paths = [_relative_path_arg(value, root) for value in positionals[1:]] or ["."]
206
- return ["grep", "-R", "-n", "--color=never", *options, pattern, *paths]
 
 
 
 
 
 
 
207
 
208
 
209
  def _build_find(tokens: list[str], root: Path) -> list[str]:
@@ -329,11 +351,46 @@ def build_kb_command_argv(
329
  return builders[executable](tokens, resolved_root), resolved_root
330
 
331
 
332
- def _truncate(text: str, max_chars: int) -> tuple[str, bool]:
333
- if len(text) <= max_chars:
334
- return text, False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  marker = f"\n... output truncated to {max_chars} characters ..."
336
- return text[: max(0, max_chars - len(marker))] + marker, True
337
 
338
 
339
  def run_kb_command(
@@ -346,6 +403,7 @@ def run_kb_command(
346
  argv, resolved_root = build_kb_command_argv(command, root=root)
347
  timeout = max(1, min(int(timeout_seconds), 30))
348
  output_limit = max(1_000, min(int(max_output_chars), 80_000))
 
349
  env = {
350
  "HOME": os.getenv("HOME", ""),
351
  "LANG": os.getenv("LANG", "C.UTF-8"),
@@ -353,30 +411,55 @@ def run_kb_command(
353
  "PATH": os.getenv("PATH", ""),
354
  }
355
  timed_out = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  try:
357
- completed = subprocess.run(
358
- argv,
359
- cwd=resolved_root,
360
- capture_output=True,
361
- text=True,
362
- timeout=timeout,
363
- env=env,
364
- check=False,
365
- )
366
- exit_code = int(completed.returncode)
367
- stdout = completed.stdout or ""
368
- stderr = completed.stderr or ""
369
- except subprocess.TimeoutExpired as exc:
370
  timed_out = True
371
  exit_code = 124
372
- stdout = exc.stdout if isinstance(exc.stdout, str) else ""
373
- stderr = exc.stderr if isinstance(exc.stderr, str) else ""
 
 
 
 
 
 
 
 
 
 
 
 
374
  stderr = (
375
  stderr + "\n" if stderr else ""
376
  ) + f"Command timed out after {timeout}s."
377
-
378
- stdout, stdout_truncated = _truncate(stdout, output_limit)
379
- stderr, stderr_truncated = _truncate(stderr, min(output_limit, 8_000))
380
  return KbCommandResult(
381
  command=command,
382
  argv=argv,
 
5
  import shlex
6
  import shutil
7
  import subprocess
8
+ import threading
9
  from dataclasses import dataclass
10
  from pathlib import Path
11
+ from typing import Any
12
 
13
  DEFAULT_KB_DIR = Path(os.getenv("AI_TUTOR_KB_DIR", "data/kb"))
14
  DEFAULT_TIMEOUT_SECONDS = 8
 
46
  GREP_FLAG_OPTIONS = frozenset(
47
  {"-i", "--ignore-case", "-w", "--word-regexp", "-F", "-E"}
48
  )
49
+ GREP_VALUE_OPTIONS = frozenset({"-m", "--max-count"})
50
  LS_FLAG_OPTIONS = frozenset({"-1", "-a", "-l", "-la", "-al"})
51
  WC_FLAG_OPTIONS = frozenset({"-l", "-w", "-c", "-m"})
52
 
 
173
  pattern = positionals[0]
174
  paths = [_relative_path_arg(value, root) for value in positionals[1:]] or ["."]
175
  _reject_unbounded_raw_search("rg", paths, has_max_count)
176
+ # `--` ends option parsing so a pattern beginning with `-`/`--` (e.g.
177
+ # `--pre=/bin/sh`, `--hostname-bin`) can never be re-interpreted by rg as a
178
+ # flag that spawns an external process or escapes the read-only jail.
179
  return [
180
  "rg",
181
  "--color=never",
182
  "--line-number",
183
  "--no-heading",
184
  *options,
185
+ "--",
186
  pattern,
187
  *paths,
188
  ]
 
191
  def _build_grep(tokens: list[str], root: Path) -> list[str]:
192
  options: list[str] = []
193
  positionals: list[str] = []
194
+ has_max_count = False
195
  idx = 1
196
  parsing_options = True
197
  while idx < len(tokens):
 
204
  options.append(token)
205
  idx += 1
206
  continue
207
+ if parsing_options and token in GREP_VALUE_OPTIONS:
208
+ if idx + 1 >= len(tokens):
209
+ raise KbCommandError(f"{token} requires a value")
210
+ options.extend([token, _numeric_value(tokens[idx + 1], token)])
211
+ has_max_count = True
212
+ idx += 2
213
+ continue
214
  positionals.append(token)
215
  parsing_options = False
216
  idx += 1
 
218
  raise KbCommandError("`grep` requires a search pattern")
219
  pattern = positionals[0]
220
  paths = [_relative_path_arg(value, root) for value in positionals[1:]] or ["."]
221
+ # Bound grep the same way as rg: a broad `raw/` recursion needs an explicit
222
+ # -m/--max-count (or a narrower path) so it can't emit unbounded output.
223
+ _reject_unbounded_raw_search("grep", paths, has_max_count)
224
+ # Lowercase `-r` follows only command-line symlinks (not symlinks discovered
225
+ # during the walk), unlike `-R`; per-file paths found during recursion are
226
+ # not re-validated by the jail, so the more permissive `-R` is avoided.
227
+ # `--` ends option parsing so a `-`/`--` pattern can't be re-read as a flag.
228
+ return ["grep", "-r", "-n", "--color=never", *options, "--", pattern, *paths]
229
 
230
 
231
  def _build_find(tokens: list[str], root: Path) -> list[str]:
 
351
  return builders[executable](tokens, resolved_root), resolved_root
352
 
353
 
354
+ def _drain_stream(stream: Any, cap: int) -> tuple[str, bool]:
355
+ """Read a child pipe to EOF but retain at most ``cap`` characters.
356
+
357
+ Reading continues past the cap (discarding the overflow) so the child never
358
+ blocks on a full pipe, but peak memory is bounded by ``cap`` instead of the
359
+ command's full output size.
360
+ """
361
+ chunks: list[str] = []
362
+ retained = 0
363
+ truncated = False
364
+ try:
365
+ while True:
366
+ data = stream.read(8192)
367
+ if not data:
368
+ break
369
+ if retained >= cap:
370
+ # Already full; this read is pure overflow we discard.
371
+ truncated = True
372
+ continue
373
+ room = cap - retained
374
+ if len(data) <= room:
375
+ chunks.append(data)
376
+ retained += len(data)
377
+ else:
378
+ chunks.append(data[:room])
379
+ retained = cap
380
+ truncated = True
381
+ finally:
382
+ try:
383
+ stream.close()
384
+ except Exception:
385
+ pass
386
+ return "".join(chunks), truncated
387
+
388
+
389
+ def _apply_truncation_marker(text: str, truncated: bool, max_chars: int) -> str:
390
+ if not truncated:
391
+ return text
392
  marker = f"\n... output truncated to {max_chars} characters ..."
393
+ return text[: max(0, max_chars - len(marker))] + marker
394
 
395
 
396
  def run_kb_command(
 
403
  argv, resolved_root = build_kb_command_argv(command, root=root)
404
  timeout = max(1, min(int(timeout_seconds), 30))
405
  output_limit = max(1_000, min(int(max_output_chars), 80_000))
406
+ stderr_limit = min(output_limit, 8_000)
407
  env = {
408
  "HOME": os.getenv("HOME", ""),
409
  "LANG": os.getenv("LANG", "C.UTF-8"),
 
411
  "PATH": os.getenv("PATH", ""),
412
  }
413
  timed_out = False
414
+ stdout_box: dict[str, Any] = {"text": "", "truncated": False}
415
+ stderr_box: dict[str, Any] = {"text": "", "truncated": False}
416
+
417
+ # Stream both pipes through capped reader threads so a command that emits a
418
+ # lot (e.g. `cat` of a large file) cannot spike memory to its full output
419
+ # size before truncation: capture_output buffers everything, this does not.
420
+ # argv is fully validated/allowlisted by build_kb_command_argv above.
421
+ process = subprocess.Popen(
422
+ argv,
423
+ cwd=resolved_root,
424
+ stdout=subprocess.PIPE,
425
+ stderr=subprocess.PIPE,
426
+ text=True,
427
+ env=env,
428
+ )
429
+
430
+ def _read(stream: Any, cap: int, box: dict[str, Any]) -> None:
431
+ box["text"], box["truncated"] = _drain_stream(stream, cap)
432
+
433
+ readers = [
434
+ threading.Thread(target=_read, args=(process.stdout, output_limit, stdout_box)),
435
+ threading.Thread(target=_read, args=(process.stderr, stderr_limit, stderr_box)),
436
+ ]
437
+ for reader in readers:
438
+ reader.start()
439
+
440
  try:
441
+ process.wait(timeout=timeout)
442
+ exit_code = int(process.returncode)
443
+ except subprocess.TimeoutExpired:
 
 
 
 
 
 
 
 
 
 
444
  timed_out = True
445
  exit_code = 124
446
+ process.kill()
447
+ process.wait()
448
+ for reader in readers:
449
+ reader.join()
450
+
451
+ stdout = _apply_truncation_marker(
452
+ stdout_box["text"], bool(stdout_box["truncated"]), output_limit
453
+ )
454
+ stdout_truncated = bool(stdout_box["truncated"])
455
+ stderr = _apply_truncation_marker(
456
+ stderr_box["text"], bool(stderr_box["truncated"]), stderr_limit
457
+ )
458
+ stderr_truncated = bool(stderr_box["truncated"])
459
+ if timed_out:
460
  stderr = (
461
  stderr + "\n" if stderr else ""
462
  ) + f"Command timed out after {timeout}s."
 
 
 
463
  return KbCommandResult(
464
  command=command,
465
  argv=argv,
app/prompts.py CHANGED
@@ -158,6 +158,24 @@ ANSWERING_RULES = """## Answering rules
158
 
159
  The retrieval tool returns JSON with matched passages and source metadata."""
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
  def _provider_key(model_name: str) -> str:
163
  normalized = (model_name or "").strip()
@@ -215,10 +233,11 @@ def build_system_prompt(
215
  model_name: str,
216
  enabled_tools: tuple[str, ...],
217
  kb_agents_instructions: str | None = None,
 
218
  ) -> str:
219
  provider = _provider_key(model_name)
220
  enabled = set(enabled_tools)
221
- tool_lines = [RETRIEVAL_TOOL_LINE, *KB_TOOL_LINES]
222
  usage_sections: list[str] = []
223
  provider_web_tools = WEB_TOOL_LINES.get(provider, {})
224
  provider_web_usage = WEB_USAGE_SECTIONS.get(provider, {})
@@ -227,26 +246,33 @@ def build_system_prompt(
227
  tool_lines.append(provider_web_tools[key])
228
  usage_sections.append(provider_web_usage[key])
229
 
230
- if len(tool_lines) == 1:
231
- intro = "You have one tool available:"
232
- else:
233
- intro = f"You have {len(tool_lines)} tools available:"
234
-
235
- parts = [
236
- BASE_PROMPT_HEADER,
237
- f"{intro}\n\n" + "\n".join(tool_lines),
238
- RETRIEVAL_USAGE_SECTION,
239
- KB_USAGE_SECTION,
240
- ]
241
  if usage_sections:
242
  parts.append(
243
  "## When to use web search / URL reading\n\n" + "\n\n".join(usage_sections)
244
  )
245
- parts.append(
246
- "Prefer `retrieve_tutor_context` first when the question is clearly about\n"
247
- "course material. Combine tools when it helps (e.g. retrieve corpus\n"
248
- "context, then search the web for the latest update)."
249
- )
 
 
 
 
 
 
 
250
  if kb_agents_instructions is None:
251
  kb_agents_instructions = load_kb_agents_instructions()
252
  if kb_agents_instructions:
 
158
 
159
  The retrieval tool returns JSON with matched passages and source metadata."""
160
 
161
+ NO_KB_NOTE = """## Knowledge base disabled
162
+
163
+ The user deselected every knowledge-base source for this conversation, so the
164
+ corpus retrieval and KB browsing tools are unavailable. Answer from general
165
+ knowledge (and the web tools above, when available). Do not claim to have
166
+ searched the course corpus; mention briefly when an answer would be stronger
167
+ with course sources enabled."""
168
+
169
+ NO_KB_ANSWERING_RULES = """## Answering rules
170
+
171
+ - When you use web tools, cite sources with inline Markdown links: place a
172
+ `[short source title](url)` citation in the same sentence, immediately
173
+ after the claim it supports.
174
+ - Synthesize a clear teaching explanation. Prefer a few solid paragraphs over
175
+ shallow bullet lists.
176
+ - Include complete, runnable code blocks when code is relevant.
177
+ - End with a short invitation for a follow-up question."""
178
+
179
 
180
  def _provider_key(model_name: str) -> str:
181
  normalized = (model_name or "").strip()
 
233
  model_name: str,
234
  enabled_tools: tuple[str, ...],
235
  kb_agents_instructions: str | None = None,
236
+ include_local_tools: bool = True,
237
  ) -> str:
238
  provider = _provider_key(model_name)
239
  enabled = set(enabled_tools)
240
+ tool_lines = [RETRIEVAL_TOOL_LINE, *KB_TOOL_LINES] if include_local_tools else []
241
  usage_sections: list[str] = []
242
  provider_web_tools = WEB_TOOL_LINES.get(provider, {})
243
  provider_web_usage = WEB_USAGE_SECTIONS.get(provider, {})
 
246
  tool_lines.append(provider_web_tools[key])
247
  usage_sections.append(provider_web_usage[key])
248
 
249
+ parts = [BASE_PROMPT_HEADER]
250
+ if tool_lines:
251
+ if len(tool_lines) == 1:
252
+ intro = "You have one tool available:"
253
+ else:
254
+ intro = f"You have {len(tool_lines)} tools available:"
255
+ parts.append(f"{intro}\n\n" + "\n".join(tool_lines))
256
+
257
+ if include_local_tools:
258
+ parts.append(RETRIEVAL_USAGE_SECTION)
259
+ parts.append(KB_USAGE_SECTION)
260
  if usage_sections:
261
  parts.append(
262
  "## When to use web search / URL reading\n\n" + "\n\n".join(usage_sections)
263
  )
264
+ if include_local_tools:
265
+ parts.append(
266
+ "Prefer `retrieve_tutor_context` first when the question is clearly about\n"
267
+ "course material. Combine tools when it helps (e.g. retrieve corpus\n"
268
+ "context, then search the web for the latest update)."
269
+ )
270
+ if not include_local_tools:
271
+ # The user deselected every source: the prompt must not describe or
272
+ # instruct tools the agent does not have this turn.
273
+ parts.append(NO_KB_NOTE)
274
+ parts.append(NO_KB_ANSWERING_RULES)
275
+ return "\n\n".join(parts) + "\n"
276
  if kb_agents_instructions is None:
277
  kb_agents_instructions = load_kb_agents_instructions()
278
  if kb_agents_instructions:
data/scraping_scripts/source_registry.py CHANGED
@@ -268,6 +268,120 @@ SOURCE_KEY_TO_LABEL = {
268
  "agentic_ai_engineering": "Agentic AI Engineering",
269
  }
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  UI_SOURCE_KEYS = (
272
  "openai_docs",
273
  "claude_code_docs",
 
268
  "agentic_ai_engineering": "Agentic AI Engineering",
269
  }
270
 
271
+ # Display metadata served to the UI via /api/tools. The frontend renders these
272
+ # verbatim (single source of truth: adding a source here means no separate UI
273
+ # edit). `ui_label` is the short sidebar name; `label` in SOURCE_KEY_TO_LABEL
274
+ # stays the full name used on source cards and in prompts. Keep descriptions
275
+ # free of em-dashes (frontend user-facing text convention).
276
+ SOURCE_DISPLAY_INFO: dict[str, dict[str, str]] = {
277
+ "transformers": {
278
+ "ui_label": "Transformers",
279
+ "description": (
280
+ "Hugging Face library for state-of-the-art NLP and multimodal "
281
+ "models. Load, run, and train pretrained transformers."
282
+ ),
283
+ "url": "https://huggingface.co/docs/transformers",
284
+ },
285
+ "peft": {
286
+ "ui_label": "PEFT",
287
+ "description": (
288
+ "Parameter-Efficient Fine-Tuning: LoRA, prefix tuning, and other "
289
+ "methods for adapting large models with minimal compute."
290
+ ),
291
+ "url": "https://huggingface.co/docs/peft",
292
+ },
293
+ "trl": {
294
+ "ui_label": "TRL",
295
+ "description": (
296
+ "Train language models with reinforcement learning. Covers SFT, "
297
+ "DPO, PPO, and other alignment techniques."
298
+ ),
299
+ "url": "https://huggingface.co/docs/trl",
300
+ },
301
+ "llama_index": {
302
+ "ui_label": "LlamaIndex",
303
+ "description": (
304
+ "Framework for building RAG apps: ingestion, indexing, "
305
+ "retrievers, and query engines over your own data."
306
+ ),
307
+ "url": "https://docs.llamaindex.ai",
308
+ },
309
+ "langchain": {
310
+ "ui_label": "LangChain",
311
+ "description": (
312
+ "Framework for building LLM apps: chains, agents, tool-calling, "
313
+ "and production observability."
314
+ ),
315
+ "url": "https://docs.langchain.com/oss/python/langchain/overview",
316
+ },
317
+ "langgraph": {
318
+ "ui_label": "LangGraph",
319
+ "description": (
320
+ "Graph-based runtime for reliable, stateful AI agents with "
321
+ "persistence, streaming, human review, and deployment patterns."
322
+ ),
323
+ "url": "https://docs.langchain.com/oss/python/langgraph/overview",
324
+ },
325
+ "deep_agents": {
326
+ "ui_label": "Deep Agents",
327
+ "description": (
328
+ "LangChain's deep agent harness for planning, delegation, "
329
+ "filesystem context, and longer-running agent workflows."
330
+ ),
331
+ "url": "https://docs.langchain.com/oss/python/deepagents/overview",
332
+ },
333
+ "openai_docs": {
334
+ "ui_label": "OpenAI",
335
+ "description": (
336
+ "Official OpenAI API, Agents SDK, and Codex documentation from "
337
+ "the developer docs Markdown index."
338
+ ),
339
+ "url": "https://developers.openai.com",
340
+ },
341
+ "claude_code_docs": {
342
+ "ui_label": "Claude Code",
343
+ "description": (
344
+ "Official Claude Code and Claude Agent SDK documentation from "
345
+ "Anthropic's Markdown index."
346
+ ),
347
+ "url": "https://code.claude.com/docs/en/overview",
348
+ },
349
+ "full_stack_ai_engineering": {
350
+ "ui_label": "Full Stack AI Engineering",
351
+ "description": (
352
+ "Full-stack LLM engineering, covering RAG, fine-tuning, "
353
+ "evaluation, and deploying production systems end-to-end. The "
354
+ "deepest technical course."
355
+ ),
356
+ "url": "https://academy.towardsai.net/courses/beginner-to-advanced-llm-dev",
357
+ },
358
+ "beginner_python_for_ai_engineering": {
359
+ "ui_label": "Beginner Python for AI Engineering",
360
+ "description": (
361
+ "Python for the LLM era: API integration, using open-source "
362
+ "models, and core training/testing workflows. Assumes no prior "
363
+ "Python."
364
+ ),
365
+ "url": "https://academy.towardsai.net/courses/python-for-genai",
366
+ },
367
+ "master_ai_for_work": {
368
+ "ui_label": "Master AI For Work",
369
+ "description": (
370
+ "Non-engineer course on using AI tools (ChatGPT, Claude, etc.) "
371
+ "for workplace productivity and rolling them out across a team."
372
+ ),
373
+ "url": "https://academy.towardsai.net/courses/ai-business-professionals",
374
+ },
375
+ "agentic_ai_engineering": {
376
+ "ui_label": "Agentic AI Engineering",
377
+ "description": (
378
+ "Designing, building, evaluating, and deploying production-grade "
379
+ "AI agents end-to-end."
380
+ ),
381
+ "url": "https://academy.towardsai.net/courses/agent-engineering",
382
+ },
383
+ }
384
+
385
  UI_SOURCE_KEYS = (
386
  "openai_docs",
387
  "claude_code_docs",
tests/test_api.py CHANGED
@@ -64,6 +64,17 @@ class ApiTestCase(unittest.TestCase):
64
  for source in retrieval["sources"]
65
  )
66
  )
 
 
 
 
 
 
 
 
 
 
 
67
  # Gemini is the default model, so web search + url reading are present.
68
  tool_keys = {tool["key"] for tool in tools}
69
  self.assertIn("web_search", tool_keys)
@@ -184,6 +195,10 @@ class ApiTestCase(unittest.TestCase):
184
  part_types = [part["type"] for part in parts]
185
 
186
  self.assertIn("data-thread", part_types)
 
 
 
 
187
  self.assertIn("start", part_types)
188
  self.assertIn("start-step", part_types)
189
  self.assertIn("reasoning-start", part_types)
@@ -499,6 +514,7 @@ class ApiTestCase(unittest.TestCase):
499
  "source_label": "PEFT Docs",
500
  "score": 0.9,
501
  "group": "docs",
 
502
  }
503
  ],
504
  },
@@ -511,6 +527,7 @@ class ApiTestCase(unittest.TestCase):
511
  self.assertEqual(matches[0]["docId"], "peft:lora")
512
  self.assertEqual(matches[0]["sourceKey"], "peft")
513
  self.assertEqual(matches[0]["score"], 0.9)
 
514
 
515
  def test_chat_rejects_oversized_query(self) -> None:
516
  from app.api import MAX_QUERY_CHARS
@@ -678,6 +695,21 @@ class ApiTestCase(unittest.TestCase):
678
 
679
  self.assertEqual(response.status_code, 422)
680
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
681
 
682
  LIVE_API_E2E = pytest.mark.skipif(
683
  os.getenv("RUN_LIVE_API_E2E") != "1",
 
64
  for source in retrieval["sources"]
65
  )
66
  )
67
+ # Display metadata is registry-owned and served per source so the
68
+ # frontend renders it verbatim (no client-side maps or label edits).
69
+ for source in retrieval["sources"]:
70
+ self.assertTrue(source["shortLabel"])
71
+ self.assertTrue(source["description"])
72
+ self.assertTrue(str(source["infoUrl"]).startswith("https://"))
73
+ transformers = next(
74
+ source for source in retrieval["sources"] if source["key"] == "transformers"
75
+ )
76
+ self.assertEqual(transformers["label"], "Transformers Docs")
77
+ self.assertEqual(transformers["shortLabel"], "Transformers")
78
  # Gemini is the default model, so web search + url reading are present.
79
  tool_keys = {tool["key"] for tool in tools}
80
  self.assertIn("web_search", tool_keys)
 
195
  part_types = [part["type"] for part in parts]
196
 
197
  self.assertIn("data-thread", part_types)
198
+ data_thread = next(part for part in parts if part["type"] == "data-thread")
199
+ # Transient: consumed via onData only; must not land in message.parts
200
+ # (it would also rely on undocumented ordering when emitted pre-start).
201
+ self.assertTrue(data_thread.get("transient"))
202
  self.assertIn("start", part_types)
203
  self.assertIn("start-step", part_types)
204
  self.assertIn("reasoning-start", part_types)
 
514
  "source_label": "PEFT Docs",
515
  "score": 0.9,
516
  "group": "docs",
517
+ "path": "raw/docs/peft/lora.md",
518
  }
519
  ],
520
  },
 
527
  self.assertEqual(matches[0]["docId"], "peft:lora")
528
  self.assertEqual(matches[0]["sourceKey"], "peft")
529
  self.assertEqual(matches[0]["score"], 0.9)
530
+ self.assertEqual(matches[0]["path"], "raw/docs/peft/lora.md")
531
 
532
  def test_chat_rejects_oversized_query(self) -> None:
533
  from app.api import MAX_QUERY_CHARS
 
695
 
696
  self.assertEqual(response.status_code, 422)
697
 
698
+ def test_explicit_empty_source_keys_disable_retrieval(self) -> None:
699
+ from app.api import ApiChatRequest, build_chat_request
700
+ from app.config import DEFAULT_SELECTED_SOURCE_KEYS
701
+
702
+ # Explicit [] is the user turning the knowledge base off; it must not
703
+ # silently coerce to the defaults (the UI shows it as "off").
704
+ explicit_empty = build_chat_request(
705
+ ApiChatRequest(query="What is RAG?", sourceKeys=[])
706
+ )
707
+ self.assertEqual(explicit_empty.source_keys, ())
708
+
709
+ # An omitted field still means "use the defaults".
710
+ omitted = build_chat_request(ApiChatRequest(query="What is RAG?"))
711
+ self.assertEqual(omitted.source_keys, tuple(DEFAULT_SELECTED_SOURCE_KEYS))
712
+
713
 
714
  LIVE_API_E2E = pytest.mark.skipif(
715
  os.getenv("RUN_LIVE_API_E2E") != "1",
tests/test_chat_service.py CHANGED
@@ -444,6 +444,42 @@ class ChatServiceTestCase(unittest.TestCase):
444
  self.assertEqual(source_matches[0].data["source_key"], "peft")
445
  self.assertNotIn("call_id", source_matches[0].data)
446
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  def test_checkpoint_history_collapses_tool_using_turn(self) -> None:
448
  # A tool-using turn checkpoints as [Human, AI(tool_calls, empty text),
449
  # ToolMessage, AI(answer)]; the visible transcript has one assistant
 
444
  self.assertEqual(source_matches[0].data["source_key"], "peft")
445
  self.assertNotIn("call_id", source_matches[0].data)
446
 
447
+ def test_stream_chat_disables_local_tools_when_no_sources_selected(self) -> None:
448
+ # An explicit empty source selection (UI "Knowledge base: off") must
449
+ # build the agent without retrieval/KB tools instead of silently
450
+ # retrieving from the defaults.
451
+ agent = FakeStreamingAgent([])
452
+ build_agent_mock = MagicMock(return_value=agent)
453
+ self.addCleanup(_drop_thread_record, "thread_no_sources")
454
+ request = ChatRequest(
455
+ query="What is RAG?",
456
+ source_keys=(),
457
+ model_name="google-genai:gemini-3.5-flash",
458
+ include_reasoning=False,
459
+ enabled_tools=(),
460
+ )
461
+
462
+ async def collect_events():
463
+ return [event async for event in stream_chat(request)]
464
+
465
+ with (
466
+ patch("app.chat_service.build_agent", build_agent_mock),
467
+ patch("app.chat_service.new_thread_id", return_value="thread_no_sources"),
468
+ ):
469
+ asyncio.run(collect_events())
470
+
471
+ build_agent_mock.assert_called_once()
472
+ self.assertFalse(build_agent_mock.call_args.kwargs["include_local_tools"])
473
+
474
+ self.assertEqual(
475
+ effective_tool_names(
476
+ "google-genai:gemini-3.5-flash",
477
+ ("web_search",),
478
+ include_local_tools=False,
479
+ ),
480
+ ("google_search",),
481
+ )
482
+
483
  def test_checkpoint_history_collapses_tool_using_turn(self) -> None:
484
  # A tool-using turn checkpoints as [Human, AI(tool_calls, empty text),
485
  # ToolMessage, AI(answer)]; the visible transcript has one assistant
tests/test_chroma_rag.py CHANGED
@@ -17,6 +17,7 @@ from app.chroma_rag import (
17
  heading_aware_markdown_chunks,
18
  reciprocal_rank_fusion,
19
  load_bm25_index,
 
20
  SearchResult,
21
  )
22
 
@@ -170,6 +171,74 @@ After the example.
170
  self.assertIn("Heading path: Guide > Setup", setup_record.text)
171
  self.assertIn("Context: Situated", setup_record.text)
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  def test_rrf_prefers_overlap_across_ranked_lists(self) -> None:
174
  dense_only = self._result("dense-only", 0.9, "dense")
175
  overlap_dense = self._result("overlap", 0.7, "dense")
 
17
  heading_aware_markdown_chunks,
18
  reciprocal_rank_fusion,
19
  load_bm25_index,
20
+ rerank_results,
21
  SearchResult,
22
  )
23
 
 
171
  self.assertIn("Heading path: Guide > Setup", setup_record.text)
172
  self.assertIn("Context: Situated", setup_record.text)
173
 
174
+ def test_rerank_scores_matched_chunk_for_retrieve_doc_results(self) -> None:
175
+ # retrieve_doc results carry the whole document in `content`; the
176
+ # reranker must score the matched chunk (`chunk_content`) instead, so
177
+ # relevance is not diluted toward the doc average and the payload stays
178
+ # within Cohere's per-document token limit.
179
+ full_doc = "Intro paragraph.\n" * 500
180
+ results = [
181
+ SearchResult(
182
+ chunk_id="doc-chunk",
183
+ doc_id="doc-1",
184
+ title="Doc",
185
+ url="",
186
+ source="test",
187
+ retrieve_doc=True,
188
+ tokens=4000,
189
+ score=0.5,
190
+ content=full_doc,
191
+ chunk_content="the matched chunk about AutoModel",
192
+ heading_path="section",
193
+ retrieval_method="dense",
194
+ ),
195
+ SearchResult(
196
+ chunk_id="plain-chunk",
197
+ doc_id="doc-2",
198
+ title="Plain",
199
+ url="",
200
+ source="test",
201
+ retrieve_doc=False,
202
+ tokens=100,
203
+ score=0.4,
204
+ content="formatted chunk body",
205
+ chunk_content="raw chunk body",
206
+ heading_path="section",
207
+ retrieval_method="dense",
208
+ ),
209
+ ]
210
+
211
+ captured: dict[str, list[str]] = {}
212
+
213
+ class _FakeItem:
214
+ def __init__(self, index: int, score: float) -> None:
215
+ self.index = index
216
+ self.relevance_score = score
217
+
218
+ class _FakeResponse:
219
+ def __init__(self, items: list["_FakeItem"]) -> None:
220
+ self.results = items
221
+
222
+ class _FakeCohere:
223
+ def rerank(self, *, model, query, documents, top_n): # type: ignore[no-untyped-def]
224
+ captured["documents"] = list(documents)
225
+ return _FakeResponse(
226
+ [
227
+ _FakeItem(i, 1.0 - i * 0.1)
228
+ for i in range(min(top_n, len(documents)))
229
+ ]
230
+ )
231
+
232
+ reranked = rerank_results(_FakeCohere(), "AutoModel", results)
233
+
234
+ # The full document never reaches the reranker; the matched chunk does.
235
+ self.assertEqual(
236
+ captured["documents"],
237
+ ["the matched chunk about AutoModel", "formatted chunk body"],
238
+ )
239
+ # The returned result still carries the full document for the answer.
240
+ self.assertEqual(reranked[0].content, full_doc)
241
+
242
  def test_rrf_prefers_overlap_across_ranked_lists(self) -> None:
243
  dense_only = self._result("dense-only", 0.9, "dense")
244
  overlap_dense = self._result("overlap", 0.7, "dense")
tests/test_kb_manifest.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import unittest
3
+ from pathlib import Path
4
+ from tempfile import TemporaryDirectory
5
+
6
+ from app.chat_types import SourceMatch
7
+ from app.kb_manifest import (
8
+ kb_root_path,
9
+ load_manifest_entries,
10
+ resolve_manifest_reference,
11
+ source_match_payload,
12
+ )
13
+
14
+
15
+ def _write_manifest(kb_dir: Path, rows: list[dict]) -> None:
16
+ generated = kb_dir / "generated"
17
+ generated.mkdir(parents=True, exist_ok=True)
18
+ lines = [json.dumps(row) for row in rows]
19
+ (generated / "corpus_manifest.jsonl").write_text("\n".join(lines))
20
+
21
+
22
+ class KbRootPathTestCase(unittest.TestCase):
23
+ def test_strips_kb_dir_prefix(self) -> None:
24
+ self.assertEqual(
25
+ kb_root_path("data/kb/raw/docs/peft/lora.md"),
26
+ "raw/docs/peft/lora.md",
27
+ )
28
+
29
+ def test_strips_leading_dot_slash(self) -> None:
30
+ self.assertEqual(
31
+ kb_root_path("./raw/docs/peft/lora.md"),
32
+ "raw/docs/peft/lora.md",
33
+ )
34
+
35
+ def test_keeps_root_relative_path(self) -> None:
36
+ self.assertEqual(
37
+ kb_root_path("raw/docs/peft/lora.md"),
38
+ "raw/docs/peft/lora.md",
39
+ )
40
+
41
+
42
+ class ManifestPathResolutionTestCase(unittest.TestCase):
43
+ def test_manifest_match_carries_kb_root_path(self) -> None:
44
+ # The client maps inline `raw/...` citations to the resolved URL via
45
+ # this path, so it must be KB-root-relative regardless of how the
46
+ # manifest spells it.
47
+ with TemporaryDirectory() as tmp:
48
+ kb_dir = Path(tmp)
49
+ _write_manifest(
50
+ kb_dir,
51
+ [
52
+ {
53
+ "doc_id": "peft:lora",
54
+ "title": "LoRA",
55
+ "url": "https://example.com/lora",
56
+ "source": "peft",
57
+ "source_group": "docs",
58
+ "path": "data/kb/raw/docs/peft/lora.md",
59
+ }
60
+ ],
61
+ )
62
+
63
+ match = resolve_manifest_reference(
64
+ "raw/docs/peft/lora.md", kb_dir=str(kb_dir)
65
+ )
66
+
67
+ self.assertIsNotNone(match)
68
+ assert match is not None
69
+ self.assertEqual(match.url, "https://example.com/lora")
70
+ self.assertEqual(match.path, "raw/docs/peft/lora.md")
71
+
72
+ by_doc_scheme = resolve_manifest_reference(
73
+ "kb://doc/peft:lora", kb_dir=str(kb_dir)
74
+ )
75
+ self.assertIsNotNone(by_doc_scheme)
76
+ assert by_doc_scheme is not None
77
+ self.assertEqual(by_doc_scheme.path, "raw/docs/peft/lora.md")
78
+
79
+
80
+ class AmbiguousTitleResolutionTestCase(unittest.TestCase):
81
+ def test_duplicate_title_does_not_resolve_to_arbitrary_doc(self) -> None:
82
+ # Two distinct docs share the title "Introduction"; a bare-label
83
+ # citation must not silently resolve to whichever was ingested last.
84
+ with TemporaryDirectory() as tmp:
85
+ kb_dir = Path(tmp)
86
+ _write_manifest(
87
+ kb_dir,
88
+ [
89
+ {
90
+ "doc_id": "peft:intro",
91
+ "title": "Introduction",
92
+ "url": "https://example.com/peft/intro",
93
+ "source": "peft",
94
+ "source_group": "docs",
95
+ "path": "data/kb/raw/docs/peft/intro.md",
96
+ },
97
+ {
98
+ "doc_id": "trl:intro",
99
+ "title": "Introduction",
100
+ "url": "https://example.com/trl/intro",
101
+ "source": "trl",
102
+ "source_group": "docs",
103
+ "path": "data/kb/raw/docs/trl/intro.md",
104
+ },
105
+ {
106
+ "doc_id": "peft:lora",
107
+ "title": "LoRA",
108
+ "url": "https://example.com/peft/lora",
109
+ "source": "peft",
110
+ "source_group": "docs",
111
+ "path": "data/kb/raw/docs/peft/lora.md",
112
+ },
113
+ ],
114
+ )
115
+
116
+ self.assertIsNone(
117
+ resolve_manifest_reference(
118
+ "Introduction", label="Introduction", kb_dir=str(kb_dir)
119
+ )
120
+ )
121
+
122
+ # A unique title still resolves by label.
123
+ lora = resolve_manifest_reference("LoRA", label="LoRA", kb_dir=str(kb_dir))
124
+ self.assertIsNotNone(lora)
125
+ assert lora is not None
126
+ self.assertEqual(lora.doc_id, "peft:lora")
127
+
128
+ # Both same-title docs remain resolvable by their unambiguous keys.
129
+ by_url = resolve_manifest_reference(
130
+ "https://example.com/trl/intro", kb_dir=str(kb_dir)
131
+ )
132
+ self.assertIsNotNone(by_url)
133
+ assert by_url is not None
134
+ self.assertEqual(by_url.doc_id, "trl:intro")
135
+
136
+
137
+ class ManifestLoaderTestCase(unittest.TestCase):
138
+ def test_missing_manifest_is_not_cached_empty(self) -> None:
139
+ # A lookup before the first-start bundle download must not pin an empty
140
+ # manifest for the process lifetime; once the file appears it loads.
141
+ with TemporaryDirectory() as tmp:
142
+ kb_dir = Path(tmp)
143
+ self.assertEqual(load_manifest_entries(str(kb_dir)), ())
144
+
145
+ _write_manifest(
146
+ kb_dir,
147
+ [
148
+ {
149
+ "doc_id": "peft:lora",
150
+ "title": "LoRA",
151
+ "url": "https://example.com/lora",
152
+ "source": "peft",
153
+ "source_group": "docs",
154
+ "path": "data/kb/raw/docs/peft/lora.md",
155
+ }
156
+ ],
157
+ )
158
+
159
+ entries = load_manifest_entries(str(kb_dir))
160
+ self.assertEqual([entry.doc_id for entry in entries], ["peft:lora"])
161
+
162
+ def test_malformed_line_is_skipped_not_fatal(self) -> None:
163
+ with TemporaryDirectory() as tmp:
164
+ kb_dir = Path(tmp)
165
+ generated = kb_dir / "generated"
166
+ generated.mkdir(parents=True, exist_ok=True)
167
+ good_a = json.dumps(
168
+ {
169
+ "doc_id": "peft:lora",
170
+ "title": "LoRA",
171
+ "url": "https://example.com/lora",
172
+ "source": "peft",
173
+ "source_group": "docs",
174
+ "path": "data/kb/raw/docs/peft/lora.md",
175
+ }
176
+ )
177
+ good_b = json.dumps(
178
+ {
179
+ "doc_id": "trl:intro",
180
+ "title": "Intro",
181
+ "url": "https://example.com/intro",
182
+ "source": "trl",
183
+ "source_group": "docs",
184
+ "path": "data/kb/raw/docs/trl/intro.md",
185
+ }
186
+ )
187
+ (generated / "corpus_manifest.jsonl").write_text(
188
+ f"{good_a}\n{{not valid json\n[1, 2, 3]\n{good_b}\n"
189
+ )
190
+
191
+ entries = load_manifest_entries(str(kb_dir))
192
+ self.assertEqual(
193
+ [entry.doc_id for entry in entries], ["peft:lora", "trl:intro"]
194
+ )
195
+
196
+
197
+ class SourceMatchPayloadTestCase(unittest.TestCase):
198
+ def test_payload_includes_path(self) -> None:
199
+ match = SourceMatch(
200
+ doc_id="peft:lora",
201
+ title="LoRA",
202
+ url="https://example.com/lora",
203
+ source_key="peft",
204
+ source_label="PEFT Docs",
205
+ score=1.0,
206
+ group="docs",
207
+ path="raw/docs/peft/lora.md",
208
+ )
209
+
210
+ payload = source_match_payload(match, message_id="m1")
211
+
212
+ self.assertEqual(payload["path"], "raw/docs/peft/lora.md")
213
+ self.assertNotIn("call_id", payload)
214
+
215
+
216
+ if __name__ == "__main__":
217
+ unittest.main()
tests/test_kb_shell.py CHANGED
@@ -8,7 +8,12 @@ import pytest
8
 
9
  from data.scraping_scripts.build_kb_artifacts import build_kb_artifacts
10
  from data.scraping_scripts.update_kb_wiki import update_kb_wiki
11
- from app.kb_shell import KbCommandError, format_command_payload, run_kb_command
 
 
 
 
 
12
 
13
 
14
  def write_jsonl(path: Path, rows: list[dict]) -> None:
@@ -82,6 +87,20 @@ def test_run_kb_command_can_read_all_raw_sources(kb_dir: Path) -> None:
82
  assert "agentic_ai_engineering" in result.stdout
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  def test_run_kb_command_rejects_shell_chaining(kb_dir: Path) -> None:
86
  with pytest.raises(KbCommandError):
87
  run_kb_command("rg LoraConfig | head", root=kb_dir)
@@ -92,6 +111,26 @@ def test_run_kb_command_rejects_path_traversal(kb_dir: Path) -> None:
92
  run_kb_command("cat ../outside.md", root=kb_dir)
93
 
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  def test_run_kb_command_rejects_unbounded_broad_raw_search(kb_dir: Path) -> None:
96
  with pytest.raises(KbCommandError, match="requires -m"):
97
  run_kb_command("rg LoraConfig raw", root=kb_dir)
@@ -100,3 +139,19 @@ def test_run_kb_command_rejects_unbounded_broad_raw_search(kb_dir: Path) -> None
100
 
101
  assert result.exit_code == 0
102
  assert "LoraConfig" in result.stdout
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  from data.scraping_scripts.build_kb_artifacts import build_kb_artifacts
10
  from data.scraping_scripts.update_kb_wiki import update_kb_wiki
11
+ from app.kb_shell import (
12
+ KbCommandError,
13
+ build_kb_command_argv,
14
+ format_command_payload,
15
+ run_kb_command,
16
+ )
17
 
18
 
19
  def write_jsonl(path: Path, rows: list[dict]) -> None:
 
87
  assert "agentic_ai_engineering" in result.stdout
88
 
89
 
90
+ def test_run_kb_command_caps_large_output_without_buffering_all(kb_dir: Path) -> None:
91
+ # A command that emits far more than the cap must be truncated to the cap;
92
+ # the streaming reader retains at most `output_limit` chars regardless of how
93
+ # much the child writes, so peak memory is bounded by the cap, not the file.
94
+ big = kb_dir / "raw" / "big.txt"
95
+ big.write_text("A" * 200_000)
96
+
97
+ result = run_kb_command("cat raw/big.txt", root=kb_dir, max_output_chars=1000)
98
+
99
+ assert result.truncated
100
+ assert len(result.stdout) <= 1000
101
+ assert "output truncated" in result.stdout
102
+
103
+
104
  def test_run_kb_command_rejects_shell_chaining(kb_dir: Path) -> None:
105
  with pytest.raises(KbCommandError):
106
  run_kb_command("rg LoraConfig | head", root=kb_dir)
 
111
  run_kb_command("cat ../outside.md", root=kb_dir)
112
 
113
 
114
+ def test_rg_pattern_is_separated_by_double_dash(kb_dir: Path) -> None:
115
+ # A pattern beginning with `-`/`--` (e.g. ripgrep's `--pre`, which spawns an
116
+ # external preprocessor binary) must be forced into the positional pattern
117
+ # slot by a `--` separator so it can never be re-parsed by rg as a flag.
118
+ argv, _ = build_kb_command_argv("rg --pre=/bin/sh raw/docs/peft", root=kb_dir)
119
+ assert "--" in argv
120
+ sep = argv.index("--")
121
+ assert argv[sep + 1 :] == ["--pre=/bin/sh", "raw/docs/peft"]
122
+ # The dangerous token never precedes the separator (i.e. is never an option).
123
+ assert "--pre=/bin/sh" not in argv[:sep]
124
+
125
+
126
+ def test_grep_pattern_is_separated_by_double_dash(kb_dir: Path) -> None:
127
+ argv, _ = build_kb_command_argv("grep --label=foo raw/docs/peft", root=kb_dir)
128
+ assert "--" in argv
129
+ sep = argv.index("--")
130
+ assert argv[sep + 1 :] == ["--label=foo", "raw/docs/peft"]
131
+ assert "--label=foo" not in argv[:sep]
132
+
133
+
134
  def test_run_kb_command_rejects_unbounded_broad_raw_search(kb_dir: Path) -> None:
135
  with pytest.raises(KbCommandError, match="requires -m"):
136
  run_kb_command("rg LoraConfig raw", root=kb_dir)
 
139
 
140
  assert result.exit_code == 0
141
  assert "LoraConfig" in result.stdout
142
+
143
+
144
+ def test_grep_is_bounded_like_rg_and_does_not_follow_symlinks(kb_dir: Path) -> None:
145
+ # grep must reject an unbounded broad raw recursion, mirroring rg.
146
+ with pytest.raises(KbCommandError, match="requires -m"):
147
+ run_kb_command("grep LoraConfig raw", root=kb_dir)
148
+
149
+ # An explicit -m/--max-count satisfies the bound and works end-to-end.
150
+ argv, _ = build_kb_command_argv("grep -m 20 LoraConfig raw", root=kb_dir)
151
+ assert argv[:4] == ["grep", "-r", "-n", "--color=never"]
152
+ assert "-R" not in argv # never the symlink-following recursion mode
153
+ assert "-m" in argv
154
+
155
+ result = run_kb_command("grep -m 20 LoraConfig raw/docs/peft", root=kb_dir)
156
+ assert result.exit_code == 0
157
+ assert "LoraConfig" in result.stdout
tests/test_prompts.py CHANGED
@@ -48,6 +48,37 @@ def test_load_kb_agents_instructions_returns_empty_for_missing_path(
48
  assert load_kb_agents_instructions(tmp_path / "missing.md") == ""
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  def test_build_system_prompt_uses_explicit_instructions_without_file_read(
52
  tmp_path: Path,
53
  monkeypatch,
 
48
  assert load_kb_agents_instructions(tmp_path / "missing.md") == ""
49
 
50
 
51
+ def test_build_system_prompt_without_local_tools_describes_none_of_them(
52
+ tmp_path: Path,
53
+ monkeypatch,
54
+ ) -> None:
55
+ # Explicit empty source selection: the prompt must not describe or
56
+ # instruct retrieval/KB tools the agent does not have this turn.
57
+ agents_path = tmp_path / "AGENTS.md"
58
+ agents_path.write_text("# From disk\n", encoding="utf-8")
59
+ monkeypatch.setenv("AI_TUTOR_KB_AGENTS_PATH", str(agents_path))
60
+
61
+ prompt = build_system_prompt(
62
+ "google-genai:gemini-3.5-flash",
63
+ ("web_search",),
64
+ include_local_tools=False,
65
+ )
66
+
67
+ assert "retrieve_tutor_context" not in prompt
68
+ assert "run_kb_command" not in prompt
69
+ assert "## Local KB Instructions" not in prompt
70
+ assert "## Knowledge base disabled" in prompt
71
+ assert "google_search" in prompt
72
+
73
+ no_tools = build_system_prompt(
74
+ "google-genai:gemini-3.5-flash",
75
+ (),
76
+ include_local_tools=False,
77
+ )
78
+ assert "tools available" not in no_tools
79
+ assert "one tool available" not in no_tools
80
+
81
+
82
  def test_build_system_prompt_uses_explicit_instructions_without_file_read(
83
  tmp_path: Path,
84
  monkeypatch,