Commit b903391a authored by Kantz's avatar Kantz
Browse files

Merge branch 'tool_calling' into 'main'

Tool calling

See merge request kantz/tutor_react!1
parents 4ff58000 c58be6a8
__all__ = [
"hint_tool",
"math_tool",
"retrieval_tool",
]
import re
import sympy as sp
from sympy.parsing.latex import parse_latex
from sympy.parsing.sympy_parser import (
parse_expr,
standard_transformations,
implicit_multiplication_application,
)
transformations = standard_transformations + (implicit_multiplication_application,)
LATEX_HINTS = re.compile(r"(\\[a-zA-Z]+)|(\$[^$]+\$)|(\^\{)|(_\{)")
def looks_like_latex(text: str) -> bool:
return bool(LATEX_HINTS.search(text))
def parse_input(expr_text: str, sympy_symbols: dict[str, sp.Symbol]) -> sp.Expr:
if looks_like_latex(expr_text):
return parse_latex(expr_text)
return parse_expr(expr_text, transformations=transformations, local_dict=sympy_symbols)
def sympy_solve(task: str, input: str, symbols: list[str] | None = None) -> str:
try:
sympy_symbols: dict[str, sp.Symbol] = {}
if symbols:
for s in symbols:
sympy_symbols[s] = sp.symbols(s)
if "=" in input:
lhs, rhs = input.split("=")
expr = sp.Eq(parse_input(lhs, sympy_symbols), parse_input(rhs, sympy_symbols))
else:
expr = parse_input(input, sympy_symbols)
if task == "solve":
result = sp.solve(expr, list(sympy_symbols.values()) if symbols else None)
elif task == "simplify":
result = sp.simplify(expr)
elif task == "diff":
result = sp.diff(expr, *sympy_symbols.values())
elif task == "integrate":
result = sp.integrate(expr, *sympy_symbols.values())
else:
return f"Unsupported task: {task}"
return str(result)
except Exception as exc:
return f"SymPy error: {str(exc)}"
TOOL_SPEC = {
"type": "function",
"function": {
"name": "sympy_solve",
"description": (
"Loese oder bearbeite mathematische Ausdruecke mit SymPy. "
"Nutze dieses Tool, wenn eine mathematische Formel oder Gleichung erscheint."
),
"parameters": {
"type": "object",
"properties": {
"task": {
"type": "string",
"enum": ["solve", "simplify", "diff", "integrate"],
"description": "Mathematische Operation",
},
"input": {
"type": "string",
"description": "Mathematischer Ausdruck oder Gleichung, z. B. x**2 - 4 = 0",
},
"symbols": {
"type": "array",
"items": {"type": "string"},
"description": 'Variablen, z. B. ["x"]',
},
},
"required": ["task", "input"],
},
},
}
......@@ -4,6 +4,9 @@ python-dotenv
mpxpy
pillow
httpx
ollama
openai
sympy
psycopg[binary]
pgvector
pyyaml
......@@ -14,8 +14,8 @@ ROOT_DIR = Path(__file__).resolve().parents[1]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from app.services.embeddings import OpenAILikeEmbeddings
from app.services import vector_store
from app.deterministic_services.embeddings import OpenAILikeEmbeddings
from app.deterministic_services import vector_store
def build_embedder() -> OpenAILikeEmbeddings:
......
......@@ -6,6 +6,8 @@ type CanvasDrawerProps = {
onToggle: () => void;
onClear?: () => void;
onSave?: (dataUrl: string) => void | Promise<void>;
statusMessage?: string;
statusKind?: "info" | "error" | "ok";
};
export default function CanvasDrawer({
......@@ -13,6 +15,8 @@ export default function CanvasDrawer({
onToggle,
onClear,
onSave,
statusMessage,
statusKind,
}: CanvasDrawerProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [isDrawing, setIsDrawing] = useState(false);
......@@ -135,6 +139,13 @@ export default function CanvasDrawer({
</div>
{isVisible ? (
<div className="canvas-body">
{statusMessage ? (
<div
className={`canvas-status ${statusKind ? statusKind : "info"}`}
>
{statusMessage}
</div>
) : null}
<div className="canvas-surface">
<canvas
ref={canvasRef}
......
import { useState } from "react";
import { useEffect, useState } from "react";
import "../styles/theme.css";
import ChatWindow from "../components/Chat/ChatWindow";
import CanvasDrawer from "../components/Canvas/CanvasDrawer";
......@@ -6,17 +6,35 @@ import DocPanel from "../components/Retrieval/DocPanel";
import type { ChatMessage } from "../components/Chat/MessageList";
import type { RetrievedDoc } from "../components/Retrieval/DocPanel";
const initialMessages: ChatMessage[] = [
{
id: "m2",
role: "assistant",
text: "How can I help you? $2+2=5$",
},
];
const initialMessages: ChatMessage[] = [];
type ArchivedChatSummary = {
chat_id: string;
saved_at: string;
message_count: number;
preview: string;
};
type ArchivedChatDetail = {
chat_id: string;
saved_at: string;
history: ChatMessage[];
};
const createSessionId = () =>
`session_${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 8)}`;
export default function App() {
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
const [draft, setDraft] = useState("");
const [chatSessionId, setChatSessionId] = useState(createSessionId);
const [isArchiving, setIsArchiving] = useState(false);
const [archivedChats, setArchivedChats] = useState<ArchivedChatSummary[]>([]);
const [selectedArchiveId, setSelectedArchiveId] = useState("");
const [archiveError, setArchiveError] = useState<string | null>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isCanvasVisible, setIsCanvasVisible] = useState(false);
const [directChildren, setDirectChildren] = useState<RetrievedDoc[]>([]);
const [indirectChildren, setIndirectChildren] = useState<RetrievedDoc[]>([]);
......@@ -24,6 +42,10 @@ export default function App() {
const [sections, setSections] = useState<RetrievedDoc[]>([]);
const [retrievalLoading, setRetrievalLoading] = useState(false);
const [retrievalError, setRetrievalError] = useState<string | null>(null);
const [canvasStatus, setCanvasStatus] = useState<{
kind: "info" | "error" | "ok";
message: string;
} | null>(null);
const handleSend = async () => {
const trimmed = draft.trim();
......@@ -71,6 +93,7 @@ export default function App() {
role: message.role,
text: message.text,
})),
draft: chatSessionId,
}),
});
......@@ -111,16 +134,99 @@ export default function App() {
setIsCanvasVisible((prev) => !prev);
};
const handleCanvasSave = async (dataUrl: string) => {
setMessages((prev) => [
...prev,
{
id: `m-${Date.now()}-assistant`,
role: "assistant",
text: "Saving drawing and converting to LaTeX...",
},
]);
const resetChatState = () => {
setMessages(initialMessages);
setDraft("");
setDirectChildren([]);
setIndirectChildren([]);
setSubsections([]);
setSections([]);
setRetrievalLoading(false);
setRetrievalError(null);
setCanvasStatus(null);
setIsCanvasVisible(false);
};
const loadArchives = async () => {
setArchiveError(null);
try {
const response = await fetch(
"http://localhost:8000/api/chat/archives?limit=50"
);
if (!response.ok) {
throw new Error(`Archive list failed: ${response.status}`);
}
const payload: ArchivedChatSummary[] = await response.json();
setArchivedChats(payload);
if (payload.length && !selectedArchiveId) {
setSelectedArchiveId(payload[0].chat_id);
}
} catch (error) {
setArchiveError("Failed to load saved chats.");
void error;
}
};
const handleLoadArchive = async (chatId?: string) => {
const targetId = chatId || selectedArchiveId;
if (!targetId) {
return;
}
setArchiveError(null);
try {
const response = await fetch(
`http://localhost:8000/api/chat/archive/${targetId}`
);
if (!response.ok) {
throw new Error(`Archive load failed: ${response.status}`);
}
const payload: ArchivedChatDetail = await response.json();
resetChatState();
setMessages(payload.history || []);
setChatSessionId(payload.chat_id);
} catch (error) {
setArchiveError("Failed to load selected chat.");
void error;
}
};
const handleNewChat = async () => {
if (messages.length) {
setIsArchiving(true);
try {
await fetch("http://localhost:8000/api/chat/archive", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: messages.map((message) => ({
role: message.role,
text: message.text,
})),
draft: chatSessionId,
}),
});
} catch (error) {
void error;
} finally {
setIsArchiving(false);
}
}
resetChatState();
setChatSessionId(createSessionId());
await loadArchives();
};
const handleOpenSidebar = () => {
setIsSidebarOpen(true);
void loadArchives();
};
const handleCanvasSave = async (dataUrl: string) => {
setCanvasStatus({
kind: "info",
message: "Saving drawing and converting to LaTeX...",
});
try {
const response = await fetch("http://localhost:8000/api/canvas/save", {
......@@ -130,24 +236,39 @@ export default function App() {
});
if (!response.ok) {
throw new Error(`Save failed: ${response.status}`);
let message = `Save failed: ${response.status}`;
try {
const errorPayload: { detail?: string } = await response.json();
if (errorPayload.detail) {
message = errorPayload.detail;
}
} catch (error) {
void error;
}
setCanvasStatus({ kind: "error", message });
return;
}
const payload: { latex?: string } = await response.json();
const payload: { status?: string; latex?: string } = await response.json();
if (payload.status && payload.status !== "ok") {
setCanvasStatus({
kind: "error",
message: payload.latex || "Canvas save failed.",
});
return;
}
if (payload.latex) {
setDraft((prev) =>
prev ? `${prev} ${payload.latex}` : payload.latex
);
setCanvasStatus({ kind: "ok", message: "Canvas saved." });
setIsCanvasVisible(false);
}
} catch (error) {
setMessages((prev) => [
...prev,
{
id: `m-${Date.now()}-assistant`,
role: "assistant",
text: "Canvas save failed. Check the API logs.",
},
]);
setCanvasStatus({
kind: "error",
message: "Canvas save failed. Check the API logs.",
});
void error;
}
};
......@@ -219,6 +340,13 @@ export default function App() {
return (
<div className="app-shell">
<header className="app-header">
<button
type="button"
className="btn sidebar-toggle"
onClick={handleOpenSidebar}
>
Chats
</button>
<div className="brand">
<span className="brand-mark">SUM</span>
<div className="brand-text">
......@@ -248,6 +376,8 @@ export default function App() {
isVisible={isCanvasVisible}
onToggle={handleToggleCanvas}
onSave={handleCanvasSave}
statusMessage={canvasStatus?.message}
statusKind={canvasStatus?.kind}
/>
) : null}
</section>
......@@ -265,6 +395,64 @@ export default function App() {
/>
</aside>
</main>
<div className={`sidebar ${isSidebarOpen ? "active" : ""}`}>
<div className="sd-header">
<h4 className="sd-title">Saved Chats</h4>
<button
type="button"
className="sidebar-button"
onClick={() => setIsSidebarOpen(false)}
>
X
</button>
</div>
<div className="sd-body">
<ul className="sd-list">
<li>
<button
type="button"
className="sd-link"
onClick={handleNewChat}
disabled={isArchiving}
>
{isArchiving ? "Saving..." : "New Chat"}
</button>
</li>
<li>
<div className="sd-separator" />
</li>
{archivedChats.length ? (
archivedChats.map((item) => (
<li key={item.chat_id}>
<button
type="button"
className={`sd-link ${
selectedArchiveId === item.chat_id ? "active" : ""
}`}
onClick={() => {
setSelectedArchiveId(item.chat_id);
void handleLoadArchive(item.chat_id);
}}
>
{item.preview || item.chat_id} ({item.message_count})
</button>
</li>
))
) : (
<li>
<div className="sd-empty">No saved chats yet.</div>
</li>
)}
</ul>
{archiveError ? (
<div className="chat-archive-error">{archiveError}</div>
) : null}
</div>
</div>
<div
className={`sidebar-overlay ${isSidebarOpen ? "active" : ""}`}
onClick={() => setIsSidebarOpen(false)}
/>
</div>
);
}
......@@ -99,6 +99,133 @@ body {
flex: 1;
}
.chat-archive-error {
padding: 8px 10px;
border-radius: 10px;
background: #f3d9d6;
color: #8a3b2f;
border: 1px solid #e2b4ae;
font-size: 12px;
}
.sidebar-toggle {
background: #ffffff;
align-self: flex-start;
}
.sidebar {
width: 280px;
min-height: 100vh;
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.16);
background-color: #ffffff;
position: fixed;
top: 0;
left: -100%;
z-index: 10;
transition: 0.5s;
border-radius: 0 16px 16px 0;
}
.sidebar.active {
left: 0;
}
.sd-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #e2ded5;
}
.sd-title {
font-size: 16px;
margin: 0;
}
.sidebar-button {
border: none;
background: #1d1b16;
color: #fef9f0;
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
}
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.4);
transition: 0.5s;
opacity: 0;
visibility: hidden;
z-index: 5;
}
.sidebar-overlay.active {
opacity: 1;
visibility: visible;
}
.sd-body {
padding: 16px;
max-height: calc(100vh - 66px);
overflow-x: hidden;
}
.sd-list {
display: inline-block;
width: 100%;
margin: 0;
padding: 0;
}
.sd-list li {
list-style: none;
margin-bottom: 8px;
}
.sd-link {
display: inline-flex;
width: 100%;
padding: 10px 14px;
color: #475f7b;
background-color: #e5e8ec;
border-radius: 6px;
cursor: pointer;
text-decoration: none;
border: none;
text-align: left;
font-family: inherit;
}
.sd-link.active {
background-color: #1d1b16;
color: #fef9f0;
}
.sd-link:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sd-separator {
height: 1px;
background: #d8d1c4;
margin: 6px 0 10px;
}
.sd-empty {
padding: 10px 12px;
background: #f6f0e4;
border-radius: 8px;
color: #6f675d;
font-size: 12px;
}
.chat-title {
font-weight: 600;
}
......@@ -204,6 +331,27 @@ body {
gap: 12px;
}
.canvas-status {
padding: 8px 10px;
border-radius: 10px;
background: #f6f0e4;
color: #6f675d;
font-size: 12px;
border: 1px solid #d8d1c4;
}
.canvas-status.error {
background: #f3d9d6;
color: #8a3b2f;
border-color: #e2b4ae;
}
.canvas-status.ok {
background: #dfe9d6;
color: #2d5c2c;
border-color: #b7c9ab;
}
.canvas-surface {
border: 1px dashed #b9b2a4;
border-radius: 12px;
......@@ -234,6 +382,7 @@ body {
.retrieval-column {
display: flex;
flex-direction: column;
gap: 16px;
}
.retrieval-panel {
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment