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

Auswahl von Unterthemen

parent 13288a8e
...@@ -26,6 +26,7 @@ class ChatRequest(BaseModel): ...@@ -26,6 +26,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,11 +53,16 @@ class SelectedTaskRef(BaseModel): ...@@ -52,11 +53,16 @@ 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
...@@ -80,6 +86,7 @@ def chat(request: ChatRequest) -> ChatResponse: ...@@ -80,6 +86,7 @@ def chat(request: ChatRequest) -> ChatResponse:
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 +132,19 @@ def get_archive(chat_id: str = Path(..., min_length=1)) -> ChatArchiveDetail: ...@@ -125,11 +132,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(),
) )
......
...@@ -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:
......
...@@ -2,60 +2,26 @@ from __future__ import annotations ...@@ -2,60 +2,26 @@ from __future__ import annotations
from app.LLM_services import socratic_LLM from app.LLM_services import socratic_LLM
import app.config as config import app.config as config
from app.deterministic_services import ( from app.deterministic_services import context_store, retrieval_store, task_catalog
context_store,
retrieval_store,
task_catalog,
)
from app.deterministic_services.orchestrators import orchestrator_base as base 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: def _apply_selected_subsection(
store_new = context_store.context_store_new state: base.ChatState,
has_task = bool(store_new.get_task(state.sheet)) selected_subsection: dict | None,
has_hints = bool(store_new.get_hints(state.sheet)) ) -> None:
has_solution = bool(store_new.get_solution(state.sheet)) if not selected_subsection:
if has_task and has_hints and has_solution: return
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)
base.append_tool_log( subsection_key = str(selected_subsection.get("subsection_key", "")).strip()
state.tool_log, if not subsection_key:
"task_json_selected", return
{"tasks_dir": str(task_catalog.TASKS_DIR)},
{ task_catalog.select_subsection_by_key(state.sheet, subsection_key)
"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
def _retrieve_context_for_task(state: base.ChatState, query_text: str) -> int: def _retrieve_context_for_subsection(state: base.ChatState, query_text: str) -> int:
refs = task_catalog.get_selected_task_subsection_refs(state.sheet) refs = task_catalog.get_selected_subsection_refs(state.sheet)
if not refs: if not refs:
return 0 return 0
...@@ -72,7 +38,7 @@ def _retrieve_context_for_task(state: base.ChatState, query_text: str) -> int: ...@@ -72,7 +38,7 @@ def _retrieve_context_for_task(state: base.ChatState, query_text: str) -> int:
result = base.log_timed_call( result = base.log_timed_call(
state.tool_log, state.tool_log,
"retrieve_context_with_task_subsections", "retrieve_context_with_socratic_subsections",
{ {
"query": query_text, "query": query_text,
"subsection_refs": refs, "subsection_refs": refs,
...@@ -83,17 +49,17 @@ def _retrieve_context_for_task(state: base.ChatState, query_text: str) -> int: ...@@ -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: def _on_bootstrap(state: base.ChatState, query_text: str) -> None:
_ensure_context_task_fields(state, query_text) _retrieve_context_for_subsection(state, query_text)
_retrieve_context_for_task(state, query_text)
def _on_turn_logic(state: base.ChatState) -> None: 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: def _on_build_reply(state: base.ChatState) -> str | None:
history_turns = context_store.get_history_turns(state.sheet) 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 = { args = {
"query": state.last_user, "query": state.last_user,
"subsection_refs": subsection_refs, "subsection_refs": subsection_refs,
...@@ -111,26 +77,18 @@ def _on_build_reply(state: base.ChatState) -> str | None: ...@@ -111,26 +77,18 @@ def _on_build_reply(state: base.ChatState) -> str | None:
def run_chat( def run_chat(
messages: list[dict], messages: list[dict],
draft: str | None = None, draft: str | None = None,
selected_subsection: dict | None = None,
selected_task: dict | None = None, selected_task: dict | None = None,
) -> dict: ) -> dict:
def _apply_selected_task(state: base.ChatState) -> None: def _apply_selected_context(state: base.ChatState) -> None:
if not selected_task: _apply_selected_subsection(state, selected_subsection)
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 on_bootstrap(state: base.ChatState, query_text: str) -> None: def on_bootstrap(state: base.ChatState, query_text: str) -> None:
_apply_selected_task(state) _apply_selected_context(state)
_on_bootstrap(state, query_text) _on_bootstrap(state, query_text)
def on_turn_logic(state: base.ChatState) -> None: def on_turn_logic(state: base.ChatState) -> None:
_apply_selected_task(state) _apply_selected_context(state)
_on_turn_logic(state) _on_turn_logic(state)
return base.run_chat_common( return base.run_chat_common(
......
...@@ -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:
......
...@@ -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 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 = { ...@@ -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();
}
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 @@ ...@@ -48,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.",
...@@ -70,10 +72,17 @@ ...@@ -70,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...",
...@@ -84,6 +93,9 @@ ...@@ -84,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",
...@@ -135,6 +147,8 @@ ...@@ -135,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.",
...@@ -162,10 +176,17 @@ ...@@ -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.", "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...",
...@@ -178,6 +199,10 @@ ...@@ -178,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>
......
...@@ -5,13 +5,15 @@ import ChatWindow from "../components/Chat/ChatWindow"; ...@@ -5,13 +5,15 @@ import ChatWindow from "../components/Chat/ChatWindow";
import CanvasDrawer from "../components/Canvas/CanvasDrawer"; import CanvasDrawer from "../components/Canvas/CanvasDrawer";
import OrchestratorSelect from "../components/Orchestrator/OrchestratorSelect"; import OrchestratorSelect from "../components/Orchestrator/OrchestratorSelect";
import DocPanel from "../components/Retrieval/DocPanel"; import DocPanel from "../components/Retrieval/DocPanel";
import SocraticPanel from "../components/Task/SocraticPanel";
import TaskPanel from "../components/Task/TaskPanel"; import TaskPanel from "../components/Task/TaskPanel";
import type { ChatMessage } from "../components/Chat/MessageList"; import type { ChatMessage } from "../components/Chat/MessageList";
import type { RetrievedDoc } from "../components/Retrieval/DocPanel"; import type { RetrievedDoc } from "../components/Retrieval/DocPanel";
import type { OrchestratorName } from "../api/orchestratorApi"; import type { OrchestratorName } from "../api/orchestratorApi";
import { selectTask } from "../api/taskApi"; import { selectSubsection, selectTask } from "../api/taskApi";
import { t } from "../i18n"; import { t } from "../i18n";
import { createSessionId, useTutorSession } from "../state/tutorSession"; import { createSessionId, useTutorSession } from "../state/tutorSession";
import { getSelectionRouteForOrchestrator, isSocraticOrchestrator } from "../utils/orchestratorRoutes";
import sumintLogo from "../../SuMINT-Logo.png"; import sumintLogo from "../../SuMINT-Logo.png";
const initialMessages: ChatMessage[] = []; const initialMessages: ChatMessage[] = [];
...@@ -30,7 +32,7 @@ const normalizeOrchestrator = (value: string | null | undefined): OrchestratorNa ...@@ -30,7 +32,7 @@ const normalizeOrchestrator = (value: string | null | undefined): OrchestratorNa
}; };
const isTaskCoupledOrchestrator = (value: string | null | undefined): boolean => const isTaskCoupledOrchestrator = (value: string | null | undefined): boolean =>
value === "task" || value === "feedback" || value === "socratic"; value === "task" || value === "feedback";
type ArchivedChatSummary = { type ArchivedChatSummary = {
chat_id: string; chat_id: string;
...@@ -49,6 +51,9 @@ type ArchivedChatDetail = { ...@@ -49,6 +51,9 @@ type ArchivedChatDetail = {
file_id: string; file_id: string;
task_id: string; task_id: string;
} | null; } | null;
selected_subsection?: {
subsection_key: string;
} | null;
}; };
type ContextSource = { type ContextSource = {
...@@ -126,6 +131,8 @@ export default function ChatPage() { ...@@ -126,6 +131,8 @@ export default function ChatPage() {
selectedTaskRef, selectedTaskRef,
selectedTask, selectedTask,
selectedTaskFile, selectedTaskFile,
selectedSubsectionRef,
selectedSubsection,
selectedOrchestrator, selectedOrchestrator,
availableOrchestrators, availableOrchestrators,
setSelectedOrchestrator, setSelectedOrchestrator,
...@@ -135,6 +142,7 @@ export default function ChatPage() { ...@@ -135,6 +142,7 @@ export default function ChatPage() {
lockTask, lockTask,
unlockTask, unlockTask,
setTaskRef, setTaskRef,
setSubsectionRef,
resetForNewChat, resetForNewChat,
} = useTutorSession(); } = useTutorSession();
...@@ -168,23 +176,31 @@ export default function ChatPage() { ...@@ -168,23 +176,31 @@ export default function ChatPage() {
.toLowerCase(); .toLowerCase();
const fileId = String(searchParams.get("file_id") || "").trim(); const fileId = String(searchParams.get("file_id") || "").trim();
const rawTaskId = String(searchParams.get("task_id") || "").trim(); const rawTaskId = String(searchParams.get("task_id") || "").trim();
const rawSubsectionKey = String(searchParams.get("subsection_key") || "").trim();
const taskId = const taskId =
/^\d{1,2}$/.test(rawTaskId) && rawTaskId.length < 2 /^\d{1,2}$/.test(rawTaskId) && rawTaskId.length < 2
? rawTaskId.padStart(2, "0") ? rawTaskId.padStart(2, "0")
: rawTaskId; : rawTaskId;
const subsectionKey = rawSubsectionKey;
const hasFileId = Boolean(fileId); const hasFileId = Boolean(fileId);
const hasTaskId = Boolean(taskId); const hasTaskId = Boolean(taskId);
const hasSubsectionKey = Boolean(subsectionKey);
const isTaskOrchestrator = isTaskCoupledOrchestrator(orchestrator); const isTaskOrchestrator = isTaskCoupledOrchestrator(orchestrator);
const hasAnyTaskParam = hasFileId || hasTaskId; const isSocratic = isSocraticOrchestrator(orchestrator as OrchestratorName);
const hasRequiredParams = hasFileId && hasTaskId; const hasAnyTaskParam = hasFileId || hasTaskId || hasSubsectionKey;
const key = `${orchestrator}|${fileId}|${taskId}`; const hasRequiredTaskParams = hasFileId && hasTaskId;
const hasRequiredSubsectionParams = hasSubsectionKey;
const key = `${orchestrator}|${fileId}|${taskId}|${subsectionKey}`;
return { return {
fileId, fileId,
taskId, taskId,
subsectionKey,
isTaskOrchestrator, isTaskOrchestrator,
isSocratic,
hasAnyTaskParam, hasAnyTaskParam,
hasRequiredParams, hasRequiredTaskParams,
hasRequiredSubsectionParams,
key, key,
}; };
}, [searchParams]); }, [searchParams]);
...@@ -193,7 +209,7 @@ export default function ChatPage() { ...@@ -193,7 +209,7 @@ export default function ChatPage() {
if (!isTasksInitialized) { if (!isTasksInitialized) {
return; return;
} }
if (!deepLinkTarget.isTaskOrchestrator || !deepLinkTarget.hasAnyTaskParam) { if ((!deepLinkTarget.isTaskOrchestrator && !deepLinkTarget.isSocratic) || !deepLinkTarget.hasAnyTaskParam) {
return; return;
} }
if (processedDeepLinkRef.current === deepLinkTarget.key) { if (processedDeepLinkRef.current === deepLinkTarget.key) {
...@@ -201,51 +217,93 @@ export default function ChatPage() { ...@@ -201,51 +217,93 @@ export default function ChatPage() {
} }
processedDeepLinkRef.current = deepLinkTarget.key; processedDeepLinkRef.current = deepLinkTarget.key;
if (!deepLinkTarget.hasRequiredParams) { const targetRoute = getSelectionRouteForOrchestrator(
setDeepLinkError(t("deepLinkInvalidTask")); searchParams.get("orchestrator") === "feedback"
? "feedback"
: searchParams.get("orchestrator") === "socratic"
? "socratic"
: "task"
);
if (
(deepLinkTarget.isTaskOrchestrator && !deepLinkTarget.hasRequiredTaskParams) ||
(deepLinkTarget.isSocratic && !deepLinkTarget.hasRequiredSubsectionParams)
) {
setDeepLinkError(
deepLinkTarget.isSocratic ? t("deepLinkInvalidSubsection") : t("deepLinkInvalidTask")
);
setTaskRef(null); setTaskRef(null);
setSubsectionRef(null);
unlockTask(); unlockTask();
navigate("/select-task", { replace: true }); navigate(targetRoute, { replace: true });
return; return;
} }
if (deepLinkTarget.isSocratic) {
setSelectedOrchestrator("socratic");
setTaskRef(null);
setSubsectionRef({ subsectionKey: deepLinkTarget.subsectionKey });
} else {
const selectedFile = taskFiles.find((file) => file.file_id === deepLinkTarget.fileId); const selectedFile = taskFiles.find((file) => file.file_id === deepLinkTarget.fileId);
const selectedTask = selectedFile?.tasks.find((task) => task.task_id === deepLinkTarget.taskId); if (!selectedFile) {
if (!selectedFile || !selectedTask) {
setDeepLinkError(t("deepLinkInvalidTask")); setDeepLinkError(t("deepLinkInvalidTask"));
setTaskRef(null); setTaskRef(null);
setSubsectionRef(null);
unlockTask(); unlockTask();
navigate("/select-task", { replace: true }); 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; return;
} }
setSelectedOrchestrator( setSelectedOrchestrator(
searchParams.get("orchestrator") === "feedback" searchParams.get("orchestrator") === "feedback"
? "feedback" ? "feedback"
: searchParams.get("orchestrator") === "socratic"
? "socratic"
: "task" : "task"
); );
setSubsectionRef(null);
setTaskRef({ fileId: selectedFile.file_id, taskId: selectedTask.task_id }); setTaskRef({ fileId: selectedFile.file_id, taskId: selectedTask.task_id });
}
unlockTask(); unlockTask();
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
try { try {
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({ await selectTask({
draft: chatSessionId, draft: chatSessionId,
fileId: selectedFile.file_id, fileId: selectedFile.file_id,
taskId: selectedTask.task_id, taskId: selectedTask.task_id,
}); });
}
if (!cancelled) { if (!cancelled) {
setDeepLinkError(null); setDeepLinkError(null);
} }
} catch (error) { } catch (error) {
if (!cancelled) { if (!cancelled) {
setDeepLinkError(t("deepLinkInitFailed")); setDeepLinkError(
deepLinkTarget.isSocratic ? t("deepLinkInitFailedSubsection") : t("deepLinkInitFailed")
);
setTaskRef(null); setTaskRef(null);
setSubsectionRef(null);
unlockTask(); unlockTask();
navigate("/select-task", { replace: true }); navigate(targetRoute, { replace: true });
} }
void error; void error;
} }
...@@ -261,6 +319,7 @@ export default function ChatPage() { ...@@ -261,6 +319,7 @@ export default function ChatPage() {
navigate, navigate,
setSelectedOrchestrator, setSelectedOrchestrator,
setTaskRef, setTaskRef,
setSubsectionRef,
taskFiles, taskFiles,
unlockTask, unlockTask,
]); ]);
...@@ -276,10 +335,24 @@ export default function ChatPage() { ...@@ -276,10 +335,24 @@ export default function ChatPage() {
) { ) {
return; return;
} }
if (selectedOrchestrator === "socratic") {
if (!selectedSubsectionRef) {
navigate("/select-socratic", { replace: true });
}
return;
}
if (!selectedTaskRef) { if (!selectedTaskRef) {
navigate("/select-task", { replace: true }); navigate("/select-task", { replace: true });
} }
}, [deepLinkTarget, isTaskModeEnabled, isTasksInitialized, navigate, selectedTaskRef]); }, [
deepLinkTarget,
isTaskModeEnabled,
isTasksInitialized,
navigate,
selectedOrchestrator,
selectedSubsectionRef,
selectedTaskRef,
]);
const docIndexes = useMemo(() => { const docIndexes = useMemo(() => {
const bySourceKey: Record<string, RetrievedDoc> = {}; const bySourceKey: Record<string, RetrievedDoc> = {};
...@@ -432,6 +505,7 @@ export default function ChatPage() { ...@@ -432,6 +505,7 @@ export default function ChatPage() {
draft: string; draft: string;
orchestrator: OrchestratorName; orchestrator: OrchestratorName;
selected_task?: { file_id: string; task_id: string }; selected_task?: { file_id: string; task_id: string };
selected_subsection?: { subsection_key: string };
} = { } = {
messages: [...messages, userMessage].map((message) => ({ messages: [...messages, userMessage].map((message) => ({
role: message.role, role: message.role,
...@@ -440,12 +514,18 @@ export default function ChatPage() { ...@@ -440,12 +514,18 @@ export default function ChatPage() {
draft: chatSessionId, draft: chatSessionId,
orchestrator: selectedOrchestrator, orchestrator: selectedOrchestrator,
}; };
if (isTaskModeEnabled && selectedTaskRef) { if (isTaskModeEnabled) {
if (selectedOrchestrator === "socratic" && selectedSubsectionRef) {
chatPayload.selected_subsection = {
subsection_key: selectedSubsectionRef.subsectionKey,
};
} else if (selectedTaskRef) {
chatPayload.selected_task = { chatPayload.selected_task = {
file_id: selectedTaskRef.fileId, file_id: selectedTaskRef.fileId,
task_id: selectedTaskRef.taskId, task_id: selectedTaskRef.taskId,
}; };
} }
}
const response = await fetch(`/api/chat`, { const response = await fetch(`/api/chat`, {
method: "POST", method: "POST",
...@@ -607,7 +687,29 @@ export default function ChatPage() { ...@@ -607,7 +687,29 @@ export default function ChatPage() {
: selectedOrchestrator; : selectedOrchestrator;
setSelectedOrchestrator(nextOrchestrator); setSelectedOrchestrator(nextOrchestrator);
if (isTaskCoupledOrchestrator(nextOrchestrator)) { 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; const restoredTask = payload.selected_task;
if (restoredTask?.file_id && restoredTask?.task_id) { if (restoredTask?.file_id && restoredTask?.task_id) {
setTaskRef({ setTaskRef({
...@@ -629,8 +731,10 @@ export default function ChatPage() { ...@@ -629,8 +731,10 @@ export default function ChatPage() {
unlockTask(); unlockTask();
navigate("/select-task", { replace: true }); navigate("/select-task", { replace: true });
} }
}
} else { } else {
setTaskRef(null); setTaskRef(null);
setSubsectionRef(null);
unlockTask(); unlockTask();
navigate("/chat", { replace: true }); navigate("/chat", { replace: true });
} }
...@@ -666,7 +770,7 @@ export default function ChatPage() { ...@@ -666,7 +770,7 @@ export default function ChatPage() {
resetChatState(); resetChatState();
resetForNewChat(); resetForNewChat();
await loadArchives(); await loadArchives();
navigate(isTaskModeEnabled ? "/select-task" : "/chat"); navigate(getSelectionRouteForOrchestrator(selectedOrchestrator));
}; };
const handleSwitchTask = async (target: { fileId: string; taskId: string } | null) => { const handleSwitchTask = async (target: { fileId: string; taskId: string } | null) => {
...@@ -727,8 +831,9 @@ export default function ChatPage() { ...@@ -727,8 +831,9 @@ export default function ChatPage() {
}; };
const handleChangeTaskArea = () => { const handleChangeTaskArea = () => {
resetForNewChat();
unlockTask(); unlockTask();
navigate("/select-task"); navigate(getSelectionRouteForOrchestrator(selectedOrchestrator));
}; };
const handleOpenSidebar = () => { const handleOpenSidebar = () => {
...@@ -742,7 +847,7 @@ export default function ChatPage() { ...@@ -742,7 +847,7 @@ export default function ChatPage() {
} }
resetChatState(); resetChatState();
switchOrchestrator(next); switchOrchestrator(next);
navigate(isTaskCoupledOrchestrator(next) ? "/select-task" : "/chat"); navigate(getSelectionRouteForOrchestrator(next));
}; };
const handleCanvasSave = async ( const handleCanvasSave = async (
...@@ -975,7 +1080,16 @@ export default function ChatPage() { ...@@ -975,7 +1080,16 @@ export default function ChatPage() {
</section> </section>
<aside className={`retrieval-column ${isTaskModeEnabled ? "retrieval-column-task-mode" : ""}`}> <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 <TaskPanel
readOnly readOnly
selectedTaskText={selectedTask.fullText} 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"; ...@@ -5,12 +5,10 @@ import OrchestratorSelect from "../components/Orchestrator/OrchestratorSelect";
import { selectTask } from "../api/taskApi"; import { selectTask } from "../api/taskApi";
import { t } from "../i18n"; import { t } from "../i18n";
import { useTutorSession } from "../state/tutorSession"; import { useTutorSession } from "../state/tutorSession";
import { getSelectionRouteForOrchestrator } from "../utils/orchestratorRoutes";
import "../styles/theme.css"; import "../styles/theme.css";
import sumintLogo from "../../SuMINT-Logo.png"; import sumintLogo from "../../SuMINT-Logo.png";
const isTaskCoupledOrchestrator = (value: OrchestratorName): boolean =>
value === "task" || value === "feedback" || value === "socratic";
export default function TaskSelectionPage() { export default function TaskSelectionPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { const {
...@@ -42,8 +40,12 @@ export default function TaskSelectionPage() { ...@@ -42,8 +40,12 @@ export default function TaskSelectionPage() {
navigate("/chat", { replace: true }); navigate("/chat", { replace: true });
return; return;
} }
if (selectedOrchestrator === "socratic") {
navigate("/select-socratic", { replace: true });
return;
}
unlockTask(); unlockTask();
}, [isTaskModeEnabled, isTasksInitialized, navigate, unlockTask]); }, [isTaskModeEnabled, isTasksInitialized, navigate, selectedOrchestrator, unlockTask]);
useEffect(() => { useEffect(() => {
if (!taskFileOptions.length) { if (!taskFileOptions.length) {
...@@ -89,7 +91,7 @@ export default function TaskSelectionPage() { ...@@ -89,7 +91,7 @@ export default function TaskSelectionPage() {
return; return;
} }
switchOrchestrator(next); switchOrchestrator(next);
navigate(isTaskCoupledOrchestrator(next) ? "/select-task" : "/chat", { replace: true }); navigate(getSelectionRouteForOrchestrator(next), { replace: true });
}; };
if (!isTasksInitialized) { if (!isTasksInitialized) {
......
...@@ -14,7 +14,13 @@ import { ...@@ -14,7 +14,13 @@ import {
getFallbackOrchestrators, getFallbackOrchestrators,
type OrchestratorName, type OrchestratorName,
} from "../api/orchestratorApi"; } 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 = { export type SelectOption = {
value: string; value: string;
...@@ -26,6 +32,12 @@ export type SelectedTask = SelectedTaskRef & { ...@@ -26,6 +32,12 @@ export type SelectedTask = SelectedTaskRef & {
fullText: string; fullText: string;
}; };
export type SelectedSubsection = SelectedSubsectionRef & {
label: string;
refs: [number, number, number][];
refsText: string;
};
export type TaskSelectionState = { export type TaskSelectionState = {
taskFiles: TaskFile[]; taskFiles: TaskFile[];
selectedTaskRef: SelectedTaskRef | null; selectedTaskRef: SelectedTaskRef | null;
...@@ -33,6 +45,10 @@ export type TaskSelectionState = { ...@@ -33,6 +45,10 @@ export type TaskSelectionState = {
selectedTaskFile: TaskFile | null; selectedTaskFile: TaskFile | null;
taskFileOptions: SelectOption[]; taskFileOptions: SelectOption[];
taskOptions: SelectOption[]; taskOptions: SelectOption[];
selectedSubsectionRef: SelectedSubsectionRef | null;
selectedSubsection: SelectedSubsection | null;
subsections: SubsectionOption[];
subsectionOptions: SelectOption[];
tasksError: string | null; tasksError: string | null;
isTaskModeEnabled: boolean; isTaskModeEnabled: boolean;
isTasksInitialized: boolean; isTasksInitialized: boolean;
...@@ -50,8 +66,10 @@ export type TutorSessionState = TaskSelectionState & { ...@@ -50,8 +66,10 @@ export type TutorSessionState = TaskSelectionState & {
isOrchestratorSelectable: boolean; isOrchestratorSelectable: boolean;
orchestratorError: string | null; orchestratorError: string | null;
setTaskRef: (value: SelectedTaskRef | null) => void; setTaskRef: (value: SelectedTaskRef | null) => void;
setSubsectionRef: (value: SelectedSubsectionRef | null) => void;
setTaskFile: (fileId: string) => void; setTaskFile: (fileId: string) => void;
setTaskId: (taskId: string) => void; setTaskId: (taskId: string) => void;
setSubsectionKey: (subsectionKey: string) => void;
lockTask: () => void; lockTask: () => void;
unlockTask: () => void; unlockTask: () => void;
resetForNewChat: () => void; resetForNewChat: () => void;
...@@ -71,6 +89,9 @@ const isSelectableTaskFile = (file: TaskFile): boolean => ...@@ -71,6 +89,9 @@ const isSelectableTaskFile = (file: TaskFile): boolean =>
const isTaskCoupledOrchestrator = (value: OrchestratorName): boolean => const isTaskCoupledOrchestrator = (value: OrchestratorName): boolean =>
value === "task" || value === "feedback" || value === "socratic"; value === "task" || value === "feedback" || value === "socratic";
const formatSubsectionRefs = (refs: [number, number, number][]): string =>
refs.map((ref) => ref.join(":")).join(", ");
export function TutorSessionProvider({ children }: PropsWithChildren) { export function TutorSessionProvider({ children }: PropsWithChildren) {
const [chatSessionId, setChatSessionId] = useState<string>(() => createSessionId()); const [chatSessionId, setChatSessionId] = useState<string>(() => createSessionId());
const [selectedOrchestrator, setSelectedOrchestratorState] = const [selectedOrchestrator, setSelectedOrchestratorState] =
...@@ -82,7 +103,10 @@ export function TutorSessionProvider({ children }: PropsWithChildren) { ...@@ -82,7 +103,10 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
const [orchestratorError, setOrchestratorError] = useState<string | null>(null); const [orchestratorError, setOrchestratorError] = useState<string | null>(null);
const [isTasksInitialized, setIsTasksInitialized] = useState(false); const [isTasksInitialized, setIsTasksInitialized] = useState(false);
const [taskFiles, setTaskFiles] = useState<TaskFile[]>([]); const [taskFiles, setTaskFiles] = useState<TaskFile[]>([]);
const [subsections, setSubsections] = useState<SubsectionOption[]>([]);
const [selectedTaskRef, setSelectedTaskRef] = useState<SelectedTaskRef | null>(null); const [selectedTaskRef, setSelectedTaskRef] = useState<SelectedTaskRef | null>(null);
const [selectedSubsectionRef, setSelectedSubsectionRef] =
useState<SelectedSubsectionRef | null>(null);
const [tasksError, setTasksError] = useState<string | null>(null); const [tasksError, setTasksError] = useState<string | null>(null);
const [taskLocked, setTaskLocked] = useState(false); const [taskLocked, setTaskLocked] = useState(false);
...@@ -136,6 +160,34 @@ export function TutorSessionProvider({ children }: PropsWithChildren) { ...@@ -136,6 +160,34 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
[selectedTaskFile] [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 () => { const initTasks = useCallback(async () => {
setTasksError(null); setTasksError(null);
setOrchestratorError(null); setOrchestratorError(null);
...@@ -160,9 +212,11 @@ export function TutorSessionProvider({ children }: PropsWithChildren) { ...@@ -160,9 +212,11 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
try { try {
const payload = await fetchTasks(); const payload = await fetchTasks();
const files = payload.task_files || []; const files = payload.task_files || [];
const subsectionsPayload = payload.subsections || [];
const selectableFiles = files.filter((file) => isSelectableTaskFile(file)); const selectableFiles = files.filter((file) => isSelectableTaskFile(file));
setTaskFiles(files); setTaskFiles(files);
setSubsections(subsectionsPayload);
setSelectedTaskRef((prev) => { setSelectedTaskRef((prev) => {
if (prev) { if (prev) {
const file = selectableFiles.find((item) => item.file_id === prev.fileId); const file = selectableFiles.find((item) => item.file_id === prev.fileId);
...@@ -181,10 +235,26 @@ export function TutorSessionProvider({ children }: PropsWithChildren) { ...@@ -181,10 +235,26 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
} }
return { fileId: firstFile.file_id, taskId: defaultTaskId }; 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) { } catch (error) {
setTasksError(t("failedLoadTasks")); setTasksError(t("failedLoadTasks"));
setTaskFiles([]); setTaskFiles([]);
setSubsections([]);
setSelectedTaskRef(null); setSelectedTaskRef(null);
setSelectedSubsectionRef(null);
void error; void error;
} finally { } finally {
setIsTasksInitialized(true); setIsTasksInitialized(true);
...@@ -223,6 +293,16 @@ export function TutorSessionProvider({ children }: PropsWithChildren) { ...@@ -223,6 +293,16 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
[selectedTaskRef] [selectedTaskRef]
); );
const setSubsectionKey = useCallback(
(subsectionKey: string) => {
if (!subsectionKey) {
return;
}
setSelectedSubsectionRef({ subsectionKey });
},
[]
);
const setSelectedOrchestrator = useCallback((value: OrchestratorName) => { const setSelectedOrchestrator = useCallback((value: OrchestratorName) => {
setSelectedOrchestratorState(value); setSelectedOrchestratorState(value);
}, []); }, []);
...@@ -231,8 +311,13 @@ export function TutorSessionProvider({ children }: PropsWithChildren) { ...@@ -231,8 +311,13 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
setSelectedOrchestratorState(value); setSelectedOrchestratorState(value);
setChatSessionId(createSessionId()); setChatSessionId(createSessionId());
setTaskLocked(false); setTaskLocked(false);
if (!isTaskCoupledOrchestrator(value)) { if (value === "socratic") {
setSelectedTaskRef(null); setSelectedTaskRef(null);
} else if (!isTaskCoupledOrchestrator(value)) {
setSelectedTaskRef(null);
}
if (value !== "socratic") {
setSelectedSubsectionRef(null);
} }
}, []); }, []);
...@@ -247,6 +332,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) { ...@@ -247,6 +332,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
const resetForNewChat = useCallback(() => { const resetForNewChat = useCallback(() => {
setChatSessionId(createSessionId()); setChatSessionId(createSessionId());
setSelectedTaskRef(null); setSelectedTaskRef(null);
setSelectedSubsectionRef(null);
setTaskLocked(false); setTaskLocked(false);
}, []); }, []);
...@@ -261,14 +347,20 @@ export function TutorSessionProvider({ children }: PropsWithChildren) { ...@@ -261,14 +347,20 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
isOrchestratorSelectable, isOrchestratorSelectable,
orchestratorError, orchestratorError,
taskFiles, taskFiles,
subsections,
selectedTaskRef, selectedTaskRef,
selectedTask, selectedTask,
selectedTaskFile, selectedTaskFile,
taskFileOptions, taskFileOptions,
taskOptions, taskOptions,
selectedSubsectionRef,
selectedSubsection,
subsectionOptions,
setTaskRef: setSelectedTaskRef, setTaskRef: setSelectedTaskRef,
setSubsectionRef: setSelectedSubsectionRef,
setTaskFile, setTaskFile,
setTaskId, setTaskId,
setSubsectionKey,
tasksError, tasksError,
isTaskModeEnabled, isTaskModeEnabled,
isTasksInitialized, 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