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