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 +53 -2
- app/config.py +67 -27
- app/prompts.py +35 -2
- tests/test_api.py +40 -2
- tests/test_chat_service.py +184 -0
- tests/test_config.py +98 -0
- tests/test_prompts.py +64 -1
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(
|
|
|
|
|
|
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
template_path = Path(KB_AGENTS_TEMPLATE_PATH)
|
| 64 |
if not template_path.exists():
|
| 65 |
return
|
|
|
|
| 66 |
target = Path(KB_AGENTS_PATH)
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
def ensure_local_vector_db() -> None:
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
try:
|
| 89 |
-
snapshot_download(
|
| 90 |
-
repo_id="towardsai-tutors/ai-tutor-vector-db",
|
| 91 |
-
local_dir="data",
|
| 92 |
-
repo_type="dataset",
|
| 93 |
)
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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() == ""
|