omarsol commited on
Commit
bc9b417
·
1 Parent(s): c205736

Enhance chat service and configuration management

Browse files

- Introduced URL extraction functionality to deduplicate and trim URLs from user queries, improving evidence handling for web citations.
- Updated the build_agent function to incorporate knowledge base agent instructions into the system prompt, ensuring proper cache management.
- Enhanced the ensure_kb_agents_md function for atomic file writing, preventing race conditions during concurrent access.
- Improved local vector database verification to ensure completeness before proceeding with operations, reducing potential errors.
- Added comprehensive tests for new functionalities, including URL extraction and knowledge base instructions handling, ensuring robustness and reliability.

app/chat_service.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  import json
4
  import logging
5
  import os
 
6
  import shlex
7
  import time
8
  from dataclasses import dataclass
@@ -44,7 +45,7 @@ from .kb_manifest import (
44
  source_match_key,
45
  source_match_payload,
46
  )
47
- from .prompts import build_system_prompt
48
  from .provider_events import (
49
  GoogleSearchActivity,
50
  extract_anthropic_source_matches,
@@ -813,7 +814,11 @@ def build_agent(
813
  model_name: str,
814
  enabled_tools: tuple[str, ...] = (),
815
  include_thoughts: bool = False,
 
816
  ):
 
 
 
817
  model = build_chat_model(model_name, include_thoughts=include_thoughts)
818
  tools: list[Any] = [
819
  retrieve_tutor_context,
@@ -867,7 +872,9 @@ def build_agent(
867
  return create_agent(
868
  model=model,
869
  tools=tools,
870
- system_prompt=build_system_prompt(model_name, enabled_tools),
 
 
871
  context_schema=AppContext,
872
  checkpointer=CHECKPOINTER,
873
  middleware=middleware,
@@ -899,6 +906,41 @@ def effective_tool_names(
899
  return tuple(names)
900
 
901
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
902
  def agent_run_config(
903
  request: ChatRequest,
904
  active_thread_id: str,
@@ -958,12 +1000,16 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
958
  # decide whether consecutive deltas need a paragraph break between them.
959
  reasoning_deltas_are_blocks = is_google_genai_model(request.model_name)
960
  google_search = GoogleSearchActivity(message_id, web_evidence)
 
 
 
961
 
962
  logger.info("Running query: %s", request.query)
963
  agent = build_agent(
964
  request.model_name,
965
  enabled_tools=tuple(request.enabled_tools),
966
  include_thoughts=include_reasoning,
 
967
  )
968
  _evict_idle_threads()
969
  active_thread_id, fork_checkpoint_id = sync_thread_with_history(
@@ -1200,6 +1246,11 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
1200
  retrieval_evidence=retrieval_evidence,
1201
  shell_evidence=shell_evidence,
1202
  web_evidence=web_evidence,
 
 
 
 
 
1203
  )
1204
  )
1205
 
 
3
  import json
4
  import logging
5
  import os
6
+ import re
7
  import shlex
8
  import time
9
  from dataclasses import dataclass
 
45
  source_match_key,
46
  source_match_payload,
47
  )
48
+ from .prompts import build_system_prompt, ensure_kb_agents_instructions
49
  from .provider_events import (
50
  GoogleSearchActivity,
51
  extract_anthropic_source_matches,
 
814
  model_name: str,
815
  enabled_tools: tuple[str, ...] = (),
816
  include_thoughts: bool = False,
817
+ kb_agents_instructions: str | None = None,
818
  ):
819
+ # kb_agents_instructions is part of the cache key on purpose: an agent
820
+ # built before data/kb/AGENTS.md existed must not pin its degraded
821
+ # system prompt for the process lifetime.
822
  model = build_chat_model(model_name, include_thoughts=include_thoughts)
823
  tools: list[Any] = [
824
  retrieve_tutor_context,
 
872
  return create_agent(
873
  model=model,
874
  tools=tools,
875
+ system_prompt=build_system_prompt(
876
+ model_name, enabled_tools, kb_agents_instructions
877
+ ),
878
  context_schema=AppContext,
879
  checkpointer=CHECKPOINTER,
880
  middleware=middleware,
 
906
  return tuple(names)
907
 
908
 
909
+ _QUERY_URL_PATTERN = re.compile(r"https?://[^\s<>()\[\]\"']+")
910
+
911
+
912
+ def extract_query_urls(text: str) -> list[str]:
913
+ """Pull http(s) URLs out of a user query, in order, deduped."""
914
+ urls: list[str] = []
915
+ for raw_url in _QUERY_URL_PATTERN.findall(text):
916
+ url = raw_url.rstrip(".,;:!?")
917
+ if url and url not in urls:
918
+ urls.append(url)
919
+ return urls
920
+
921
+
922
+ def url_context_evidence(query: str) -> list[SourceMatch]:
923
+ """Evidence for URLs the user pasted when `url_context` is enabled.
924
+
925
+ langchain-google-genai (4.2.4) never surfaces Gemini's
926
+ `url_context_metadata`, so fetches are invisible to the stream. Treating
927
+ pasted URLs as web evidence at least lets the model's citation of a
928
+ fetched page resolve to a source card instead of being dropped.
929
+ """
930
+ return [
931
+ SourceMatch(
932
+ doc_id=f"url_context::{url}",
933
+ title=url,
934
+ url=url,
935
+ source_key="url_context",
936
+ source_label="Web page",
937
+ score=1.0,
938
+ group="web",
939
+ )
940
+ for url in extract_query_urls(query)
941
+ ]
942
+
943
+
944
  def agent_run_config(
945
  request: ChatRequest,
946
  active_thread_id: str,
 
1000
  # decide whether consecutive deltas need a paragraph break between them.
1001
  reasoning_deltas_are_blocks = is_google_genai_model(request.model_name)
1002
  google_search = GoogleSearchActivity(message_id, web_evidence)
1003
+ effective_tools = effective_tool_names(request.model_name, request.enabled_tools)
1004
+ if "url_context" in effective_tools:
1005
+ _record_evidence(web_evidence, url_context_evidence(request.query))
1006
 
1007
  logger.info("Running query: %s", request.query)
1008
  agent = build_agent(
1009
  request.model_name,
1010
  enabled_tools=tuple(request.enabled_tools),
1011
  include_thoughts=include_reasoning,
1012
+ kb_agents_instructions=ensure_kb_agents_instructions(),
1013
  )
1014
  _evict_idle_threads()
1015
  active_thread_id, fork_checkpoint_id = sync_thread_with_history(
 
1246
  retrieval_evidence=retrieval_evidence,
1247
  shell_evidence=shell_evidence,
1248
  web_evidence=web_evidence,
1249
+ # url_context is the only tool whose fetches are invisible to the
1250
+ # stream (the other web tools report results into web_evidence),
1251
+ # so only then is a cited-but-unmatched URL plausibly a fetched
1252
+ # page rather than a memory link: keep it as a low-trust Web chip.
1253
+ keep_unresolved_sources="url_context" in effective_tools,
1254
  )
1255
  )
1256
 
app/config.py CHANGED
@@ -1,6 +1,7 @@
1
  import logging
2
  import os
3
  from pathlib import Path
 
4
 
5
  from dotenv import load_dotenv
6
 
@@ -44,6 +45,7 @@ VECTOR_DB_DIR = "data/chroma-db-all_sources"
44
  VECTOR_COLLECTION_NAME = "chroma-db-all_sources"
45
  DOCUMENT_DICT_PATH = f"{VECTOR_DB_DIR}/document_dict_all_sources.pkl"
46
  BM25_INDEX_PATH = f"{VECTOR_DB_DIR}/bm25_index_all_sources.pkl"
 
47
  KB_DIR = "data/kb"
48
  KB_MANIFEST_PATH = f"{KB_DIR}/generated/corpus_manifest.jsonl"
49
  KB_INDEX_PATH = f"{KB_DIR}/wiki/index.md"
@@ -58,42 +60,80 @@ AVAILABLE_MODELS: tuple[dict[str, str], ...] = (
58
  )
59
 
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  def ensure_kb_agents_md() -> None:
62
- """Overwrite data/kb/AGENTS.md from the in-git template on every startup."""
 
 
 
 
63
  template_path = Path(KB_AGENTS_TEMPLATE_PATH)
64
  if not template_path.exists():
65
  return
 
66
  target = Path(KB_AGENTS_PATH)
67
- target.parent.mkdir(parents=True, exist_ok=True)
68
- target.write_text(template_path.read_text(encoding="utf-8"), encoding="utf-8")
 
 
 
 
 
 
 
 
69
 
70
 
71
  def ensure_local_vector_db() -> None:
72
- needs_download = not (
73
- os.path.exists(VECTOR_DB_DIR)
74
- and os.path.exists(DOCUMENT_DICT_PATH)
75
- and os.path.exists(KB_MANIFEST_PATH)
76
- and os.path.exists(KB_INDEX_PATH)
77
- )
78
- if needs_download:
79
- logger.warning(
80
- "Vector database does not exist locally, downloading from Hugging Face"
81
- )
82
- from huggingface_hub import snapshot_download
83
-
84
- # Mute httpx's per-file flood during the cold-start download only.
85
- httpx_logger = logging.getLogger("httpx")
86
- previous_level = httpx_logger.level
87
- httpx_logger.setLevel(logging.WARNING)
88
- try:
89
- snapshot_download(
90
- repo_id="towardsai-tutors/ai-tutor-vector-db",
91
- local_dir="data",
92
- repo_type="dataset",
93
  )
94
- finally:
95
- httpx_logger.setLevel(previous_level)
96
- ensure_kb_agents_md()
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
 
99
  __all__ = [
 
1
  import logging
2
  import os
3
  from pathlib import Path
4
+ from threading import Lock
5
 
6
  from dotenv import load_dotenv
7
 
 
45
  VECTOR_COLLECTION_NAME = "chroma-db-all_sources"
46
  DOCUMENT_DICT_PATH = f"{VECTOR_DB_DIR}/document_dict_all_sources.pkl"
47
  BM25_INDEX_PATH = f"{VECTOR_DB_DIR}/bm25_index_all_sources.pkl"
48
+ CHROMA_SQLITE_PATH = f"{VECTOR_DB_DIR}/chroma.sqlite3"
49
  KB_DIR = "data/kb"
50
  KB_MANIFEST_PATH = f"{KB_DIR}/generated/corpus_manifest.jsonl"
51
  KB_INDEX_PATH = f"{KB_DIR}/wiki/index.md"
 
60
  )
61
 
62
 
63
+ _BUNDLE_LOCK = Lock()
64
+ _BUNDLE_READY = False
65
+ _KB_AGENTS_WRITE_LOCK = Lock()
66
+
67
+
68
+ def _bundle_complete() -> bool:
69
+ required = (
70
+ VECTOR_DB_DIR,
71
+ CHROMA_SQLITE_PATH,
72
+ DOCUMENT_DICT_PATH,
73
+ BM25_INDEX_PATH,
74
+ KB_MANIFEST_PATH,
75
+ KB_INDEX_PATH,
76
+ )
77
+ return all(os.path.exists(path) for path in required)
78
+
79
+
80
  def ensure_kb_agents_md() -> None:
81
+ """Write data/kb/AGENTS.md from the in-git template.
82
+
83
+ Atomic (temp file + os.replace, so a concurrent prompt build never reads
84
+ a truncated file) and skipped when the content already matches.
85
+ """
86
  template_path = Path(KB_AGENTS_TEMPLATE_PATH)
87
  if not template_path.exists():
88
  return
89
+ content = template_path.read_text(encoding="utf-8")
90
  target = Path(KB_AGENTS_PATH)
91
+ with _KB_AGENTS_WRITE_LOCK:
92
+ try:
93
+ if target.read_text(encoding="utf-8") == content:
94
+ return
95
+ except OSError:
96
+ pass
97
+ target.parent.mkdir(parents=True, exist_ok=True)
98
+ tmp_path = target.with_name(target.name + ".tmp")
99
+ tmp_path.write_text(content, encoding="utf-8")
100
+ os.replace(tmp_path, target)
101
 
102
 
103
  def ensure_local_vector_db() -> None:
104
+ """Make sure the vector-db/KB bundle and AGENTS.md exist locally.
105
+
106
+ A flag check once the bundle has been verified, so per-tool-call use is
107
+ effectively free; the verification and download run single-flight under
108
+ a lock so parallel first-turn tool calls cannot race two downloads.
109
+ """
110
+ global _BUNDLE_READY
111
+ if _BUNDLE_READY:
112
+ return
113
+ with _BUNDLE_LOCK:
114
+ if _BUNDLE_READY:
115
+ return
116
+ if not _bundle_complete():
117
+ logger.warning(
118
+ "Vector database missing or incomplete locally, downloading "
119
+ "from Hugging Face"
 
 
 
 
 
120
  )
121
+ from huggingface_hub import snapshot_download
122
+
123
+ # Mute httpx's per-file flood during the cold-start download only.
124
+ httpx_logger = logging.getLogger("httpx")
125
+ previous_level = httpx_logger.level
126
+ httpx_logger.setLevel(logging.WARNING)
127
+ try:
128
+ snapshot_download(
129
+ repo_id="towardsai-tutors/ai-tutor-vector-db",
130
+ local_dir="data",
131
+ repo_type="dataset",
132
+ )
133
+ finally:
134
+ httpx_logger.setLevel(previous_level)
135
+ ensure_kb_agents_md()
136
+ _BUNDLE_READY = _bundle_complete() and os.path.exists(KB_AGENTS_PATH)
137
 
138
 
139
  __all__ = [
app/prompts.py CHANGED
@@ -1,8 +1,11 @@
1
  from __future__ import annotations
2
 
 
3
  import os
4
  from pathlib import Path
5
 
 
 
6
 
7
  BASE_PROMPT_HEADER = """You are an AI teacher for applied AI, LLM, RAG, and Python topics.
8
 
@@ -183,7 +186,36 @@ def load_kb_agents_instructions(path: Path | None = None) -> str:
183
  return ""
184
 
185
 
186
- def build_system_prompt(model_name: str, enabled_tools: tuple[str, ...]) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  provider = _provider_key(model_name)
188
  enabled = set(enabled_tools)
189
  tool_lines = [RETRIEVAL_TOOL_LINE, *KB_TOOL_LINES]
@@ -215,7 +247,8 @@ def build_system_prompt(model_name: str, enabled_tools: tuple[str, ...]) -> str:
215
  "course material. Combine tools when it helps (e.g. retrieve corpus\n"
216
  "context, then search the web for the latest update)."
217
  )
218
- kb_agents_instructions = load_kb_agents_instructions()
 
219
  if kb_agents_instructions:
220
  parts.append(
221
  "## Local KB Instructions\n\n"
 
1
  from __future__ import annotations
2
 
3
+ import logging
4
  import os
5
  from pathlib import Path
6
 
7
+ logger = logging.getLogger(__name__)
8
+
9
 
10
  BASE_PROMPT_HEADER = """You are an AI teacher for applied AI, LLM, RAG, and Python topics.
11
 
 
186
  return ""
187
 
188
 
189
+ def ensure_kb_agents_instructions() -> str:
190
+ """Load the Local KB Instructions, materializing the file if missing.
191
+
192
+ `data/kb/AGENTS.md` is generated from an in-git template, so it can be
193
+ written without the KB bundle download. Returns "" (with a loud log)
194
+ only when even the template is unavailable.
195
+ """
196
+ instructions = load_kb_agents_instructions()
197
+ if instructions:
198
+ return instructions
199
+ try:
200
+ from .config import ensure_kb_agents_md
201
+
202
+ ensure_kb_agents_md()
203
+ except OSError as exc:
204
+ logger.warning("Could not materialize KB agent instructions: %s", exc)
205
+ instructions = load_kb_agents_instructions()
206
+ if not instructions:
207
+ logger.warning(
208
+ "KB agent instructions unavailable; the system prompt will omit "
209
+ "the Local KB section for this request."
210
+ )
211
+ return instructions
212
+
213
+
214
+ 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]
 
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:
253
  parts.append(
254
  "## Local KB Instructions\n\n"
tests/test_api.py CHANGED
@@ -325,11 +325,15 @@ def live_api_server() -> Iterator[int]:
325
  thread.join(timeout=10)
326
 
327
 
328
- def live_chat_payload(messages: list[dict], thread_id: str = "") -> dict:
 
 
 
 
329
  return {
330
  "messages": messages,
331
  "sourceKeys": ["peft", "transformers"],
332
- "enabledTools": [],
333
  "model": os.getenv("LIVE_API_E2E_MODEL", "google-genai:gemini-3.5-flash"),
334
  "includeReasoning": False,
335
  "threadId": thread_id,
@@ -535,6 +539,40 @@ def test_live_api_edit_forks_thread_from_checkpoint() -> None:
535
  )
536
 
537
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  @LIVE_API_E2E
539
  def test_live_api_stream_obeys_retrieval_only_instruction() -> None:
540
  require_live_api_e2e_prereqs()
 
325
  thread.join(timeout=10)
326
 
327
 
328
+ def live_chat_payload(
329
+ messages: list[dict],
330
+ thread_id: str = "",
331
+ enabled_tools: list[str] | None = None,
332
+ ) -> dict:
333
  return {
334
  "messages": messages,
335
  "sourceKeys": ["peft", "transformers"],
336
+ "enabledTools": enabled_tools or [],
337
  "model": os.getenv("LIVE_API_E2E_MODEL", "google-genai:gemini-3.5-flash"),
338
  "includeReasoning": False,
339
  "threadId": thread_id,
 
539
  )
540
 
541
 
542
+ @LIVE_API_E2E
543
+ def test_live_api_pasted_url_gets_source_card_with_url_context() -> None:
544
+ """The paste-a-URL flow must not lose its source card.
545
+
546
+ Gemini's url_context fetches never reach the stream (the library drops
547
+ url_context_metadata), so the pasted URL is synthesized as web evidence;
548
+ whether the model cites it inline or cites nothing, a source card for
549
+ the page must surface.
550
+ """
551
+ require_live_api_e2e_prereqs()
552
+
553
+ page_url = "https://huggingface.co/docs/peft/index"
554
+ prompt = (
555
+ f"Read {page_url} and answer in two sentences: what is PEFT? "
556
+ "Cite that page inline in your answer."
557
+ )
558
+
559
+ with live_api_server() as port:
560
+ parts = post_live_api_chat(
561
+ port,
562
+ live_chat_payload(
563
+ [live_user_message(prompt)],
564
+ enabled_tools=["url_context"],
565
+ ),
566
+ )
567
+
568
+ assert "finish" in live_part_types(parts)
569
+ assert live_answer_text(parts)
570
+ sources = live_sources(parts)
571
+ assert any(source["url"] == page_url for source in sources), (
572
+ f"pasted URL missing from source cards: {sources}"
573
+ )
574
+
575
+
576
  @LIVE_API_E2E
577
  def test_live_api_stream_obeys_retrieval_only_instruction() -> None:
578
  require_live_api_e2e_prereqs()
tests/test_chat_service.py CHANGED
@@ -22,6 +22,7 @@ from app.chat_service import (
22
  build_agent,
23
  checkpoint_messages_to_history,
24
  effective_tool_names,
 
25
  resolve_answer_citations,
26
  retrieve_tutor_context,
27
  sync_thread_with_history,
@@ -106,6 +107,20 @@ class FakeStreamingAgent(FakeAgent):
106
  }
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  class ChatServiceTestCase(unittest.TestCase):
110
  def test_effective_tool_names_follow_provider(self) -> None:
111
  self.assertEqual(
@@ -710,6 +725,175 @@ class ChatServiceTestCase(unittest.TestCase):
710
  ),
711
  )
712
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
713
  def test_idle_threads_evicted_with_checkpoints_and_records(self) -> None:
714
  self.addCleanup(_drop_thread_record, "thread_stale")
715
  self.addCleanup(_drop_thread_record, "thread_fresh")
 
22
  build_agent,
23
  checkpoint_messages_to_history,
24
  effective_tool_names,
25
+ extract_query_urls,
26
  resolve_answer_citations,
27
  retrieve_tutor_context,
28
  sync_thread_with_history,
 
107
  }
108
 
109
 
110
+ class FakeAnswerAgent(FakeAgent):
111
+ """Agent that streams nothing and answers with one final AI message."""
112
+
113
+ def __init__(self, answer: str):
114
+ super().__init__([])
115
+ self.answer = answer
116
+
117
+ async def astream(self, *args, **kwargs):
118
+ yield {
119
+ "type": "updates",
120
+ "data": {"model": {"messages": [AIMessage(content=self.answer)]}},
121
+ }
122
+
123
+
124
  class ChatServiceTestCase(unittest.TestCase):
125
  def test_effective_tool_names_follow_provider(self) -> None:
126
  self.assertEqual(
 
725
  ),
726
  )
727
 
728
+ def test_build_agent_cache_busts_when_kb_instructions_appear(self) -> None:
729
+ build_agent.cache_clear()
730
+ created_agents = []
731
+
732
+ def fake_create_agent(**kwargs):
733
+ agent = types.SimpleNamespace(kwargs=kwargs)
734
+ created_agents.append(agent)
735
+ return agent
736
+
737
+ try:
738
+ with (
739
+ patch(
740
+ "app.chat_service.build_chat_model",
741
+ return_value=types.SimpleNamespace(_llm_type="fake-chat-model"),
742
+ ),
743
+ patch("app.chat_service.create_agent", side_effect=fake_create_agent),
744
+ ):
745
+ degraded = build_agent(
746
+ "google-genai:gemini-3.5-flash",
747
+ enabled_tools=(),
748
+ include_thoughts=False,
749
+ kb_agents_instructions="",
750
+ )
751
+ healthy = build_agent(
752
+ "google-genai:gemini-3.5-flash",
753
+ enabled_tools=(),
754
+ include_thoughts=False,
755
+ kb_agents_instructions="# KB rules marker",
756
+ )
757
+ healthy_again = build_agent(
758
+ "google-genai:gemini-3.5-flash",
759
+ enabled_tools=(),
760
+ include_thoughts=False,
761
+ kb_agents_instructions="# KB rules marker",
762
+ )
763
+ finally:
764
+ build_agent.cache_clear()
765
+
766
+ # A degraded (no KB instructions) build must not pin the cache entry.
767
+ self.assertIsNot(degraded, healthy)
768
+ self.assertIs(healthy, healthy_again)
769
+ self.assertNotIn(
770
+ "## Local KB Instructions", created_agents[0].kwargs["system_prompt"]
771
+ )
772
+ self.assertIn("# KB rules marker", created_agents[1].kwargs["system_prompt"])
773
+
774
+ def test_stream_chat_passes_ensured_kb_instructions_to_build_agent(self) -> None:
775
+ agent = FakeAnswerAgent("Answer.")
776
+ request = ChatRequest(
777
+ query="hello",
778
+ source_keys=("peft",),
779
+ model_name="google-genai:gemini-3.5-flash",
780
+ include_reasoning=False,
781
+ enabled_tools=(),
782
+ )
783
+ self.addCleanup(_drop_thread_record, "thread_kbwire")
784
+
785
+ async def collect_events():
786
+ return [event async for event in stream_chat(request)]
787
+
788
+ with (
789
+ patch(
790
+ "app.chat_service.build_agent", return_value=agent
791
+ ) as build_agent_mock,
792
+ patch(
793
+ "app.chat_service.ensure_kb_agents_instructions",
794
+ return_value="# Ensured rules",
795
+ ),
796
+ patch("app.chat_service.new_thread_id", return_value="thread_kbwire"),
797
+ ):
798
+ asyncio.run(collect_events())
799
+
800
+ self.assertEqual(
801
+ build_agent_mock.call_args.kwargs["kb_agents_instructions"],
802
+ "# Ensured rules",
803
+ )
804
+
805
+ def test_extract_query_urls_dedupes_and_trims_punctuation(self) -> None:
806
+ self.assertEqual(
807
+ extract_query_urls(
808
+ "Read https://example.com/post, then https://example.com/post "
809
+ "and (https://other.example/page)."
810
+ ),
811
+ ["https://example.com/post", "https://other.example/page"],
812
+ )
813
+ self.assertEqual(extract_query_urls("no links here"), [])
814
+
815
+ def run_answer_stream(
816
+ self,
817
+ answer: str,
818
+ *,
819
+ query: str,
820
+ enabled_tools: tuple[str, ...],
821
+ thread_id: str,
822
+ ) -> list:
823
+ agent = FakeAnswerAgent(answer)
824
+ request = ChatRequest(
825
+ query=query,
826
+ source_keys=("peft",),
827
+ model_name="google-genai:gemini-3.5-flash",
828
+ include_reasoning=False,
829
+ enabled_tools=enabled_tools,
830
+ )
831
+ self.addCleanup(_drop_thread_record, thread_id)
832
+
833
+ async def collect_events():
834
+ return [event async for event in stream_chat(request)]
835
+
836
+ with (
837
+ patch("app.chat_service.build_agent", return_value=agent),
838
+ patch("app.chat_service.new_thread_id", return_value=thread_id),
839
+ patch("app.chat_service.resolve_manifest_reference", return_value=None),
840
+ ):
841
+ events = asyncio.run(collect_events())
842
+ return [event for event in events if event.type == "source_match"]
843
+
844
+ def test_cited_pasted_url_resolves_via_url_context_evidence(self) -> None:
845
+ sources = self.run_answer_stream(
846
+ "Summary. See [the post](https://example.com/post).",
847
+ query="Read https://example.com/post and summarize it",
848
+ enabled_tools=("url_context",),
849
+ thread_id="thread_urlctx",
850
+ )
851
+
852
+ self.assertEqual(len(sources), 1)
853
+ self.assertEqual(sources[0].data["source_key"], "url_context")
854
+ self.assertEqual(sources[0].data["url"], "https://example.com/post")
855
+ self.assertEqual(sources[0].data["group"], "web")
856
+
857
+ def test_unmatched_web_citation_kept_only_for_url_context(self) -> None:
858
+ # Only url_context fetches invisibly; the other web tools report
859
+ # their results into evidence, so an unmatched citation there still
860
+ # means a memory link and stays gated.
861
+ answer = "From [some site](https://unsourced.example/page)."
862
+
863
+ kept = self.run_answer_stream(
864
+ answer,
865
+ query="What does that site say?",
866
+ enabled_tools=("url_context",),
867
+ thread_id="thread_webkeep",
868
+ )
869
+ self.assertEqual(len(kept), 1)
870
+ self.assertEqual(kept[0].data["source_key"], "web")
871
+ self.assertEqual(kept[0].data["group"], "web")
872
+
873
+ for enabled_tools, thread_id in (
874
+ (("web_search",), "thread_webdrop_search"),
875
+ ((), "thread_webdrop_none"),
876
+ ):
877
+ dropped = self.run_answer_stream(
878
+ answer,
879
+ query="What does that site say?",
880
+ enabled_tools=enabled_tools,
881
+ thread_id=thread_id,
882
+ )
883
+ self.assertEqual(dropped, [])
884
+
885
+ def test_pasted_url_surfaces_in_fallback_when_nothing_cited(self) -> None:
886
+ sources = self.run_answer_stream(
887
+ "Here is a summary with no inline citations.",
888
+ query="Summarize https://example.com/post please",
889
+ enabled_tools=("url_context",),
890
+ thread_id="thread_urlfallback",
891
+ )
892
+
893
+ self.assertEqual(len(sources), 1)
894
+ self.assertEqual(sources[0].data["source_key"], "url_context")
895
+ self.assertEqual(sources[0].data["url"], "https://example.com/post")
896
+
897
  def test_idle_threads_evicted_with_checkpoints_and_records(self) -> None:
898
  self.addCleanup(_drop_thread_record, "thread_stale")
899
  self.addCleanup(_drop_thread_record, "thread_fresh")
tests/test_config.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from contextlib import ExitStack
5
+ from pathlib import Path
6
+ from unittest.mock import patch
7
+
8
+ from app import config
9
+
10
+
11
+ def _patched_bundle(tmp_path: Path) -> ExitStack:
12
+ db_dir = tmp_path / "chroma"
13
+ kb_dir = tmp_path / "kb"
14
+ stack = ExitStack()
15
+ for target, value in (
16
+ ("VECTOR_DB_DIR", db_dir),
17
+ ("CHROMA_SQLITE_PATH", db_dir / "chroma.sqlite3"),
18
+ ("DOCUMENT_DICT_PATH", db_dir / "document_dict.pkl"),
19
+ ("BM25_INDEX_PATH", db_dir / "bm25.pkl"),
20
+ ("KB_MANIFEST_PATH", kb_dir / "generated" / "corpus_manifest.jsonl"),
21
+ ("KB_INDEX_PATH", kb_dir / "wiki" / "index.md"),
22
+ ("KB_AGENTS_PATH", kb_dir / "AGENTS.md"),
23
+ ("KB_AGENTS_TEMPLATE_PATH", tmp_path / "template.md"),
24
+ ):
25
+ stack.enter_context(patch(f"app.config.{target}", str(value)))
26
+ stack.enter_context(patch("app.config._BUNDLE_READY", False))
27
+ return stack
28
+
29
+
30
+ def _write_bundle_files(tmp_path: Path, *, include_bm25: bool = True) -> None:
31
+ db_dir = tmp_path / "chroma"
32
+ db_dir.mkdir(parents=True, exist_ok=True)
33
+ (db_dir / "chroma.sqlite3").write_bytes(b"")
34
+ (db_dir / "document_dict.pkl").write_bytes(b"")
35
+ if include_bm25:
36
+ (db_dir / "bm25.pkl").write_bytes(b"")
37
+ (tmp_path / "kb" / "generated").mkdir(parents=True, exist_ok=True)
38
+ (tmp_path / "kb" / "generated" / "corpus_manifest.jsonl").write_text(
39
+ "", encoding="utf-8"
40
+ )
41
+ (tmp_path / "kb" / "wiki").mkdir(parents=True, exist_ok=True)
42
+ (tmp_path / "kb" / "wiki" / "index.md").write_text("# index", encoding="utf-8")
43
+ (tmp_path / "template.md").write_text("# KB rules", encoding="utf-8")
44
+
45
+
46
+ def test_missing_bm25_triggers_download(tmp_path: Path) -> None:
47
+ # An interrupted download that left BM25 behind must be repaired, not
48
+ # silently degrade hybrid retrieval to dense-only forever.
49
+ _write_bundle_files(tmp_path, include_bm25=False)
50
+
51
+ with _patched_bundle(tmp_path):
52
+ with patch("huggingface_hub.snapshot_download") as snapshot_download:
53
+ config.ensure_local_vector_db()
54
+
55
+ snapshot_download.assert_called_once()
56
+ # The mock download produced nothing, so readiness must stay false
57
+ # (the next call retries instead of trusting a broken bundle).
58
+ assert config._BUNDLE_READY is False
59
+
60
+
61
+ def test_complete_bundle_skips_download_and_caches_readiness(tmp_path: Path) -> None:
62
+ _write_bundle_files(tmp_path)
63
+
64
+ with _patched_bundle(tmp_path):
65
+ with patch("huggingface_hub.snapshot_download") as snapshot_download:
66
+ config.ensure_local_vector_db()
67
+ assert snapshot_download.call_count == 0
68
+ assert config._BUNDLE_READY is True
69
+ assert (tmp_path / "kb" / "AGENTS.md").read_text(
70
+ encoding="utf-8"
71
+ ) == "# KB rules"
72
+
73
+ # Once verified, later calls are flag-checks: no re-stat, no
74
+ # download attempt even if files vanish mid-process.
75
+ (tmp_path / "chroma" / "bm25.pkl").unlink()
76
+ config.ensure_local_vector_db()
77
+ assert snapshot_download.call_count == 0
78
+
79
+
80
+ def test_ensure_kb_agents_md_writes_once_and_atomically(tmp_path: Path) -> None:
81
+ _write_bundle_files(tmp_path)
82
+
83
+ with _patched_bundle(tmp_path):
84
+ with patch("app.config.os.replace", wraps=os.replace) as replace:
85
+ config.ensure_kb_agents_md()
86
+ assert replace.call_count == 1
87
+ # Unchanged content: no rewrite at all.
88
+ config.ensure_kb_agents_md()
89
+ assert replace.call_count == 1
90
+
91
+ # Changed template: rewritten through the atomic path.
92
+ (tmp_path / "template.md").write_text("# KB rules v2", encoding="utf-8")
93
+ config.ensure_kb_agents_md()
94
+ assert replace.call_count == 2
95
+
96
+ agents_path = tmp_path / "kb" / "AGENTS.md"
97
+ assert agents_path.read_text(encoding="utf-8") == "# KB rules v2"
98
+ assert not agents_path.with_name("AGENTS.md.tmp").exists()
tests/test_prompts.py CHANGED
@@ -1,8 +1,13 @@
1
  from __future__ import annotations
2
 
3
  from pathlib import Path
 
4
 
5
- from app.prompts import build_system_prompt, load_kb_agents_instructions
 
 
 
 
6
 
7
 
8
  def test_build_system_prompt_includes_local_kb_agents_instructions(
@@ -41,3 +46,61 @@ def test_load_kb_agents_instructions_returns_empty_for_missing_path(
41
  tmp_path: Path,
42
  ) -> None:
43
  assert load_kb_agents_instructions(tmp_path / "missing.md") == ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  from pathlib import Path
4
+ from unittest.mock import patch
5
 
6
+ from app.prompts import (
7
+ build_system_prompt,
8
+ ensure_kb_agents_instructions,
9
+ load_kb_agents_instructions,
10
+ )
11
 
12
 
13
  def test_build_system_prompt_includes_local_kb_agents_instructions(
 
46
  tmp_path: Path,
47
  ) -> None:
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,
54
+ ) -> None:
55
+ # The file on disk must be ignored when instructions are passed in.
56
+ agents_path = tmp_path / "AGENTS.md"
57
+ agents_path.write_text("# From disk\n", encoding="utf-8")
58
+ monkeypatch.setenv("AI_TUTOR_KB_AGENTS_PATH", str(agents_path))
59
+
60
+ explicit = build_system_prompt(
61
+ "google-genai:gemini-3.5-flash",
62
+ (),
63
+ kb_agents_instructions="# Explicit KB rules",
64
+ )
65
+ assert "# Explicit KB rules" in explicit
66
+ assert "# From disk" not in explicit
67
+
68
+ empty = build_system_prompt(
69
+ "google-genai:gemini-3.5-flash",
70
+ (),
71
+ kb_agents_instructions="",
72
+ )
73
+ assert "## Local KB Instructions" not in empty
74
+
75
+
76
+ def test_ensure_kb_agents_instructions_materializes_from_template(
77
+ tmp_path: Path,
78
+ monkeypatch,
79
+ ) -> None:
80
+ template_path = tmp_path / "kb_agents_template.md"
81
+ template_path.write_text("# Generated KB rules\n", encoding="utf-8")
82
+ agents_path = tmp_path / "kb" / "AGENTS.md"
83
+ monkeypatch.setenv("AI_TUTOR_KB_AGENTS_PATH", str(agents_path))
84
+
85
+ with (
86
+ patch("app.config.KB_AGENTS_PATH", str(agents_path)),
87
+ patch("app.config.KB_AGENTS_TEMPLATE_PATH", str(template_path)),
88
+ ):
89
+ instructions = ensure_kb_agents_instructions()
90
+
91
+ assert instructions == "# Generated KB rules"
92
+ assert agents_path.is_file()
93
+
94
+
95
+ def test_ensure_kb_agents_instructions_empty_when_template_missing(
96
+ tmp_path: Path,
97
+ monkeypatch,
98
+ ) -> None:
99
+ agents_path = tmp_path / "kb" / "AGENTS.md"
100
+ monkeypatch.setenv("AI_TUTOR_KB_AGENTS_PATH", str(agents_path))
101
+
102
+ with (
103
+ patch("app.config.KB_AGENTS_PATH", str(agents_path)),
104
+ patch("app.config.KB_AGENTS_TEMPLATE_PATH", str(tmp_path / "missing.md")),
105
+ ):
106
+ assert ensure_kb_agents_instructions() == ""