Commit 653dd6a6 authored by Kantz's avatar Kantz
Browse files

Merge branch 'dev' into 'main'

Dev

See merge request kantz/tutor_react!8
parents 962df6a9 9640e4df
......@@ -3,19 +3,18 @@ from app.deterministic_services import llm_client
HINT_SYSTEM_PROMPT = """
Du bist ein didaktischer Mathe-Tutor."
1) Antworte NUR mit einem kurzen Tipp (1-2 Sätze), keine Beispiele, keine Herleitung, keine komplette Lösung.
2) Beziehe dich PRIMÄR auf die 'Aktuelle Frage'. Ignoriere ältere Nebenfragen, außer sie sind nötig.
3) Wenn der Nutzer etwas Falsches sagt weiße ihn darauf hin gib aber keine Lösung an.
4) Basiere deine Tipps auf den Hinweisen.
5) Die Übergebene Lösung ist IMMER korrekt.
6) Der Nutzer kenn die Lösung und die Hinweise nicht.
Du bist ein didaktischer Mathe-Tutor. Du beantwortest die mathematischen Fragen des Benutzers. Wenn ein Benutzer dir eine mathematische Aufgabe vorlegt, solltest du nicht direkt die Lösung nennen. Stattdessen sollst du den Benutzer zur Lösung der Aufgabe anleiten, indem Sie die sokratische Fragetechnik oder Scaffolding-Fragen verwenden, um den Schülern zu helfen, die Antwort selbst zu finden. Nutze dazu den exemplarischen Lösungsweg. Du kannst den Schüler beispielsweise zu einem bestimmten Teil der Frage befragen und ihn bitten, seine Gedanken dazu näher zu erläutern. Du sollst nach der Frage nicht selbst antworten, sondern den Nutzer zuerst antworten lassen. Stattdessen kannst du mögliche Richtungen aufzeigen und die Schüler bitten, weiter darüber nachzudenken, oder sie bitten, ihre Gedanken zu einem bestimmten Teil des Problems zu präzisieren und näher zu erläutern.
1) Antworte NUR mit einem kurzen Tipp (1 Satz), keine Beispiele, keine Herleitung, keine komplette Lösung.
2) Beziehe dich PRIMÄR auf die 'AKTUELLE Eingabe'.
3) Wenn der Nutzer etwas Falsches sagt weiße ihn darauf hin.
4) Die Übergebene Lösung ist IMMER korrekt.
5) Der Nutzer kenn die korrekte Lösung und den exemplarischer Lösungsweg nicht.
verwende die $ für mathematische Formeln, z.B. $a^2 + b^2 = c^2$
Antworte mit folgender Struktur
Das war richtig/das ist noch nicht richtig/gute Frage
[Hier soll der nächster Tipp um weiter zu machen/um den Fehler zu korrigieren stehen ohne Klammern]
Das war richtig. / Das ist noch nicht richtig. / Gute Frage.
Hier soll der nächster Tipp, um weiter zu machen oder um den Fehler zu korrigieren, stehen.
"""
def generate_hint(
......
......@@ -3,20 +3,15 @@ from __future__ import annotations
import logging
from typing import List, Optional
import app.config as config
from app.deterministic_services import session_store
from app.deterministic_services.orchestrators.registry import (
get_default_orchestrator,
is_valid_orchestrator,
resolve_orchestrator,
)
from fastapi import APIRouter, HTTPException, Path, Query
from pydantic import BaseModel, Field
if config.get_orchestrator() == "tutor":
from app.deterministic_services.orchestrators import (
orchestrator_tutor as orchestrator,
)
elif config.get_orchestrator() == "task":
from app.deterministic_services.orchestrators import orchestrator_task as orchestrator
else:
from app.deterministic_services.orchestrators import orchestrator_qa as orchestrator
router = APIRouter()
logger = logging.getLogger(__name__)
......@@ -31,6 +26,7 @@ class ChatRequest(BaseModel):
messages: List[ChatMessage]
draft: Optional[str] = None
selected_task: Optional[dict[str, str]] = None
orchestrator: Optional[str] = None
class ChatResponse(BaseModel):
......@@ -48,6 +44,7 @@ class ChatArchiveSummary(BaseModel):
saved_at: str
message_count: int
preview: str
orchestrator: Optional[str] = None
class SelectedTaskRef(BaseModel):
......@@ -60,6 +57,7 @@ class ChatArchiveDetail(BaseModel):
saved_at: str
history: List[ChatMessage]
selected_task: Optional[SelectedTaskRef] = None
orchestrator: str
@router.post("/api/chat", response_model=ChatResponse)
......@@ -67,16 +65,24 @@ def chat(request: ChatRequest) -> ChatResponse:
if not request.messages:
raise HTTPException(status_code=400, detail="messages required")
requested_orchestrator = str(request.orchestrator or "").strip().lower()
if requested_orchestrator and not is_valid_orchestrator(requested_orchestrator):
raise HTTPException(status_code=422, detail="unsupported orchestrator")
orchestrator_name, orchestrator_impl = resolve_orchestrator(
requested_orchestrator or None
)
try:
payload_messages = [{"role": m.role, "content": m.text} for m in request.messages]
if config.get_orchestrator() == "task":
result = orchestrator.run_chat(
if orchestrator_name == "task":
result = orchestrator_impl.run_chat(
payload_messages,
draft=request.draft,
selected_task=request.selected_task,
)
else:
result = orchestrator.run_chat(
result = orchestrator_impl.run_chat(
payload_messages,
draft=request.draft,
)
......@@ -124,6 +130,7 @@ def get_archive(chat_id: str = Path(..., min_length=1)) -> ChatArchiveDetail:
saved_at=record.get("saved_at", ""),
history=[ChatMessage(role=item["role"], text=item["text"]) for item in record["history"]],
selected_task=selected_task,
orchestrator=record.get("orchestrator") or get_default_orchestrator(),
)
......@@ -132,10 +139,16 @@ def archive_chat(request: ChatRequest) -> ChatArchiveResponse:
if not request.messages:
return ChatArchiveResponse(status="skipped", chat_id="unknown")
requested_orchestrator = str(request.orchestrator or "").strip().lower()
if requested_orchestrator and not is_valid_orchestrator(requested_orchestrator):
raise HTTPException(status_code=422, detail="unsupported orchestrator")
archive_orchestrator = requested_orchestrator or get_default_orchestrator()
try:
chat_id = session_store.archive_chat(
[{"role": m.role, "content": m.text} for m in request.messages],
draft=request.draft,
orchestrator=archive_orchestrator,
)
except Exception as exc:
logger.exception("Chat archive failed")
......
......@@ -74,9 +74,10 @@ def health() -> Dict[str, Any]:
"openai": _check_openai(),
"postgres": _check_postgres(),
}
overall = "ok"
if any(value["status"] in ("error", "unauthorized") for value in services.values()):
overall = "degraded"
required_statuses = {"ok"}
overall = "ok" if all(
service.get("status") in required_statuses for service in services.values()
) else "degraded"
return {"status": overall, "services": services}
......
from __future__ import annotations
from fastapi import APIRouter
from pydantic import BaseModel
from app.deterministic_services.orchestrators.registry import (
AVAILABLE_ORCHESTRATORS,
get_default_orchestrator,
)
router = APIRouter()
class OrchestratorConfigResponse(BaseModel):
default_orchestrator: str
available_orchestrators: list[str]
@router.get("/api/orchestrator/config", response_model=OrchestratorConfigResponse)
def get_orchestrator_config() -> OrchestratorConfigResponse:
return OrchestratorConfigResponse(
default_orchestrator=get_default_orchestrator(),
available_orchestrators=list(AVAILABLE_ORCHESTRATORS),
)
from __future__ import annotations
from typing import Any
import app.config as config
from app.deterministic_services.orchestrators import (
orchestrator_qa,
orchestrator_task,
orchestrator_tutor,
)
AVAILABLE_ORCHESTRATORS: tuple[str, ...] = ("qa", "tutor", "task")
_ORCHESTRATOR_MODULES: dict[str, Any] = {
"qa": orchestrator_qa,
"tutor": orchestrator_tutor,
"task": orchestrator_task,
}
def get_default_orchestrator() -> str:
configured = str(config.get_orchestrator() or "").strip().lower()
if configured in _ORCHESTRATOR_MODULES:
return configured
return "qa"
def is_valid_orchestrator(value: str) -> bool:
return value in _ORCHESTRATOR_MODULES
def resolve_orchestrator(value: str | None = None) -> tuple[str, Any]:
if value:
normalized = value.strip().lower()
if normalized in _ORCHESTRATOR_MODULES:
return normalized, _ORCHESTRATOR_MODULES[normalized]
default_name = get_default_orchestrator()
return default_name, _ORCHESTRATOR_MODULES[default_name]
......@@ -26,7 +26,11 @@ def _extract_selected_task(sheet: dict[str, Any]) -> dict[str, str] | None:
return {"file_id": file_id, "task_id": task_id}
def archive_chat(messages: list[dict[str, Any]], draft: str | None = None) -> str:
def archive_chat(
messages: list[dict[str, Any]],
draft: str | None = None,
orchestrator: str | None = None,
) -> str:
chat_id = context_store.get_chat_id(messages, draft=draft)
sheet = context_store.load_sheet(chat_id)
if not sheet:
......@@ -38,6 +42,7 @@ def archive_chat(messages: list[dict[str, Any]], draft: str | None = None) -> st
record = {
"chat_id": chat_id,
"saved_at": _utc_now(),
"orchestrator": str(orchestrator or "").strip().lower() or None,
"history": sheet.get("history", []),
"context_sheet": context_store.format_sheet(sheet),
"retrieval_contexts": sheet.get("retrieval_contexts", []),
......@@ -65,6 +70,7 @@ def _summarize_record(record: dict[str, Any]) -> dict[str, Any]:
return {
"chat_id": record.get("chat_id", "unknown"),
"saved_at": record.get("saved_at", ""),
"orchestrator": record.get("orchestrator"),
"message_count": len(history),
"preview": preview,
}
......@@ -123,6 +129,7 @@ def load_archive(chat_id: str) -> dict[str, Any] | None:
return {
"chat_id": record.get("chat_id", chat_id),
"saved_at": record.get("saved_at", ""),
"orchestrator": record.get("orchestrator"),
"history": history,
"selected_task": selected_task,
}
......
......@@ -3,7 +3,7 @@ import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import canvas, chat, health, context, tasks
from app.api import canvas, chat, context, health, orchestrator, tasks
from app.config import get_frontend_url
from app.deterministic_services import embedding_provider
......@@ -36,4 +36,5 @@ app.include_router(chat.router)
app.include_router(canvas.router)
app.include_router(context.router)
app.include_router(health.router)
app.include_router(orchestrator.router)
app.include_router(tasks.router)
export type HealthStatus = "ok" | "degraded" | "error" | "unknown";
export type HealthResponse = {
status: HealthStatus;
services?: Record<string, { status?: string; [key: string]: unknown }>;
};
const normalizeHealthStatus = (value: unknown): HealthStatus => {
if (value === "ok" || value === "degraded" || value === "error") {
return value;
}
return "unknown";
};
export async function fetchHealth(): Promise<HealthResponse> {
const response = await fetch("/api/health");
if (!response.ok) {
throw new Error(`Health check failed: ${response.status}`);
}
const payload = await response.json();
return {
status: normalizeHealthStatus(payload?.status),
services:
payload && typeof payload === "object" && payload.services
? (payload.services as HealthResponse["services"])
: undefined,
};
}
export type OrchestratorName = "qa" | "tutor" | "task";
export type OrchestratorConfigResponse = {
default_orchestrator: OrchestratorName;
available_orchestrators: OrchestratorName[];
};
const FALLBACK_ORCHESTRATORS: OrchestratorName[] = ["qa", "tutor", "task"];
const normalizeOrchestrator = (value: string): OrchestratorName | null => {
if (value === "qa" || value === "tutor" || value === "task") {
return value;
}
return null;
};
export const getFallbackOrchestrators = (): OrchestratorName[] =>
[...FALLBACK_ORCHESTRATORS];
export async function fetchOrchestratorConfig(): Promise<OrchestratorConfigResponse> {
const response = await fetch("/api/orchestrator/config");
if (!response.ok) {
throw new Error(`Orchestrator config failed: ${response.status}`);
}
const payload = await response.json();
const defaultOrchestrator =
normalizeOrchestrator(String(payload?.default_orchestrator || "")) || "qa";
const availableRaw = Array.isArray(payload?.available_orchestrators)
? payload.available_orchestrators
: [];
const available = availableRaw
.map((item: unknown) => normalizeOrchestrator(String(item || "")))
.filter((item: OrchestratorName | null): item is OrchestratorName => Boolean(item));
return {
default_orchestrator: defaultOrchestrator,
available_orchestrators: available.length ? available : getFallbackOrchestrators(),
};
}
......@@ -46,7 +46,7 @@ export default function MessageInput({
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && event.ctrlKey) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSend();
}
......
import { useId } from "react";
import type { OrchestratorName } from "../../api/orchestratorApi";
import { t } from "../../i18n";
type OrchestratorSelectProps = {
value: OrchestratorName;
options: OrchestratorName[];
onChange: (value: OrchestratorName) => void;
disabled?: boolean;
};
type ModeMeta = {
labelKey:
| "orchestratorModeQaLabel"
| "orchestratorModeTutorLabel"
| "orchestratorModeTaskLabel";
descriptionKey:
| "orchestratorModeQaDescription"
| "orchestratorModeTutorDescription"
| "orchestratorModeTaskDescription";
};
const modeMetaMap: Record<OrchestratorName, ModeMeta> = {
qa: {
labelKey: "orchestratorModeQaLabel",
descriptionKey: "orchestratorModeQaDescription",
},
tutor: {
labelKey: "orchestratorModeTutorLabel",
descriptionKey: "orchestratorModeTutorDescription",
},
task: {
labelKey: "orchestratorModeTaskLabel",
descriptionKey: "orchestratorModeTaskDescription",
},
};
type ModeDescriptionDisplay = "tooltip" | "helperText" | "optionSuffix";
// Variants kept local for easy switching without touching page-level integrations.
const MODE_DESCRIPTION_DISPLAY: ModeDescriptionDisplay = "tooltip";
export default function OrchestratorSelect({
value,
options,
onChange,
disabled = false,
}: OrchestratorSelectProps) {
const selectId = useId();
const helpPopoverId = useId();
const selectedModeMeta = modeMetaMap[value];
const renderTooltip = MODE_DESCRIPTION_DISPLAY === "tooltip";
const renderHelperText = MODE_DESCRIPTION_DISPLAY === "helperText";
const renderOptionSuffix = MODE_DESCRIPTION_DISPLAY === "optionSuffix";
return (
<div className="orchestrator-select-wrap">
<div className="orchestrator-select-label-row">
<label className="orchestrator-select-label" htmlFor={selectId}>
{t("orchestratorMode")}
</label>
{renderTooltip ? (
<div className="orchestrator-help">
<button
type="button"
className="orchestrator-help-trigger"
aria-label={t("orchestratorModeDescriptionTitle")}
aria-describedby={helpPopoverId}
>
i
</button>
<div id={helpPopoverId} role="tooltip" className="orchestrator-help-popover">
<div className="orchestrator-help-title">{t("orchestratorModeDescriptionTitle")}</div>
<div className="orchestrator-help-list">
{options.map((option) => (
<div key={option} className="orchestrator-help-item">
<span className="orchestrator-help-mode">{t(modeMetaMap[option].labelKey)}:</span>
<span>{t(modeMetaMap[option].descriptionKey)}</span>
</div>
))}
</div>
</div>
</div>
) : null}
</div>
<select
id={selectId}
className="task-select orchestrator-select"
value={value}
onChange={(event) => onChange(event.target.value as OrchestratorName)}
disabled={disabled}
>
{options.map((option) => (
<option key={option} value={option}>
{renderOptionSuffix
? `${t(modeMetaMap[option].labelKey)} - ${t(modeMetaMap[option].descriptionKey)}`
: t(modeMetaMap[option].labelKey)}
</option>
))}
</select>
{renderHelperText ? (
<div className="orchestrator-mode-helper">{t(selectedModeMeta.descriptionKey)}</div>
) : null}
</div>
);
}
import { useCallback, useEffect, useMemo, useState, type PropsWithChildren } from "react";
import { fetchHealth, type HealthResponse } from "../../api/healthApi";
import { t } from "../../i18n";
import "../../styles/theme.css";
type GateState = "checking" | "healthy" | "unhealthy";
const HEALTH_POLL_INTERVAL_MS = 5000;
const formatServiceStatus = (health: HealthResponse | null): string | null => {
if (!health?.services) {
return null;
}
const failing = Object.entries(health.services)
.filter(([, value]) => {
const status = String(value?.status || "").toLowerCase();
return status && status !== "ok";
})
.map(([service, value]) => `${service}: ${String(value?.status || "unknown")}`);
return failing.length ? failing.join(" | ") : null;
};
export default function BackendHealthGate({ children }: PropsWithChildren) {
const [gateState, setGateState] = useState<GateState>("checking");
const [lastError, setLastError] = useState<string | null>(null);
const [lastHealth, setLastHealth] = useState<HealthResponse | null>(null);
const [isRetrying, setIsRetrying] = useState(false);
const runHealthCheck = useCallback(async () => {
try {
const health = await fetchHealth();
setLastHealth(health);
if (health.status === "ok") {
setGateState("healthy");
setLastError(null);
} else {
setGateState("unhealthy");
setLastError(`status=${health.status}`);
}
} catch (error) {
setGateState("unhealthy");
setLastHealth(null);
if (error instanceof Error && error.message) {
setLastError(error.message);
} else {
setLastError("network_error");
}
}
}, []);
useEffect(() => {
void runHealthCheck();
}, [runHealthCheck]);
useEffect(() => {
if (gateState !== "unhealthy") {
return;
}
const timer = window.setInterval(() => {
void runHealthCheck();
}, HEALTH_POLL_INTERVAL_MS);
return () => {
window.clearInterval(timer);
};
}, [gateState, runHealthCheck]);
const handleRetry = async () => {
setIsRetrying(true);
try {
await runHealthCheck();
} finally {
setIsRetrying(false);
}
};
const failureDetails = useMemo(() => {
const serviceDetails = formatServiceStatus(lastHealth);
if (serviceDetails) {
return serviceDetails;
}
return lastError;
}, [lastError, lastHealth]);
if (gateState === "healthy") {
return <>{children}</>;
}
return (
<div className="backend-health-shell">
<header className="app-header backend-health-header">
<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>
</div>
</div>
<div className="header-meta">
<span className="pill">Postgres + pgvector</span>
<span className="pill">Python FastAPI</span>
<span className="pill">Mathpix</span>
</div>
</header>
<main className="backend-health-main">
<section className="backend-health-card">
{gateState === "checking" ? (
<p className="backend-health-loading">{t("backendChecking")}</p>
) : (
<>
<h1 className="backend-health-title">{t("backendUnavailableTitle")}</h1>
<p className="backend-health-text">{t("backendUnavailableBody")}</p>
{failureDetails ? (
<p className="backend-health-detail">
{t("lastCheckFailed", { detail: failureDetails })}
</p>
) : null}
<button
type="button"
className="btn primary"
onClick={handleRetry}
disabled={isRetrying}
>
{isRetrying ? t("loading") : t("retryConnection")}
</button>
</>
)}
</section>
</main>
</div>
);
}
......@@ -16,6 +16,14 @@
show: "Show",
newChat: "New Chat",
loading: "Loading...",
orchestratorMode: "Mode",
orchestratorModeQaLabel: "QA",
orchestratorModeTutorLabel: "Tutor",
orchestratorModeTaskLabel: "Task",
orchestratorModeDescriptionTitle: "Mode help",
orchestratorModeQaDescription: "Direct answers based on the script",
orchestratorModeTutorDescription: "Help with your own questions",
orchestratorModeTaskDescription: "Help with textbook exercises",
directChildren: "Direct children",
taskChildren: "Task sources",
indirectChildren: "Indirect children",
......@@ -35,6 +43,7 @@
noSavedChatsYet: "No saved chats yet.",
canvasHidden: "Canvas hidden",
failedLoadTasks: "Could not load tasks.",
failedLoadOrchestratorConfig: "Could not load orchestrator config. Fallback mode active.",
chatRequestFailed: "The chat request failed. Please check backend logs.",
retrievalFailed: "Source retrieval failed. Please check backend logs.",
failedLoadSavedChats: "Could not load saved chats.",
......@@ -53,6 +62,12 @@
changeTaskArea: "Change Task Area",
previousTask: "Previous Task",
nextTask: "Next Task",
backendChecking: "Checking backend availability...",
backendUnavailableTitle: "Backend not reachable",
backendUnavailableBody:
"The tutor backend is currently unavailable or still starting up. We will keep trying automatically.",
retryConnection: "Retry now",
lastCheckFailed: "Last check: {detail}",
},
de: {
chats: "Chats",
......@@ -62,7 +77,7 @@
canvas: "Canvas",
retrievedNotes: "Quellen",
typeQuestionOrLatex:
"Gebe hier deine Frage ein. Nutze $ $ fuer mathematische Eingaben...",
"Gebe hier deine Frage ein. Nutze $ $ für mathematische Eingaben...",
noResultsYet: "bisher keine Quellen",
eraser: "Radierer",
pen: "Stift",
......@@ -72,6 +87,14 @@
show: "anzeigen",
newChat: "neuer Chat",
loading: "Lade...",
orchestratorMode: "Modus",
orchestratorModeQaLabel: "QA",
orchestratorModeTutorLabel: "Tutor",
orchestratorModeTaskLabel: "Task",
orchestratorModeDescriptionTitle: "Modus-Hilfe",
orchestratorModeQaDescription: "Direkte Antworten basierend auf dem Skript",
orchestratorModeTutorDescription: "Hilfe bei selbst gestellten Fragen",
orchestratorModeTaskDescription: "Hilfe bei Aufgaben aus dem Lehrwerk",
directChildren: "Direkte Quellen",
taskChildren: "Aufgaben-Quellen",
indirectChildren: "Indirekte Quellen",
......@@ -84,34 +107,42 @@
send: "Senden",
roleUser: "nutzer",
roleAssistant: "assistent",
noTasksAvailable: "Keine Aufgaben verfuegbar",
noTasksAvailable: "Keine Aufgaben verfügbar",
noTaskSelected: "Keine Aufgabe ausgewählt.",
savedChats: "Gespeicherte Chats",
saving: "Speichere...",
noSavedChatsYet: "Noch keine gespeicherten Chats.",
canvasHidden: "Canvas ausgeblendet",
failedLoadTasks: "Aufgaben konnten nicht geladen werden.",
failedLoadOrchestratorConfig:
"Orchestrator-Konfiguration konnte nicht geladen werden. Fallback-Modus aktiv.",
chatRequestFailed:
"Chat-Anfrage ist fehlgeschlagen. Bitte pruefe die Backend-Logs.",
"Da ist wohl die Chat-Anfrage fehlgeschlagen. Gib gerne deinem Dozenten bescheid. In vielen Fällen hilft es die Seite neu zu laden.",
retrievalFailed:
"Quellenabruf ist fehlgeschlagen. Bitte pruefe die Backend-Logs.",
"Da ist wohl der Quellenabruf fehlgeschlagen. Gib gerne deinem Dozenten bescheid. In vielen Fällen hilft es die Seite neu zu laden.",
failedLoadSavedChats: "Gespeicherte Chats konnten nicht geladen werden.",
failedLoadSelectedChat: "Der ausgewählte Chat konnte nicht geladen werden.",
savingDrawingConverting:
"Zeichnung wird gespeichert und in LaTeX konvertiert...",
saveFailedStatus: "Speichern fehlgeschlagen (Status: {status}).",
canvasSaveFailed: "Speichern des Canvas fehlgeschlagen.",
saveFailedStatus: "Speichern fehlgeschlagen (Status: {status}). Probiere es nochmal oder lade die Seite neu.",
canvasSaveFailed: "Speichern des Canvas fehlgeschlagen. Probiere es nochmal oder lade die Seite neu.",
canvasSaved: "Canvas gespeichert.",
canvasSaveFailedCheckLogs:
"Speichern des Canvas fehlgeschlagen. Bitte pruefe die Backend-Logs.",
taskSelectionTitle: "Aufgabe auswaehlen",
taskSelectionSubtitle: "Waehle eine Aufgabe und starte den Tutor-Chat",
"Da ist wohl das Speichern des Canvas fehlgeschlagen. Gib gerne deinem Dozenten bescheid. In vielen Fällen hilft es die Seite neu zu laden.",
taskSelectionTitle: "Aufgabe auswählen",
taskSelectionSubtitle: "Wähle eine Aufgabe und starte den Tutor-Chat",
taskFile: "Aufgabenset",
taskId: "Aufgaben-ID",
solveWithTutor: "Mit Tutor lösen",
changeTaskArea: "Aufgabengebiet ändern",
previousTask: "Vorherige Aufgabe",
nextTask: "Nächste Aufgabe",
backendChecking: "Backend-Verbindung wird geprüft...",
backendUnavailableTitle: "Backend nicht erreichbar",
backendUnavailableBody:
"Das Tutor-Backend ist aktuell nicht erreichbar oder startet noch. Wir versuchen es automatisch weiter.",
retryConnection: "Erneut prüfen",
lastCheckFailed: "Letzte Prüfung: {detail}",
},
} as const;
......
......@@ -3,11 +3,14 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
import App from "./pages/App.tsx";
import BackendHealthGate from "./components/System/BackendHealthGate.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
<BackendHealthGate>
<App />
</BackendHealthGate>
</BrowserRouter>
</StrictMode>
);
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import "../styles/theme.css";
import ChatWindow from "../components/Chat/ChatWindow";
import CanvasDrawer from "../components/Canvas/CanvasDrawer";
import OrchestratorSelect from "../components/Orchestrator/OrchestratorSelect";
import DocPanel from "../components/Retrieval/DocPanel";
import TaskPanel from "../components/Task/TaskPanel";
import type { ChatMessage } from "../components/Chat/MessageList";
import type { RetrievedDoc } from "../components/Retrieval/DocPanel";
import { t } from "../i18n";
import type { OrchestratorName } from "../api/orchestratorApi";
import { selectTask } from "../api/taskApi";
import { t } from "../i18n";
import { createSessionId, useTutorSession } from "../state/tutorSession";
const initialMessages: ChatMessage[] = [];
const normalizeOrchestrator = (value: string | null | undefined): OrchestratorName | null => {
if (value === "qa" || value === "tutor" || value === "task") {
return value;
}
return null;
};
type ArchivedChatSummary = {
chat_id: string;
saved_at: string;
message_count: number;
preview: string;
orchestrator?: string | null;
};
type ArchivedChatDetail = {
chat_id: string;
saved_at: string;
history: ChatMessage[];
orchestrator?: string | null;
selected_task?: {
file_id: string;
task_id: string;
......@@ -91,9 +102,7 @@ const toRetrievedDoc = (source: ContextSource, index: number): RetrievedDoc => (
});
export default function ChatPage() {
const showChatsButton = String(
import.meta.env.VITE_SHOW_CHATS_BUTTON ?? "true"
)
const showChatsButton = String(import.meta.env.VITE_SHOW_CHATS_BUTTON ?? "true")
.trim()
.toLowerCase() !== "false";
const navigate = useNavigate();
......@@ -105,6 +114,12 @@ export default function ChatPage() {
selectedTaskRef,
selectedTask,
selectedTaskFile,
selectedOrchestrator,
availableOrchestrators,
setSelectedOrchestrator,
switchOrchestrator,
isOrchestratorSelectable,
orchestratorError,
taskLocked,
lockTask,
unlockTask,
......@@ -139,13 +154,7 @@ export default function ChatPage() {
if (!selectedTaskRef || !taskLocked) {
navigate("/select-task", { replace: true });
}
}, [
isTaskModeEnabled,
isTasksInitialized,
navigate,
selectedTaskRef,
taskLocked,
]);
}, [isTaskModeEnabled, isTasksInitialized, navigate, selectedTaskRef, taskLocked]);
const docIndexes = useMemo(() => {
const bySourceKey: Record<string, RetrievedDoc> = {};
......@@ -243,6 +252,7 @@ export default function ChatPage() {
const chatPayload: {
messages: Array<{ role: "user" | "assistant"; text: string }>;
draft: string;
orchestrator: OrchestratorName;
selected_task?: { file_id: string; task_id: string };
} = {
messages: [...messages, userMessage].map((message) => ({
......@@ -250,6 +260,7 @@ export default function ChatPage() {
text: message.text,
})),
draft: chatSessionId,
orchestrator: selectedOrchestrator,
};
if (isTaskModeEnabled && selectedTaskRef) {
chatPayload.selected_task = {
......@@ -410,7 +421,14 @@ export default function ChatPage() {
setMessages(payload.history || []);
setChatSessionId(payload.chat_id);
if (isTaskModeEnabled) {
const restoredOrchestrator = normalizeOrchestrator(payload.orchestrator);
const nextOrchestrator =
restoredOrchestrator && availableOrchestrators.includes(restoredOrchestrator)
? restoredOrchestrator
: selectedOrchestrator;
setSelectedOrchestrator(nextOrchestrator);
if (nextOrchestrator === "task") {
const restoredTask = payload.selected_task;
if (restoredTask?.file_id && restoredTask?.task_id) {
setTaskRef({
......@@ -432,6 +450,10 @@ export default function ChatPage() {
unlockTask();
navigate("/select-task", { replace: true });
}
} else {
setTaskRef(null);
unlockTask();
navigate("/chat", { replace: true });
}
} catch (error) {
setArchiveError(t("failedLoadSelectedChat"));
......@@ -452,6 +474,7 @@ export default function ChatPage() {
text: message.text,
})),
draft: chatSessionId,
orchestrator: selectedOrchestrator,
}),
});
} catch (error) {
......@@ -484,6 +507,7 @@ export default function ChatPage() {
text: message.text,
})),
draft: chatSessionId,
orchestrator: selectedOrchestrator,
}),
});
} catch (error) {
......@@ -533,6 +557,15 @@ export default function ChatPage() {
void loadArchives();
};
const handleSwitchOrchestrator = (next: OrchestratorName) => {
if (next === selectedOrchestrator) {
return;
}
resetChatState();
switchOrchestrator(next);
navigate(next === "task" ? "/select-task" : "/chat");
};
const handleCanvasSave = async (dataUrl: string) => {
setCanvasStatus({
kind: "info",
......@@ -655,11 +688,7 @@ export default function ChatPage() {
<div className="app-shell">
<header className="app-header">
{showChatsButton ? (
<button
type="button"
className="btn sidebar-toggle"
onClick={handleOpenSidebar}
>
<button type="button" className="btn sidebar-toggle" onClick={handleOpenSidebar}>
{t("chats")}
</button>
) : null}
......@@ -671,11 +700,18 @@ export default function ChatPage() {
</div>
</div>
<div className="header-meta">
<OrchestratorSelect
value={selectedOrchestrator}
options={availableOrchestrators}
onChange={handleSwitchOrchestrator}
disabled={!isOrchestratorSelectable}
/>
<span className="pill">Postgres + pgvector</span>
<span className="pill">Python FastAPI</span>
<span className="pill">Mathpix</span>
</div>
</header>
{orchestratorError ? <div className="chat-archive-error">{orchestratorError}</div> : null}
<main className="app-main">
<section className="chat-column">
......@@ -701,11 +737,7 @@ export default function ChatPage() {
) : null}
</section>
<aside
className={`retrieval-column ${
isTaskModeEnabled ? "retrieval-column-task-mode" : ""
}`}
>
<aside className={`retrieval-column ${isTaskModeEnabled ? "retrieval-column-task-mode" : ""}`}>
{isTaskModeEnabled && selectedTask ? (
<TaskPanel
readOnly
......@@ -736,23 +768,14 @@ export default function ChatPage() {
<div className={`sidebar ${isSidebarOpen ? "active" : ""}`}>
<div className="sd-header">
<h4 className="sd-title">{t("savedChats")}</h4>
<button
type="button"
className="sidebar-button"
onClick={() => setIsSidebarOpen(false)}
>
<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}
>
<button type="button" className="sd-link" onClick={handleNewChat} disabled={isArchiving}>
{isArchiving ? t("saving") : t("newChat")}
</button>
</li>
......@@ -764,9 +787,7 @@ export default function ChatPage() {
<li key={item.chat_id}>
<button
type="button"
className={`sd-link ${
selectedArchiveId === item.chat_id ? "active" : ""
}`}
className={`sd-link ${selectedArchiveId === item.chat_id ? "active" : ""}`}
onClick={() => {
setSelectedArchiveId(item.chat_id);
void handleLoadArchive(item.chat_id);
......@@ -782,9 +803,7 @@ export default function ChatPage() {
</li>
)}
</ul>
{archiveError ? (
<div className="chat-archive-error">{archiveError}</div>
) : null}
{archiveError ? <div className="chat-archive-error">{archiveError}</div> : null}
</div>
</div>
<div
......
import { useEffect, useRef } from "react";
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import "../styles/theme.css";
import { t } from "../i18n";
import type { OrchestratorName } from "../api/orchestratorApi";
import OrchestratorSelect from "../components/Orchestrator/OrchestratorSelect";
import { selectTask } from "../api/taskApi";
import { t } from "../i18n";
import { useTutorSession } from "../state/tutorSession";
import "../styles/theme.css";
export default function TaskSelectionPage() {
const navigate = useNavigate();
......@@ -11,6 +13,11 @@ export default function TaskSelectionPage() {
chatSessionId,
isTaskModeEnabled,
isTasksInitialized,
selectedOrchestrator,
availableOrchestrators,
switchOrchestrator,
isOrchestratorSelectable,
orchestratorError,
selectedTaskRef,
selectedTask,
taskFileOptions,
......@@ -63,6 +70,14 @@ export default function TaskSelectionPage() {
}
};
const handleSwitchOrchestrator = (next: OrchestratorName) => {
if (next === selectedOrchestrator) {
return;
}
switchOrchestrator(next);
navigate(next === "task" ? "/select-task" : "/chat", { replace: true });
};
if (!isTasksInitialized) {
return <div className="app-loading">{t("loading")}</div>;
}
......@@ -77,7 +92,16 @@ export default function TaskSelectionPage() {
<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">
......
......@@ -9,6 +9,11 @@ import {
type PropsWithChildren,
} from "react";
import { t } from "../i18n";
import {
fetchOrchestratorConfig,
getFallbackOrchestrators,
type OrchestratorName,
} from "../api/orchestratorApi";
import { fetchTasks, type SelectedTaskRef, type TaskFile } from "../api/taskApi";
export type SelectOption = {
......@@ -38,6 +43,12 @@ export type TutorSessionState = TaskSelectionState & {
chatSessionId: string;
setChatSessionId: (value: string) => void;
initTasks: () => Promise<void>;
selectedOrchestrator: OrchestratorName;
availableOrchestrators: OrchestratorName[];
setSelectedOrchestrator: (value: OrchestratorName) => void;
switchOrchestrator: (value: OrchestratorName) => void;
isOrchestratorSelectable: boolean;
orchestratorError: string | null;
setTaskRef: (value: SelectedTaskRef | null) => void;
setTaskFile: (fileId: string) => void;
setTaskId: (taskId: string) => void;
......@@ -56,13 +67,21 @@ export const getDefaultTaskId = (tasks: Array<{ task_id: string }>): string =>
export function TutorSessionProvider({ children }: PropsWithChildren) {
const [chatSessionId, setChatSessionId] = useState<string>(() => createSessionId());
const [isTaskModeEnabled, setIsTaskModeEnabled] = useState(false);
const [selectedOrchestrator, setSelectedOrchestratorState] =
useState<OrchestratorName>("qa");
const [availableOrchestrators, setAvailableOrchestrators] = useState<OrchestratorName[]>(
() => getFallbackOrchestrators()
);
const [isOrchestratorSelectable, setIsOrchestratorSelectable] = useState(false);
const [orchestratorError, setOrchestratorError] = useState<string | null>(null);
const [isTasksInitialized, setIsTasksInitialized] = useState(false);
const [taskFiles, setTaskFiles] = useState<TaskFile[]>([]);
const [selectedTaskRef, setSelectedTaskRef] = useState<SelectedTaskRef | null>(null);
const [tasksError, setTasksError] = useState<string | null>(null);
const [taskLocked, setTaskLocked] = useState(false);
const isTaskModeEnabled = selectedOrchestrator === "task";
const selectedTask = useMemo<SelectedTask | null>(() => {
if (!selectedTaskRef) {
return null;
......@@ -108,39 +127,50 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
const initTasks = useCallback(async () => {
setTasksError(null);
setOrchestratorError(null);
try {
const orchestratorPayload = await fetchOrchestratorConfig();
const available = orchestratorPayload.available_orchestrators.length
? orchestratorPayload.available_orchestrators
: getFallbackOrchestrators();
setAvailableOrchestrators(available);
setIsOrchestratorSelectable(true);
setSelectedOrchestratorState((prev) =>
available.includes(prev) ? prev : orchestratorPayload.default_orchestrator
);
} catch (error) {
setAvailableOrchestrators(getFallbackOrchestrators());
setSelectedOrchestratorState("qa");
setIsOrchestratorSelectable(false);
setOrchestratorError(t("failedLoadOrchestratorConfig"));
void error;
}
try {
const payload = await fetchTasks();
const enabled = Boolean(payload.enabled);
const files = payload.task_files || [];
setIsTaskModeEnabled(enabled);
setTaskFiles(files);
if (!enabled) {
setSelectedTaskRef(null);
} else {
setSelectedTaskRef((prev) => {
if (prev) {
const file = files.find((item) => item.file_id === prev.fileId);
if (file && file.tasks.some((task) => task.task_id === prev.taskId)) {
return prev;
}
setSelectedTaskRef((prev) => {
if (prev) {
const file = files.find((item) => item.file_id === prev.fileId);
if (file && file.tasks.some((task) => task.task_id === prev.taskId)) {
return prev;
}
}
const firstFile = files[0];
if (!firstFile) {
return null;
}
const defaultTaskId = getDefaultTaskId(firstFile.tasks || []);
if (!defaultTaskId) {
return null;
}
return { fileId: firstFile.file_id, taskId: defaultTaskId };
});
}
const firstFile = files[0];
if (!firstFile) {
return null;
}
const defaultTaskId = getDefaultTaskId(firstFile.tasks || []);
if (!defaultTaskId) {
return null;
}
return { fileId: firstFile.file_id, taskId: defaultTaskId };
});
} catch (error) {
setTasksError(t("failedLoadTasks"));
setIsTaskModeEnabled(false);
setTaskFiles([]);
setSelectedTaskRef(null);
void error;
......@@ -153,27 +183,46 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
void initTasks();
}, [initTasks]);
const setTaskFile = useCallback((fileId: string) => {
if (!fileId) {
return;
}
const file = taskFiles.find((item) => item.file_id === fileId);
if (!file) {
return;
}
const defaultTaskId = getDefaultTaskId(file.tasks || []);
if (!defaultTaskId) {
return;
}
setSelectedTaskRef({ fileId: file.file_id, taskId: defaultTaskId });
}, [taskFiles]);
const setTaskFile = useCallback(
(fileId: string) => {
if (!fileId) {
return;
}
const file = taskFiles.find((item) => item.file_id === fileId);
if (!file) {
return;
}
const defaultTaskId = getDefaultTaskId(file.tasks || []);
if (!defaultTaskId) {
return;
}
setSelectedTaskRef({ fileId: file.file_id, taskId: defaultTaskId });
},
[taskFiles]
);
const setTaskId = useCallback((taskId: string) => {
if (!taskId || !selectedTaskRef?.fileId) {
return;
const setTaskId = useCallback(
(taskId: string) => {
if (!taskId || !selectedTaskRef?.fileId) {
return;
}
setSelectedTaskRef({ fileId: selectedTaskRef.fileId, taskId });
},
[selectedTaskRef]
);
const setSelectedOrchestrator = useCallback((value: OrchestratorName) => {
setSelectedOrchestratorState(value);
}, []);
const switchOrchestrator = useCallback((value: OrchestratorName) => {
setSelectedOrchestratorState(value);
setChatSessionId(createSessionId());
setTaskLocked(false);
if (value !== "task") {
setSelectedTaskRef(null);
}
setSelectedTaskRef({ fileId: selectedTaskRef.fileId, taskId });
}, [selectedTaskRef]);
}, []);
const lockTask = useCallback(() => {
setTaskLocked(true);
......@@ -193,6 +242,12 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
chatSessionId,
setChatSessionId,
initTasks,
selectedOrchestrator,
availableOrchestrators,
setSelectedOrchestrator,
switchOrchestrator,
isOrchestratorSelectable,
orchestratorError,
taskFiles,
selectedTaskRef,
selectedTask,
......
......@@ -61,6 +61,111 @@ body {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.orchestrator-select-wrap {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 4px 8px;
border: 1px solid #d8d1c4;
border-radius: 999px;
background: #fef9f0;
}
.orchestrator-select-label-row {
display: flex;
align-items: center;
gap: 6px;
}
.orchestrator-select-label {
font-size: 12px;
color: #6f675d;
}
.orchestrator-select {
min-width: 96px;
padding: 4px 8px;
}
.orchestrator-help {
position: relative;
}
.orchestrator-help-trigger {
width: 18px;
height: 18px;
border-radius: 999px;
border: 1px solid #b9b2a4;
background: #fff;
color: #6f675d;
font-size: 11px;
font-weight: 600;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: help;
}
.orchestrator-help-trigger:focus-visible {
outline: 2px solid #6f675d;
outline-offset: 1px;
}
.orchestrator-help-popover {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: 320px;
padding: 10px;
border: 1px solid #d8d1c4;
border-radius: 12px;
background: #fff;
box-shadow: 0 10px 26px rgba(29, 27, 22, 0.15);
color: #1d1b16;
font-size: 12px;
z-index: 40;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.15s ease;
}
.orchestrator-help:hover .orchestrator-help-popover,
.orchestrator-help:focus-within .orchestrator-help-popover {
opacity: 1;
visibility: visible;
}
.orchestrator-help-title {
font-weight: 600;
margin-bottom: 6px;
}
.orchestrator-help-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.orchestrator-help-item {
display: flex;
gap: 6px;
}
.orchestrator-help-mode {
font-weight: 600;
min-width: 42px;
}
.orchestrator-mode-helper {
flex-basis: 100%;
font-size: 12px;
color: #6f675d;
}
.pill {
......@@ -561,6 +666,13 @@ body {
width: 100%;
}
.orchestrator-help-popover {
left: auto;
right: 0;
width: auto;
max-width: min(92vw, 360px);
}
.chat-window {
min-height: 360px;
}
......@@ -592,6 +704,61 @@ body {
color: #6f675d;
}
.backend-health-shell {
min-height: 100vh;
padding: 24px;
background: radial-gradient(circle at top left, #fff7e6, #f4f1ea);
display: flex;
flex-direction: column;
}
.backend-health-header {
margin-bottom: 24px;
}
.backend-health-main {
flex: 1;
display: grid;
place-items: center;
}
.backend-health-card {
width: min(620px, 100%);
background: #fef9f0;
border: 1px solid #d8d1c4;
border-radius: 16px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 10px;
}
.backend-health-title {
margin: 0;
font-size: 20px;
}
.backend-health-text {
margin: 0;
color: #6f675d;
}
.backend-health-loading {
margin: 0;
color: #6f675d;
}
.backend-health-detail {
margin: 0;
padding: 8px 10px;
border-radius: 10px;
background: #f3d9d6;
color: #8a3b2f;
border: 1px solid #e2b4ae;
font-size: 12px;
overflow-wrap: anywhere;
}
.task-select-main {
flex: 1;
display: grid;
......@@ -657,6 +824,14 @@ body {
}
@media (max-width: 900px) {
.backend-health-shell {
padding: 16px;
}
.backend-health-card {
width: 100%;
}
.task-select-controls {
grid-template-columns: 1fr;
}
......
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