Commit f0368860 authored by Kantz's avatar Kantz
Browse files

Merge branch 'dev_socratic' into 'dev'

Dev socratic

See merge request kantz/tutor_react!17
parents 22db3d83 8e5526b8
...@@ -3,16 +3,17 @@ from app.deterministic_services import llm_client ...@@ -3,16 +3,17 @@ from app.deterministic_services import llm_client
SYSTEM_PROMPT = """ SYSTEM_PROMPT = """
Du bist ein didaktischer Mathe-Tutor. Du gibts Feedback zu den Lösungen des Nutzers zur Gegebenen Frage. Du bist ein didaktischer Mathe-Tutor. Du gibst Feedback zu den Lösungen des Nutzers zur Gegebenen Frage.
Vergleiche das Ergebnis mit der korrekten Lösung. Vergleiche das Ergebnis mit der korrekten Lösung.
Stelle Nachfragen wenn der Lösungsweg nicht vollständig ist. Stelle Nachfragen wenn der Lösungsweg nicht vollständig ist.
Die Reihnfolge in mathematischen Ausdrücken ist irrellevant wenn sie äquivalent sind also a+b = b+a und a^bc = a^cb. Die Reihenfolge in mathematischen Ausdrücken ist irrelevant wenn sie äquivalent sind also a+b = b+a und a^bc = a^cb.
Zitiere 1 zu 1 aus der AKTUELLE Eingabe des Nutzers wenn du auf Fehler aufmerksam machst. Zitiere 1 zu 1 aus der AKTUELLE Eingabe des Nutzers wenn du auf Fehler aufmerksam machst.
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$
minf steht für minus unendlich.
Halte dich kurz und präzise. Halte dich kurz und präzise.
Denke nach bevor du eine Ausgabe macht. Denke nach bevor du eine Ausgabe macht.
......
...@@ -8,6 +8,7 @@ Du bist ein didaktischer Mathe-Tutor. ...@@ -8,6 +8,7 @@ Du bist ein didaktischer Mathe-Tutor.
3) Wenn der Nutzer etwas Falsches sagt, antworte: "Das ist noch nicht richtig. Prüfe [konkreter Aspekt]." 3) Wenn der Nutzer etwas Falsches sagt, antworte: "Das ist noch nicht richtig. Prüfe [konkreter Aspekt]."
4) Falls eine 'Mathematische Lösung' (Formel/Algorithmus) existiert, gib NUR diese als Tipp an – selbst wenn der Nutzer nach Alternativen fragt. 4) Falls eine 'Mathematische Lösung' (Formel/Algorithmus) existiert, gib NUR diese als Tipp an – selbst wenn der Nutzer nach Alternativen fragt.
5) Verwende IMMER die $-Notation für Formeln (z. B. $a^2 + b^2 = c^2$). Kein LaTeX außerhalb der $-Blöcke. 5) Verwende IMMER die $-Notation für Formeln (z. B. $a^2 + b^2 = c^2$). Kein LaTeX außerhalb der $-Blöcke.
6) minf steht für minus unendlich.
Antworte IMMER in dieser Form: Antworte IMMER in dieser Form:
- "Das war richtig. [Tipp in einem Satz]." - "Das war richtig. [Tipp in einem Satz]."
......
...@@ -7,6 +7,9 @@ Nutze ausschließlich den bereitgestellten Kontext. ...@@ -7,6 +7,9 @@ Nutze ausschließlich den bereitgestellten Kontext.
Wenn dort nichts zu dem Thema steht, antworte nur mit: Wenn dort nichts zu dem Thema steht, antworte nur mit:
„Dazu steht nichts im Material.“ „Dazu steht nichts im Material.“
minf steht für minus unendlich.
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$
Liefer wenn möglich ein Zahlenbeispiel mit. Liefer wenn möglich ein Zahlenbeispiel mit.
......
from __future__ import annotations
from typing import Iterable
from app.deterministic_services import llm_client
HINT_SYSTEM_PROMPT = """
Du bist ein didaktischer Mathe-Tutor.
Antworte auf Deutsch, kurz und präzise.
Nutze ausschließlich den bereitgestellten Kontext.
Stelle vor allem Verständnisfragen und leite den Studeten sokratisch.
Gib keine komplette Lösung aus.
Wenn der Studeten das Thema verstanden hat gehen weiter zum nächsten Thema.
Wenn der Student alle Themen verstanden hat sag im das.
Verwende für mathematische Formeln immer die $-Notation, z.B. $a^2 + b^2 = c^2$.
minf steht für minus unendlich.
"""
def _format_subsection_refs(subsection_refs: Iterable[tuple[int, int, int]] | None) -> str:
if not subsection_refs:
return "Keine Subsection-Referenzen übergeben."
formatted = [f"{chap}:{sec}:{sub}" for chap, sec, sub in subsection_refs]
return ", ".join(formatted)
def generate_dialog(
query: str | None,
subsection_refs: list[tuple[int, int, int]] | None = None,
history: list[dict] | None = None,
sources: str | None = None,
) -> str:
context_parts = [
f"Subsection-Referenzen:\n{_format_subsection_refs(subsection_refs)}",
]
if sources:
context_parts.append(f"Kontext/Sources:\n{sources}")
messages = [{"role": "system", "content": HINT_SYSTEM_PROMPT}]
messages.append({"role": "user", "content": "\n\n".join(context_parts)})
if history:
messages.extend(history)
if query:
messages.append(
{
"role": "user",
"content": (
"AKTUELLE Eingabe des Studenten (höchste Priorität):\n"
f"{query}\n\n"
"Reagiere ausschließlich auf diese Eingabe und bleibe sokratisch."
),
}
)
result = llm_client.chat(messages=messages)
return llm_client.get_message_content(result)
...@@ -9,7 +9,9 @@ Du bist ein didaktischer Mathe-Tutor. Du beantwortest die mathematischen Fragen ...@@ -9,7 +9,9 @@ Du bist ein didaktischer Mathe-Tutor. Du beantwortest die mathematischen Fragen
3) Wenn der Nutzer etwas Falsches sagt weiße ihn darauf hin. 3) Wenn der Nutzer etwas Falsches sagt weiße ihn darauf hin.
4) Die Übergebene Lösung ist IMMER korrekt. 4) Die Übergebene Lösung ist IMMER korrekt.
5) Der Nutzer kenn die korrekte Lösung und den exemplarischer Lösungsweg nicht. 5) Der Nutzer kenn die korrekte Lösung und den exemplarischer Lösungsweg nicht.
6) Die Reihnfolge in mathematischen Ausdrücken ist irrellevant wenn sie äquivalent sind also a+b = b+a und a^bc = a^cb. 6) Die Reihenfolge in mathematischen Ausdrücken ist irrelevant wenn sie äquivalent sind also a+b = b+a und a^bc = a^cb.
7) Zitiere 1 zu 1 aus der AKTUELLE Eingabe des Nutzers wenn du auf Fehler aufmerksam machst.
8) minf steht für minus unendlich.
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$
......
...@@ -3,7 +3,10 @@ from __future__ import annotations ...@@ -3,7 +3,10 @@ from __future__ import annotations
import logging import logging
from typing import List, Optional from typing import List, Optional
import app.config as config
from app.deterministic_services import session_store from app.deterministic_services import session_store
from app.deterministic_services import context_store, retrieval_store, task_catalog
from app.LLM_services import socratic_LLM
from app.deterministic_services.orchestrators.registry import ( from app.deterministic_services.orchestrators.registry import (
get_default_orchestrator, get_default_orchestrator,
is_valid_orchestrator, is_valid_orchestrator,
...@@ -26,6 +29,7 @@ class ChatRequest(BaseModel): ...@@ -26,6 +29,7 @@ class ChatRequest(BaseModel):
messages: List[ChatMessage] messages: List[ChatMessage]
draft: Optional[str] = None draft: Optional[str] = None
selected_task: Optional[dict[str, str]] = None selected_task: Optional[dict[str, str]] = None
selected_subsection: Optional[dict[str, str]] = None
orchestrator: Optional[str] = None orchestrator: Optional[str] = None
...@@ -52,14 +56,29 @@ class SelectedTaskRef(BaseModel): ...@@ -52,14 +56,29 @@ class SelectedTaskRef(BaseModel):
task_id: str task_id: str
class SelectedSubsectionRef(BaseModel):
subsection_key: str
class ChatArchiveDetail(BaseModel): class ChatArchiveDetail(BaseModel):
chat_id: str chat_id: str
saved_at: str saved_at: str
history: List[ChatMessage] history: List[ChatMessage]
selected_task: Optional[SelectedTaskRef] = None selected_task: Optional[SelectedTaskRef] = None
selected_subsection: Optional[SelectedSubsectionRef] = None
orchestrator: str orchestrator: str
class SocraticBootstrapRequest(BaseModel):
draft: str = Field(..., min_length=1)
subsection_key: str = Field(..., min_length=1)
class SocraticBootstrapResponse(BaseModel):
reply: str
sources: List[dict] = []
@router.post("/api/chat", response_model=ChatResponse) @router.post("/api/chat", response_model=ChatResponse)
def chat(request: ChatRequest) -> ChatResponse: def chat(request: ChatRequest) -> ChatResponse:
if not request.messages: if not request.messages:
...@@ -75,11 +94,12 @@ def chat(request: ChatRequest) -> ChatResponse: ...@@ -75,11 +94,12 @@ def chat(request: ChatRequest) -> ChatResponse:
try: try:
payload_messages = [{"role": m.role, "content": m.text} for m in request.messages] payload_messages = [{"role": m.role, "content": m.text} for m in request.messages]
if orchestrator_name in {"task", "feedback"}: if orchestrator_name in {"task", "feedback", "socratic"}:
result = orchestrator_impl.run_chat( result = orchestrator_impl.run_chat(
payload_messages, payload_messages,
draft=request.draft, draft=request.draft,
selected_task=request.selected_task, selected_task=request.selected_task,
selected_subsection=request.selected_subsection,
) )
else: else:
result = orchestrator_impl.run_chat( result = orchestrator_impl.run_chat(
...@@ -125,11 +145,19 @@ def get_archive(chat_id: str = Path(..., min_length=1)) -> ChatArchiveDetail: ...@@ -125,11 +145,19 @@ def get_archive(chat_id: str = Path(..., min_length=1)) -> ChatArchiveDetail:
if file_id and task_id: if file_id and task_id:
selected_task = SelectedTaskRef(file_id=file_id, task_id=task_id) selected_task = SelectedTaskRef(file_id=file_id, task_id=task_id)
selected_subsection_raw = record.get("selected_subsection")
selected_subsection: Optional[SelectedSubsectionRef] = None
if isinstance(selected_subsection_raw, dict):
subsection_key = str(selected_subsection_raw.get("subsection_key", "")).strip()
if subsection_key:
selected_subsection = SelectedSubsectionRef(subsection_key=subsection_key)
return ChatArchiveDetail( return ChatArchiveDetail(
chat_id=record["chat_id"], chat_id=record["chat_id"],
saved_at=record.get("saved_at", ""), saved_at=record.get("saved_at", ""),
history=[ChatMessage(role=item["role"], text=item["text"]) for item in record["history"]], history=[ChatMessage(role=item["role"], text=item["text"]) for item in record["history"]],
selected_task=selected_task, selected_task=selected_task,
selected_subsection=selected_subsection,
orchestrator=record.get("orchestrator") or get_default_orchestrator(), orchestrator=record.get("orchestrator") or get_default_orchestrator(),
) )
...@@ -155,3 +183,41 @@ def archive_chat(request: ChatRequest) -> ChatArchiveResponse: ...@@ -155,3 +183,41 @@ def archive_chat(request: ChatRequest) -> ChatArchiveResponse:
raise HTTPException(status_code=502, detail="chat archive failed") from exc raise HTTPException(status_code=502, detail="chat archive failed") from exc
return ChatArchiveResponse(status="ok", chat_id=chat_id) return ChatArchiveResponse(status="ok", chat_id=chat_id)
@router.post("/api/chat/bootstrap-socratic", response_model=SocraticBootstrapResponse)
def bootstrap_socratic(request: SocraticBootstrapRequest) -> SocraticBootstrapResponse:
chat_id = context_store.get_chat_id([], draft=request.draft)
sheet = context_store.load_sheet(chat_id)
if not sheet:
sheet = context_store.context_store_new.init_sheet(chat_id, [])
if not task_catalog.select_subsection_by_key(sheet, request.subsection_key):
raise HTTPException(status_code=404, detail="subsection not found")
refs = task_catalog.get_selected_subsection_refs(sheet)
sources = retrieval_store.retrieve_for_subsections(
pg_url=config.get_postgres_url(),
subsection_refs=refs,
)
context_store.set_sources(sheet, sources)
context_store.set_initialized(sheet, True)
context_store.save_sheet(sheet)
sources_text = "\n".join(source.to_string() for source in sources)
reply = socratic_LLM.generate_dialog(
query="Was sind die Themen dieses Abschnitts? Frage mich, mit welchem ich mich zuerst beschäftigen möchte.",
subsection_refs=refs,
history=None,
sources=sources_text,
)
if not reply:
reply = "Was sind die Themen dieses Abschnitts? Womit möchtest du anfangen?"
context_store.append_history_message(sheet, "assistant", reply)
context_store.save_sheet(sheet)
return SocraticBootstrapResponse(
reply=reply,
sources=[source.model_dump() for source in sources],
)
...@@ -10,7 +10,7 @@ from app.deterministic_services import context_store, task_catalog ...@@ -10,7 +10,7 @@ from app.deterministic_services import context_store, task_catalog
router = APIRouter() router = APIRouter()
TASK_ORCHESTRATORS = {"task", "feedback"} TASK_ORCHESTRATORS = {"task", "feedback", "socratic"}
class TaskItem(BaseModel): class TaskItem(BaseModel):
...@@ -19,6 +19,12 @@ class TaskItem(BaseModel): ...@@ -19,6 +19,12 @@ class TaskItem(BaseModel):
full_text: str full_text: str
class SubsectionEntry(BaseModel):
subsection_key: str
label: str
refs: List[List[int]]
class TaskFile(BaseModel): class TaskFile(BaseModel):
file_id: str file_id: str
title: str title: str
...@@ -31,6 +37,7 @@ class TasksResponse(BaseModel): ...@@ -31,6 +37,7 @@ class TasksResponse(BaseModel):
orchestrator: str orchestrator: str
enabled: bool enabled: bool
task_files: List[TaskFile] task_files: List[TaskFile]
subsections: List[SubsectionEntry] = Field(default_factory=list)
class SelectTaskRequest(BaseModel): class SelectTaskRequest(BaseModel):
...@@ -45,6 +52,16 @@ class SelectTaskResponse(BaseModel): ...@@ -45,6 +52,16 @@ class SelectTaskResponse(BaseModel):
task_id: str task_id: str
class SelectSubsectionRequest(BaseModel):
draft: str = Field(..., min_length=1)
subsection_key: str = Field(..., min_length=1)
class SelectSubsectionResponse(BaseModel):
status: str
subsection_key: str
@router.get("/api/tasks/config") @router.get("/api/tasks/config")
def get_task_config() -> dict[str, object]: def get_task_config() -> dict[str, object]:
orchestrator = config.get_orchestrator() orchestrator = config.get_orchestrator()
...@@ -55,10 +72,12 @@ def get_task_config() -> dict[str, object]: ...@@ -55,10 +72,12 @@ def get_task_config() -> dict[str, object]:
def list_tasks() -> TasksResponse: def list_tasks() -> TasksResponse:
orchestrator = config.get_orchestrator() orchestrator = config.get_orchestrator()
task_files = task_catalog.build_task_catalog() task_files = task_catalog.build_task_catalog()
subsections = task_catalog.build_subsection_catalog()
return TasksResponse( return TasksResponse(
orchestrator=orchestrator, orchestrator=orchestrator,
enabled=orchestrator in TASK_ORCHESTRATORS, enabled=orchestrator in TASK_ORCHESTRATORS,
task_files=task_files, task_files=task_files,
subsections=subsections,
) )
# Eigentlich sollte die Context-Selection erst passieren wen das schon fest steht # Eigentlich sollte die Context-Selection erst passieren wen das schon fest steht
...@@ -85,3 +104,22 @@ def select_task(request: SelectTaskRequest) -> SelectTaskResponse: ...@@ -85,3 +104,22 @@ def select_task(request: SelectTaskRequest) -> SelectTaskResponse:
file_id=file_id or request.file_id, file_id=file_id or request.file_id,
task_id=task_id or request.task_id, task_id=task_id or request.task_id,
) )
@router.post("/api/tasks/select-subsection", response_model=SelectSubsectionResponse)
def select_subsection(request: SelectSubsectionRequest) -> SelectSubsectionResponse:
chat_id = context_store.get_chat_id([], draft=request.draft)
sheet = context_store.load_sheet(chat_id)
if not sheet:
sheet = context_store.context_store_new.init_sheet(chat_id, [])
updated = task_catalog.select_subsection_by_key(sheet, subsection_key=request.subsection_key)
if not updated:
raise HTTPException(status_code=404, detail="subsection not found")
context_store.save_sheet(sheet)
_, subsection_key = task_catalog.get_selected_subsection_ids(sheet)
return SelectSubsectionResponse(
status="ok",
subsection_key=subsection_key or request.subsection_key,
)
...@@ -120,6 +120,7 @@ def run_chat( ...@@ -120,6 +120,7 @@ def run_chat(
messages: list[dict], messages: list[dict],
draft: str | None = None, draft: str | None = None,
selected_task: dict | None = None, selected_task: dict | None = None,
selected_subsection: dict | None = None,
) -> dict: ) -> dict:
def _apply_selected_task(state: base.ChatState) -> None: def _apply_selected_task(state: base.ChatState) -> None:
if not selected_task: if not selected_task:
......
from __future__ import annotations
from app.LLM_services import socratic_LLM
import app.config as config
from app.deterministic_services import context_store, retrieval_store, task_catalog
from app.deterministic_services.orchestrators import orchestrator_base as base
def _apply_selected_subsection(
state: base.ChatState,
selected_subsection: dict | None,
) -> None:
if not selected_subsection:
return
subsection_key = str(selected_subsection.get("subsection_key", "")).strip()
if not subsection_key:
return
task_catalog.select_subsection_by_key(state.sheet, subsection_key)
def _retrieve_context_for_subsection(state: base.ChatState, query_text: str) -> int:
refs = task_catalog.get_selected_subsection_refs(state.sheet)
if not refs:
return 0
def _retrieve() -> dict:
sources = retrieval_store.retrieve_for_subsections(
pg_url=config.get_postgres_url(),
subsection_refs=refs,
)
context_store.update_retrieval_context(state.sheet, sources)
return {
"subsection_refs": refs,
"source_count": len(sources),
}
result = base.log_timed_call(
state.tool_log,
"retrieve_context_with_socratic_subsections",
{
"query": query_text,
"subsection_refs": refs,
},
_retrieve,
)
return int(result.get("source_count", 0))
def _on_bootstrap(state: base.ChatState, query_text: str) -> None:
_retrieve_context_for_subsection(state, query_text)
def _on_turn_logic(state: base.ChatState) -> None:
if not task_catalog.get_selected_subsection_refs(state.sheet):
return
def _on_build_reply(state: base.ChatState) -> str | None:
history_turns = context_store.get_history_turns(state.sheet)
subsection_refs = task_catalog.get_selected_subsection_refs(state.sheet)
args = {
"query": state.last_user,
"subsection_refs": subsection_refs,
"history": history_turns,
"sources": "\n".join([source.to_string() for source in context_store.get_retrieval(state.sheet)]),
}
return base.log_timed_call(
state.tool_log,
"socratic_generate_dialog",
args,
lambda: socratic_LLM.generate_dialog(**args),
)
def run_chat(
messages: list[dict],
draft: str | None = None,
selected_subsection: dict | None = None,
selected_task: dict | None = None,
) -> dict:
def _apply_selected_context(state: base.ChatState) -> None:
_apply_selected_subsection(state, selected_subsection)
def on_bootstrap(state: base.ChatState, query_text: str) -> None:
_apply_selected_context(state)
_on_bootstrap(state, query_text)
def on_turn_logic(state: base.ChatState) -> None:
_apply_selected_context(state)
_on_turn_logic(state)
return base.run_chat_common(
messages=messages,
draft=draft,
on_bootstrap=on_bootstrap,
on_turn_logic=on_turn_logic,
on_build_reply=_on_build_reply,
init_sheet_fn=context_store.context_store_new.init_sheet,
)
...@@ -123,6 +123,7 @@ def run_chat( ...@@ -123,6 +123,7 @@ def run_chat(
messages: list[dict], messages: list[dict],
draft: str | None = None, draft: str | None = None,
selected_task: dict | None = None, selected_task: dict | None = None,
selected_subsection: dict | None = None,
) -> dict: ) -> dict:
def _apply_selected_task(state: base.ChatState) -> None: def _apply_selected_task(state: base.ChatState) -> None:
if not selected_task: if not selected_task:
......
...@@ -6,17 +6,19 @@ import app.config as config ...@@ -6,17 +6,19 @@ import app.config as config
from app.deterministic_services.orchestrators import ( from app.deterministic_services.orchestrators import (
orchestrator_feedback, orchestrator_feedback,
orchestrator_qa, orchestrator_qa,
orchestrator_socratic,
orchestrator_task, orchestrator_task,
orchestrator_tutor, orchestrator_tutor,
) )
AVAILABLE_ORCHESTRATORS: tuple[str, ...] = ("qa", "tutor", "task", "feedback") AVAILABLE_ORCHESTRATORS: tuple[str, ...] = ("qa", "tutor", "task", "feedback", "socratic")
_ORCHESTRATOR_MODULES: dict[str, Any] = { _ORCHESTRATOR_MODULES: dict[str, Any] = {
"qa": orchestrator_qa, "qa": orchestrator_qa,
"tutor": orchestrator_tutor, "tutor": orchestrator_tutor,
"task": orchestrator_task, "task": orchestrator_task,
"feedback": orchestrator_feedback, "feedback": orchestrator_feedback,
"socratic": orchestrator_socratic,
} }
......
...@@ -26,6 +26,13 @@ def _extract_selected_task(sheet: dict[str, Any]) -> dict[str, str] | None: ...@@ -26,6 +26,13 @@ def _extract_selected_task(sheet: dict[str, Any]) -> dict[str, str] | None:
return {"file_id": file_id, "task_id": task_id} return {"file_id": file_id, "task_id": task_id}
def _extract_selected_subsection(sheet: dict[str, Any]) -> dict[str, str] | None:
subsection_key = str(sheet.get("selected_subsection_key", "")).strip()
if not subsection_key:
return None
return {"subsection_key": subsection_key}
def archive_chat( def archive_chat(
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
draft: str | None = None, draft: str | None = None,
...@@ -49,6 +56,7 @@ def archive_chat( ...@@ -49,6 +56,7 @@ def archive_chat(
"math_solutions": sheet.get("math_solutions", []), "math_solutions": sheet.get("math_solutions", []),
"sources": sheet.get("sources", []), "sources": sheet.get("sources", []),
"selected_task": _extract_selected_task(sheet), "selected_task": _extract_selected_task(sheet),
"selected_subsection": _extract_selected_subsection(sheet),
} }
os.makedirs(_LOG_DIR, exist_ok=True) os.makedirs(_LOG_DIR, exist_ok=True)
...@@ -126,11 +134,19 @@ def load_archive(chat_id: str) -> dict[str, Any] | None: ...@@ -126,11 +134,19 @@ def load_archive(chat_id: str) -> dict[str, Any] | None:
if file_id and task_id: if file_id and task_id:
selected_task = {"file_id": file_id, "task_id": task_id} selected_task = {"file_id": file_id, "task_id": task_id}
selected_subsection_raw = record.get("selected_subsection")
selected_subsection: dict[str, str] | None = None
if isinstance(selected_subsection_raw, dict):
subsection_key = str(selected_subsection_raw.get("subsection_key", "")).strip()
if subsection_key:
selected_subsection = {"subsection_key": subsection_key}
return { return {
"chat_id": record.get("chat_id", chat_id), "chat_id": record.get("chat_id", chat_id),
"saved_at": record.get("saved_at", ""), "saved_at": record.get("saved_at", ""),
"orchestrator": record.get("orchestrator"), "orchestrator": record.get("orchestrator"),
"history": history, "history": history,
"selected_task": selected_task, "selected_task": selected_task,
"selected_subsection": selected_subsection,
} }
return None return None
...@@ -71,6 +71,61 @@ def _resolve_task_subsection_refs( ...@@ -71,6 +71,61 @@ def _resolve_task_subsection_refs(
return sorted(refs) return sorted(refs)
def _format_subsection_label(value: str) -> str:
cleaned = re.sub(r"[-_]+", " ", value.strip())
cleaned = re.sub(r"\s+", " ", cleaned).strip()
if not cleaned:
return ""
return cleaned.title()
def build_subsection_catalog(path: Path = SUBSECTION_MAP_PATH) -> list[dict[str, Any]]:
subsection_map = load_subsection_map(path)
response: list[dict[str, Any]] = []
for key, ref in sorted(subsection_map.items(), key=lambda item: (item[0], item[1])):
response.append(
{
"subsection_key": key,
"label": _format_subsection_label(key),
"refs": [[int(ref[0]), int(ref[1]), int(ref[2])]],
}
)
return response
def _resolve_task_subsection_options(
task_file: dict[str, Any],
subsection_map: dict[str, tuple[int, int, int]] | None = None,
) -> list[dict[str, Any]]:
mapping = subsection_map if subsection_map is not None else load_subsection_map()
subsections = task_file.get("subsections", [])
if not isinstance(subsections, list):
return []
options: list[dict[str, Any]] = []
seen_refs: set[tuple[int, int, int]] = set()
for subsection in subsections:
raw_label = str(subsection).strip()
key = _normalize_subsection_key(raw_label)
if not key:
continue
ref = mapping.get(key)
if ref is None:
continue
normalized_ref = (int(ref[0]), int(ref[1]), int(ref[2]))
if normalized_ref in seen_refs:
continue
seen_refs.add(normalized_ref)
options.append(
{
"subsection_key": key,
"label": _format_subsection_label(raw_label) or raw_label,
"refs": [[normalized_ref[0], normalized_ref[1], normalized_ref[2]]],
}
)
return options
def _match_score(query_text: str, candidate_text: str) -> int: def _match_score(query_text: str, candidate_text: str) -> int:
query_tokens = _tokenize(query_text) query_tokens = _tokenize(query_text)
if not query_tokens: if not query_tokens:
...@@ -142,6 +197,37 @@ def set_selected_task( ...@@ -142,6 +197,37 @@ def set_selected_task(
sheet["task_id"] = str(task_entry.get("id", "")).zfill(2) sheet["task_id"] = str(task_entry.get("id", "")).zfill(2)
refs = _resolve_task_subsection_refs(task_file) refs = _resolve_task_subsection_refs(task_file)
sheet["task_subsection_refs"] = [[chap, sec, sub] for chap, sec, sub in refs] sheet["task_subsection_refs"] = [[chap, sec, sub] for chap, sec, sub in refs]
sheet.pop("selected_subsection_key", None)
sheet.pop("selected_subsection_label", None)
sheet.pop("selected_subsection_refs", None)
def set_selected_subsection(
sheet: dict[str, Any],
task_file: dict[str, Any],
subsection_option: dict[str, Any],
) -> None:
refs_raw = subsection_option.get("refs", [])
refs: list[tuple[int, int, int]] = []
if isinstance(refs_raw, list):
for item in refs_raw:
if isinstance(item, (list, tuple)) and len(item) >= 3:
try:
refs.append((int(item[0]), int(item[1]), int(item[2])))
except Exception:
continue
if not refs:
return
sheet["selected_subsection_key"] = str(subsection_option.get("subsection_key", "")).strip()
sheet["selected_subsection_label"] = str(subsection_option.get("label", "")).strip()
sheet["selected_subsection_refs"] = [[chap, sec, sub] for chap, sec, sub in sorted({*refs})]
sheet.pop("task_id", None)
sheet.pop("task_subsection_refs", None)
sheet.pop("task", None)
sheet.pop("hints", None)
sheet.pop("solution", None)
def select_task_by_ids( def select_task_by_ids(
...@@ -161,12 +247,81 @@ def select_task_by_ids( ...@@ -161,12 +247,81 @@ def select_task_by_ids(
return True return True
def select_subsection_by_ids(
sheet: dict[str, Any],
file_id: str,
subsection_key: str,
task_files: list[dict[str, Any]] | None = None,
) -> bool:
catalog = task_files if task_files is not None else load_task_files()
task_file = _find_task_file(catalog, file_id)
if not task_file:
return False
subsection_map = load_subsection_map()
options = _resolve_task_subsection_options(task_file, subsection_map=subsection_map)
normalized_key = _normalize_subsection_key(subsection_key)
if not normalized_key:
return False
for option in options:
if str(option.get("subsection_key", "")).strip() == normalized_key:
set_selected_subsection(sheet, task_file, option)
return True
return False
def select_subsection_by_key(
sheet: dict[str, Any],
subsection_key: str,
subsection_map: dict[str, tuple[int, int, int]] | None = None,
) -> bool:
mapping = subsection_map if subsection_map is not None else load_subsection_map()
normalized_key = _normalize_subsection_key(subsection_key)
if not normalized_key:
return False
ref = mapping.get(normalized_key)
if ref is None:
return False
sheet["selected_subsection_key"] = normalized_key
sheet["selected_subsection_label"] = _format_subsection_label(normalized_key)
sheet["selected_subsection_refs"] = [[int(ref[0]), int(ref[1]), int(ref[2])]]
sheet.pop("task_file_id", None)
sheet.pop("task_id", None)
sheet.pop("task_subsection_refs", None)
sheet.pop("task", None)
sheet.pop("hints", None)
sheet.pop("solution", None)
return True
def get_selected_task_ids(sheet: dict[str, Any]) -> tuple[str | None, str | None]: def get_selected_task_ids(sheet: dict[str, Any]) -> tuple[str | None, str | None]:
file_id = str(sheet.get("task_file_id", "")).strip() file_id = str(sheet.get("task_file_id", "")).strip()
task_id = str(sheet.get("task_id", "")).strip() task_id = str(sheet.get("task_id", "")).strip()
return (file_id or None, task_id or None) return (file_id or None, task_id or None)
def get_selected_subsection_ids(sheet: dict[str, Any]) -> tuple[str | None, str | None]:
subsection_key = str(sheet.get("selected_subsection_key", "")).strip()
return (None, subsection_key or None)
def get_selected_subsection_refs(sheet: dict[str, Any]) -> list[tuple[int, int, int]]:
refs_raw = sheet.get("selected_subsection_refs", [])
if not isinstance(refs_raw, list):
return []
refs: set[tuple[int, int, int]] = set()
for item in refs_raw:
if isinstance(item, (list, tuple)) and len(item) >= 3:
try:
refs.add((int(item[0]), int(item[1]), int(item[2])))
except Exception:
continue
return sorted(refs)
def get_selected_task_subsection_refs(sheet: dict[str, Any]) -> list[tuple[int, int, int]]: def get_selected_task_subsection_refs(sheet: dict[str, Any]) -> list[tuple[int, int, int]]:
refs_raw = sheet.get("task_subsection_refs", []) refs_raw = sheet.get("task_subsection_refs", [])
if not isinstance(refs_raw, list): if not isinstance(refs_raw, list):
...@@ -260,6 +415,7 @@ def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[d ...@@ -260,6 +415,7 @@ def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[d
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", []) subsections = task_file.get("subsections", [])
subsection_options = _resolve_task_subsection_options(task_file)
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):
...@@ -282,6 +438,7 @@ def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[d ...@@ -282,6 +438,7 @@ def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[d
"intro": intro, "intro": intro,
"tasks": tasks, "tasks": tasks,
"subsections": subsections, "subsections": subsections,
"subsection_options": subsection_options,
} }
) )
return response return response
from __future__ import annotations
import json
import os
import unittest
from pathlib import Path
from unittest.mock import patch
os.environ.setdefault("OPENAI_BASE_URL", "http://localhost:9999")
os.environ.setdefault("OPENAI_API_KEY", "test-key")
os.environ.setdefault("POSTGRES_URL", "postgresql://localhost/test")
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api import chat, tasks
from app.deterministic_services import session_store, task_catalog
class TaskCatalogSocraticTest(unittest.TestCase):
def test_build_subsection_catalog_uses_only_map(self) -> None:
with patch(
"app.deterministic_services.task_catalog.load_subsection_map",
return_value={
"quadratische gleichungen": (1, 3, 3),
"mengen": (1, 1, 1),
},
):
catalog = task_catalog.build_subsection_catalog()
self.assertEqual(
catalog,
[
{
"subsection_key": "mengen",
"label": "Mengen",
"refs": [[1, 1, 1]],
},
{
"subsection_key": "quadratische gleichungen",
"label": "Quadratische Gleichungen",
"refs": [[1, 3, 3]],
},
],
)
def test_select_subsection_by_ids_sets_sheet_fields(self) -> None:
task_files = [
{
"_file_id": "analysis_1",
"title": "Analysis",
"subsections": ["quadratische_gleichungen"],
"tasks": [],
}
]
sheet: dict[str, object] = {}
with patch(
"app.deterministic_services.task_catalog.load_subsection_map",
return_value={"quadratische gleichungen": (1, 3, 3)},
):
updated = task_catalog.select_subsection_by_ids(
sheet,
"analysis_1",
"quadratische_gleichungen",
task_files=task_files,
)
self.assertTrue(updated)
self.assertEqual(sheet["selected_subsection_key"], "quadratische gleichungen")
self.assertEqual(sheet["selected_subsection_refs"], [[1, 3, 3]])
def test_select_subsection_by_key_sets_sheet_fields(self) -> None:
sheet: dict[str, object] = {}
with patch(
"app.deterministic_services.task_catalog.load_subsection_map",
return_value={"quadratische gleichungen": (1, 3, 3)},
):
updated = task_catalog.select_subsection_by_key(sheet, "quadratische_gleichungen")
self.assertTrue(updated)
self.assertEqual(sheet["selected_subsection_key"], "quadratische gleichungen")
self.assertEqual(sheet["selected_subsection_refs"], [[1, 3, 3]])
class TaskApiSocraticTest(unittest.TestCase):
def setUp(self) -> None:
app = FastAPI()
app.include_router(tasks.router)
self.client = TestClient(app)
def test_list_tasks_includes_subsection_options(self) -> None:
payload = [
{
"file_id": "analysis_1",
"title": "Analysis",
"intro": "Intro",
"tasks": [],
}
]
subsections = [
{
"subsection_key": "quadratische gleichungen",
"label": "Quadratische Gleichungen",
"refs": [[1, 3, 3]],
}
]
with patch("app.api.tasks.config.get_orchestrator", return_value="socratic"), patch(
"app.api.tasks.task_catalog.build_task_catalog",
return_value=payload,
), patch(
"app.api.tasks.task_catalog.build_subsection_catalog",
return_value=subsections,
):
response = self.client.get("/api/tasks")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["orchestrator"], "socratic")
self.assertEqual(body["subsections"][0]["subsection_key"], "quadratische gleichungen")
def test_select_subsection_endpoint_returns_selected_key(self) -> None:
sheet: dict[str, object] = {}
with patch("app.api.tasks.context_store.get_chat_id", return_value="chat-1"), patch(
"app.api.tasks.context_store.load_sheet",
return_value=sheet,
), patch(
"app.api.tasks.context_store.context_store_new.init_sheet",
return_value=sheet,
), patch(
"app.api.tasks.task_catalog.select_subsection_by_key",
return_value=True,
) as select_mock, patch(
"app.api.tasks.context_store.save_sheet"
), patch(
"app.api.tasks.task_catalog.get_selected_subsection_ids",
return_value=(None, "quadratische gleichungen"),
):
response = self.client.post(
"/api/tasks/select-subsection",
json={
"draft": "chat-1",
"subsection_key": "quadratische_gleichungen",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["subsection_key"], "quadratische gleichungen")
select_mock.assert_called_once_with(sheet, subsection_key="quadratische_gleichungen")
class ChatBootstrapSocraticTest(unittest.TestCase):
def setUp(self) -> None:
app = FastAPI()
app.include_router(chat.router)
self.client = TestClient(app)
def test_bootstrap_socratic_loads_sources_and_writes_only_assistant_history(self) -> None:
sheet: dict[str, object] = {}
with patch("app.api.chat.context_store.get_chat_id", return_value="chat-1"), patch(
"app.api.chat.context_store.load_sheet",
return_value=sheet,
), patch(
"app.api.chat.context_store.context_store_new.init_sheet",
return_value=sheet,
), patch(
"app.api.chat.context_store.save_sheet"
), patch(
"app.api.chat.context_store.set_sources"
), patch(
"app.api.chat.context_store.set_initialized"
), patch(
"app.api.chat.task_catalog.load_subsection_map",
return_value={"quadratische gleichungen": (1, 3, 3)},
), patch(
"app.api.chat.retrieval_store.retrieve_for_subsections",
return_value=[],
) as retrieve_mock, patch(
"app.api.chat.socratic_LLM.generate_dialog",
return_value="Was sind die Themen dieses Abschnitts?",
):
response = self.client.post(
"/api/chat/bootstrap-socratic",
json={
"draft": "chat-1",
"subsection_key": "quadratische_gleichungen",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["reply"], "Was sind die Themen dieses Abschnitts?")
retrieve_mock.assert_called_once_with(pg_url="postgresql://localhost/test", subsection_refs=[(1, 3, 3)])
self.assertEqual(sheet["selected_subsection_key"], "quadratische gleichungen")
self.assertEqual(sheet["history"], [{"role": "assistant", "content": "Was sind die Themen dieses Abschnitts?"}])
class SessionStoreSocraticTest(unittest.TestCase):
def test_load_archive_restores_selected_subsection(self) -> None:
record = {
"chat_id": "chat-1",
"saved_at": "2026-03-31T10:00:00Z",
"orchestrator": "socratic",
"history": [],
"selected_task": None,
"selected_subsection": {
"subsection_key": "quadratische gleichungen",
},
}
temp_dir = Path(__file__).resolve().parent / "_tmp_socratic_archive"
temp_dir.mkdir(exist_ok=True)
log_path = temp_dir / "archive.jsonl"
log_path.write_text(json.dumps(record, ensure_ascii=False) + "\n", encoding="utf-8")
try:
with patch.object(session_store, "_LOG_PATH", str(log_path)):
archive = session_store.load_archive("chat-1")
finally:
if log_path.exists():
log_path.unlink()
if temp_dir.exists():
temp_dir.rmdir()
self.assertIsNotNone(archive)
self.assertEqual(archive["selected_subsection"], record["selected_subsection"])
if __name__ == "__main__":
unittest.main()
export type OrchestratorName = "qa" | "tutor" | "task" | "feedback"; export type OrchestratorName = "qa" | "tutor" | "task" | "feedback" | "socratic";
export type OrchestratorConfigResponse = { export type OrchestratorConfigResponse = {
default_orchestrator: OrchestratorName; default_orchestrator: OrchestratorName;
available_orchestrators: OrchestratorName[]; available_orchestrators: OrchestratorName[];
}; };
const FALLBACK_ORCHESTRATORS: OrchestratorName[] = ["qa", "tutor", "task", "feedback"]; const FALLBACK_ORCHESTRATORS: OrchestratorName[] = ["qa", "tutor", "task", "feedback", "socratic"];
const normalizeOrchestrator = (value: string): OrchestratorName | null => { const normalizeOrchestrator = (value: string): OrchestratorName | null => {
if (value === "qa" || value === "tutor" || value === "task" || value === "feedback") { if (
value === "qa" ||
value === "tutor" ||
value === "task" ||
value === "feedback" ||
value === "socratic"
) {
return value; return value;
} }
return null; return null;
......
...@@ -10,12 +10,20 @@ export type TaskFile = { ...@@ -10,12 +10,20 @@ export type TaskFile = {
intro: string; intro: string;
tasks: TaskItem[]; tasks: TaskItem[];
subsections?: string[]; subsections?: string[];
subsection_options?: SubsectionOption[];
};
export type SubsectionOption = {
subsection_key: string;
label: string;
refs: [number, number, number][];
}; };
export type TasksResponse = { export type TasksResponse = {
orchestrator: string; orchestrator: string;
enabled: boolean; enabled: boolean;
task_files: TaskFile[]; task_files: TaskFile[];
subsections: SubsectionOption[];
}; };
export type SelectedTaskRef = { export type SelectedTaskRef = {
...@@ -23,12 +31,21 @@ export type SelectedTaskRef = { ...@@ -23,12 +31,21 @@ export type SelectedTaskRef = {
taskId: string; taskId: string;
}; };
export type SelectedSubsectionRef = {
subsectionKey: string;
};
export type SelectTaskResponse = { export type SelectTaskResponse = {
status: string; status: string;
file_id: string; file_id: string;
task_id: string; task_id: string;
}; };
export type SelectSubsectionResponse = {
status: string;
subsection_key: string;
};
export async function fetchTasks(): Promise<TasksResponse> { export async function fetchTasks(): Promise<TasksResponse> {
const response = await fetch("/api/tasks"); const response = await fetch("/api/tasks");
if (!response.ok) { if (!response.ok) {
...@@ -57,3 +74,22 @@ export async function selectTask(input: { ...@@ -57,3 +74,22 @@ export async function selectTask(input: {
} }
return response.json(); return response.json();
} }
export async function selectSubsection(input: {
draft: string;
subsectionKey: string;
}): Promise<SelectSubsectionResponse> {
const response = await fetch("/api/tasks/select-subsection", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
draft: input.draft,
subsection_key: input.subsectionKey,
}),
});
if (!response.ok) {
throw new Error(`Subsection selection failed: ${response.status}`);
}
return response.json();
}
...@@ -14,12 +14,14 @@ type ModeMeta = { ...@@ -14,12 +14,14 @@ type ModeMeta = {
| "orchestratorModeQaLabel" | "orchestratorModeQaLabel"
| "orchestratorModeTutorLabel" | "orchestratorModeTutorLabel"
| "orchestratorModeTaskLabel" | "orchestratorModeTaskLabel"
| "orchestratorModeFeedbackLabel"; | "orchestratorModeFeedbackLabel"
| "orchestratorModeSocraticLabel";
descriptionKey: descriptionKey:
| "orchestratorModeQaDescription" | "orchestratorModeQaDescription"
| "orchestratorModeTutorDescription" | "orchestratorModeTutorDescription"
| "orchestratorModeTaskDescription" | "orchestratorModeTaskDescription"
| "orchestratorModeFeedbackDescription"; | "orchestratorModeFeedbackDescription"
| "orchestratorModeSocraticDescription";
}; };
const modeMetaMap: Record<OrchestratorName, ModeMeta> = { const modeMetaMap: Record<OrchestratorName, ModeMeta> = {
...@@ -39,6 +41,10 @@ const modeMetaMap: Record<OrchestratorName, ModeMeta> = { ...@@ -39,6 +41,10 @@ const modeMetaMap: Record<OrchestratorName, ModeMeta> = {
labelKey: "orchestratorModeFeedbackLabel", labelKey: "orchestratorModeFeedbackLabel",
descriptionKey: "orchestratorModeFeedbackDescription", descriptionKey: "orchestratorModeFeedbackDescription",
}, },
socratic: {
labelKey: "orchestratorModeSocraticLabel",
descriptionKey: "orchestratorModeSocraticDescription",
},
}; };
type ModeDescriptionDisplay = "tooltip" | "helperText" | "optionSuffix"; type ModeDescriptionDisplay = "tooltip" | "helperText" | "optionSuffix";
......
import { useEffect, useRef } from "react";
import { t } from "../../i18n";
type SocraticPanelProps = {
selectedSubsectionLabel?: string;
selectedSubsectionKey?: string;
selectedSubsectionRefsText?: string;
onChangeSelection?: () => void;
};
export default function SocraticPanel({
selectedSubsectionLabel,
selectedSubsectionKey,
selectedSubsectionRefsText,
onChangeSelection,
}: SocraticPanelProps) {
const contentRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!contentRef.current) {
return;
}
const mathjax = window.MathJax;
if (!mathjax?.typesetPromise) {
return;
}
mathjax.typesetPromise([contentRef.current]).catch(() => undefined);
}, [selectedSubsectionLabel, selectedSubsectionRefsText]);
return (
<section className="task-panel">
<div className="task-panel-header">
<div className="task-panel-title">{t("orchestratorModeSocraticLabel")}</div>
{onChangeSelection ? (
<button type="button" className="btn task-panel-change-btn" onClick={onChangeSelection}>
{t("changeSubsectionArea")}
</button>
) : null}
</div>
<div className="task-panel-meta">
<div>{selectedSubsectionKey ? `${t("subsectionKey")}: ${selectedSubsectionKey}` : ""}</div>
</div>
<div className="task-panel-content" ref={contentRef}>
{selectedSubsectionLabel || t("noSubsectionSelected")}
{selectedSubsectionRefsText ? <div>{selectedSubsectionRefsText}</div> : null}
</div>
</section>
);
}
...@@ -22,11 +22,14 @@ ...@@ -22,11 +22,14 @@
orchestratorModeTutorLabel: "Tutor", orchestratorModeTutorLabel: "Tutor",
orchestratorModeTaskLabel: "Task", orchestratorModeTaskLabel: "Task",
orchestratorModeFeedbackLabel: "Feedback", orchestratorModeFeedbackLabel: "Feedback",
orchestratorModeSocraticLabel: "Socratic",
orchestratorModeDescriptionTitle: "Mode help", orchestratorModeDescriptionTitle: "Mode help",
orchestratorModeQaDescription: "Direct answers based on the script", orchestratorModeQaDescription: "Direct answers based on the script",
orchestratorModeTutorDescription: "Help with your own questions", orchestratorModeTutorDescription: "Help with your own questions",
orchestratorModeTaskDescription: "Help with textbook exercises", orchestratorModeTaskDescription: "Help with textbook exercises",
orchestratorModeFeedbackDescription: "Short feedback on your solution for textbook exercises", orchestratorModeFeedbackDescription: "Short feedback on your solution for textbook exercises",
orchestratorModeSocraticDescription:
"Socratic guidance based on fixed subsection context for textbook exercises",
directChildren: "Direct children", directChildren: "Direct children",
taskChildren: "Task sources", taskChildren: "Task sources",
indirectChildren: "Indirect children", indirectChildren: "Indirect children",
...@@ -45,6 +48,8 @@ ...@@ -45,6 +48,8 @@
hideThinking: "Hide thinking", hideThinking: "Hide thinking",
noTasksAvailable: "No tasks available", noTasksAvailable: "No tasks available",
noTaskSelected: "No task selected.", noTaskSelected: "No task selected.",
noSubsectionsAvailable: "No subsections available",
noSubsectionSelected: "No subsection selected.",
savedChats: "Saved Chats", savedChats: "Saved Chats",
saving: "Saving...", saving: "Saving...",
noSavedChatsYet: "No saved chats yet.", noSavedChatsYet: "No saved chats yet.",
...@@ -67,10 +72,17 @@ ...@@ -67,10 +72,17 @@
"Saving the canvas failed. Please check backend logs.", "Saving the canvas failed. Please check backend logs.",
taskSelectionTitle: "Select a Task", taskSelectionTitle: "Select a Task",
taskSelectionSubtitle: "Choose a task and start a tutor session", taskSelectionSubtitle: "Choose a task and start a tutor session",
socraticSelectionTitle: "Select a Subsection",
socraticSelectionSubtitle: "Choose a subsection and start a socratic session",
subsection: "Subsection",
taskFile: "Task Set", taskFile: "Task Set",
taskId: "Task ID", taskId: "Task ID",
subsectionFile: "Subsection Set",
subsectionKey: "Subsection Key",
solveWithTutor: "Solve with Tutor", solveWithTutor: "Solve with Tutor",
startSocratic: "Start Socratic",
changeTaskArea: "Change Task Area", changeTaskArea: "Change Task Area",
changeSubsectionArea: "Change Subsection Area",
previousTask: "Previous Task", previousTask: "Previous Task",
nextTask: "Next Task", nextTask: "Next Task",
backendChecking: "Checking backend availability...", backendChecking: "Checking backend availability...",
...@@ -81,6 +93,9 @@ ...@@ -81,6 +93,9 @@
lastCheckFailed: "Last check: {detail}", lastCheckFailed: "Last check: {detail}",
deepLinkInvalidTask: "Invalid task link. Please choose a task manually.", deepLinkInvalidTask: "Invalid task link. Please choose a task manually.",
deepLinkInitFailed: "Task link initialization failed. Please choose a task manually.", deepLinkInitFailed: "Task link initialization failed. Please choose a task manually.",
deepLinkInvalidSubsection: "Invalid subsection link. Please choose a subsection manually.",
deepLinkInitFailedSubsection:
"Subsection link initialization failed. Please choose a subsection manually.",
}, },
de: { de: {
chats: "Chats", chats: "Chats",
...@@ -106,11 +121,14 @@ ...@@ -106,11 +121,14 @@
orchestratorModeTutorLabel: "Tutor", orchestratorModeTutorLabel: "Tutor",
orchestratorModeTaskLabel: "Task", orchestratorModeTaskLabel: "Task",
orchestratorModeFeedbackLabel: "Feedback", orchestratorModeFeedbackLabel: "Feedback",
orchestratorModeSocraticLabel: "Socratic",
orchestratorModeDescriptionTitle: "Modus-Hilfe", orchestratorModeDescriptionTitle: "Modus-Hilfe",
orchestratorModeQaDescription: "Direkte Antworten basierend auf dem Skript", orchestratorModeQaDescription: "Direkte Antworten basierend auf dem Skript",
orchestratorModeTutorDescription: "Hilfe bei selbst gestellten Fragen", orchestratorModeTutorDescription: "Hilfe bei selbst gestellten Fragen",
orchestratorModeTaskDescription: "Hilfe bei Aufgaben aus dem Lehrwerk", orchestratorModeTaskDescription: "Hilfe bei Aufgaben aus dem Lehrwerk",
orchestratorModeFeedbackDescription: "Kurzes Feedback zu deiner Lösung bei Aufgaben aus dem Lehrwerk", orchestratorModeFeedbackDescription: "Kurzes Feedback zu deiner Lösung bei Aufgaben aus dem Lehrwerk",
orchestratorModeSocraticDescription:
"Sokratische Anleitung auf Basis fester Subsection-Kontexte für Aufgaben aus dem Lehrwerk",
directChildren: "Direkte Quellen", directChildren: "Direkte Quellen",
taskChildren: "Aufgaben-Quellen", taskChildren: "Aufgaben-Quellen",
indirectChildren: "Indirekte Quellen", indirectChildren: "Indirekte Quellen",
...@@ -129,6 +147,8 @@ ...@@ -129,6 +147,8 @@
hideThinking: "Thinking ausblenden", hideThinking: "Thinking ausblenden",
noTasksAvailable: "Keine Aufgaben verfügbar", noTasksAvailable: "Keine Aufgaben verfügbar",
noTaskSelected: "Keine Aufgabe ausgewählt.", noTaskSelected: "Keine Aufgabe ausgewählt.",
noSubsectionsAvailable: "Keine Unterabschnitte verfügbar",
noSubsectionSelected: "Kein Unterabschnitt ausgewählt.",
savedChats: "Gespeicherte Chats", savedChats: "Gespeicherte Chats",
saving: "Speichere...", saving: "Speichere...",
noSavedChatsYet: "Noch keine gespeicherten Chats.", noSavedChatsYet: "Noch keine gespeicherten Chats.",
...@@ -156,10 +176,17 @@ ...@@ -156,10 +176,17 @@
"Da ist wohl das Speichern des Canvas fehlgeschlagen. Gib gerne deinem Dozenten bescheid. In vielen Fällen hilft es die Seite neu zu laden.", "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", taskSelectionTitle: "Aufgabe auswählen",
taskSelectionSubtitle: "Wähle eine Aufgabe und starte den Tutor-Chat", taskSelectionSubtitle: "Wähle eine Aufgabe und starte den Tutor-Chat",
socraticSelectionTitle: "Unterabschnitt auswählen",
socraticSelectionSubtitle: "Wähle einen Unterabschnitt und starte den sokratischen Chat",
subsection: "Unterabschnitt",
taskFile: "Aufgabenset", taskFile: "Aufgabenset",
taskId: "Aufgaben-ID", taskId: "Aufgaben-ID",
subsectionFile: "Unterabschnitt-Set",
subsectionKey: "Unterabschnitt-Schlüssel",
solveWithTutor: "Mit Tutor lösen", solveWithTutor: "Mit Tutor lösen",
startSocratic: "Sokratisch starten",
changeTaskArea: "Aufgabengebiet ändern", changeTaskArea: "Aufgabengebiet ändern",
changeSubsectionArea: "Unterabschnitt ändern",
previousTask: "Vorherige Aufgabe", previousTask: "Vorherige Aufgabe",
nextTask: "Nächste Aufgabe", nextTask: "Nächste Aufgabe",
backendChecking: "Backend-Verbindung wird geprüft...", backendChecking: "Backend-Verbindung wird geprüft...",
...@@ -172,6 +199,10 @@ ...@@ -172,6 +199,10 @@
"Ungültiger Aufgaben-Link. Bitte wähle die Aufgabe manuell aus.", "Ungültiger Aufgaben-Link. Bitte wähle die Aufgabe manuell aus.",
deepLinkInitFailed: deepLinkInitFailed:
"Der Aufgaben-Link konnte nicht initialisiert werden. Bitte wähle die Aufgabe manuell aus.", "Der Aufgaben-Link konnte nicht initialisiert werden. Bitte wähle die Aufgabe manuell aus.",
deepLinkInvalidSubsection:
"Ungültiger Unterabschnitt-Link. Bitte wähle den Unterabschnitt manuell aus.",
deepLinkInitFailedSubsection:
"Der Unterabschnitt-Link konnte nicht initialisiert werden. Bitte wähle den Unterabschnitt manuell aus.",
}, },
} as const; } as const;
......
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { t } from "../i18n"; import { t } from "../i18n";
import ChatPage from "./ChatPage"; import ChatPage from "./ChatPage";
import SocraticSelectionPage from "./SocraticSelectionPage";
import TaskSelectionPage from "./TaskSelectionPage"; import TaskSelectionPage from "./TaskSelectionPage";
import { TutorSessionProvider, useTutorSession } from "../state/tutorSession"; import { TutorSessionProvider, useTutorSession } from "../state/tutorSession";
import { getSelectionRouteForOrchestrator } from "../utils/orchestratorRoutes";
function StartRoute() { function StartRoute() {
const { isTasksInitialized, isTaskModeEnabled } = useTutorSession(); const { isTasksInitialized, selectedOrchestrator } = useTutorSession();
if (!isTasksInitialized) { if (!isTasksInitialized) {
return <div className="app-loading">{t("loading")}</div>; return <div className="app-loading">{t("loading")}</div>;
} }
return <Navigate to={isTaskModeEnabled ? "/select-task" : "/chat"} replace />; return <Navigate to={getSelectionRouteForOrchestrator(selectedOrchestrator)} replace />;
} }
export default function App() { export default function App() {
...@@ -20,6 +22,7 @@ export default function App() { ...@@ -20,6 +22,7 @@ export default function App() {
<Routes> <Routes>
<Route path="/" element={<StartRoute />} /> <Route path="/" element={<StartRoute />} />
<Route path="/select-task" element={<TaskSelectionPage />} /> <Route path="/select-task" element={<TaskSelectionPage />} />
<Route path="/select-socratic" element={<SocraticSelectionPage />} />
<Route path="/chat" element={<ChatPage />} /> <Route path="/chat" element={<ChatPage />} />
<Route path="*" element={<StartRoute />} /> <Route path="*" element={<StartRoute />} />
</Routes> </Routes>
......
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