Commit 3c67b18f authored by Kantz's avatar Kantz
Browse files

Auswahl von Unterthemen

parent 13288a8e
......@@ -26,6 +26,7 @@ class ChatRequest(BaseModel):
messages: List[ChatMessage]
draft: Optional[str] = None
selected_task: Optional[dict[str, str]] = None
selected_subsection: Optional[dict[str, str]] = None
orchestrator: Optional[str] = None
......@@ -52,11 +53,16 @@ class SelectedTaskRef(BaseModel):
task_id: str
class SelectedSubsectionRef(BaseModel):
subsection_key: str
class ChatArchiveDetail(BaseModel):
chat_id: str
saved_at: str
history: List[ChatMessage]
selected_task: Optional[SelectedTaskRef] = None
selected_subsection: Optional[SelectedSubsectionRef] = None
orchestrator: str
......@@ -80,6 +86,7 @@ def chat(request: ChatRequest) -> ChatResponse:
payload_messages,
draft=request.draft,
selected_task=request.selected_task,
selected_subsection=request.selected_subsection,
)
else:
result = orchestrator_impl.run_chat(
......@@ -125,11 +132,19 @@ def get_archive(chat_id: str = Path(..., min_length=1)) -> ChatArchiveDetail:
if file_id and 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(
chat_id=record["chat_id"],
saved_at=record.get("saved_at", ""),
history=[ChatMessage(role=item["role"], text=item["text"]) for item in record["history"]],
selected_task=selected_task,
selected_subsection=selected_subsection,
orchestrator=record.get("orchestrator") or get_default_orchestrator(),
)
......
......@@ -19,6 +19,12 @@ class TaskItem(BaseModel):
full_text: str
class SubsectionEntry(BaseModel):
subsection_key: str
label: str
refs: List[List[int]]
class TaskFile(BaseModel):
file_id: str
title: str
......@@ -31,6 +37,7 @@ class TasksResponse(BaseModel):
orchestrator: str
enabled: bool
task_files: List[TaskFile]
subsections: List[SubsectionEntry] = Field(default_factory=list)
class SelectTaskRequest(BaseModel):
......@@ -45,6 +52,16 @@ class SelectTaskResponse(BaseModel):
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")
def get_task_config() -> dict[str, object]:
orchestrator = config.get_orchestrator()
......@@ -55,10 +72,12 @@ def get_task_config() -> dict[str, object]:
def list_tasks() -> TasksResponse:
orchestrator = config.get_orchestrator()
task_files = task_catalog.build_task_catalog()
subsections = task_catalog.build_subsection_catalog()
return TasksResponse(
orchestrator=orchestrator,
enabled=orchestrator in TASK_ORCHESTRATORS,
task_files=task_files,
subsections=subsections,
)
# Eigentlich sollte die Context-Selection erst passieren wen das schon fest steht
......@@ -85,3 +104,22 @@ def select_task(request: SelectTaskRequest) -> SelectTaskResponse:
file_id=file_id or request.file_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(
messages: list[dict],
draft: str | None = None,
selected_task: dict | None = None,
selected_subsection: dict | None = None,
) -> dict:
def _apply_selected_task(state: base.ChatState) -> None:
if not selected_task:
......
......@@ -2,60 +2,26 @@ 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 import context_store, retrieval_store, task_catalog
from app.deterministic_services.orchestrators import orchestrator_base as base
def _ensure_context_task_fields(state: base.ChatState, query_text: str) -> tuple[str, str] | None:
store_new = context_store.context_store_new
has_task = bool(store_new.get_task(state.sheet))
has_hints = bool(store_new.get_hints(state.sheet))
has_solution = bool(store_new.get_solution(state.sheet))
if has_task and has_hints and has_solution:
selected = task_catalog.get_selected_task_ids(state.sheet)
if selected[0] and selected[1]:
was_selected = task_catalog.select_task_by_ids(
state.sheet,
selected[0],
selected[1],
)
if was_selected:
return selected[0], selected[1]
sources_text = "\n".join([source.to_string() for source in context_store.get_retrieval(state.sheet)])
selection = task_catalog.select_task_for_context(
state.sheet,
query_text=query_text,
sources_text=sources_text,
history=context_store.get_history_turns(state.sheet),
)
if not selection:
return None
task_file, task_entry = selection
selected_file_id = str(task_file.get("_file_id", ""))
selected_task_id = str(task_entry.get("id", "")).zfill(2)
def _apply_selected_subsection(
state: base.ChatState,
selected_subsection: dict | None,
) -> None:
if not selected_subsection:
return
base.append_tool_log(
state.tool_log,
"task_json_selected",
{"tasks_dir": str(task_catalog.TASKS_DIR)},
{
"file": task_file.get("_path", ""),
"file_id": selected_file_id,
"task_id": selected_task_id,
"hint_count": len(store_new.get_hints(state.sheet)),
"has_solution": bool(store_new.get_solution(state.sheet)),
},
)
return selected_file_id, selected_task_id
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_task(state: base.ChatState, query_text: str) -> int:
refs = task_catalog.get_selected_task_subsection_refs(state.sheet)
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
......@@ -72,7 +38,7 @@ def _retrieve_context_for_task(state: base.ChatState, query_text: str) -> int:
result = base.log_timed_call(
state.tool_log,
"retrieve_context_with_task_subsections",
"retrieve_context_with_socratic_subsections",
{
"query": query_text,
"subsection_refs": refs,
......@@ -83,17 +49,17 @@ def _retrieve_context_for_task(state: base.ChatState, query_text: str) -> int:
def _on_bootstrap(state: base.ChatState, query_text: str) -> None:
_ensure_context_task_fields(state, query_text)
_retrieve_context_for_task(state, query_text)
_retrieve_context_for_subsection(state, query_text)
def _on_turn_logic(state: base.ChatState) -> None:
_ensure_context_task_fields(state, state.last_user)
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_task_subsection_refs(state.sheet)
subsection_refs = task_catalog.get_selected_subsection_refs(state.sheet)
args = {
"query": state.last_user,
"subsection_refs": subsection_refs,
......@@ -111,26 +77,18 @@ def _on_build_reply(state: base.ChatState) -> str | None:
def run_chat(
messages: list[dict],
draft: str | None = None,
selected_subsection: dict | None = None,
selected_task: dict | None = None,
) -> dict:
def _apply_selected_task(state: base.ChatState) -> None:
if not selected_task:
return
selected_file_id = str(selected_task.get("file_id", "")).strip()
selected_task_id = str(selected_task.get("task_id", "")).strip()
if selected_file_id and selected_task_id:
task_catalog.select_task_by_ids(
state.sheet,
selected_file_id,
selected_task_id,
)
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_task(state)
_apply_selected_context(state)
_on_bootstrap(state, query_text)
def on_turn_logic(state: base.ChatState) -> None:
_apply_selected_task(state)
_apply_selected_context(state)
_on_turn_logic(state)
return base.run_chat_common(
......
......@@ -123,6 +123,7 @@ def run_chat(
messages: list[dict],
draft: str | None = None,
selected_task: dict | None = None,
selected_subsection: dict | None = None,
) -> dict:
def _apply_selected_task(state: base.ChatState) -> None:
if not selected_task:
......
......@@ -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}
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(
messages: list[dict[str, Any]],
draft: str | None = None,
......@@ -49,6 +56,7 @@ def archive_chat(
"math_solutions": sheet.get("math_solutions", []),
"sources": sheet.get("sources", []),
"selected_task": _extract_selected_task(sheet),
"selected_subsection": _extract_selected_subsection(sheet),
}
os.makedirs(_LOG_DIR, exist_ok=True)
......@@ -126,11 +134,19 @@ def load_archive(chat_id: str) -> dict[str, Any] | None:
if file_id and 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 {
"chat_id": record.get("chat_id", chat_id),
"saved_at": record.get("saved_at", ""),
"orchestrator": record.get("orchestrator"),
"history": history,
"selected_task": selected_task,
"selected_subsection": selected_subsection,
}
return None
......@@ -71,6 +71,61 @@ def _resolve_task_subsection_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:
query_tokens = _tokenize(query_text)
if not query_tokens:
......@@ -142,6 +197,37 @@ def set_selected_task(
sheet["task_id"] = str(task_entry.get("id", "")).zfill(2)
refs = _resolve_task_subsection_refs(task_file)
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(
......@@ -161,12 +247,81 @@ def select_task_by_ids(
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]:
file_id = str(sheet.get("task_file_id", "")).strip()
task_id = str(sheet.get("task_id", "")).strip()
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]]:
refs_raw = sheet.get("task_subsection_refs", [])
if not isinstance(refs_raw, list):
......@@ -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()
intro = str(task_file.get("intro", "")).strip()
subsections = task_file.get("subsections", [])
subsection_options = _resolve_task_subsection_options(task_file)
tasks: list[dict[str, str]] = []
for item in task_file.get("tasks", []):
if not isinstance(item, dict):
......@@ -282,6 +438,7 @@ def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[d
"intro": intro,
"tasks": tasks,
"subsections": subsections,
"subsection_options": subsection_options,
}
)
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 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 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()
......@@ -10,12 +10,20 @@ export type TaskFile = {
intro: string;
tasks: TaskItem[];
subsections?: string[];
subsection_options?: SubsectionOption[];
};
export type SubsectionOption = {
subsection_key: string;
label: string;
refs: [number, number, number][];
};
export type TasksResponse = {
orchestrator: string;
enabled: boolean;
task_files: TaskFile[];
subsections: SubsectionOption[];
};
export type SelectedTaskRef = {
......@@ -23,12 +31,21 @@ export type SelectedTaskRef = {
taskId: string;
};
export type SelectedSubsectionRef = {
subsectionKey: string;
};
export type SelectTaskResponse = {
status: string;
file_id: string;
task_id: string;
};
export type SelectSubsectionResponse = {
status: string;
subsection_key: string;
};
export async function fetchTasks(): Promise<TasksResponse> {
const response = await fetch("/api/tasks");
if (!response.ok) {
......@@ -57,3 +74,22 @@ export async function selectTask(input: {
}
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();
}
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>
);
}
......@@ -48,6 +48,8 @@
hideThinking: "Hide thinking",
noTasksAvailable: "No tasks available",
noTaskSelected: "No task selected.",
noSubsectionsAvailable: "No subsections available",
noSubsectionSelected: "No subsection selected.",
savedChats: "Saved Chats",
saving: "Saving...",
noSavedChatsYet: "No saved chats yet.",
......@@ -70,10 +72,17 @@
"Saving the canvas failed. Please check backend logs.",
taskSelectionTitle: "Select a Task",
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",
taskId: "Task ID",
subsectionFile: "Subsection Set",
subsectionKey: "Subsection Key",
solveWithTutor: "Solve with Tutor",
startSocratic: "Start Socratic",
changeTaskArea: "Change Task Area",
changeSubsectionArea: "Change Subsection Area",
previousTask: "Previous Task",
nextTask: "Next Task",
backendChecking: "Checking backend availability...",
......@@ -84,6 +93,9 @@
lastCheckFailed: "Last check: {detail}",
deepLinkInvalidTask: "Invalid task link. 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: {
chats: "Chats",
......@@ -135,6 +147,8 @@
hideThinking: "Thinking ausblenden",
noTasksAvailable: "Keine Aufgaben verfügbar",
noTaskSelected: "Keine Aufgabe ausgewählt.",
noSubsectionsAvailable: "Keine Unterabschnitte verfügbar",
noSubsectionSelected: "Kein Unterabschnitt ausgewählt.",
savedChats: "Gespeicherte Chats",
saving: "Speichere...",
noSavedChatsYet: "Noch keine gespeicherten Chats.",
......@@ -162,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.",
taskSelectionTitle: "Aufgabe auswählen",
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",
taskId: "Aufgaben-ID",
subsectionFile: "Unterabschnitt-Set",
subsectionKey: "Unterabschnitt-Schlüssel",
solveWithTutor: "Mit Tutor lösen",
startSocratic: "Sokratisch starten",
changeTaskArea: "Aufgabengebiet ändern",
changeSubsectionArea: "Unterabschnitt ändern",
previousTask: "Vorherige Aufgabe",
nextTask: "Nächste Aufgabe",
backendChecking: "Backend-Verbindung wird geprüft...",
......@@ -178,6 +199,10 @@
"Ungültiger Aufgaben-Link. Bitte wähle die Aufgabe manuell aus.",
deepLinkInitFailed:
"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;
......
import { Navigate, Route, Routes } from "react-router-dom";
import { Navigate, Route, Routes } from "react-router-dom";
import { t } from "../i18n";
import ChatPage from "./ChatPage";
import SocraticSelectionPage from "./SocraticSelectionPage";
import TaskSelectionPage from "./TaskSelectionPage";
import { TutorSessionProvider, useTutorSession } from "../state/tutorSession";
import { getSelectionRouteForOrchestrator } from "../utils/orchestratorRoutes";
function StartRoute() {
const { isTasksInitialized, isTaskModeEnabled } = useTutorSession();
const { isTasksInitialized, selectedOrchestrator } = useTutorSession();
if (!isTasksInitialized) {
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() {
......@@ -20,6 +22,7 @@ export default function App() {
<Routes>
<Route path="/" element={<StartRoute />} />
<Route path="/select-task" element={<TaskSelectionPage />} />
<Route path="/select-socratic" element={<SocraticSelectionPage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="*" element={<StartRoute />} />
</Routes>
......
......@@ -5,13 +5,15 @@ 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 SocraticPanel from "../components/Task/SocraticPanel";
import TaskPanel from "../components/Task/TaskPanel";
import type { ChatMessage } from "../components/Chat/MessageList";
import type { RetrievedDoc } from "../components/Retrieval/DocPanel";
import type { OrchestratorName } from "../api/orchestratorApi";
import { selectTask } from "../api/taskApi";
import { selectSubsection, selectTask } from "../api/taskApi";
import { t } from "../i18n";
import { createSessionId, useTutorSession } from "../state/tutorSession";
import { getSelectionRouteForOrchestrator, isSocraticOrchestrator } from "../utils/orchestratorRoutes";
import sumintLogo from "../../SuMINT-Logo.png";
const initialMessages: ChatMessage[] = [];
......@@ -30,7 +32,7 @@ const normalizeOrchestrator = (value: string | null | undefined): OrchestratorNa
};
const isTaskCoupledOrchestrator = (value: string | null | undefined): boolean =>
value === "task" || value === "feedback" || value === "socratic";
value === "task" || value === "feedback";
type ArchivedChatSummary = {
chat_id: string;
......@@ -49,6 +51,9 @@ type ArchivedChatDetail = {
file_id: string;
task_id: string;
} | null;
selected_subsection?: {
subsection_key: string;
} | null;
};
type ContextSource = {
......@@ -126,6 +131,8 @@ export default function ChatPage() {
selectedTaskRef,
selectedTask,
selectedTaskFile,
selectedSubsectionRef,
selectedSubsection,
selectedOrchestrator,
availableOrchestrators,
setSelectedOrchestrator,
......@@ -135,6 +142,7 @@ export default function ChatPage() {
lockTask,
unlockTask,
setTaskRef,
setSubsectionRef,
resetForNewChat,
} = useTutorSession();
......@@ -168,23 +176,31 @@ export default function ChatPage() {
.toLowerCase();
const fileId = String(searchParams.get("file_id") || "").trim();
const rawTaskId = String(searchParams.get("task_id") || "").trim();
const rawSubsectionKey = String(searchParams.get("subsection_key") || "").trim();
const taskId =
/^\d{1,2}$/.test(rawTaskId) && rawTaskId.length < 2
? rawTaskId.padStart(2, "0")
: rawTaskId;
const subsectionKey = rawSubsectionKey;
const hasFileId = Boolean(fileId);
const hasTaskId = Boolean(taskId);
const hasSubsectionKey = Boolean(subsectionKey);
const isTaskOrchestrator = isTaskCoupledOrchestrator(orchestrator);
const hasAnyTaskParam = hasFileId || hasTaskId;
const hasRequiredParams = hasFileId && hasTaskId;
const key = `${orchestrator}|${fileId}|${taskId}`;
const isSocratic = isSocraticOrchestrator(orchestrator as OrchestratorName);
const hasAnyTaskParam = hasFileId || hasTaskId || hasSubsectionKey;
const hasRequiredTaskParams = hasFileId && hasTaskId;
const hasRequiredSubsectionParams = hasSubsectionKey;
const key = `${orchestrator}|${fileId}|${taskId}|${subsectionKey}`;
return {
fileId,
taskId,
subsectionKey,
isTaskOrchestrator,
isSocratic,
hasAnyTaskParam,
hasRequiredParams,
hasRequiredTaskParams,
hasRequiredSubsectionParams,
key,
};
}, [searchParams]);
......@@ -193,7 +209,7 @@ export default function ChatPage() {
if (!isTasksInitialized) {
return;
}
if (!deepLinkTarget.isTaskOrchestrator || !deepLinkTarget.hasAnyTaskParam) {
if ((!deepLinkTarget.isTaskOrchestrator && !deepLinkTarget.isSocratic) || !deepLinkTarget.hasAnyTaskParam) {
return;
}
if (processedDeepLinkRef.current === deepLinkTarget.key) {
......@@ -201,51 +217,93 @@ export default function ChatPage() {
}
processedDeepLinkRef.current = deepLinkTarget.key;
if (!deepLinkTarget.hasRequiredParams) {
setDeepLinkError(t("deepLinkInvalidTask"));
setTaskRef(null);
unlockTask();
navigate("/select-task", { replace: true });
return;
}
const targetRoute = getSelectionRouteForOrchestrator(
searchParams.get("orchestrator") === "feedback"
? "feedback"
: searchParams.get("orchestrator") === "socratic"
? "socratic"
: "task"
);
const selectedFile = taskFiles.find((file) => file.file_id === deepLinkTarget.fileId);
const selectedTask = selectedFile?.tasks.find((task) => task.task_id === deepLinkTarget.taskId);
if (!selectedFile || !selectedTask) {
setDeepLinkError(t("deepLinkInvalidTask"));
if (
(deepLinkTarget.isTaskOrchestrator && !deepLinkTarget.hasRequiredTaskParams) ||
(deepLinkTarget.isSocratic && !deepLinkTarget.hasRequiredSubsectionParams)
) {
setDeepLinkError(
deepLinkTarget.isSocratic ? t("deepLinkInvalidSubsection") : t("deepLinkInvalidTask")
);
setTaskRef(null);
setSubsectionRef(null);
unlockTask();
navigate("/select-task", { replace: true });
navigate(targetRoute, { replace: true });
return;
}
setSelectedOrchestrator(
searchParams.get("orchestrator") === "feedback"
? "feedback"
: searchParams.get("orchestrator") === "socratic"
? "socratic"
if (deepLinkTarget.isSocratic) {
setSelectedOrchestrator("socratic");
setTaskRef(null);
setSubsectionRef({ subsectionKey: deepLinkTarget.subsectionKey });
} else {
const selectedFile = taskFiles.find((file) => file.file_id === deepLinkTarget.fileId);
if (!selectedFile) {
setDeepLinkError(t("deepLinkInvalidTask"));
setTaskRef(null);
setSubsectionRef(null);
unlockTask();
navigate(targetRoute, { replace: true });
return;
}
const selectedTask = selectedFile.tasks.find((task) => task.task_id === deepLinkTarget.taskId);
if (!selectedTask) {
setDeepLinkError(t("deepLinkInvalidTask"));
setTaskRef(null);
setSubsectionRef(null);
unlockTask();
navigate(targetRoute, { replace: true });
return;
}
setSelectedOrchestrator(
searchParams.get("orchestrator") === "feedback"
? "feedback"
: "task"
);
setTaskRef({ fileId: selectedFile.file_id, taskId: selectedTask.task_id });
);
setSubsectionRef(null);
setTaskRef({ fileId: selectedFile.file_id, taskId: selectedTask.task_id });
}
unlockTask();
let cancelled = false;
void (async () => {
try {
await selectTask({
draft: chatSessionId,
fileId: selectedFile.file_id,
taskId: selectedTask.task_id,
});
if (deepLinkTarget.isSocratic) {
await selectSubsection({
draft: chatSessionId,
subsectionKey: deepLinkTarget.subsectionKey,
});
} else {
const selectedFile = taskFiles.find((file) => file.file_id === deepLinkTarget.fileId);
const selectedTask = selectedFile?.tasks.find((task) => task.task_id === deepLinkTarget.taskId);
if (!selectedFile || !selectedTask) {
throw new Error("invalid task deep link");
}
await selectTask({
draft: chatSessionId,
fileId: selectedFile.file_id,
taskId: selectedTask.task_id,
});
}
if (!cancelled) {
setDeepLinkError(null);
}
} catch (error) {
if (!cancelled) {
setDeepLinkError(t("deepLinkInitFailed"));
setDeepLinkError(
deepLinkTarget.isSocratic ? t("deepLinkInitFailedSubsection") : t("deepLinkInitFailed")
);
setTaskRef(null);
setSubsectionRef(null);
unlockTask();
navigate("/select-task", { replace: true });
navigate(targetRoute, { replace: true });
}
void error;
}
......@@ -261,6 +319,7 @@ export default function ChatPage() {
navigate,
setSelectedOrchestrator,
setTaskRef,
setSubsectionRef,
taskFiles,
unlockTask,
]);
......@@ -276,10 +335,24 @@ export default function ChatPage() {
) {
return;
}
if (selectedOrchestrator === "socratic") {
if (!selectedSubsectionRef) {
navigate("/select-socratic", { replace: true });
}
return;
}
if (!selectedTaskRef) {
navigate("/select-task", { replace: true });
}
}, [deepLinkTarget, isTaskModeEnabled, isTasksInitialized, navigate, selectedTaskRef]);
}, [
deepLinkTarget,
isTaskModeEnabled,
isTasksInitialized,
navigate,
selectedOrchestrator,
selectedSubsectionRef,
selectedTaskRef,
]);
const docIndexes = useMemo(() => {
const bySourceKey: Record<string, RetrievedDoc> = {};
......@@ -432,6 +505,7 @@ export default function ChatPage() {
draft: string;
orchestrator: OrchestratorName;
selected_task?: { file_id: string; task_id: string };
selected_subsection?: { subsection_key: string };
} = {
messages: [...messages, userMessage].map((message) => ({
role: message.role,
......@@ -440,11 +514,17 @@ export default function ChatPage() {
draft: chatSessionId,
orchestrator: selectedOrchestrator,
};
if (isTaskModeEnabled && selectedTaskRef) {
chatPayload.selected_task = {
file_id: selectedTaskRef.fileId,
task_id: selectedTaskRef.taskId,
};
if (isTaskModeEnabled) {
if (selectedOrchestrator === "socratic" && selectedSubsectionRef) {
chatPayload.selected_subsection = {
subsection_key: selectedSubsectionRef.subsectionKey,
};
} else if (selectedTaskRef) {
chatPayload.selected_task = {
file_id: selectedTaskRef.fileId,
task_id: selectedTaskRef.taskId,
};
}
}
const response = await fetch(`/api/chat`, {
......@@ -607,30 +687,54 @@ export default function ChatPage() {
: selectedOrchestrator;
setSelectedOrchestrator(nextOrchestrator);
if (isTaskCoupledOrchestrator(nextOrchestrator)) {
const restoredTask = payload.selected_task;
if (restoredTask?.file_id && restoredTask?.task_id) {
setTaskRef({
fileId: restoredTask.file_id,
taskId: restoredTask.task_id,
});
lockTask();
try {
await selectTask({
draft: payload.chat_id,
if (nextOrchestrator === "socratic" || isTaskCoupledOrchestrator(nextOrchestrator)) {
if (nextOrchestrator === "socratic") {
const restoredSubsection = payload.selected_subsection;
if (restoredSubsection?.subsection_key) {
setTaskRef(null);
setSubsectionRef({ subsectionKey: restoredSubsection.subsection_key });
lockTask();
try {
await selectSubsection({
draft: payload.chat_id,
subsectionKey: restoredSubsection.subsection_key,
});
} catch (error) {
void error;
}
} else {
setTaskRef(null);
setSubsectionRef(null);
unlockTask();
navigate("/select-socratic", { replace: true });
}
} else {
setSubsectionRef(null);
const restoredTask = payload.selected_task;
if (restoredTask?.file_id && restoredTask?.task_id) {
setTaskRef({
fileId: restoredTask.file_id,
taskId: restoredTask.task_id,
});
} catch (error) {
void error;
lockTask();
try {
await selectTask({
draft: payload.chat_id,
fileId: restoredTask.file_id,
taskId: restoredTask.task_id,
});
} catch (error) {
void error;
}
} else {
setTaskRef(null);
unlockTask();
navigate("/select-task", { replace: true });
}
} else {
setTaskRef(null);
unlockTask();
navigate("/select-task", { replace: true });
}
} else {
setTaskRef(null);
setSubsectionRef(null);
unlockTask();
navigate("/chat", { replace: true });
}
......@@ -666,7 +770,7 @@ export default function ChatPage() {
resetChatState();
resetForNewChat();
await loadArchives();
navigate(isTaskModeEnabled ? "/select-task" : "/chat");
navigate(getSelectionRouteForOrchestrator(selectedOrchestrator));
};
const handleSwitchTask = async (target: { fileId: string; taskId: string } | null) => {
......@@ -727,8 +831,9 @@ export default function ChatPage() {
};
const handleChangeTaskArea = () => {
resetForNewChat();
unlockTask();
navigate("/select-task");
navigate(getSelectionRouteForOrchestrator(selectedOrchestrator));
};
const handleOpenSidebar = () => {
......@@ -742,7 +847,7 @@ export default function ChatPage() {
}
resetChatState();
switchOrchestrator(next);
navigate(isTaskCoupledOrchestrator(next) ? "/select-task" : "/chat");
navigate(getSelectionRouteForOrchestrator(next));
};
const handleCanvasSave = async (
......@@ -975,7 +1080,16 @@ export default function ChatPage() {
</section>
<aside className={`retrieval-column ${isTaskModeEnabled ? "retrieval-column-task-mode" : ""}`}>
{isTaskModeEnabled && selectedTask ? (
{selectedOrchestrator === "socratic" && selectedSubsection ? (
<SocraticPanel
selectedSubsectionLabel={selectedSubsection.label}
selectedSubsectionKey={selectedSubsection.subsectionKey}
selectedSubsectionRefsText={selectedSubsection.refsText}
onChangeSelection={handleChangeTaskArea}
/>
) : null}
{selectedOrchestrator !== "socratic" && isTaskModeEnabled && selectedTask ? (
<TaskPanel
readOnly
selectedTaskText={selectedTask.fullText}
......
import { useEffect, useMemo, useRef } from "react";
import { useNavigate } from "react-router-dom";
import type { OrchestratorName } from "../api/orchestratorApi";
import { selectSubsection } from "../api/taskApi";
import OrchestratorSelect from "../components/Orchestrator/OrchestratorSelect";
import { t } from "../i18n";
import { useTutorSession } from "../state/tutorSession";
import { getSelectionRouteForOrchestrator } from "../utils/orchestratorRoutes";
import "../styles/theme.css";
import sumintLogo from "../../SuMINT-Logo.png";
export default function SocraticSelectionPage() {
const navigate = useNavigate();
const {
chatSessionId,
selectedOrchestrator,
availableOrchestrators,
switchOrchestrator,
isOrchestratorSelectable,
orchestratorError,
subsections,
selectedSubsectionRef,
selectedSubsection,
tasksError,
setSubsectionKey,
lockTask,
unlockTask,
isTasksInitialized,
} = useTutorSession();
const subsectionDisplayRef = useRef<HTMLDivElement | null>(null);
const subsectionMenuOptions = useMemo(
() =>
subsections.map((option) => ({
value: option.subsection_key,
label: option.label || option.subsection_key,
})),
[subsections]
);
useEffect(() => {
if (!isTasksInitialized) {
return;
}
if (selectedOrchestrator !== "socratic") {
navigate(getSelectionRouteForOrchestrator(selectedOrchestrator), { replace: true });
return;
}
unlockTask();
}, [isTasksInitialized, navigate, selectedOrchestrator, unlockTask]);
useEffect(() => {
if (!selectedSubsection?.label || !subsectionDisplayRef.current) {
return;
}
const mathjax = window.MathJax;
if (!mathjax?.typesetPromise) {
return;
}
mathjax.typesetPromise([subsectionDisplayRef.current]).catch(() => undefined);
}, [selectedSubsection?.label]);
useEffect(() => {
if (!subsectionMenuOptions.length) {
return;
}
if (
selectedSubsectionRef &&
subsectionMenuOptions.some((option) => option.value === selectedSubsectionRef.subsectionKey)
) {
return;
}
setSubsectionKey(subsectionMenuOptions[0].value);
}, [selectedSubsectionRef, setSubsectionKey, subsectionMenuOptions]);
const handleStartSocratic = async () => {
if (!selectedSubsectionRef) {
return;
}
try {
await selectSubsection({
draft: chatSessionId,
subsectionKey: selectedSubsectionRef.subsectionKey,
});
lockTask();
navigate("/chat");
} catch (error) {
void error;
}
};
const handleSwitchOrchestrator = (next: OrchestratorName) => {
if (next === selectedOrchestrator) {
return;
}
switchOrchestrator(next);
navigate(getSelectionRouteForOrchestrator(next), { replace: true });
};
if (!isTasksInitialized) {
return <div className="app-loading">{t("loading")}</div>;
}
return (
<div className="app-shell">
<header className="app-header">
<div className="brand">
<img className="brand-logo" src={sumintLogo} alt="SuMINT Logo" />
<div className="brand-text">
<div className="brand-title">Mathe Tutor</div>
<div className="brand-subtitle">{t("socraticSelectionSubtitle")}</div>
</div>
</div>
</header>
{orchestratorError ? <div className="chat-archive-error">{orchestratorError}</div> : null}
<main className="task-select-main">
<div className="task-select-wrap">
<div className="task-mode-row">
<OrchestratorSelect
value={selectedOrchestrator}
options={availableOrchestrators}
onChange={handleSwitchOrchestrator}
disabled={!isOrchestratorSelectable}
/>
</div>
<section className="task-select-card">
<div className="task-select-header">
<h2 className="task-select-title">{t("socraticSelectionTitle")}</h2>
</div>
<div className="task-select-controls">
<label className="task-select-label" htmlFor="socratic-subsection-select">
{t("subsection")}
</label>
<select
id="socratic-subsection-select"
className="task-select"
value={selectedSubsectionRef?.subsectionKey || ""}
onChange={(event) => setSubsectionKey(event.target.value)}
disabled={!subsectionMenuOptions.length}
>
{subsectionMenuOptions.length ? (
subsectionMenuOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
) : (
<option value="">{t("noSubsectionsAvailable")}</option>
)}
</select>
</div>
<div className="task-panel-content" ref={subsectionDisplayRef}>
{selectedSubsection ? (
<div>
<div>{selectedSubsection.label}</div>
<div>{selectedSubsection.refsText}</div>
</div>
) : (
t("noSubsectionSelected")
)}
</div>
{tasksError ? <div className="task-panel-error">{tasksError}</div> : null}
<button
type="button"
className="btn primary task-solve-btn"
onClick={handleStartSocratic}
disabled={!selectedSubsectionRef}
>
{t("startSocratic")}
</button>
</section>
</div>
</main>
</div>
);
}
......@@ -5,12 +5,10 @@ import OrchestratorSelect from "../components/Orchestrator/OrchestratorSelect";
import { selectTask } from "../api/taskApi";
import { t } from "../i18n";
import { useTutorSession } from "../state/tutorSession";
import { getSelectionRouteForOrchestrator } from "../utils/orchestratorRoutes";
import "../styles/theme.css";
import sumintLogo from "../../SuMINT-Logo.png";
const isTaskCoupledOrchestrator = (value: OrchestratorName): boolean =>
value === "task" || value === "feedback" || value === "socratic";
export default function TaskSelectionPage() {
const navigate = useNavigate();
const {
......@@ -42,8 +40,12 @@ export default function TaskSelectionPage() {
navigate("/chat", { replace: true });
return;
}
if (selectedOrchestrator === "socratic") {
navigate("/select-socratic", { replace: true });
return;
}
unlockTask();
}, [isTaskModeEnabled, isTasksInitialized, navigate, unlockTask]);
}, [isTaskModeEnabled, isTasksInitialized, navigate, selectedOrchestrator, unlockTask]);
useEffect(() => {
if (!taskFileOptions.length) {
......@@ -89,7 +91,7 @@ export default function TaskSelectionPage() {
return;
}
switchOrchestrator(next);
navigate(isTaskCoupledOrchestrator(next) ? "/select-task" : "/chat", { replace: true });
navigate(getSelectionRouteForOrchestrator(next), { replace: true });
};
if (!isTasksInitialized) {
......
......@@ -14,7 +14,13 @@ import {
getFallbackOrchestrators,
type OrchestratorName,
} from "../api/orchestratorApi";
import { fetchTasks, type SelectedTaskRef, type TaskFile } from "../api/taskApi";
import {
fetchTasks,
type SelectedSubsectionRef,
type SelectedTaskRef,
type SubsectionOption,
type TaskFile,
} from "../api/taskApi";
export type SelectOption = {
value: string;
......@@ -26,6 +32,12 @@ export type SelectedTask = SelectedTaskRef & {
fullText: string;
};
export type SelectedSubsection = SelectedSubsectionRef & {
label: string;
refs: [number, number, number][];
refsText: string;
};
export type TaskSelectionState = {
taskFiles: TaskFile[];
selectedTaskRef: SelectedTaskRef | null;
......@@ -33,6 +45,10 @@ export type TaskSelectionState = {
selectedTaskFile: TaskFile | null;
taskFileOptions: SelectOption[];
taskOptions: SelectOption[];
selectedSubsectionRef: SelectedSubsectionRef | null;
selectedSubsection: SelectedSubsection | null;
subsections: SubsectionOption[];
subsectionOptions: SelectOption[];
tasksError: string | null;
isTaskModeEnabled: boolean;
isTasksInitialized: boolean;
......@@ -50,8 +66,10 @@ export type TutorSessionState = TaskSelectionState & {
isOrchestratorSelectable: boolean;
orchestratorError: string | null;
setTaskRef: (value: SelectedTaskRef | null) => void;
setSubsectionRef: (value: SelectedSubsectionRef | null) => void;
setTaskFile: (fileId: string) => void;
setTaskId: (taskId: string) => void;
setSubsectionKey: (subsectionKey: string) => void;
lockTask: () => void;
unlockTask: () => void;
resetForNewChat: () => void;
......@@ -71,6 +89,9 @@ const isSelectableTaskFile = (file: TaskFile): boolean =>
const isTaskCoupledOrchestrator = (value: OrchestratorName): boolean =>
value === "task" || value === "feedback" || value === "socratic";
const formatSubsectionRefs = (refs: [number, number, number][]): string =>
refs.map((ref) => ref.join(":")).join(", ");
export function TutorSessionProvider({ children }: PropsWithChildren) {
const [chatSessionId, setChatSessionId] = useState<string>(() => createSessionId());
const [selectedOrchestrator, setSelectedOrchestratorState] =
......@@ -82,7 +103,10 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
const [orchestratorError, setOrchestratorError] = useState<string | null>(null);
const [isTasksInitialized, setIsTasksInitialized] = useState(false);
const [taskFiles, setTaskFiles] = useState<TaskFile[]>([]);
const [subsections, setSubsections] = useState<SubsectionOption[]>([]);
const [selectedTaskRef, setSelectedTaskRef] = useState<SelectedTaskRef | null>(null);
const [selectedSubsectionRef, setSelectedSubsectionRef] =
useState<SelectedSubsectionRef | null>(null);
const [tasksError, setTasksError] = useState<string | null>(null);
const [taskLocked, setTaskLocked] = useState(false);
......@@ -136,6 +160,34 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
[selectedTaskFile]
);
const subsectionOptions = useMemo(
() =>
subsections.map((option) => ({
value: option.subsection_key,
label: option.label || option.subsection_key,
})),
[subsections]
);
const selectedSubsection = useMemo<SelectedSubsection | null>(() => {
if (!selectedSubsectionRef) {
return null;
}
const option = subsections.find(
(item) => item.subsection_key === selectedSubsectionRef.subsectionKey
);
if (!option) {
return null;
}
const refs = option.refs ?? [];
return {
subsectionKey: option.subsection_key,
label: option.label,
refs,
refsText: formatSubsectionRefs(refs),
};
}, [selectedSubsectionRef, subsections]);
const initTasks = useCallback(async () => {
setTasksError(null);
setOrchestratorError(null);
......@@ -160,9 +212,11 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
try {
const payload = await fetchTasks();
const files = payload.task_files || [];
const subsectionsPayload = payload.subsections || [];
const selectableFiles = files.filter((file) => isSelectableTaskFile(file));
setTaskFiles(files);
setSubsections(subsectionsPayload);
setSelectedTaskRef((prev) => {
if (prev) {
const file = selectableFiles.find((item) => item.file_id === prev.fileId);
......@@ -181,10 +235,26 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
}
return { fileId: firstFile.file_id, taskId: defaultTaskId };
});
setSelectedSubsectionRef((prev) => {
if (prev) {
const option = subsectionsPayload.find((item) => item.subsection_key === prev.subsectionKey);
if (option) {
return prev;
}
}
const firstOption = subsectionsPayload[0];
if (!firstOption) {
return null;
}
return { subsectionKey: firstOption.subsection_key };
});
} catch (error) {
setTasksError(t("failedLoadTasks"));
setTaskFiles([]);
setSubsections([]);
setSelectedTaskRef(null);
setSelectedSubsectionRef(null);
void error;
} finally {
setIsTasksInitialized(true);
......@@ -223,6 +293,16 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
[selectedTaskRef]
);
const setSubsectionKey = useCallback(
(subsectionKey: string) => {
if (!subsectionKey) {
return;
}
setSelectedSubsectionRef({ subsectionKey });
},
[]
);
const setSelectedOrchestrator = useCallback((value: OrchestratorName) => {
setSelectedOrchestratorState(value);
}, []);
......@@ -231,9 +311,14 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
setSelectedOrchestratorState(value);
setChatSessionId(createSessionId());
setTaskLocked(false);
if (!isTaskCoupledOrchestrator(value)) {
if (value === "socratic") {
setSelectedTaskRef(null);
} else if (!isTaskCoupledOrchestrator(value)) {
setSelectedTaskRef(null);
}
if (value !== "socratic") {
setSelectedSubsectionRef(null);
}
}, []);
const lockTask = useCallback(() => {
......@@ -247,6 +332,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
const resetForNewChat = useCallback(() => {
setChatSessionId(createSessionId());
setSelectedTaskRef(null);
setSelectedSubsectionRef(null);
setTaskLocked(false);
}, []);
......@@ -261,14 +347,20 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
isOrchestratorSelectable,
orchestratorError,
taskFiles,
subsections,
selectedTaskRef,
selectedTask,
selectedTaskFile,
taskFileOptions,
taskOptions,
selectedSubsectionRef,
selectedSubsection,
subsectionOptions,
setTaskRef: setSelectedTaskRef,
setSubsectionRef: setSelectedSubsectionRef,
setTaskFile,
setTaskId,
setSubsectionKey,
tasksError,
isTaskModeEnabled,
isTasksInitialized,
......
import type { OrchestratorName } from "../api/orchestratorApi";
export const isTaskSelectionOrchestrator = (value: OrchestratorName): boolean =>
value === "task" || value === "feedback";
export const isSocraticOrchestrator = (value: OrchestratorName): boolean =>
value === "socratic";
export const getSelectionRouteForOrchestrator = (value: OrchestratorName): string => {
if (value === "socratic") {
return "/select-socratic";
}
if (value === "task" || value === "feedback") {
return "/select-task";
}
return "/chat";
};
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