Commit 6c805864 authored by Kantz's avatar Kantz
Browse files

Merge branch 'dev' into 'main'

Dev

See merge request kantz/tutor_react!13
parents 5a28f888 358ac39b
......@@ -43,14 +43,14 @@ ODER
"""
def solve_with_tools(user_text: str) -> str:
return "none"
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": user_text + "\nAntworte nur mit 'Keine Lösung' falls es in dieser Nachricht KEINE Berechung gibt."},
]
result, tool_outputs = llm_client.chat_with_tools(
result = llm_client.chat(
messages=messages,
tools=[sympy_solve],
use_ollama=True,
)
return llm_client.get_message_content(result)
......@@ -4,7 +4,7 @@ QA_SYSTEM_PROMPT ="""
Du bist ein Mathe-Tutor. Antworte auf Deutsch, klar, korrekt und sehr kurz.
Nutze ausschließlich den bereitgestellten Kontext.
Wenn die Frage dort nicht beantwortet wird, antworte nur mit:
Wenn dort nichts zu dem Thema steht, antworte nur mit:
„Dazu steht nichts im Material.“
verwende die $ für mathematische Formeln, z.B. $a^2 + b^2 = c^2$
......
......@@ -22,6 +22,7 @@ class TaskFile(BaseModel):
title: str
intro: str
tasks: List[TaskItem]
subsections: List[str] = Field(default_factory=list)
class TasksResponse(BaseModel):
......
......@@ -73,7 +73,7 @@ def save_sheet(sheet: dict[str, Any]) -> None:
chat_id = sheet.get("chat_id", "unknown")
latest_path = os.path.join(_LOG_DIR, f"{chat_id}_latest.json")
payload = json.dumps(sheet, ensure_ascii=True, indent=2)
payload = json.dumps(sheet, ensure_ascii=False, indent=2)
with open(latest_path, "w", encoding="utf-8") as file_handle:
file_handle.write(payload)
......
......@@ -54,10 +54,10 @@ def _ensure_context_task_fields(state: base.ChatState, query_text: str) -> tuple
return selected_file_id, selected_task_id
def _retrieve_context_for_task(state: base.ChatState, query_text: str) -> None:
def _retrieve_context_for_task(state: base.ChatState, query_text: str) -> int:
refs = task_catalog.get_selected_task_subsection_refs(state.sheet)
if not refs:
return
return 0
def _retrieve() -> dict:
sources = retrieval_store.retrieve_for_subsections(
......@@ -70,7 +70,7 @@ def _retrieve_context_for_task(state: base.ChatState, query_text: str) -> None:
"source_count": len(sources),
}
base.log_timed_call(
result = base.log_timed_call(
state.tool_log,
"retrieve_context_with_task_subsections",
{
......@@ -79,13 +79,20 @@ def _retrieve_context_for_task(state: base.ChatState, query_text: str) -> None:
},
_retrieve,
)
return int(result.get("source_count", 0))
# beim ersten Aufruf
def _on_bootstrap(state: base.ChatState, query_text: str) -> None:
base.bootstrap_retrieval(state.sheet, query_text, state.tool_log)
_ensure_context_task_fields(state, query_text)
_retrieve_context_for_task(state, query_text)
source_count = _retrieve_context_for_task(state, query_text)
print(f"_retrieve_context_for_task source_count={source_count}")
if source_count > 0:
_ensure_context_task_fields(state, query_text)
task_text = context_store.context_store_new.get_task(state.sheet).strip()
retrieval_query = query_text
if task_text:
retrieval_query = f"Aufgabe:\n{task_text}\n\n{query_text}"
base.bootstrap_retrieval(state.sheet, retrieval_query, state.tool_log)
# Sonstiges bei jedem Aufruf
......
......@@ -52,7 +52,7 @@ def archive_chat(
}
os.makedirs(_LOG_DIR, exist_ok=True)
payload = json.dumps(record, ensure_ascii=True)
payload = json.dumps(record, ensure_ascii=False)
with _LOCK:
with open(_LOG_PATH, "a", encoding="utf-8") as f:
f.write(payload + "\n")
......
......@@ -259,6 +259,7 @@ def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[d
file_id = str(task_file.get("_file_id", ""))
title = str(task_file.get("title", "")).strip()
intro = str(task_file.get("intro", "")).strip()
subsections = task_file.get("subsections", [])
tasks: list[dict[str, str]] = []
for item in task_file.get("tasks", []):
if not isinstance(item, dict):
......@@ -280,6 +281,7 @@ def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[d
"title": title,
"intro": intro,
"tasks": tasks,
"subsections": subsections,
}
)
return response
......@@ -42,5 +42,5 @@ def write_tool_log(entries: list[dict], created_at: str | None = None, chat_id:
except (OSError, json.JSONDecodeError):
pass
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=True, indent=2)
json.dump(payload, f, ensure_ascii=False, indent=2)
return path
......@@ -9,6 +9,7 @@ export type TaskFile = {
title: string;
intro: string;
tasks: TaskItem[];
subsections?: string[];
};
export type TasksResponse = {
......
......@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, type PropsWithChildren } fro
import { fetchHealth, type HealthResponse } from "../../api/healthApi";
import { t } from "../../i18n";
import "../../styles/theme.css";
import sumintLogo from "../../../SuMINT-Logo.png";
type GateState = "checking" | "healthy" | "unhealthy";
......@@ -93,7 +94,7 @@ export default function BackendHealthGate({ children }: PropsWithChildren) {
<div className="backend-health-shell">
<header className="app-header backend-health-header">
<div className="brand">
<span className="brand-mark">SUM</span>
<img className="brand-logo" src={sumintLogo} alt="SuMINT Logo" />
<div className="brand-text">
<div className="brand-title">Math Tutor</div>
<div className="brand-subtitle">{t("prototypeWorkspace")}</div>
......
......@@ -87,8 +87,8 @@
pen: "Stift",
clear: "Leeren",
saveAndConvert: "Speichern + Konvertieren",
hide: "verstecken",
show: "anzeigen",
hide: "Verstecken",
show: "Anzeigen",
newChat: "neuer Chat",
loading: "Lade...",
orchestratorMode: "Modus",
......
......@@ -12,6 +12,7 @@ import type { OrchestratorName } from "../api/orchestratorApi";
import { selectTask } from "../api/taskApi";
import { t } from "../i18n";
import { createSessionId, useTutorSession } from "../state/tutorSession";
import sumintLogo from "../../SuMINT-Logo.png";
const initialMessages: ChatMessage[] = [];
......@@ -793,33 +794,33 @@ export default function ChatPage() {
return (
<div className="app-shell">
<header className="app-header">
<header className="app-header app-header-chat">
{showChatsButton ? (
<button type="button" className="btn sidebar-toggle" onClick={handleOpenSidebar}>
{t("chats")}
</button>
) : null}
<div className="brand">
<span className="brand-mark">SUM</span>
<div className="brand-text">
<div className="brand-title">Math Tutor</div>
<div className="brand-subtitle">{t("prototypeWorkspace")}</div>
) : null}
<div className="brand">
<img className="brand-logo" src={sumintLogo} alt="SuMINT Logo" />
<div className="brand-text">
<div className="brand-title">Math Tutor</div>
<div className="brand-subtitle">{t("prototypeWorkspace")}</div>
</div>
</div>
<div className="header-meta">
<OrchestratorSelect
value={selectedOrchestrator}
options={availableOrchestrators}
onChange={handleSwitchOrchestrator}
disabled={!isOrchestratorSelectable}
/>
</div>
</header>
{orchestratorError ? <div className="chat-archive-error">{orchestratorError}</div> : null}
{deepLinkError ? <div className="chat-archive-error">{deepLinkError}</div> : null}
<main className="app-main">
<section className="chat-column">
<div className="chat-mode-row">
<OrchestratorSelect
value={selectedOrchestrator}
options={availableOrchestrators}
onChange={handleSwitchOrchestrator}
disabled={!isOrchestratorSelectable}
/>
</div>
<ChatWindow
messages={messages}
draft={draft}
......
......@@ -6,6 +6,7 @@ import { selectTask } from "../api/taskApi";
import { t } from "../i18n";
import { useTutorSession } from "../state/tutorSession";
import "../styles/theme.css";
import sumintLogo from "../../SuMINT-Logo.png";
export default function TaskSelectionPage() {
const navigate = useNavigate();
......@@ -41,6 +42,16 @@ export default function TaskSelectionPage() {
unlockTask();
}, [isTaskModeEnabled, isTasksInitialized, navigate, unlockTask]);
useEffect(() => {
if (!taskFileOptions.length) {
return;
}
if (selectedTaskRef && taskFileOptions.some((option) => option.value === selectedTaskRef.fileId)) {
return;
}
setTaskFile(taskFileOptions[0].value);
}, [isTasksInitialized, selectedTaskRef, setTaskFile, taskFileOptions]);
useEffect(() => {
if (!selectedTask?.fullText || !taskDisplayRef.current) {
return;
......@@ -86,88 +97,90 @@ export default function TaskSelectionPage() {
<div className="app-shell">
<header className="app-header">
<div className="brand">
<span className="brand-mark">SUM</span>
<img className="brand-logo" src={sumintLogo} alt="SuMINT Logo" />
<div className="brand-text">
<div className="brand-title">Math Tutor</div>
<div className="brand-subtitle">{t("taskSelectionSubtitle")}</div>
</div>
</div>
<div className="header-meta">
<OrchestratorSelect
value={selectedOrchestrator}
options={availableOrchestrators}
onChange={handleSwitchOrchestrator}
disabled={!isOrchestratorSelectable}
/>
</div>
</header>
{orchestratorError ? <div className="chat-archive-error">{orchestratorError}</div> : null}
<main className="task-select-main">
<section className="task-select-card">
<div className="task-select-header">
<h2 className="task-select-title">{t("taskSelectionTitle")}</h2>
<div className="task-select-wrap">
<div className="task-mode-row">
<OrchestratorSelect
value={selectedOrchestrator}
options={availableOrchestrators}
onChange={handleSwitchOrchestrator}
disabled={!isOrchestratorSelectable}
/>
</div>
<section className="task-select-card">
<div className="task-select-header">
<h2 className="task-select-title">{t("taskSelectionTitle")}</h2>
</div>
<div className="task-select-controls">
<label className="task-select-label" htmlFor="task-file-select">
{t("taskFile")}
</label>
<select
id="task-file-select"
className="task-select"
value={selectedTaskRef?.fileId || ""}
onChange={(event) => setTaskFile(event.target.value)}
disabled={!taskFileOptions.length}
>
{taskFileOptions.length ? (
taskFileOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
) : (
<option value="">{t("noTasksAvailable")}</option>
)}
</select>
<div className="task-select-controls">
<label className="task-select-label" htmlFor="task-file-select">
{t("taskFile")}
</label>
<select
id="task-file-select"
className="task-select"
value={selectedTaskRef?.fileId || ""}
onChange={(event) => setTaskFile(event.target.value)}
disabled={!taskFileOptions.length}
>
{taskFileOptions.length ? (
taskFileOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
) : (
<option value="">{t("noTasksAvailable")}</option>
)}
</select>
<label className="task-select-label" htmlFor="task-id-select">
{t("taskId")}
</label>
<select
id="task-id-select"
className="task-select"
value={selectedTaskRef?.taskId || ""}
onChange={(event) => setTaskId(event.target.value)}
disabled={!taskOptions.length}
>
{taskOptions.length ? (
taskOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
) : (
<option value="">{t("noTasksAvailable")}</option>
)}
</select>
</div>
<label className="task-select-label" htmlFor="task-id-select">
{t("taskId")}
</label>
<select
id="task-id-select"
className="task-select"
value={selectedTaskRef?.taskId || ""}
onChange={(event) => setTaskId(event.target.value)}
disabled={!taskOptions.length}
>
{taskOptions.length ? (
taskOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
) : (
<option value="">{t("noTasksAvailable")}</option>
)}
</select>
</div>
<div className="task-panel-content" ref={taskDisplayRef}>
{selectedTask?.fullText || t("noTaskSelected")}
</div>
<div className="task-panel-content" ref={taskDisplayRef}>
{selectedTask?.fullText || t("noTaskSelected")}
</div>
{tasksError ? <div className="task-panel-error">{tasksError}</div> : null}
{tasksError ? <div className="task-panel-error">{tasksError}</div> : null}
<button
type="button"
className="btn primary task-solve-btn"
onClick={handleSolveWithTutor}
disabled={!selectedTaskRef}
>
{t("solveWithTutor")}
</button>
</section>
<button
type="button"
className="btn primary task-solve-btn"
onClick={handleSolveWithTutor}
disabled={!selectedTaskRef}
>
{t("solveWithTutor")}
</button>
</section>
</div>
</main>
</div>
);
......
......@@ -65,6 +65,9 @@ export const createSessionId = () =>
export const getDefaultTaskId = (tasks: Array<{ task_id: string }>): string =>
tasks.find((task) => task.task_id === "01")?.task_id || tasks[0]?.task_id || "";
const isSelectableTaskFile = (file: TaskFile): boolean =>
Array.isArray(file.subsections) && file.subsections.length > 0;
export function TutorSessionProvider({ children }: PropsWithChildren) {
const [chatSessionId, setChatSessionId] = useState<string>(() => createSessionId());
const [selectedOrchestrator, setSelectedOrchestratorState] =
......@@ -107,13 +110,18 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
[selectedTaskRef?.fileId, taskFiles]
);
const selectableTaskFiles = useMemo(
() => taskFiles.filter((file) => isSelectableTaskFile(file)),
[taskFiles]
);
const taskFileOptions = useMemo(
() =>
taskFiles.map((file) => ({
selectableTaskFiles.map((file) => ({
value: file.file_id,
label: file.title || file.file_id,
})),
[taskFiles]
[selectableTaskFiles]
);
const taskOptions = useMemo(
......@@ -149,17 +157,18 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
try {
const payload = await fetchTasks();
const files = payload.task_files || [];
const selectableFiles = files.filter((file) => isSelectableTaskFile(file));
setTaskFiles(files);
setSelectedTaskRef((prev) => {
if (prev) {
const file = files.find((item) => item.file_id === prev.fileId);
const file = selectableFiles.find((item) => item.file_id === prev.fileId);
if (file && file.tasks.some((task) => task.task_id === prev.taskId)) {
return prev;
}
}
const firstFile = files[0];
const firstFile = selectableFiles[0];
if (!firstFile) {
return null;
}
......@@ -188,7 +197,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
if (!fileId) {
return;
}
const file = taskFiles.find((item) => item.file_id === fileId);
const file = taskFiles.find((item) => item.file_id === fileId && isSelectableTaskFile(item));
if (!file) {
return;
}
......
......@@ -29,22 +29,22 @@ body {
margin-bottom: 24px;
}
.app-header-chat {
justify-content: flex-start;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
background: #1d1b16;
color: #fef9f0;
font-weight: 700;
.brand-logo {
display: block;
width: auto;
height: 100px;
max-width: 300px;
object-fit: contain;
}
.brand-title {
......@@ -190,6 +190,14 @@ body {
gap: 16px;
min-height: 0;
max-height: 85vh;
position: relative;
}
.chat-mode-row {
position: absolute;
top: -52px;
right: 0;
z-index: 2;
}
.chat-window {
......@@ -700,6 +708,10 @@ body {
align-items: flex-start;
}
.app-header-chat {
justify-content: flex-start;
}
.header-meta {
width: 100%;
}
......@@ -715,6 +727,10 @@ body {
min-height: 360px;
}
.chat-mode-row {
position: static;
}
.composer-row {
width: 100%;
}
......@@ -803,8 +819,20 @@ body {
place-items: center;
}
.task-select-card {
.task-select-wrap {
width: min(820px, 100%);
position: relative;
}
.task-mode-row {
position: absolute;
top: -52px;
right: 0;
z-index: 2;
}
.task-select-card {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
......@@ -874,6 +902,11 @@ body {
grid-template-columns: 1fr;
}
.task-mode-row {
position: static;
margin-bottom: 12px;
}
.task-solve-btn {
width: 100%;
}
......
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