Add enabled tools management to ChatShell and SourceSidebar components. Update API to handle enabled tools in chat requests and adjust prompts accordingly. Enhance tool toggling functionality and improve UI for tool selection.
Browse files- frontend/components/chat-shell.tsx +23 -0
- frontend/components/source-sidebar.tsx +228 -54
- scripts/api.py +22 -2
- scripts/chat_service.py +36 -22
- scripts/chat_types.py +1 -0
- scripts/prompts.py +118 -35
- tests/test_api.py +5 -2
frontend/components/chat-shell.tsx
CHANGED
|
@@ -40,6 +40,7 @@ export function ChatShell() {
|
|
| 40 |
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([]);
|
| 41 |
const [selectedModel, setSelectedModel] = useState<string>("");
|
| 42 |
const [selectedSourceKeys, setSelectedSourceKeys] = useState<string[]>([]);
|
|
|
|
| 43 |
const [sourceError, setSourceError] = useState<string | null>(null);
|
| 44 |
const [input, setInput] = useState("");
|
| 45 |
const [threadId, setThreadId] = useState("");
|
|
@@ -99,6 +100,11 @@ export function ChatShell() {
|
|
| 99 |
.map((source) => source.key),
|
| 100 |
);
|
| 101 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
setSourceError(null);
|
| 103 |
} catch (loadError) {
|
| 104 |
if (controller.signal.aborted) {
|
|
@@ -133,6 +139,11 @@ export function ChatShell() {
|
|
| 133 |
await fetchTools(controller.signal, selectedModel);
|
| 134 |
setTools(loadedTools);
|
| 135 |
setAvailableModels(models ?? []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
} catch (loadError) {
|
| 137 |
if (controller.signal.aborted) {
|
| 138 |
return;
|
|
@@ -254,6 +265,7 @@ export function ChatShell() {
|
|
| 254 |
{
|
| 255 |
body: {
|
| 256 |
sourceKeys: selectedSourceKeys,
|
|
|
|
| 257 |
includeReasoning: true,
|
| 258 |
threadId,
|
| 259 |
model: selectedModel,
|
|
@@ -331,6 +343,7 @@ export function ChatShell() {
|
|
| 331 |
{
|
| 332 |
body: {
|
| 333 |
sourceKeys: selectedSourceKeys,
|
|
|
|
| 334 |
includeReasoning: true,
|
| 335 |
threadId,
|
| 336 |
model: selectedModel,
|
|
@@ -360,13 +373,23 @@ export function ChatShell() {
|
|
| 360 |
);
|
| 361 |
}
|
| 362 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
return (
|
| 364 |
<main className="min-h-screen p-2 lg:h-screen lg:overflow-hidden">
|
| 365 |
<div className="flex min-h-[calc(100vh-1rem)] w-full flex-col gap-2 lg:h-[calc(100vh-1rem)] lg:min-h-0 lg:grid lg:grid-cols-[248px_minmax(0,1fr)]">
|
| 366 |
<SourceSidebar
|
| 367 |
onNewChat={handleNewChat}
|
| 368 |
onToggleSource={toggleSource}
|
|
|
|
| 369 |
selectedSourceKeys={selectedSourceKeys}
|
|
|
|
| 370 |
sourceError={sourceError}
|
| 371 |
tools={tools}
|
| 372 |
/>
|
|
|
|
| 40 |
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([]);
|
| 41 |
const [selectedModel, setSelectedModel] = useState<string>("");
|
| 42 |
const [selectedSourceKeys, setSelectedSourceKeys] = useState<string[]>([]);
|
| 43 |
+
const [enabledToolKeys, setEnabledToolKeys] = useState<string[]>([]);
|
| 44 |
const [sourceError, setSourceError] = useState<string | null>(null);
|
| 45 |
const [input, setInput] = useState("");
|
| 46 |
const [threadId, setThreadId] = useState("");
|
|
|
|
| 100 |
.map((source) => source.key),
|
| 101 |
);
|
| 102 |
}
|
| 103 |
+
setEnabledToolKeys(
|
| 104 |
+
loadedTools
|
| 105 |
+
.filter((tool) => tool.kind === "toggle" && tool.active)
|
| 106 |
+
.map((tool) => tool.key),
|
| 107 |
+
);
|
| 108 |
setSourceError(null);
|
| 109 |
} catch (loadError) {
|
| 110 |
if (controller.signal.aborted) {
|
|
|
|
| 139 |
await fetchTools(controller.signal, selectedModel);
|
| 140 |
setTools(loadedTools);
|
| 141 |
setAvailableModels(models ?? []);
|
| 142 |
+
setEnabledToolKeys(
|
| 143 |
+
loadedTools
|
| 144 |
+
.filter((tool) => tool.kind === "toggle" && tool.active)
|
| 145 |
+
.map((tool) => tool.key),
|
| 146 |
+
);
|
| 147 |
} catch (loadError) {
|
| 148 |
if (controller.signal.aborted) {
|
| 149 |
return;
|
|
|
|
| 265 |
{
|
| 266 |
body: {
|
| 267 |
sourceKeys: selectedSourceKeys,
|
| 268 |
+
enabledTools: enabledToolKeys,
|
| 269 |
includeReasoning: true,
|
| 270 |
threadId,
|
| 271 |
model: selectedModel,
|
|
|
|
| 343 |
{
|
| 344 |
body: {
|
| 345 |
sourceKeys: selectedSourceKeys,
|
| 346 |
+
enabledTools: enabledToolKeys,
|
| 347 |
includeReasoning: true,
|
| 348 |
threadId,
|
| 349 |
model: selectedModel,
|
|
|
|
| 373 |
);
|
| 374 |
}
|
| 375 |
|
| 376 |
+
function toggleTool(toolKey: string) {
|
| 377 |
+
setEnabledToolKeys((current) =>
|
| 378 |
+
current.includes(toolKey)
|
| 379 |
+
? current.filter((key) => key !== toolKey)
|
| 380 |
+
: [...current, toolKey],
|
| 381 |
+
);
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
return (
|
| 385 |
<main className="min-h-screen p-2 lg:h-screen lg:overflow-hidden">
|
| 386 |
<div className="flex min-h-[calc(100vh-1rem)] w-full flex-col gap-2 lg:h-[calc(100vh-1rem)] lg:min-h-0 lg:grid lg:grid-cols-[248px_minmax(0,1fr)]">
|
| 387 |
<SourceSidebar
|
| 388 |
onNewChat={handleNewChat}
|
| 389 |
onToggleSource={toggleSource}
|
| 390 |
+
onToggleTool={toggleTool}
|
| 391 |
selectedSourceKeys={selectedSourceKeys}
|
| 392 |
+
enabledToolKeys={enabledToolKeys}
|
| 393 |
sourceError={sourceError}
|
| 394 |
tools={tools}
|
| 395 |
/>
|
frontend/components/source-sidebar.tsx
CHANGED
|
@@ -15,11 +15,13 @@ import {
|
|
| 15 |
} from "lucide-react";
|
| 16 |
import {
|
| 17 |
useEffect,
|
|
|
|
| 18 |
useRef,
|
| 19 |
useState,
|
| 20 |
type ComponentType,
|
| 21 |
type SVGProps,
|
| 22 |
} from "react";
|
|
|
|
| 23 |
import type { TutorSource, TutorTool } from "@/lib/api";
|
| 24 |
import { COURSE_METADATA } from "@/lib/course-metadata";
|
| 25 |
import { DOC_METADATA } from "@/lib/doc-metadata";
|
|
@@ -27,25 +29,42 @@ import { DOC_METADATA } from "@/lib/doc-metadata";
|
|
| 27 |
type SourceSidebarProps = {
|
| 28 |
onNewChat: () => void;
|
| 29 |
onToggleSource: (sourceKey: string) => void;
|
|
|
|
| 30 |
selectedSourceKeys: string[];
|
|
|
|
| 31 |
sourceError: string | null;
|
| 32 |
tools: TutorTool[];
|
| 33 |
};
|
| 34 |
|
| 35 |
type ToggleToolMeta = {
|
| 36 |
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
|
|
|
| 37 |
};
|
| 38 |
|
| 39 |
const TOGGLE_TOOL_META: Record<string, ToggleToolMeta> = {
|
| 40 |
-
web_search: {
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
};
|
| 44 |
|
| 45 |
export function SourceSidebar({
|
| 46 |
onNewChat,
|
| 47 |
onToggleSource,
|
|
|
|
| 48 |
selectedSourceKeys,
|
|
|
|
| 49 |
sourceError,
|
| 50 |
tools,
|
| 51 |
}: SourceSidebarProps) {
|
|
@@ -59,8 +78,31 @@ export function SourceSidebar({
|
|
| 59 |
);
|
| 60 |
const activeCount =
|
| 61 |
(retrievalTool && selectedSourceKeys.length > 0 ? 1 : 0) +
|
| 62 |
-
toggleTools.filter((tool) => tool.
|
| 63 |
const totalCount = (retrievalTool ? 1 : 0) + toggleTools.length;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
return (
|
| 66 |
<aside className="glass-panel relative overflow-hidden rounded-[1.5rem] p-2.5 lg:flex lg:min-h-0 lg:max-h-[calc(100vh-1rem)] lg:min-h-[calc(100vh-1rem)] lg:flex-col">
|
|
@@ -106,7 +148,7 @@ export function SourceSidebar({
|
|
| 106 |
</span>
|
| 107 |
</div>
|
| 108 |
<p className="text-[11px] leading-[1.4] text-[var(--muted)]">
|
| 109 |
-
|
| 110 |
</p>
|
| 111 |
</div>
|
| 112 |
|
|
@@ -118,8 +160,21 @@ export function SourceSidebar({
|
|
| 118 |
onToggleSource={onToggleSource}
|
| 119 |
/>
|
| 120 |
) : null}
|
|
|
|
|
|
|
| 121 |
{toggleTools.map((tool) => (
|
| 122 |
-
<ToggleToolRow
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
))}
|
| 124 |
</div>
|
| 125 |
|
|
@@ -160,6 +215,7 @@ function RetrievalTool({
|
|
| 160 |
const [isOpen, setIsOpen] = useState(true);
|
| 161 |
const courseSources = tool.sources.filter((source) => source.group === "courses");
|
| 162 |
const docSources = tool.sources.filter((source) => source.group === "docs");
|
|
|
|
| 163 |
|
| 164 |
return (
|
| 165 |
<section className="space-y-1">
|
|
@@ -167,12 +223,34 @@ function RetrievalTool({
|
|
| 167 |
type="button"
|
| 168 |
onClick={() => setIsOpen((current) => !current)}
|
| 169 |
aria-expanded={isOpen}
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
>
|
| 172 |
-
<Library
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
{tool.label}
|
| 175 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
<ChevronDown
|
| 177 |
className={clsx(
|
| 178 |
"h-3.5 w-3.5 shrink-0 text-[var(--muted)] transition-transform",
|
|
@@ -226,10 +304,13 @@ function SourceGroup({
|
|
| 226 |
useEffect(() => {
|
| 227 |
if (openPopoverKey === null) return;
|
| 228 |
function onDocMouseDown(event: MouseEvent) {
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
| 233 |
setOpenPopoverKey(null);
|
| 234 |
}
|
| 235 |
}
|
|
@@ -325,9 +406,35 @@ function SourceRow({
|
|
| 325 |
}) {
|
| 326 |
const isCourse = source.group === "courses";
|
| 327 |
const popover = getPopoverInfo(source);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
|
| 329 |
return (
|
| 330 |
-
<div className="relative">
|
| 331 |
<div
|
| 332 |
className={clsx(
|
| 333 |
"flex items-center gap-1 rounded-[0.75rem] border py-1.5 pl-2 pr-1 transition",
|
|
@@ -380,61 +487,128 @@ function SourceRow({
|
|
| 380 |
<span className="h-5 w-5 shrink-0" aria-hidden />
|
| 381 |
)}
|
| 382 |
</div>
|
| 383 |
-
{popover && popoverOpen
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
</div>
|
| 408 |
);
|
| 409 |
}
|
| 410 |
|
| 411 |
function ToggleToolRow({
|
| 412 |
tool,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
}: {
|
| 414 |
tool: Extract<TutorTool, { kind: "toggle" }>;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
}) {
|
| 416 |
const meta = TOGGLE_TOOL_META[tool.key];
|
| 417 |
const Icon = meta?.icon ?? Globe;
|
|
|
|
| 418 |
return (
|
| 419 |
-
<div
|
| 420 |
-
|
| 421 |
-
title={tool.active ? "Always on for this model" : "Unavailable"}
|
| 422 |
-
>
|
| 423 |
-
<Icon className="h-3.5 w-3.5 shrink-0 text-[var(--accent)]" />
|
| 424 |
-
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium tracking-[-0.01em] text-[var(--ink)]">
|
| 425 |
-
{tool.label}
|
| 426 |
-
</span>
|
| 427 |
-
<span
|
| 428 |
-
aria-label={tool.active ? "on" : "off"}
|
| 429 |
className={clsx(
|
| 430 |
-
"
|
| 431 |
-
|
| 432 |
-
? "
|
| 433 |
-
: "
|
| 434 |
)}
|
| 435 |
>
|
| 436 |
-
|
| 437 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
</div>
|
| 439 |
);
|
| 440 |
}
|
|
|
|
| 15 |
} from "lucide-react";
|
| 16 |
import {
|
| 17 |
useEffect,
|
| 18 |
+
useLayoutEffect,
|
| 19 |
useRef,
|
| 20 |
useState,
|
| 21 |
type ComponentType,
|
| 22 |
type SVGProps,
|
| 23 |
} from "react";
|
| 24 |
+
import { createPortal } from "react-dom";
|
| 25 |
import type { TutorSource, TutorTool } from "@/lib/api";
|
| 26 |
import { COURSE_METADATA } from "@/lib/course-metadata";
|
| 27 |
import { DOC_METADATA } from "@/lib/doc-metadata";
|
|
|
|
| 29 |
type SourceSidebarProps = {
|
| 30 |
onNewChat: () => void;
|
| 31 |
onToggleSource: (sourceKey: string) => void;
|
| 32 |
+
onToggleTool: (toolKey: string) => void;
|
| 33 |
selectedSourceKeys: string[];
|
| 34 |
+
enabledToolKeys: string[];
|
| 35 |
sourceError: string | null;
|
| 36 |
tools: TutorTool[];
|
| 37 |
};
|
| 38 |
|
| 39 |
type ToggleToolMeta = {
|
| 40 |
icon: ComponentType<SVGProps<SVGSVGElement>>;
|
| 41 |
+
description?: string;
|
| 42 |
};
|
| 43 |
|
| 44 |
const TOGGLE_TOOL_META: Record<string, ToggleToolMeta> = {
|
| 45 |
+
web_search: {
|
| 46 |
+
icon: Globe,
|
| 47 |
+
description:
|
| 48 |
+
"Live web search for recent events or facts outside the course corpus.",
|
| 49 |
+
},
|
| 50 |
+
url_context: {
|
| 51 |
+
icon: LinkIcon,
|
| 52 |
+
description:
|
| 53 |
+
"Reads a specific URL you paste in the chat so the tutor can answer from its content.",
|
| 54 |
+
},
|
| 55 |
+
web_fetch: {
|
| 56 |
+
icon: LinkIcon,
|
| 57 |
+
description:
|
| 58 |
+
"Reads a specific URL you paste in the chat so the tutor can answer from its content.",
|
| 59 |
+
},
|
| 60 |
};
|
| 61 |
|
| 62 |
export function SourceSidebar({
|
| 63 |
onNewChat,
|
| 64 |
onToggleSource,
|
| 65 |
+
onToggleTool,
|
| 66 |
selectedSourceKeys,
|
| 67 |
+
enabledToolKeys,
|
| 68 |
sourceError,
|
| 69 |
tools,
|
| 70 |
}: SourceSidebarProps) {
|
|
|
|
| 78 |
);
|
| 79 |
const activeCount =
|
| 80 |
(retrievalTool && selectedSourceKeys.length > 0 ? 1 : 0) +
|
| 81 |
+
toggleTools.filter((tool) => enabledToolKeys.includes(tool.key)).length;
|
| 82 |
const totalCount = (retrievalTool ? 1 : 0) + toggleTools.length;
|
| 83 |
+
const [openToolInfoKey, setOpenToolInfoKey] = useState<string | null>(null);
|
| 84 |
+
const toggleToolsRef = useRef<HTMLDivElement>(null);
|
| 85 |
+
|
| 86 |
+
useEffect(() => {
|
| 87 |
+
if (openToolInfoKey === null) return;
|
| 88 |
+
function onDocMouseDown(event: MouseEvent) {
|
| 89 |
+
if (
|
| 90 |
+
toggleToolsRef.current &&
|
| 91 |
+
!toggleToolsRef.current.contains(event.target as Node)
|
| 92 |
+
) {
|
| 93 |
+
setOpenToolInfoKey(null);
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
function onKey(event: KeyboardEvent) {
|
| 97 |
+
if (event.key === "Escape") setOpenToolInfoKey(null);
|
| 98 |
+
}
|
| 99 |
+
document.addEventListener("mousedown", onDocMouseDown);
|
| 100 |
+
document.addEventListener("keydown", onKey);
|
| 101 |
+
return () => {
|
| 102 |
+
document.removeEventListener("mousedown", onDocMouseDown);
|
| 103 |
+
document.removeEventListener("keydown", onKey);
|
| 104 |
+
};
|
| 105 |
+
}, [openToolInfoKey]);
|
| 106 |
|
| 107 |
return (
|
| 108 |
<aside className="glass-panel relative overflow-hidden rounded-[1.5rem] p-2.5 lg:flex lg:min-h-0 lg:max-h-[calc(100vh-1rem)] lg:min-h-[calc(100vh-1rem)] lg:flex-col">
|
|
|
|
| 148 |
</span>
|
| 149 |
</div>
|
| 150 |
<p className="text-[11px] leading-[1.4] text-[var(--muted)]">
|
| 151 |
+
Toggle tools, pick sources.
|
| 152 |
</p>
|
| 153 |
</div>
|
| 154 |
|
|
|
|
| 160 |
onToggleSource={onToggleSource}
|
| 161 |
/>
|
| 162 |
) : null}
|
| 163 |
+
</div>
|
| 164 |
+
<div ref={toggleToolsRef} className="relative z-10 space-y-2 pr-0.5">
|
| 165 |
{toggleTools.map((tool) => (
|
| 166 |
+
<ToggleToolRow
|
| 167 |
+
key={tool.key}
|
| 168 |
+
tool={tool}
|
| 169 |
+
enabled={enabledToolKeys.includes(tool.key)}
|
| 170 |
+
onToggle={() => onToggleTool(tool.key)}
|
| 171 |
+
infoOpen={openToolInfoKey === tool.key}
|
| 172 |
+
onInfoToggle={() =>
|
| 173 |
+
setOpenToolInfoKey((current) =>
|
| 174 |
+
current === tool.key ? null : tool.key,
|
| 175 |
+
)
|
| 176 |
+
}
|
| 177 |
+
/>
|
| 178 |
))}
|
| 179 |
</div>
|
| 180 |
|
|
|
|
| 215 |
const [isOpen, setIsOpen] = useState(true);
|
| 216 |
const courseSources = tool.sources.filter((source) => source.group === "courses");
|
| 217 |
const docSources = tool.sources.filter((source) => source.group === "docs");
|
| 218 |
+
const enabled = selectedSourceKeys.length > 0;
|
| 219 |
|
| 220 |
return (
|
| 221 |
<section className="space-y-1">
|
|
|
|
| 223 |
type="button"
|
| 224 |
onClick={() => setIsOpen((current) => !current)}
|
| 225 |
aria-expanded={isOpen}
|
| 226 |
+
title="Expand to pick sources. On when at least one source is selected."
|
| 227 |
+
className={clsx(
|
| 228 |
+
"flex w-full items-center gap-2 rounded-[0.9rem] border px-2 py-2 text-left transition",
|
| 229 |
+
enabled
|
| 230 |
+
? "border-[var(--line-strong)] bg-[var(--accent-faint)] hover:border-[var(--accent)]"
|
| 231 |
+
: "border-[var(--line)] bg-[var(--surface-subtle)] hover:border-[var(--line-strong)]",
|
| 232 |
+
)}
|
| 233 |
>
|
| 234 |
+
<Library
|
| 235 |
+
className={clsx(
|
| 236 |
+
"h-3.5 w-3.5 shrink-0",
|
| 237 |
+
enabled ? "text-[var(--accent)]" : "text-[var(--muted)]",
|
| 238 |
+
)}
|
| 239 |
+
/>
|
| 240 |
+
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium tracking-[-0.01em] text-[var(--ink)]">
|
| 241 |
{tool.label}
|
| 242 |
</span>
|
| 243 |
+
<span
|
| 244 |
+
aria-hidden
|
| 245 |
+
className={clsx(
|
| 246 |
+
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[9.5px] font-semibold uppercase tracking-[0.1em]",
|
| 247 |
+
enabled
|
| 248 |
+
? "bg-[var(--accent-faint)] text-[var(--accent)]"
|
| 249 |
+
: "bg-[var(--muted)]/15 text-[var(--muted)]",
|
| 250 |
+
)}
|
| 251 |
+
>
|
| 252 |
+
{enabled ? "on" : "off"}
|
| 253 |
+
</span>
|
| 254 |
<ChevronDown
|
| 255 |
className={clsx(
|
| 256 |
"h-3.5 w-3.5 shrink-0 text-[var(--muted)] transition-transform",
|
|
|
|
| 304 |
useEffect(() => {
|
| 305 |
if (openPopoverKey === null) return;
|
| 306 |
function onDocMouseDown(event: MouseEvent) {
|
| 307 |
+
const target = event.target as Node | null;
|
| 308 |
+
const insideGroup = !!groupRef.current && !!target && groupRef.current.contains(target);
|
| 309 |
+
const insidePortaledDialog =
|
| 310 |
+
!!target &&
|
| 311 |
+
target instanceof Element &&
|
| 312 |
+
!!target.closest('[data-source-popover="true"]');
|
| 313 |
+
if (!insideGroup && !insidePortaledDialog) {
|
| 314 |
setOpenPopoverKey(null);
|
| 315 |
}
|
| 316 |
}
|
|
|
|
| 406 |
}) {
|
| 407 |
const isCourse = source.group === "courses";
|
| 408 |
const popover = getPopoverInfo(source);
|
| 409 |
+
const rowRef = useRef<HTMLDivElement>(null);
|
| 410 |
+
const [dialogPos, setDialogPos] = useState<
|
| 411 |
+
{ top: number; left: number; width: number } | null
|
| 412 |
+
>(null);
|
| 413 |
+
|
| 414 |
+
useLayoutEffect(() => {
|
| 415 |
+
if (!popoverOpen || !rowRef.current) {
|
| 416 |
+
setDialogPos(null);
|
| 417 |
+
return;
|
| 418 |
+
}
|
| 419 |
+
function recompute() {
|
| 420 |
+
if (!rowRef.current) return;
|
| 421 |
+
const row = rowRef.current.getBoundingClientRect();
|
| 422 |
+
const estimatedHeight = 140;
|
| 423 |
+
const spaceBelow = window.innerHeight - row.bottom;
|
| 424 |
+
const openAbove = spaceBelow < estimatedHeight + 16 && row.top > spaceBelow;
|
| 425 |
+
setDialogPos({
|
| 426 |
+
top: openAbove ? row.top - estimatedHeight - 4 : row.bottom + 4,
|
| 427 |
+
left: row.left,
|
| 428 |
+
width: row.width,
|
| 429 |
+
});
|
| 430 |
+
}
|
| 431 |
+
recompute();
|
| 432 |
+
window.addEventListener("resize", recompute);
|
| 433 |
+
return () => window.removeEventListener("resize", recompute);
|
| 434 |
+
}, [popoverOpen]);
|
| 435 |
|
| 436 |
return (
|
| 437 |
+
<div ref={rowRef} className="relative">
|
| 438 |
<div
|
| 439 |
className={clsx(
|
| 440 |
"flex items-center gap-1 rounded-[0.75rem] border py-1.5 pl-2 pr-1 transition",
|
|
|
|
| 487 |
<span className="h-5 w-5 shrink-0" aria-hidden />
|
| 488 |
)}
|
| 489 |
</div>
|
| 490 |
+
{popover && popoverOpen && dialogPos && typeof document !== "undefined"
|
| 491 |
+
? createPortal(
|
| 492 |
+
<div
|
| 493 |
+
role="dialog"
|
| 494 |
+
data-source-popover="true"
|
| 495 |
+
style={{
|
| 496 |
+
position: "fixed",
|
| 497 |
+
top: dialogPos.top,
|
| 498 |
+
left: dialogPos.left,
|
| 499 |
+
width: dialogPos.width,
|
| 500 |
+
zIndex: 50,
|
| 501 |
+
}}
|
| 502 |
+
className="rounded-[0.9rem] border border-[var(--line-strong)] bg-[var(--surface-strong)] p-3 shadow-[0_12px_32px_rgba(0,0,0,0.18)] backdrop-blur-md"
|
| 503 |
+
>
|
| 504 |
+
<p className="text-[12px] leading-[1.45] text-[var(--ink)]">
|
| 505 |
+
{popover.description}
|
| 506 |
+
</p>
|
| 507 |
+
{popover.meta ? (
|
| 508 |
+
<p className="mt-1.5 text-[11px] font-medium tracking-[-0.005em] text-[var(--muted)]">
|
| 509 |
+
{popover.meta}
|
| 510 |
+
</p>
|
| 511 |
+
) : null}
|
| 512 |
+
<a
|
| 513 |
+
href={popover.linkUrl}
|
| 514 |
+
target="_blank"
|
| 515 |
+
rel="noreferrer"
|
| 516 |
+
className="mt-2 inline-flex items-center gap-1 text-[11.5px] font-semibold text-[var(--accent)] hover:underline"
|
| 517 |
+
>
|
| 518 |
+
{popover.linkLabel}
|
| 519 |
+
<ExternalLink className="h-3 w-3" />
|
| 520 |
+
</a>
|
| 521 |
+
</div>,
|
| 522 |
+
document.body,
|
| 523 |
+
)
|
| 524 |
+
: null}
|
| 525 |
</div>
|
| 526 |
);
|
| 527 |
}
|
| 528 |
|
| 529 |
function ToggleToolRow({
|
| 530 |
tool,
|
| 531 |
+
enabled,
|
| 532 |
+
onToggle,
|
| 533 |
+
infoOpen,
|
| 534 |
+
onInfoToggle,
|
| 535 |
}: {
|
| 536 |
tool: Extract<TutorTool, { kind: "toggle" }>;
|
| 537 |
+
enabled: boolean;
|
| 538 |
+
onToggle: () => void;
|
| 539 |
+
infoOpen: boolean;
|
| 540 |
+
onInfoToggle: () => void;
|
| 541 |
}) {
|
| 542 |
const meta = TOGGLE_TOOL_META[tool.key];
|
| 543 |
const Icon = meta?.icon ?? Globe;
|
| 544 |
+
const description = meta?.description;
|
| 545 |
return (
|
| 546 |
+
<div className="relative">
|
| 547 |
+
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
className={clsx(
|
| 549 |
+
"flex items-center gap-1 rounded-[0.9rem] border pl-2 pr-1 py-2 transition",
|
| 550 |
+
enabled
|
| 551 |
+
? "border-[var(--line-strong)] bg-[var(--accent-faint)]"
|
| 552 |
+
: "border-[var(--line)] bg-[var(--surface-subtle)]",
|
| 553 |
)}
|
| 554 |
>
|
| 555 |
+
<button
|
| 556 |
+
type="button"
|
| 557 |
+
onClick={onToggle}
|
| 558 |
+
aria-pressed={enabled}
|
| 559 |
+
title={enabled ? "Click to disable" : "Click to enable"}
|
| 560 |
+
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
| 561 |
+
>
|
| 562 |
+
<Icon
|
| 563 |
+
className={clsx(
|
| 564 |
+
"h-3.5 w-3.5 shrink-0",
|
| 565 |
+
enabled ? "text-[var(--accent)]" : "text-[var(--muted)]",
|
| 566 |
+
)}
|
| 567 |
+
/>
|
| 568 |
+
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium tracking-[-0.01em] text-[var(--ink)]">
|
| 569 |
+
{tool.label}
|
| 570 |
+
</span>
|
| 571 |
+
<span
|
| 572 |
+
aria-hidden
|
| 573 |
+
className={clsx(
|
| 574 |
+
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[9.5px] font-semibold uppercase tracking-[0.1em]",
|
| 575 |
+
enabled
|
| 576 |
+
? "bg-[var(--accent-faint)] text-[var(--accent)]"
|
| 577 |
+
: "bg-[var(--muted)]/15 text-[var(--muted)]",
|
| 578 |
+
)}
|
| 579 |
+
>
|
| 580 |
+
{enabled ? "on" : "off"}
|
| 581 |
+
</span>
|
| 582 |
+
</button>
|
| 583 |
+
{description ? (
|
| 584 |
+
<button
|
| 585 |
+
type="button"
|
| 586 |
+
onClick={onInfoToggle}
|
| 587 |
+
aria-label={`About ${tool.label}`}
|
| 588 |
+
aria-expanded={infoOpen}
|
| 589 |
+
className={clsx(
|
| 590 |
+
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full transition",
|
| 591 |
+
enabled
|
| 592 |
+
? "text-[var(--accent)] hover:bg-[var(--accent-soft)]"
|
| 593 |
+
: "text-[var(--muted)] hover:bg-[var(--surface-hover)] hover:text-[var(--ink)]",
|
| 594 |
+
)}
|
| 595 |
+
>
|
| 596 |
+
<Info className="h-3.5 w-3.5" />
|
| 597 |
+
</button>
|
| 598 |
+
) : (
|
| 599 |
+
<span className="h-5 w-5 shrink-0" aria-hidden />
|
| 600 |
+
)}
|
| 601 |
+
</div>
|
| 602 |
+
{description && infoOpen ? (
|
| 603 |
+
<div
|
| 604 |
+
role="dialog"
|
| 605 |
+
className="absolute left-0 right-0 bottom-full z-20 mb-1 rounded-[0.9rem] border border-[var(--line-strong)] bg-[var(--surface-strong)] p-3 shadow-[0_12px_32px_rgba(0,0,0,0.18)] backdrop-blur-md"
|
| 606 |
+
>
|
| 607 |
+
<p className="text-[12px] leading-[1.45] text-[var(--ink)]">
|
| 608 |
+
{description}
|
| 609 |
+
</p>
|
| 610 |
+
</div>
|
| 611 |
+
) : null}
|
| 612 |
</div>
|
| 613 |
);
|
| 614 |
}
|
scripts/api.py
CHANGED
|
@@ -45,6 +45,7 @@ class ApiChatRequest(BaseModel):
|
|
| 45 |
history: list[ApiChatTurn] = Field(default_factory=list)
|
| 46 |
messages: list[dict[str, Any]] | None = None
|
| 47 |
sourceKeys: list[str] | None = None
|
|
|
|
| 48 |
model: str | None = None
|
| 49 |
includeReasoning: bool = True
|
| 50 |
threadId: str = ""
|
|
@@ -147,13 +148,32 @@ def build_chat_request(payload: ApiChatRequest) -> ChatRequest:
|
|
| 147 |
key for key in requested_source_keys if key in allowed_source_keys
|
| 148 |
)
|
| 149 |
) or DEFAULT_SELECTED_SOURCE_KEYS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
return ChatRequest(
|
| 151 |
query=query,
|
| 152 |
history=history,
|
| 153 |
source_keys=source_keys,
|
| 154 |
-
model_name=
|
| 155 |
include_reasoning=bool(payload.includeReasoning),
|
| 156 |
thread_id=(payload.threadId or "").strip(),
|
|
|
|
| 157 |
)
|
| 158 |
|
| 159 |
|
|
@@ -378,7 +398,7 @@ def _source_entries() -> list[dict[str, Any]]:
|
|
| 378 |
def _tool_catalog(model_name: str) -> list[dict[str, Any]]:
|
| 379 |
retrieval_tool: dict[str, Any] = {
|
| 380 |
"key": "retrieval",
|
| 381 |
-
"label": "
|
| 382 |
"kind": "configurable",
|
| 383 |
"active": True,
|
| 384 |
"sources": _source_entries(),
|
|
|
|
| 45 |
history: list[ApiChatTurn] = Field(default_factory=list)
|
| 46 |
messages: list[dict[str, Any]] | None = None
|
| 47 |
sourceKeys: list[str] | None = None
|
| 48 |
+
enabledTools: list[str] | None = None
|
| 49 |
model: str | None = None
|
| 50 |
includeReasoning: bool = True
|
| 51 |
threadId: str = ""
|
|
|
|
| 148 |
key for key in requested_source_keys if key in allowed_source_keys
|
| 149 |
)
|
| 150 |
) or DEFAULT_SELECTED_SOURCE_KEYS
|
| 151 |
+
model_name = (payload.model or DEFAULT_MODEL_NAME).strip()
|
| 152 |
+
if payload.enabledTools is None:
|
| 153 |
+
enabled_tools = tuple(
|
| 154 |
+
tool["key"]
|
| 155 |
+
for tool in _tool_catalog(model_name)
|
| 156 |
+
if tool["kind"] == "toggle"
|
| 157 |
+
)
|
| 158 |
+
else:
|
| 159 |
+
allowed_tool_keys = {
|
| 160 |
+
tool["key"]
|
| 161 |
+
for tool in _tool_catalog(model_name)
|
| 162 |
+
if tool["kind"] == "toggle"
|
| 163 |
+
}
|
| 164 |
+
enabled_tools = tuple(
|
| 165 |
+
dict.fromkeys(
|
| 166 |
+
key for key in payload.enabledTools if key in allowed_tool_keys
|
| 167 |
+
)
|
| 168 |
+
)
|
| 169 |
return ChatRequest(
|
| 170 |
query=query,
|
| 171 |
history=history,
|
| 172 |
source_keys=source_keys,
|
| 173 |
+
model_name=model_name,
|
| 174 |
include_reasoning=bool(payload.includeReasoning),
|
| 175 |
thread_id=(payload.threadId or "").strip(),
|
| 176 |
+
enabled_tools=enabled_tools,
|
| 177 |
)
|
| 178 |
|
| 179 |
|
|
|
|
| 398 |
def _tool_catalog(model_name: str) -> list[dict[str, Any]]:
|
| 399 |
retrieval_tool: dict[str, Any] = {
|
| 400 |
"key": "retrieval",
|
| 401 |
+
"label": "Knowledge base",
|
| 402 |
"kind": "configurable",
|
| 403 |
"active": True,
|
| 404 |
"sources": _source_entries(),
|
scripts/chat_service.py
CHANGED
|
@@ -17,7 +17,7 @@ from langgraph.checkpoint.memory import InMemorySaver
|
|
| 17 |
|
| 18 |
from .chat_types import ChatEvent, ChatRequest, ChatTurn, SourceMatch
|
| 19 |
from .chroma_rag import LocalChromaRetriever, format_tool_payload, parse_tool_payload
|
| 20 |
-
from .prompts import
|
| 21 |
from .setup import (
|
| 22 |
DOCUMENT_DICT_PATH,
|
| 23 |
SOURCE_KEY_TO_LABEL,
|
|
@@ -383,34 +383,44 @@ class GeminiServerSideToolsMiddleware(AgentMiddleware):
|
|
| 383 |
return await handler(self._inject(request))
|
| 384 |
|
| 385 |
|
| 386 |
-
@lru_cache(maxsize=
|
| 387 |
-
def build_agent(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
model = build_chat_model(model_name, include_thoughts=include_thoughts)
|
| 389 |
tools: list[Any] = [retrieve_tutor_context]
|
| 390 |
middleware: list[AgentMiddleware] = []
|
|
|
|
| 391 |
if is_google_genai_model(model_name):
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
|
|
|
|
|
|
|
|
|
| 395 |
elif is_anthropic_model(model_name):
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
|
|
|
|
|
|
| 410 |
return create_agent(
|
| 411 |
model=model,
|
| 412 |
tools=tools,
|
| 413 |
-
system_prompt=
|
| 414 |
context_schema=AppContext,
|
| 415 |
checkpointer=CHECKPOINTER,
|
| 416 |
middleware=middleware,
|
|
@@ -604,7 +614,11 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
|
|
| 604 |
google_search_match_count = 0
|
| 605 |
|
| 606 |
logfire.info("Running query", query=request.query)
|
| 607 |
-
agent = build_agent(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 608 |
active_thread_id = sync_thread_with_history(
|
| 609 |
agent,
|
| 610 |
request.thread_id.strip() or new_thread_id(),
|
|
|
|
| 17 |
|
| 18 |
from .chat_types import ChatEvent, ChatRequest, ChatTurn, SourceMatch
|
| 19 |
from .chroma_rag import LocalChromaRetriever, format_tool_payload, parse_tool_payload
|
| 20 |
+
from .prompts import build_system_prompt
|
| 21 |
from .setup import (
|
| 22 |
DOCUMENT_DICT_PATH,
|
| 23 |
SOURCE_KEY_TO_LABEL,
|
|
|
|
| 383 |
return await handler(self._inject(request))
|
| 384 |
|
| 385 |
|
| 386 |
+
@lru_cache(maxsize=32)
|
| 387 |
+
def build_agent(
|
| 388 |
+
model_name: str,
|
| 389 |
+
enabled_tools: tuple[str, ...] = (),
|
| 390 |
+
include_thoughts: bool = False,
|
| 391 |
+
):
|
| 392 |
model = build_chat_model(model_name, include_thoughts=include_thoughts)
|
| 393 |
tools: list[Any] = [retrieve_tutor_context]
|
| 394 |
middleware: list[AgentMiddleware] = []
|
| 395 |
+
enabled = set(enabled_tools)
|
| 396 |
if is_google_genai_model(model_name):
|
| 397 |
+
if "web_search" in enabled:
|
| 398 |
+
tools.append({"google_search": {}})
|
| 399 |
+
if "url_context" in enabled:
|
| 400 |
+
tools.append({"url_context": {}})
|
| 401 |
+
if enabled & {"web_search", "url_context"}:
|
| 402 |
+
middleware.append(GeminiServerSideToolsMiddleware())
|
| 403 |
elif is_anthropic_model(model_name):
|
| 404 |
+
if "web_search" in enabled:
|
| 405 |
+
tools.append(
|
| 406 |
+
{
|
| 407 |
+
"type": "web_search_20250305",
|
| 408 |
+
"name": "web_search",
|
| 409 |
+
"allowed_callers": ["direct"],
|
| 410 |
+
}
|
| 411 |
+
)
|
| 412 |
+
if "web_fetch" in enabled:
|
| 413 |
+
tools.append(
|
| 414 |
+
{
|
| 415 |
+
"type": "web_fetch_20260209",
|
| 416 |
+
"name": "web_fetch",
|
| 417 |
+
"allowed_callers": ["direct"],
|
| 418 |
+
}
|
| 419 |
+
)
|
| 420 |
return create_agent(
|
| 421 |
model=model,
|
| 422 |
tools=tools,
|
| 423 |
+
system_prompt=build_system_prompt(model_name, enabled_tools),
|
| 424 |
context_schema=AppContext,
|
| 425 |
checkpointer=CHECKPOINTER,
|
| 426 |
middleware=middleware,
|
|
|
|
| 614 |
google_search_match_count = 0
|
| 615 |
|
| 616 |
logfire.info("Running query", query=request.query)
|
| 617 |
+
agent = build_agent(
|
| 618 |
+
request.model_name,
|
| 619 |
+
enabled_tools=tuple(request.enabled_tools),
|
| 620 |
+
include_thoughts=include_reasoning,
|
| 621 |
+
)
|
| 622 |
active_thread_id = sync_thread_with_history(
|
| 623 |
agent,
|
| 624 |
request.thread_id.strip() or new_thread_id(),
|
scripts/chat_types.py
CHANGED
|
@@ -28,6 +28,7 @@ class ChatRequest:
|
|
| 28 |
model_name: str = ""
|
| 29 |
include_reasoning: bool = False
|
| 30 |
thread_id: str = ""
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
@dataclass(frozen=True, slots=True)
|
|
|
|
| 28 |
model_name: str = ""
|
| 29 |
include_reasoning: bool = False
|
| 30 |
thread_id: str = ""
|
| 31 |
+
enabled_tools: tuple[str, ...] = ()
|
| 32 |
|
| 33 |
|
| 34 |
@dataclass(frozen=True, slots=True)
|
scripts/prompts.py
CHANGED
|
@@ -1,17 +1,69 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
USE retrieval for:
|
| 17 |
- Questions about course content, concepts, code examples, or documentation.
|
|
@@ -20,24 +72,9 @@ USE retrieval for:
|
|
| 20 |
DO NOT use retrieval for:
|
| 21 |
- Greetings or small talk.
|
| 22 |
- Questions about your own role or capabilities in this app (answer directly).
|
| 23 |
-
- Questions you can answer fully from general knowledge with no corpus dependency.
|
| 24 |
-
|
| 25 |
-
## When to use web search / URL reading
|
| 26 |
-
|
| 27 |
-
USE `google_search` for:
|
| 28 |
-
- Questions about recent events, releases, or API changes that may post-date
|
| 29 |
-
the corpus.
|
| 30 |
-
- Facts the corpus likely does not cover (product pricing, news, etc.).
|
| 31 |
-
|
| 32 |
-
USE `url_context` for:
|
| 33 |
-
- Any URL the user pastes in their question.
|
| 34 |
-
- A search result you need to read in detail to answer accurately.
|
| 35 |
-
|
| 36 |
-
Prefer `retrieve_tutor_context` first when the question is clearly about
|
| 37 |
-
course material. Combine tools when it helps (e.g. retrieve corpus context,
|
| 38 |
-
then search the web for the latest update).
|
| 39 |
|
| 40 |
-
## How to call the retrieval tool
|
| 41 |
|
| 42 |
You have two strategies. Pick one per turn:
|
| 43 |
|
|
@@ -55,9 +92,9 @@ You have two strategies. Pick one per turn:
|
|
| 55 |
Start with one focused query. If the results are off-topic, too sparse,
|
| 56 |
or miss a key aspect of the question, call the tool again with a
|
| 57 |
refined query (different keywords, more specific, or targeting the gap).
|
| 58 |
-
Stop after at most 2 sequential calls.
|
| 59 |
|
| 60 |
-
## Answering rules
|
| 61 |
|
| 62 |
- Ground factual claims about the corpus in the retrieved results.
|
| 63 |
- If retrieval results are weak or missing, say the topic is not well
|
|
@@ -69,5 +106,51 @@ You have two strategies. Pick one per turn:
|
|
| 69 |
- Include complete, runnable code blocks when code is relevant.
|
| 70 |
- End with a short invitation for a follow-up question.
|
| 71 |
|
| 72 |
-
The retrieval tool returns JSON with matched passages and source metadata.
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
BASE_PROMPT_HEADER = """You are an AI teacher for applied AI, LLM, RAG, and Python topics.
|
| 5 |
+
|
| 6 |
+
Your job is to answer student questions clearly and accurately."""
|
| 7 |
+
|
| 8 |
+
RETRIEVAL_TOOL_LINE = (
|
| 9 |
+
"- `retrieve_tutor_context` — retrieval over the course and documentation\n"
|
| 10 |
+
" corpus. Use this for anything that depends on course content."
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
WEB_TOOL_LINES = {
|
| 14 |
+
"google-genai": {
|
| 15 |
+
"web_search": (
|
| 16 |
+
"- `google_search` (Gemini built-in) — live web search for current\n"
|
| 17 |
+
" events, recent library releases, or facts outside the corpus."
|
| 18 |
+
),
|
| 19 |
+
"url_context": (
|
| 20 |
+
"- `url_context` (Gemini built-in) — read a specific URL in depth when\n"
|
| 21 |
+
" the user provides one or when a search result needs to be inspected\n"
|
| 22 |
+
" closely."
|
| 23 |
+
),
|
| 24 |
+
},
|
| 25 |
+
"anthropic": {
|
| 26 |
+
"web_search": (
|
| 27 |
+
"- `web_search` (Claude built-in) — live web search for current events,\n"
|
| 28 |
+
" recent library releases, or facts outside the corpus."
|
| 29 |
+
),
|
| 30 |
+
"web_fetch": (
|
| 31 |
+
"- `web_fetch` (Claude built-in) — read a specific URL in depth when the\n"
|
| 32 |
+
" user provides one or when a search result needs to be inspected closely."
|
| 33 |
+
),
|
| 34 |
+
},
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
WEB_USAGE_SECTIONS = {
|
| 38 |
+
"google-genai": {
|
| 39 |
+
"web_search": (
|
| 40 |
+
"USE `google_search` for:\n"
|
| 41 |
+
"- Questions about recent events, releases, or API changes that may\n"
|
| 42 |
+
" post-date the corpus.\n"
|
| 43 |
+
"- Facts the corpus likely does not cover (product pricing, news, etc.)."
|
| 44 |
+
),
|
| 45 |
+
"url_context": (
|
| 46 |
+
"USE `url_context` for:\n"
|
| 47 |
+
"- Any URL the user pastes in their question.\n"
|
| 48 |
+
"- A search result you need to read in detail to answer accurately."
|
| 49 |
+
),
|
| 50 |
+
},
|
| 51 |
+
"anthropic": {
|
| 52 |
+
"web_search": (
|
| 53 |
+
"USE `web_search` for:\n"
|
| 54 |
+
"- Questions about recent events, releases, or API changes that may\n"
|
| 55 |
+
" post-date the corpus.\n"
|
| 56 |
+
"- Facts the corpus likely does not cover (product pricing, news, etc.)."
|
| 57 |
+
),
|
| 58 |
+
"web_fetch": (
|
| 59 |
+
"USE `web_fetch` for:\n"
|
| 60 |
+
"- Any URL the user pastes in their question.\n"
|
| 61 |
+
"- A search result you need to read in detail to answer accurately."
|
| 62 |
+
),
|
| 63 |
+
},
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
RETRIEVAL_USAGE_SECTION = """## When to use the retrieval tool
|
| 67 |
|
| 68 |
USE retrieval for:
|
| 69 |
- Questions about course content, concepts, code examples, or documentation.
|
|
|
|
| 72 |
DO NOT use retrieval for:
|
| 73 |
- Greetings or small talk.
|
| 74 |
- Questions about your own role or capabilities in this app (answer directly).
|
| 75 |
+
- Questions you can answer fully from general knowledge with no corpus dependency."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
+
RETRIEVAL_CALL_STRATEGY = """## How to call the retrieval tool
|
| 78 |
|
| 79 |
You have two strategies. Pick one per turn:
|
| 80 |
|
|
|
|
| 92 |
Start with one focused query. If the results are off-topic, too sparse,
|
| 93 |
or miss a key aspect of the question, call the tool again with a
|
| 94 |
refined query (different keywords, more specific, or targeting the gap).
|
| 95 |
+
Stop after at most 2 sequential calls."""
|
| 96 |
|
| 97 |
+
ANSWERING_RULES = """## Answering rules
|
| 98 |
|
| 99 |
- Ground factual claims about the corpus in the retrieved results.
|
| 100 |
- If retrieval results are weak or missing, say the topic is not well
|
|
|
|
| 106 |
- Include complete, runnable code blocks when code is relevant.
|
| 107 |
- End with a short invitation for a follow-up question.
|
| 108 |
|
| 109 |
+
The retrieval tool returns JSON with matched passages and source metadata."""
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _provider_key(model_name: str) -> str:
|
| 113 |
+
normalized = (model_name or "").strip()
|
| 114 |
+
if ":" in normalized:
|
| 115 |
+
return normalized.split(":", 1)[0]
|
| 116 |
+
if normalized.startswith("gpt-"):
|
| 117 |
+
return "openai"
|
| 118 |
+
if normalized.startswith("claude"):
|
| 119 |
+
return "anthropic"
|
| 120 |
+
if normalized.startswith("gemini"):
|
| 121 |
+
return "google-genai"
|
| 122 |
+
return ""
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def build_system_prompt(model_name: str, enabled_tools: tuple[str, ...]) -> str:
|
| 126 |
+
provider = _provider_key(model_name)
|
| 127 |
+
enabled = set(enabled_tools)
|
| 128 |
+
tool_lines = [RETRIEVAL_TOOL_LINE]
|
| 129 |
+
usage_sections: list[str] = []
|
| 130 |
+
provider_web_tools = WEB_TOOL_LINES.get(provider, {})
|
| 131 |
+
provider_web_usage = WEB_USAGE_SECTIONS.get(provider, {})
|
| 132 |
+
for key in ("web_search", "url_context", "web_fetch"):
|
| 133 |
+
if key in enabled and key in provider_web_tools:
|
| 134 |
+
tool_lines.append(provider_web_tools[key])
|
| 135 |
+
usage_sections.append(provider_web_usage[key])
|
| 136 |
+
|
| 137 |
+
if len(tool_lines) == 1:
|
| 138 |
+
intro = "You have one tool available:"
|
| 139 |
+
else:
|
| 140 |
+
intro = f"You have {len(tool_lines)} tools available:"
|
| 141 |
+
|
| 142 |
+
parts = [
|
| 143 |
+
BASE_PROMPT_HEADER,
|
| 144 |
+
f"{intro}\n\n" + "\n".join(tool_lines),
|
| 145 |
+
RETRIEVAL_USAGE_SECTION,
|
| 146 |
+
]
|
| 147 |
+
if usage_sections:
|
| 148 |
+
parts.append("## When to use web search / URL reading\n\n" + "\n\n".join(usage_sections))
|
| 149 |
+
parts.append(
|
| 150 |
+
"Prefer `retrieve_tutor_context` first when the question is clearly about\n"
|
| 151 |
+
"course material. Combine tools when it helps (e.g. retrieve corpus\n"
|
| 152 |
+
"context, then search the web for the latest update)."
|
| 153 |
+
)
|
| 154 |
+
parts.append(RETRIEVAL_CALL_STRATEGY)
|
| 155 |
+
parts.append(ANSWERING_RULES)
|
| 156 |
+
return "\n\n".join(parts) + "\n"
|
tests/test_api.py
CHANGED
|
@@ -48,7 +48,7 @@ class ApiTestCase(unittest.TestCase):
|
|
| 48 |
self.assertIn("web_search", tool_keys)
|
| 49 |
self.assertIn("url_context", tool_keys)
|
| 50 |
|
| 51 |
-
def
|
| 52 |
with TestClient(app) as client:
|
| 53 |
response = client.get(
|
| 54 |
"/api/tools", params={"model": "anthropic:claude-sonnet-4-6"}
|
|
@@ -57,7 +57,8 @@ class ApiTestCase(unittest.TestCase):
|
|
| 57 |
self.assertEqual(response.status_code, 200)
|
| 58 |
tool_keys = {tool["key"] for tool in response.json()["tools"]}
|
| 59 |
self.assertIn("retrieval", tool_keys)
|
| 60 |
-
self.
|
|
|
|
| 61 |
self.assertNotIn("url_context", tool_keys)
|
| 62 |
|
| 63 |
def test_chat_stream_returns_ai_sdk_parts(self) -> None:
|
|
@@ -66,6 +67,7 @@ class ApiTestCase(unittest.TestCase):
|
|
| 66 |
self.assertEqual(request.history[0].role, "assistant")
|
| 67 |
self.assertEqual(request.history[0].content, "Previous answer")
|
| 68 |
self.assertEqual(request.source_keys, ("langchain", "transformers"))
|
|
|
|
| 69 |
self.assertEqual(request.thread_id, "thread_0")
|
| 70 |
yield ChatEvent("thread_started", {"thread_id": "thread_1"})
|
| 71 |
yield ChatEvent("message_started", {"message_id": "message_1"})
|
|
@@ -127,6 +129,7 @@ class ApiTestCase(unittest.TestCase):
|
|
| 127 |
},
|
| 128 |
],
|
| 129 |
"sourceKeys": ["langchain", "transformers"],
|
|
|
|
| 130 |
"threadId": "thread_0",
|
| 131 |
}
|
| 132 |
|
|
|
|
| 48 |
self.assertIn("web_search", tool_keys)
|
| 49 |
self.assertIn("url_context", tool_keys)
|
| 50 |
|
| 51 |
+
def test_list_tools_for_anthropic_model(self) -> None:
|
| 52 |
with TestClient(app) as client:
|
| 53 |
response = client.get(
|
| 54 |
"/api/tools", params={"model": "anthropic:claude-sonnet-4-6"}
|
|
|
|
| 57 |
self.assertEqual(response.status_code, 200)
|
| 58 |
tool_keys = {tool["key"] for tool in response.json()["tools"]}
|
| 59 |
self.assertIn("retrieval", tool_keys)
|
| 60 |
+
self.assertIn("web_search", tool_keys)
|
| 61 |
+
self.assertIn("web_fetch", tool_keys)
|
| 62 |
self.assertNotIn("url_context", tool_keys)
|
| 63 |
|
| 64 |
def test_chat_stream_returns_ai_sdk_parts(self) -> None:
|
|
|
|
| 67 |
self.assertEqual(request.history[0].role, "assistant")
|
| 68 |
self.assertEqual(request.history[0].content, "Previous answer")
|
| 69 |
self.assertEqual(request.source_keys, ("langchain", "transformers"))
|
| 70 |
+
self.assertEqual(request.enabled_tools, ("web_search",))
|
| 71 |
self.assertEqual(request.thread_id, "thread_0")
|
| 72 |
yield ChatEvent("thread_started", {"thread_id": "thread_1"})
|
| 73 |
yield ChatEvent("message_started", {"message_id": "message_1"})
|
|
|
|
| 129 |
},
|
| 130 |
],
|
| 131 |
"sourceKeys": ["langchain", "transformers"],
|
| 132 |
+
"enabledTools": ["web_search", "not_a_real_tool"],
|
| 133 |
"threadId": "thread_0",
|
| 134 |
}
|
| 135 |
|