Add LangSmith tracing support and enhance documentation
Browse files- Introduced LangSmith tracing capabilities by adding environment variables in .env.example and implementing related functions in agent_tracing.py.
- Updated README.md to include instructions for enabling LangSmith tracing and its integration with the chatbot.
- Refactored chat_service.py to support tracing metadata in agent runs and added warm-up functionality for the retriever.
- Enhanced setup.py to configure LangSmith environment variables automatically.
- Added unit tests for agent tracing functionality to ensure proper behavior and configuration.
- Updated main.py to invoke retriever warm-up during application startup.
- .env.example +5 -0
- README.md +15 -0
- scripts/agent_tracing.py +39 -0
- scripts/api.py +2 -9
- scripts/chat_service.py +87 -2
- scripts/main.py +15 -3
- scripts/setup.py +11 -2
- tests/test_agent_tracing.py +59 -0
- tests/test_chat_service.py +100 -2
.env.example
CHANGED
|
@@ -4,6 +4,11 @@ ANTHROPIC_API_KEY=...
|
|
| 4 |
GEMINI_API_KEY=...
|
| 5 |
COHERE_API_KEY=...
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
# To update documentation/add new course
|
| 8 |
HF_TOKEN=... https://huggingface.co/settings/tokens to read or write to private DATASET repos e.g. https://huggingface.co/datasets/towardsai-tutors/ai-tutor-data/tree/main
|
| 9 |
GITHUB_TOKEN=...https://github.com/settings/tokens to use the Github API
|
|
|
|
| 4 |
GEMINI_API_KEY=...
|
| 5 |
COHERE_API_KEY=...
|
| 6 |
|
| 7 |
+
# Optional: trace agent/model/tool runs in LangSmith
|
| 8 |
+
LANGSMITH_API_KEY=...
|
| 9 |
+
LANGSMITH_TRACING=true
|
| 10 |
+
LANGSMITH_PROJECT=ai-tutor-app
|
| 11 |
+
|
| 12 |
# To update documentation/add new course
|
| 13 |
HF_TOKEN=... https://huggingface.co/settings/tokens to read or write to private DATASET repos e.g. https://huggingface.co/datasets/towardsai-tutors/ai-tutor-data/tree/main
|
| 14 |
GITHUB_TOKEN=...https://github.com/settings/tokens to use the Github API
|
README.md
CHANGED
|
@@ -31,6 +31,7 @@ The Gradio demo is deployed on Hugging Face Spaces at: [AI Tutor Chatbot on Hugg
|
|
| 31 |
```
|
| 32 |
|
| 33 |
The chat model is provider-agnostic. Use the UI field in `provider:model` format, for example `openai:gpt-5.4-mini`. Optional provider keys include `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, and `GOOGLE_API_KEY`. Anthropic support is wired through the `anthropic` SDK, and Gemini support is wired through Google’s `google-genai` SDK.
|
|
|
|
| 34 |
|
| 35 |
3. Run:
|
| 36 |
|
|
@@ -40,6 +41,20 @@ The Gradio demo is deployed on Hugging Face Spaces at: [AI Tutor Chatbot on Hugg
|
|
| 40 |
|
| 41 |
Starts the Gradio AI Tutor interface.
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
### Next.js Frontend — Quick Start
|
| 44 |
|
| 45 |
The repo now also includes a separate Next.js frontend in [frontend](./frontend) that talks to the FastAPI backend instead of the Gradio transport.
|
|
|
|
| 31 |
```
|
| 32 |
|
| 33 |
The chat model is provider-agnostic. Use the UI field in `provider:model` format, for example `openai:gpt-5.4-mini`. Optional provider keys include `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, and `GOOGLE_API_KEY`. Anthropic support is wired through the `anthropic` SDK, and Gemini support is wired through Google’s `google-genai` SDK.
|
| 34 |
+
To trace requests in LangSmith, set `LANGSMITH_API_KEY`. The app enables tracing automatically when that key is present unless `LANGSMITH_TRACING=false` is set.
|
| 35 |
|
| 36 |
3. Run:
|
| 37 |
|
|
|
|
| 41 |
|
| 42 |
Starts the Gradio AI Tutor interface.
|
| 43 |
|
| 44 |
+
### LangSmith Agent Tracing
|
| 45 |
+
|
| 46 |
+
The chatbot is built with `langchain.agents.create_agent()`, so LangSmith can trace the LangGraph/LangChain run tree without extra dependencies. Add these values to `.env`:
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
LANGSMITH_API_KEY=ls_...
|
| 50 |
+
LANGSMITH_TRACING=true
|
| 51 |
+
LANGSMITH_PROJECT=ai-tutor-app
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
Each chat turn is traced as `ai-tutor-agent-turn` with metadata for the backend thread id, message id, selected sources, requested model, and effective tool list. Child runs capture the model calls and `retrieve_tutor_context` tool executions, including tool inputs and outputs. Provider-side tools such as Gemini `google_search` / `url_context` and Claude `web_search` / `web_fetch` are visible through the model request/response metadata that LangChain receives from those providers.
|
| 55 |
+
|
| 56 |
+
Tracing sends prompts, retrieved snippets, tool inputs, tool outputs, and model responses to LangSmith. Set `LANGSMITH_TRACING=false` to keep tracing disabled while leaving the key in your environment.
|
| 57 |
+
|
| 58 |
### Next.js Frontend — Quick Start
|
| 59 |
|
| 60 |
The repo now also includes a separate Next.js frontend in [frontend](./frontend) that talks to the FastAPI backend instead of the Gradio transport.
|
scripts/agent_tracing.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
DEFAULT_LANGSMITH_PROJECT = "ai-tutor-app"
|
| 6 |
+
|
| 7 |
+
TRUE_VALUES = {"1", "true", "t", "yes", "y", "on"}
|
| 8 |
+
FALSE_VALUES = {"0", "false", "f", "no", "n", "off"}
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def parse_env_bool(value: str | None) -> bool | None:
|
| 12 |
+
if value is None:
|
| 13 |
+
return None
|
| 14 |
+
|
| 15 |
+
normalized = value.strip().lower()
|
| 16 |
+
if normalized in TRUE_VALUES:
|
| 17 |
+
return True
|
| 18 |
+
if normalized in FALSE_VALUES:
|
| 19 |
+
return False
|
| 20 |
+
return None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def langsmith_tracing_enabled() -> bool:
|
| 24 |
+
explicit = parse_env_bool(os.getenv("LANGSMITH_TRACING"))
|
| 25 |
+
if explicit is not None:
|
| 26 |
+
return explicit
|
| 27 |
+
return bool(os.getenv("LANGSMITH_API_KEY"))
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def configure_langsmith_environment() -> None:
|
| 31 |
+
"""Apply app defaults for LangSmith without requiring code changes in deploys."""
|
| 32 |
+
if os.getenv("LANGSMITH_API_KEY") and os.getenv("LANGSMITH_TRACING") is None:
|
| 33 |
+
os.environ["LANGSMITH_TRACING"] = "true"
|
| 34 |
+
|
| 35 |
+
if not langsmith_tracing_enabled():
|
| 36 |
+
return
|
| 37 |
+
|
| 38 |
+
if not os.getenv("LANGSMITH_PROJECT"):
|
| 39 |
+
os.environ["LANGSMITH_PROJECT"] = DEFAULT_LANGSMITH_PROJECT
|
scripts/api.py
CHANGED
|
@@ -16,11 +16,11 @@ from fastapi.staticfiles import StaticFiles
|
|
| 16 |
from pydantic import BaseModel, Field
|
| 17 |
|
| 18 |
from .chat_service import (
|
| 19 |
-
get_retriever,
|
| 20 |
is_anthropic_model,
|
| 21 |
is_google_genai_model,
|
| 22 |
message_content_to_text,
|
| 23 |
stream_chat,
|
|
|
|
| 24 |
)
|
| 25 |
from .chat_types import ChatEvent, ChatRequest, ChatTurn
|
| 26 |
from .setup import (
|
|
@@ -78,14 +78,7 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
|
| 78 |
`Could not connect to tenant default_tenant`. Touching the retriever
|
| 79 |
at startup forces single-threaded tenant init.
|
| 80 |
"""
|
| 81 |
-
|
| 82 |
-
try:
|
| 83 |
-
get_retriever()
|
| 84 |
-
except Exception as exc: # pragma: no cover - diagnostic logging only
|
| 85 |
-
logfire.warn(
|
| 86 |
-
"Retriever warm-up failed; first retrieval call may retry.",
|
| 87 |
-
error=str(exc),
|
| 88 |
-
)
|
| 89 |
yield
|
| 90 |
|
| 91 |
|
|
|
|
| 16 |
from pydantic import BaseModel, Field
|
| 17 |
|
| 18 |
from .chat_service import (
|
|
|
|
| 19 |
is_anthropic_model,
|
| 20 |
is_google_genai_model,
|
| 21 |
message_content_to_text,
|
| 22 |
stream_chat,
|
| 23 |
+
warm_up_retriever,
|
| 24 |
)
|
| 25 |
from .chat_types import ChatEvent, ChatRequest, ChatTurn
|
| 26 |
from .setup import (
|
|
|
|
| 78 |
`Could not connect to tenant default_tenant`. Touching the retriever
|
| 79 |
at startup forces single-threaded tenant init.
|
| 80 |
"""
|
| 81 |
+
warm_up_retriever()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
yield
|
| 83 |
|
| 84 |
|
scripts/chat_service.py
CHANGED
|
@@ -4,6 +4,7 @@ import json
|
|
| 4 |
import os
|
| 5 |
from dataclasses import dataclass
|
| 6 |
from functools import lru_cache
|
|
|
|
| 7 |
from typing import Any, AsyncIterator
|
| 8 |
from uuid import uuid4
|
| 9 |
|
|
@@ -37,6 +38,7 @@ ANSWER_HEADER = "**Answer**"
|
|
| 37 |
LEGACY_THOUGHTS_DETAILS_OPEN = "<details><summary>Gemini thoughts</summary>"
|
| 38 |
LEGACY_THOUGHTS_DETAILS_OPEN_EXPANDED = "<details open><summary>Gemini thoughts</summary>"
|
| 39 |
CHECKPOINTER = InMemorySaver()
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
@dataclass(frozen=True)
|
|
@@ -45,7 +47,7 @@ class AppContext:
|
|
| 45 |
|
| 46 |
|
| 47 |
@lru_cache(maxsize=1)
|
| 48 |
-
def
|
| 49 |
ensure_local_vector_db()
|
| 50 |
cohere_api_key = os.environ["COHERE_API_KEY"]
|
| 51 |
return LocalChromaRetriever(
|
|
@@ -57,6 +59,23 @@ def get_retriever() -> LocalChromaRetriever:
|
|
| 57 |
)
|
| 58 |
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
RETRIEVE_TUTOR_CONTEXT_SCHEMA = {
|
| 61 |
"title": "retrieve_tutor_context",
|
| 62 |
"description": "Retrieve relevant course and documentation context for an AI tutor question.",
|
|
@@ -431,6 +450,72 @@ def build_agent(
|
|
| 431 |
)
|
| 432 |
|
| 433 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
def extract_web_search_queries(response_metadata: Any) -> list[str]:
|
| 435 |
"""Pull the queries Gemini ran against google_search from grounding metadata."""
|
| 436 |
if not isinstance(response_metadata, dict):
|
|
@@ -643,7 +728,7 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
|
|
| 643 |
|
| 644 |
async for chunk in agent.astream(
|
| 645 |
{"messages": [{"role": "user", "content": request.query}]},
|
| 646 |
-
|
| 647 |
context=AppContext(allowed_sources=request.source_keys),
|
| 648 |
stream_mode=["messages", "updates"],
|
| 649 |
version="v2",
|
|
|
|
| 4 |
import os
|
| 5 |
from dataclasses import dataclass
|
| 6 |
from functools import lru_cache
|
| 7 |
+
from threading import Lock
|
| 8 |
from typing import Any, AsyncIterator
|
| 9 |
from uuid import uuid4
|
| 10 |
|
|
|
|
| 38 |
LEGACY_THOUGHTS_DETAILS_OPEN = "<details><summary>Gemini thoughts</summary>"
|
| 39 |
LEGACY_THOUGHTS_DETAILS_OPEN_EXPANDED = "<details open><summary>Gemini thoughts</summary>"
|
| 40 |
CHECKPOINTER = InMemorySaver()
|
| 41 |
+
_RETRIEVER_INIT_LOCK = Lock()
|
| 42 |
|
| 43 |
|
| 44 |
@dataclass(frozen=True)
|
|
|
|
| 47 |
|
| 48 |
|
| 49 |
@lru_cache(maxsize=1)
|
| 50 |
+
def _build_retriever() -> LocalChromaRetriever:
|
| 51 |
ensure_local_vector_db()
|
| 52 |
cohere_api_key = os.environ["COHERE_API_KEY"]
|
| 53 |
return LocalChromaRetriever(
|
|
|
|
| 59 |
)
|
| 60 |
|
| 61 |
|
| 62 |
+
def get_retriever() -> LocalChromaRetriever:
|
| 63 |
+
with _RETRIEVER_INIT_LOCK:
|
| 64 |
+
return _build_retriever()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def warm_up_retriever() -> None:
|
| 68 |
+
if not os.environ.get("COHERE_API_KEY"):
|
| 69 |
+
return
|
| 70 |
+
try:
|
| 71 |
+
get_retriever()
|
| 72 |
+
except Exception as exc: # pragma: no cover - diagnostic logging only
|
| 73 |
+
logfire.warn(
|
| 74 |
+
"Retriever warm-up failed; first retrieval call may retry.",
|
| 75 |
+
error=str(exc),
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
RETRIEVE_TUTOR_CONTEXT_SCHEMA = {
|
| 80 |
"title": "retrieve_tutor_context",
|
| 81 |
"description": "Retrieve relevant course and documentation context for an AI tutor question.",
|
|
|
|
| 450 |
)
|
| 451 |
|
| 452 |
|
| 453 |
+
def model_provider_and_name(model_name: str) -> tuple[str, str]:
|
| 454 |
+
provider_model = normalize_model_name(model_name)
|
| 455 |
+
provider, _, actual_model = provider_model.partition(":")
|
| 456 |
+
return provider or "unknown", actual_model or provider_model
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
def effective_tool_names(
|
| 460 |
+
model_name: str,
|
| 461 |
+
enabled_tools: tuple[str, ...],
|
| 462 |
+
) -> tuple[str, ...]:
|
| 463 |
+
names = ["retrieve_tutor_context"]
|
| 464 |
+
enabled = set(enabled_tools)
|
| 465 |
+
if is_google_genai_model(model_name):
|
| 466 |
+
if "web_search" in enabled:
|
| 467 |
+
names.append("google_search")
|
| 468 |
+
if "url_context" in enabled:
|
| 469 |
+
names.append("url_context")
|
| 470 |
+
elif is_anthropic_model(model_name):
|
| 471 |
+
if "web_search" in enabled:
|
| 472 |
+
names.append("web_search")
|
| 473 |
+
if "web_fetch" in enabled:
|
| 474 |
+
names.append("web_fetch")
|
| 475 |
+
return tuple(names)
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def agent_run_config(
|
| 479 |
+
request: ChatRequest,
|
| 480 |
+
active_thread_id: str,
|
| 481 |
+
message_id: str,
|
| 482 |
+
) -> dict[str, Any]:
|
| 483 |
+
provider, actual_model = model_provider_and_name(request.model_name)
|
| 484 |
+
tools = effective_tool_names(request.model_name, request.enabled_tools)
|
| 485 |
+
source_labels = [
|
| 486 |
+
SOURCE_KEY_TO_LABEL.get(source_key, source_key)
|
| 487 |
+
for source_key in request.source_keys
|
| 488 |
+
]
|
| 489 |
+
config = thread_config(active_thread_id)
|
| 490 |
+
config.update(
|
| 491 |
+
{
|
| 492 |
+
"run_name": "ai-tutor-agent-turn",
|
| 493 |
+
"tags": [
|
| 494 |
+
"ai-tutor-app",
|
| 495 |
+
"knowledge-base-chatbot",
|
| 496 |
+
f"provider:{provider}",
|
| 497 |
+
f"model:{actual_model}",
|
| 498 |
+
*(f"tool:{tool_name}" for tool_name in tools),
|
| 499 |
+
],
|
| 500 |
+
"metadata": {
|
| 501 |
+
"app": "ai-tutor-app",
|
| 502 |
+
"thread_id": active_thread_id,
|
| 503 |
+
"conversation_id": active_thread_id,
|
| 504 |
+
"message_id": message_id,
|
| 505 |
+
"model_provider": provider,
|
| 506 |
+
"model_name": actual_model,
|
| 507 |
+
"requested_model": request.model_name,
|
| 508 |
+
"available_tools": list(tools),
|
| 509 |
+
"enabled_tool_toggles": list(request.enabled_tools),
|
| 510 |
+
"source_keys": list(request.source_keys),
|
| 511 |
+
"source_labels": source_labels,
|
| 512 |
+
"include_reasoning": bool(request.include_reasoning),
|
| 513 |
+
},
|
| 514 |
+
}
|
| 515 |
+
)
|
| 516 |
+
return config
|
| 517 |
+
|
| 518 |
+
|
| 519 |
def extract_web_search_queries(response_metadata: Any) -> list[str]:
|
| 520 |
"""Pull the queries Gemini ran against google_search from grounding metadata."""
|
| 521 |
if not isinstance(response_metadata, dict):
|
|
|
|
| 728 |
|
| 729 |
async for chunk in agent.astream(
|
| 730 |
{"messages": [{"role": "user", "content": request.query}]},
|
| 731 |
+
agent_run_config(request, active_thread_id, message_id),
|
| 732 |
context=AppContext(allowed_sources=request.source_keys),
|
| 733 |
stream_mode=["messages", "updates"],
|
| 734 |
version="v2",
|
scripts/main.py
CHANGED
|
@@ -2,7 +2,12 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import gradio as gr
|
| 4 |
|
| 5 |
-
from .chat_service import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from .gradio_presenter import GradioPresenterState
|
| 7 |
from .setup import (
|
| 8 |
AVAILABLE_SOURCES_UI,
|
|
@@ -63,7 +68,7 @@ model = gr.Textbox(
|
|
| 63 |
label="Model (provider:model)",
|
| 64 |
value=DEFAULT_MODEL_NAME,
|
| 65 |
interactive=False,
|
| 66 |
-
placeholder="openai:gpt-5.4-mini | anthropic:claude-opus-4-6 | google-genai:gemini-3.
|
| 67 |
)
|
| 68 |
enable_web_search = gr.Checkbox(
|
| 69 |
label="Web search",
|
|
@@ -128,7 +133,13 @@ with gr.Blocks(
|
|
| 128 |
gr.ChatInterface(
|
| 129 |
fn=generate_completion,
|
| 130 |
chatbot=chatbot,
|
| 131 |
-
additional_inputs=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
additional_outputs=[thread_id],
|
| 133 |
additional_inputs_accordion=accordion,
|
| 134 |
api_name="chat",
|
|
@@ -136,4 +147,5 @@ with gr.Blocks(
|
|
| 136 |
|
| 137 |
|
| 138 |
if __name__ == "__main__":
|
|
|
|
| 139 |
demo.launch(server_name="0.0.0.0", server_port=7860, debug=False, share=False)
|
|
|
|
| 2 |
|
| 3 |
import gradio as gr
|
| 4 |
|
| 5 |
+
from .chat_service import (
|
| 6 |
+
ChatRequest,
|
| 7 |
+
normalize_history,
|
| 8 |
+
stream_chat,
|
| 9 |
+
warm_up_retriever,
|
| 10 |
+
)
|
| 11 |
from .gradio_presenter import GradioPresenterState
|
| 12 |
from .setup import (
|
| 13 |
AVAILABLE_SOURCES_UI,
|
|
|
|
| 68 |
label="Model (provider:model)",
|
| 69 |
value=DEFAULT_MODEL_NAME,
|
| 70 |
interactive=False,
|
| 71 |
+
placeholder="openai:gpt-5.4-mini | anthropic:claude-opus-4-6 | google-genai:gemini-3.5-flash",
|
| 72 |
)
|
| 73 |
enable_web_search = gr.Checkbox(
|
| 74 |
label="Web search",
|
|
|
|
| 133 |
gr.ChatInterface(
|
| 134 |
fn=generate_completion,
|
| 135 |
chatbot=chatbot,
|
| 136 |
+
additional_inputs=[
|
| 137 |
+
sources,
|
| 138 |
+
model,
|
| 139 |
+
thread_id,
|
| 140 |
+
enable_web_search,
|
| 141 |
+
enable_url_read,
|
| 142 |
+
],
|
| 143 |
additional_outputs=[thread_id],
|
| 144 |
additional_inputs_accordion=accordion,
|
| 145 |
api_name="chat",
|
|
|
|
| 147 |
|
| 148 |
|
| 149 |
if __name__ == "__main__":
|
| 150 |
+
warm_up_retriever()
|
| 151 |
demo.launch(server_name="0.0.0.0", server_port=7860, debug=False, share=False)
|
scripts/setup.py
CHANGED
|
@@ -12,22 +12,31 @@ from data.scraping_scripts.source_registry import (
|
|
| 12 |
SOURCE_KEY_TO_LABEL,
|
| 13 |
SOURCE_UI_TO_KEY,
|
| 14 |
)
|
|
|
|
|
|
|
| 15 |
from .utils import init_mongo_db
|
| 16 |
|
| 17 |
load_dotenv(override=True)
|
|
|
|
| 18 |
try:
|
| 19 |
logfire.configure()
|
| 20 |
except Exception:
|
| 21 |
pass
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
VECTOR_DB_DIR = "data/chroma-db-all_sources"
|
| 24 |
VECTOR_COLLECTION_NAME = "chroma-db-all_sources"
|
| 25 |
DOCUMENT_DICT_PATH = f"{VECTOR_DB_DIR}/document_dict_all_sources.pkl"
|
| 26 |
BM25_INDEX_PATH = f"{VECTOR_DB_DIR}/bm25_index_all_sources.pkl"
|
| 27 |
-
DEFAULT_MODEL_NAME = "google-genai:gemini-3-flash
|
| 28 |
|
| 29 |
AVAILABLE_MODELS: tuple[dict[str, str], ...] = (
|
| 30 |
-
{"id": "google-genai:gemini-3-flash
|
| 31 |
{"id": "anthropic:claude-haiku-4-5", "label": "Claude Haiku 4.5"},
|
| 32 |
)
|
| 33 |
|
|
|
|
| 12 |
SOURCE_KEY_TO_LABEL,
|
| 13 |
SOURCE_UI_TO_KEY,
|
| 14 |
)
|
| 15 |
+
|
| 16 |
+
from .agent_tracing import configure_langsmith_environment, langsmith_tracing_enabled
|
| 17 |
from .utils import init_mongo_db
|
| 18 |
|
| 19 |
load_dotenv(override=True)
|
| 20 |
+
configure_langsmith_environment()
|
| 21 |
try:
|
| 22 |
logfire.configure()
|
| 23 |
except Exception:
|
| 24 |
pass
|
| 25 |
|
| 26 |
+
if langsmith_tracing_enabled():
|
| 27 |
+
logfire.info(
|
| 28 |
+
"LangSmith tracing enabled.",
|
| 29 |
+
project=os.getenv("LANGSMITH_PROJECT", "default"),
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
VECTOR_DB_DIR = "data/chroma-db-all_sources"
|
| 33 |
VECTOR_COLLECTION_NAME = "chroma-db-all_sources"
|
| 34 |
DOCUMENT_DICT_PATH = f"{VECTOR_DB_DIR}/document_dict_all_sources.pkl"
|
| 35 |
BM25_INDEX_PATH = f"{VECTOR_DB_DIR}/bm25_index_all_sources.pkl"
|
| 36 |
+
DEFAULT_MODEL_NAME = "google-genai:gemini-3.5-flash"
|
| 37 |
|
| 38 |
AVAILABLE_MODELS: tuple[dict[str, str], ...] = (
|
| 39 |
+
{"id": "google-genai:gemini-3.5-flash", "label": "Gemini 3.5 Flash"},
|
| 40 |
{"id": "anthropic:claude-haiku-4-5", "label": "Claude Haiku 4.5"},
|
| 41 |
)
|
| 42 |
|
tests/test_agent_tracing.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import unittest
|
| 5 |
+
from unittest.mock import patch
|
| 6 |
+
|
| 7 |
+
from scripts.agent_tracing import (
|
| 8 |
+
DEFAULT_LANGSMITH_PROJECT,
|
| 9 |
+
configure_langsmith_environment,
|
| 10 |
+
langsmith_tracing_enabled,
|
| 11 |
+
parse_env_bool,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class AgentTracingTestCase(unittest.TestCase):
|
| 16 |
+
def test_parse_env_bool(self) -> None:
|
| 17 |
+
self.assertIs(parse_env_bool(None), None)
|
| 18 |
+
self.assertTrue(parse_env_bool("true"))
|
| 19 |
+
self.assertTrue(parse_env_bool("1"))
|
| 20 |
+
self.assertFalse(parse_env_bool("false"))
|
| 21 |
+
self.assertFalse(parse_env_bool("0"))
|
| 22 |
+
self.assertIs(parse_env_bool("maybe"), None)
|
| 23 |
+
|
| 24 |
+
def test_api_key_enables_langsmith_by_default(self) -> None:
|
| 25 |
+
with patch.dict(os.environ, {"LANGSMITH_API_KEY": "ls_test"}, clear=True):
|
| 26 |
+
configure_langsmith_environment()
|
| 27 |
+
|
| 28 |
+
self.assertTrue(langsmith_tracing_enabled())
|
| 29 |
+
self.assertEqual(os.environ["LANGSMITH_TRACING"], "true")
|
| 30 |
+
self.assertEqual(os.environ["LANGSMITH_PROJECT"], DEFAULT_LANGSMITH_PROJECT)
|
| 31 |
+
|
| 32 |
+
def test_explicit_disable_wins_over_api_key(self) -> None:
|
| 33 |
+
with patch.dict(
|
| 34 |
+
os.environ,
|
| 35 |
+
{"LANGSMITH_API_KEY": "ls_test", "LANGSMITH_TRACING": "false"},
|
| 36 |
+
clear=True,
|
| 37 |
+
):
|
| 38 |
+
configure_langsmith_environment()
|
| 39 |
+
|
| 40 |
+
self.assertFalse(langsmith_tracing_enabled())
|
| 41 |
+
self.assertNotIn("LANGSMITH_PROJECT", os.environ)
|
| 42 |
+
|
| 43 |
+
def test_project_is_preserved(self) -> None:
|
| 44 |
+
with patch.dict(
|
| 45 |
+
os.environ,
|
| 46 |
+
{
|
| 47 |
+
"LANGSMITH_API_KEY": "ls_test",
|
| 48 |
+
"LANGSMITH_TRACING": "true",
|
| 49 |
+
"LANGSMITH_PROJECT": "custom-project",
|
| 50 |
+
},
|
| 51 |
+
clear=True,
|
| 52 |
+
):
|
| 53 |
+
configure_langsmith_environment()
|
| 54 |
+
|
| 55 |
+
self.assertEqual(os.environ["LANGSMITH_PROJECT"], "custom-project")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
if __name__ == "__main__":
|
| 59 |
+
unittest.main()
|
tests/test_chat_service.py
CHANGED
|
@@ -6,8 +6,13 @@ from unittest.mock import patch
|
|
| 6 |
|
| 7 |
from langchain_core.messages import AIMessage, HumanMessage
|
| 8 |
|
| 9 |
-
from scripts.chat_service import
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
class FakeAgent:
|
|
@@ -23,6 +28,99 @@ class FakeAgent:
|
|
| 23 |
|
| 24 |
|
| 25 |
class ChatServiceTestCase(unittest.TestCase):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
def test_shorter_history_branches_to_fresh_thread(self) -> None:
|
| 27 |
agent = FakeAgent(
|
| 28 |
[
|
|
|
|
| 6 |
|
| 7 |
from langchain_core.messages import AIMessage, HumanMessage
|
| 8 |
|
| 9 |
+
from scripts.chat_service import (
|
| 10 |
+
agent_run_config,
|
| 11 |
+
build_agent,
|
| 12 |
+
effective_tool_names,
|
| 13 |
+
sync_thread_with_history,
|
| 14 |
+
)
|
| 15 |
+
from scripts.chat_types import ChatRequest, ChatTurn
|
| 16 |
|
| 17 |
|
| 18 |
class FakeAgent:
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
class ChatServiceTestCase(unittest.TestCase):
|
| 31 |
+
def test_effective_tool_names_follow_provider(self) -> None:
|
| 32 |
+
self.assertEqual(
|
| 33 |
+
effective_tool_names(
|
| 34 |
+
"google-genai:gemini-3.5-flash",
|
| 35 |
+
("web_search", "url_context", "web_fetch"),
|
| 36 |
+
),
|
| 37 |
+
("retrieve_tutor_context", "google_search", "url_context"),
|
| 38 |
+
)
|
| 39 |
+
self.assertEqual(
|
| 40 |
+
effective_tool_names(
|
| 41 |
+
"anthropic:claude-haiku-4-5",
|
| 42 |
+
("web_search", "url_context", "web_fetch"),
|
| 43 |
+
),
|
| 44 |
+
("retrieve_tutor_context", "web_search", "web_fetch"),
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
def test_agent_run_config_adds_langsmith_metadata(self) -> None:
|
| 48 |
+
request = ChatRequest(
|
| 49 |
+
query="What is RAG?",
|
| 50 |
+
source_keys=("langchain", "transformers"),
|
| 51 |
+
model_name="google-genai:gemini-3.5-flash",
|
| 52 |
+
include_reasoning=True,
|
| 53 |
+
enabled_tools=("web_search",),
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
config = agent_run_config(request, "thread_123", "message_456")
|
| 57 |
+
|
| 58 |
+
self.assertEqual(config["configurable"], {"thread_id": "thread_123"})
|
| 59 |
+
self.assertEqual(config["run_name"], "ai-tutor-agent-turn")
|
| 60 |
+
self.assertIn("provider:google-genai", config["tags"])
|
| 61 |
+
self.assertIn("tool:retrieve_tutor_context", config["tags"])
|
| 62 |
+
self.assertIn("tool:google_search", config["tags"])
|
| 63 |
+
self.assertEqual(config["metadata"]["thread_id"], "thread_123")
|
| 64 |
+
self.assertEqual(config["metadata"]["conversation_id"], "thread_123")
|
| 65 |
+
self.assertEqual(config["metadata"]["message_id"], "message_456")
|
| 66 |
+
self.assertEqual(
|
| 67 |
+
config["metadata"]["available_tools"],
|
| 68 |
+
["retrieve_tutor_context", "google_search"],
|
| 69 |
+
)
|
| 70 |
+
self.assertEqual(
|
| 71 |
+
config["metadata"]["source_keys"],
|
| 72 |
+
["langchain", "transformers"],
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
def test_build_agent_cache_keys_include_tool_toggles(self) -> None:
|
| 76 |
+
build_agent.cache_clear()
|
| 77 |
+
created_agents = []
|
| 78 |
+
|
| 79 |
+
def fake_create_agent(**kwargs):
|
| 80 |
+
agent = types.SimpleNamespace(kwargs=kwargs)
|
| 81 |
+
created_agents.append(agent)
|
| 82 |
+
return agent
|
| 83 |
+
|
| 84 |
+
try:
|
| 85 |
+
with (
|
| 86 |
+
patch("scripts.chat_service.build_chat_model", return_value=object()),
|
| 87 |
+
patch(
|
| 88 |
+
"scripts.chat_service.build_system_prompt", return_value="prompt"
|
| 89 |
+
),
|
| 90 |
+
patch(
|
| 91 |
+
"scripts.chat_service.create_agent", side_effect=fake_create_agent
|
| 92 |
+
),
|
| 93 |
+
):
|
| 94 |
+
with_web_tools = build_agent(
|
| 95 |
+
"google-genai:gemini-3.5-flash",
|
| 96 |
+
enabled_tools=("web_search", "url_context"),
|
| 97 |
+
include_thoughts=True,
|
| 98 |
+
)
|
| 99 |
+
without_web_tools = build_agent(
|
| 100 |
+
"google-genai:gemini-3.5-flash",
|
| 101 |
+
enabled_tools=(),
|
| 102 |
+
include_thoughts=True,
|
| 103 |
+
)
|
| 104 |
+
without_web_tools_again = build_agent(
|
| 105 |
+
"google-genai:gemini-3.5-flash",
|
| 106 |
+
enabled_tools=(),
|
| 107 |
+
include_thoughts=True,
|
| 108 |
+
)
|
| 109 |
+
finally:
|
| 110 |
+
build_agent.cache_clear()
|
| 111 |
+
|
| 112 |
+
self.assertIsNot(with_web_tools, without_web_tools)
|
| 113 |
+
self.assertIs(without_web_tools, without_web_tools_again)
|
| 114 |
+
self.assertEqual(len(created_agents), 2)
|
| 115 |
+
|
| 116 |
+
enabled_tool_defs = created_agents[0].kwargs["tools"]
|
| 117 |
+
disabled_tool_defs = created_agents[1].kwargs["tools"]
|
| 118 |
+
self.assertIn({"google_search": {}}, enabled_tool_defs)
|
| 119 |
+
self.assertIn({"url_context": {}}, enabled_tool_defs)
|
| 120 |
+
self.assertEqual(disabled_tool_defs, [enabled_tool_defs[0]])
|
| 121 |
+
self.assertEqual(len(created_agents[0].kwargs["middleware"]), 1)
|
| 122 |
+
self.assertEqual(created_agents[1].kwargs["middleware"], [])
|
| 123 |
+
|
| 124 |
def test_shorter_history_branches_to_fresh_thread(self) -> None:
|
| 125 |
agent = FakeAgent(
|
| 126 |
[
|