Commit 134a9c5c authored by Kantz's avatar Kantz
Browse files

subsection struktur elemente entfertn

parent f3444904
......@@ -79,7 +79,7 @@ You can open a socratic chat directly with URL query parameters:
Example:
`http://localhost:5173/chat?orchestrator=socratic&subsection_key=quadratische-gleichungen`
`http://localhost:5173/chat?orchestrator=socratic&subsection_key=mengen`
Notes:
......
......@@ -29,7 +29,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
selected_topic: Optional[dict[str, str]] = None
orchestrator: Optional[str] = None
......@@ -56,8 +56,8 @@ class SelectedTaskRef(BaseModel):
task_id: str
class SelectedSubsectionRef(BaseModel):
subsection_key: str
class SelectedTopicRef(BaseModel):
topic_key: str
class ChatArchiveDetail(BaseModel):
......@@ -65,13 +65,13 @@ class ChatArchiveDetail(BaseModel):
saved_at: str
history: List[ChatMessage]
selected_task: Optional[SelectedTaskRef] = None
selected_subsection: Optional[SelectedSubsectionRef] = None
selected_topic: Optional[SelectedTopicRef] = None
orchestrator: str
class SocraticBootstrapRequest(BaseModel):
draft: str = Field(..., min_length=1)
subsection_key: str = Field(..., min_length=1)
topic_key: str = Field(..., min_length=1)
class SocraticBootstrapResponse(BaseModel):
......@@ -99,7 +99,7 @@ def chat(request: ChatRequest) -> ChatResponse:
payload_messages,
draft=request.draft,
selected_task=request.selected_task,
selected_subsection=request.selected_subsection,
selected_topic=request.selected_topic,
)
else:
result = orchestrator_impl.run_chat(
......@@ -145,19 +145,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)
selected_topic_raw = record.get("selected_topic")
selected_topic: Optional[SelectedTopicRef] = None
if isinstance(selected_topic_raw, dict):
topic_key = str(selected_topic_raw.get("topic_key", "")).strip()
if topic_key:
selected_topic = SelectedTopicRef(topic_key=topic_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,
selected_topic=selected_topic,
orchestrator=record.get("orchestrator") or get_default_orchestrator(),
)
......@@ -192,10 +192,13 @@ def bootstrap_socratic(request: SocraticBootstrapRequest) -> SocraticBootstrapRe
if not sheet:
sheet = context_store.context_store_new.init_sheet(chat_id, [])
if not task_catalog.select_subsection_by_key(sheet, request.subsection_key):
raise HTTPException(status_code=404, detail="subsection not found")
try:
topic_entry = socratic_oranisator.get_topic_entry(request.topic_key)
except ValueError as exc:
raise HTTPException(status_code=404, detail="topic not found") from exc
refs = task_catalog.get_selected_subsection_parent_refs(sheet)
task_catalog.set_selected_topic(sheet, {}, topic_entry)
refs = task_catalog.get_selected_topic_parent_refs(sheet)
sources = retrieval_store.retrieve_for_parent_refs(
pg_url=config.get_postgres_url(),
parent_refs=refs,
......@@ -205,7 +208,7 @@ def bootstrap_socratic(request: SocraticBootstrapRequest) -> SocraticBootstrapRe
context_store.save_sheet(sheet)
try:
reply = socratic_oranisator.get_initial_message(request.subsection_key)
reply = socratic_oranisator.get_initial_message(request.topic_key)
except ValueError as exc:
logger.exception("Socratic bootstrap prompt lookup failed")
raise HTTPException(status_code=500, detail=str(exc)) from exc
......
......@@ -23,8 +23,8 @@ class TaskItem(BaseModel):
task_id: str
class SubsectionEntry(BaseModel):
subsection_key: str
class TopicEntry(BaseModel):
topic_key: str
label: str
level: str = ""
refs: List[List[int]]
......@@ -54,7 +54,7 @@ class TaskDetailsResponse(BaseModel):
class SocraticResponse(BaseModel):
orchestrator: str
enabled: bool
subsections: List[SubsectionEntry] = Field(default_factory=list)
topics: List[TopicEntry] = Field(default_factory=list)
class SelectTaskRequest(BaseModel):
draft: str = Field(..., min_length=1)
......@@ -68,14 +68,23 @@ class SelectTaskResponse(BaseModel):
task_id: str
class SelectSubsectionRequest(BaseModel):
class SelectTopicRequest(BaseModel):
draft: str = Field(..., min_length=1)
subsection_key: str = Field(..., min_length=1)
topic_key: str = Field(..., min_length=1)
class SelectSubsectionResponse(BaseModel):
class SelectTopicResponse(BaseModel):
status: str
subsection_key: str
topic_key: str
def _set_selected_socratic_topic(sheet: dict[str, object], topic_key: str) -> str:
entry = socratic_oranisator.get_topic_entry(topic_key)
task_catalog.set_selected_topic(sheet, {}, entry)
_, selected_topic_key = task_catalog.get_selected_topic_ids(sheet)
if not selected_topic_key:
raise HTTPException(status_code=404, detail="topic not found")
return selected_topic_key
@router.get("/api/tasks/assets/{asset_path:path}")
......@@ -122,14 +131,14 @@ def get_task_details(file_id: str, task_id: str) -> TaskDetailsResponse:
return TaskDetailsResponse(**payload)
@router.get("/api/tasks/socratic-subsections", response_model=SocraticResponse)
def list_socratic_subsections() -> SocraticResponse:
@router.get("/api/tasks/socratic-topics", response_model=SocraticResponse)
def list_socratic_topics() -> SocraticResponse:
orchestrator = config.get_orchestrator()
subsections = socratic_oranisator.build_subsection_catalog()
topics = socratic_oranisator.build_topic_catalog()
return SocraticResponse(
orchestrator=orchestrator,
enabled=orchestrator == "socratic",
subsections=subsections,
topics=topics,
)
@router.post("/api/tasks/select", response_model=SelectTaskResponse)
......@@ -156,20 +165,20 @@ def select_task(request: SelectTaskRequest) -> SelectTaskResponse:
)
@router.post("/api/tasks/select-subsection", response_model=SelectSubsectionResponse)
def select_subsection(request: SelectSubsectionRequest) -> SelectSubsectionResponse:
@router.post("/api/tasks/select-topic", response_model=SelectTopicResponse)
def select_topic(request: SelectTopicRequest) -> SelectTopicResponse:
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")
try:
topic_key = _set_selected_socratic_topic(sheet, request.topic_key)
except ValueError as exc:
raise HTTPException(status_code=404, detail="topic not found") from exc
context_store.save_sheet(sheet)
_, subsection_key = task_catalog.get_selected_subsection_ids(sheet)
return SelectSubsectionResponse(
return SelectTopicResponse(
status="ok",
subsection_key=subsection_key or request.subsection_key,
topic_key=topic_key,
)
......@@ -6,22 +6,22 @@ from app.deterministic_services import context_store, retrieval_store, task_cata
from app.deterministic_services.orchestrators import orchestrator_base as base
def _apply_selected_subsection(
def _apply_selected_topic(
state: base.ChatState,
selected_subsection: dict | None,
selected_topic: dict | None,
) -> None:
if not selected_subsection:
if not selected_topic:
return
subsection_key = str(selected_subsection.get("subsection_key", "")).strip()
if not subsection_key:
topic_key = str(selected_topic.get("topic_key", "")).strip()
if not topic_key:
return
task_catalog.select_subsection_by_key(state.sheet, subsection_key)
task_catalog.select_topic_by_key(state.sheet, topic_key)
def _retrieve_context_for_subsection(state: base.ChatState, query_text: str) -> int:
refs = task_catalog.get_selected_subsection_parent_refs(state.sheet)
def _retrieve_context_for_topic(state: base.ChatState, query_text: str) -> int:
refs = task_catalog.get_selected_topic_parent_refs(state.sheet)
if not refs:
return 0
......@@ -49,17 +49,17 @@ def _retrieve_context_for_subsection(state: base.ChatState, query_text: str) ->
def _on_bootstrap(state: base.ChatState, query_text: str) -> None:
_retrieve_context_for_subsection(state, query_text)
_retrieve_context_for_topic(state, query_text)
def _on_turn_logic(state: base.ChatState) -> None:
if not task_catalog.get_selected_subsection_parent_refs(state.sheet):
if not task_catalog.get_selected_topic_parent_refs(state.sheet):
return
def _on_build_reply(state: base.ChatState) -> str | None:
history_turns = context_store.get_history_turns(state.sheet)
parent_refs = task_catalog.get_selected_subsection_parent_refs(state.sheet)
parent_refs = task_catalog.get_selected_topic_parent_refs(state.sheet)
args = {
"query": state.last_user,
"parent_refs": parent_refs,
......@@ -77,11 +77,11 @@ 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_topic: dict | None = None,
selected_task: dict | None = None,
) -> dict:
def _apply_selected_context(state: base.ChatState) -> None:
_apply_selected_subsection(state, selected_subsection)
_apply_selected_topic(state, selected_topic)
def on_bootstrap(state: base.ChatState, query_text: str) -> None:
_apply_selected_context(state)
......
......@@ -123,7 +123,7 @@ def run_chat(
messages: list[dict],
draft: str | None = None,
selected_task: dict | None = None,
selected_subsection: dict | None = None,
selected_topic: dict | None = None,
) -> dict:
def _apply_selected_task(state: base.ChatState) -> None:
if not selected_task:
......
......@@ -26,11 +26,11 @@ 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:
def _extract_selected_topic(sheet: dict[str, Any]) -> dict[str, str] | None:
topic_key = str(sheet.get("selected_topic_key", "")).strip()
if not topic_key:
return None
return {"subsection_key": subsection_key}
return {"topic_key": topic_key}
def archive_chat(
......@@ -56,7 +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),
"selected_topic": _extract_selected_topic(sheet),
}
os.makedirs(_LOG_DIR, exist_ok=True)
......@@ -134,12 +134,12 @@ 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}
selected_topic_raw = record.get("selected_topic")
selected_topic: dict[str, str] | None = None
if isinstance(selected_topic_raw, dict):
topic_key = str(selected_topic_raw.get("topic_key", "")).strip()
if topic_key:
selected_topic = {"topic_key": topic_key}
return {
"chat_id": record.get("chat_id", chat_id),
......@@ -147,6 +147,6 @@ def load_archive(chat_id: str) -> dict[str, Any] | None:
"orchestrator": record.get("orchestrator"),
"history": history,
"selected_task": selected_task,
"selected_subsection": selected_subsection,
"selected_topic": selected_topic,
}
return None
......@@ -35,60 +35,75 @@ def load_initial_prompt_items(path: Path = PROMPTS_PATH) -> dict[str, dict[str,
return items
def get_initial_message(subsection_key: str, path: Path = PROMPTS_PATH) -> str:
normalized_key = task_catalog._normalize_parent_ref_key(subsection_key)
def get_initial_message(topic_key: str, path: Path = PROMPTS_PATH) -> str:
normalized_key = task_catalog._normalize_parent_ref_key(topic_key)
if not normalized_key:
raise ValueError("invalid subsection key")
raise ValueError("invalid topic key")
items = load_initial_prompt_items(path)
item = items.get(normalized_key)
if item is None:
raise ValueError(f"missing socratic bootstrap prompt for subsection '{normalized_key}'")
raise ValueError(f"missing socratic bootstrap prompt for topic '{normalized_key}'")
message = str(item.get("inital_message") or "").strip()
if not message:
raise ValueError(f"empty socratic bootstrap prompt for subsection '{normalized_key}'")
raise ValueError(f"empty socratic bootstrap prompt for topic '{normalized_key}'")
return message
def build_subsection_catalog(path: Path = PROMPTS_PATH) -> list[dict[str, Any]]:
def get_topic_entry(topic_key: str, path: Path = PROMPTS_PATH) -> dict[str, Any]:
normalized_key = task_catalog._normalize_parent_ref_key(topic_key)
if not normalized_key:
raise ValueError("invalid topic key")
items = load_initial_prompt_items(path)
item = items.get(normalized_key)
if item is None:
raise ValueError(f"missing socratic bootstrap prompt for topic '{normalized_key}'")
refs: list[list[int]] = []
refs_raw = item.get("refs", [])
if isinstance(refs_raw, list):
for raw_ref in refs_raw:
parsed = task_catalog._parse_parent_ref(raw_ref)
if parsed is not None:
refs.append(task_catalog._parent_ref_to_list(parsed))
if not refs:
parsed_index = task_catalog._parse_parent_ref(str(item.get("index") or ""))
if parsed_index is not None:
refs.append(task_catalog._parent_ref_to_list(parsed_index))
if not refs:
raise ValueError(f"missing refs for topic '{normalized_key}'")
label_source = str(item.get("label") or item.get("topic") or item.get("subsection") or normalized_key)
return {
"topic_key": normalized_key,
"label": task_catalog._format_topic_label(label_source) or task_catalog._format_topic_label(normalized_key),
"level": str(item.get("level") or task_catalog._parent_ref_level(tuple(refs[0]))).strip(),
"refs": refs,
}
def build_topic_catalog(path: Path = PROMPTS_PATH) -> list[dict[str, Any]]:
items = load_initial_prompt_items(path)
topic_summaries = task_catalog.load_topic_summaries()
response: list[dict[str, Any]] = []
for raw_key, item in items.items():
subsection_key = task_catalog._normalize_parent_ref_key(str(raw_key))
if not subsection_key:
topic_key = task_catalog._normalize_parent_ref_key(str(raw_key))
if not topic_key:
continue
label_source = str(item.get("label") or item.get("subsection") or subsection_key)
label = task_catalog._format_subsection_label(label_source)
refs: list[list[int]] = []
refs_raw = item.get("refs", [])
if isinstance(refs_raw, list):
for raw_ref in refs_raw:
parsed = task_catalog._parse_parent_ref(raw_ref)
if parsed is not None:
refs.append(task_catalog._parent_ref_to_list(parsed))
if not refs:
parsed_index = task_catalog._parse_parent_ref(str(item.get("index") or ""))
if parsed_index is not None:
refs.append(task_catalog._parent_ref_to_list(parsed_index))
response.append(
{
"subsection_key": subsection_key,
"label": label or task_catalog._format_subsection_label(subsection_key),
"level": str(item.get("level") or (task_catalog._parent_ref_level(tuple(refs[0])) if refs else "")).strip(),
"refs": refs,
"summary": topic_summaries.get(subsection_key, ""),
}
)
try:
entry = get_topic_entry(topic_key, path)
except ValueError:
continue
entry["summary"] = topic_summaries.get(topic_key, "")
response.append(entry)
response.sort(
key=lambda item: (
item["refs"][0] if item["refs"] else (9999, 9999, 9999, 9999),
item["subsection_key"],
item["topic_key"],
)
)
return response
......@@ -18,13 +18,12 @@ TASKS_DIR = config.get_task_folder()
TASK_IMAGES_DIR = TASKS_DIR / "images"
SOURCES_DIR = Path(__file__).resolve().parents[2] / "sources"
TOPIC_MAP_PATH = TASKS_DIR / "_topic_to_index_map.yaml"
SUBSECTION_MAP_PATH = TOPIC_MAP_PATH
TASK_ASSET_URL_PREFIX = "/api/tasks/assets"
ParentRef = tuple[int, int, int, int]
def _normalize_parent_ref_key(value: str) -> str:
return _normalize_subsection_key(value)
return _normalize_topic_key(value)
def _normalize_text(value: str) -> str:
return re.sub(r"\s+", " ", value.strip().lower())
......@@ -34,7 +33,7 @@ def _tokenize(value: str) -> set[str]:
return set(re.findall(r"[a-z0-9_]+", _normalize_text(value)))
def _normalize_subsection_key(value: str) -> str:
def _normalize_topic_key(value: str) -> str:
normalized = unicodedata.normalize("NFKD", str(value))
ascii_value = normalized.encode("ascii", "ignore").decode("ascii")
collapsed = re.sub(r"[-_]+", " ", ascii_value.strip().lower())
......@@ -42,13 +41,6 @@ def _normalize_subsection_key(value: str) -> str:
return re.sub(r"\s+", " ", collapsed).strip()
def _parse_subsection_ref(value: str) -> tuple[int, int, int] | None:
match = re.match(r"^\s*(\d+)\s*[-.]\s*(\d+)\s*[-.]\s*(\d+)\s*$", str(value))
if not match:
return None
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def _parse_parent_ref(value: Any) -> ParentRef | None:
if isinstance(value, (list, tuple)):
parts = [str(item).strip() for item in value if str(item).strip()]
......@@ -142,18 +134,7 @@ def load_topic_map(path: Path = TOPIC_MAP_PATH) -> dict[str, ParentRef]:
return mapped
def load_subsection_map(path: Path = SUBSECTION_MAP_PATH) -> dict[str, tuple[int, int, int]]:
topic_map = load_topic_map(path)
mapped: dict[str, tuple[int, int, int]] = {}
for key, ref in topic_map.items():
chap, sec, sub, subsub = ref
if sub <= 0 or subsub > 0:
continue
mapped[key] = (chap, sec, sub)
return mapped
def _extract_subsection_summary(text: str) -> str:
def _extract_topic_summary(text: str) -> str:
body = text.replace("\r\n", "\n").strip()
if not body:
return ""
......@@ -204,17 +185,13 @@ def load_topic_summaries(base_dir: Path = SOURCES_DIR) -> dict[str, str]:
or ""
).strip()
key = _normalize_parent_ref_key(title)
summary = _extract_subsection_summary(body)
summary = _extract_topic_summary(body)
if not key or not summary:
continue
summaries.setdefault(key, summary)
return summaries
def load_subsection_summaries(base_dir: Path = SOURCES_DIR) -> dict[str, str]:
return load_topic_summaries(base_dir)
def _normalize_topic_ref_entries(
raw_entries: Any,
topic_map: dict[str, ParentRef],
......@@ -232,10 +209,10 @@ def _normalize_topic_ref_entries(
if isinstance(raw_entry, str):
normalized_key = _normalize_parent_ref_key(raw_entry)
label = _format_subsection_label(raw_entry)
label = _format_topic_label(raw_entry)
ref = topic_map.get(normalized_key)
elif isinstance(raw_entry, dict):
raw_key = str(raw_entry.get("key") or raw_entry.get("topic_key") or raw_entry.get("subsection_key") or "").strip()
raw_key = str(raw_entry.get("key") or raw_entry.get("topic_key") or "").strip()
normalized_key = _normalize_parent_ref_key(raw_key) if raw_key else ""
label = str(raw_entry.get("label") or "").strip()
summary = str(raw_entry.get("summary") or "").strip()
......@@ -273,9 +250,9 @@ def _resolve_task_topic_options(
summaries = topic_summaries if topic_summaries is not None else load_topic_summaries()
raw_entries: list[Any] = []
legacy_topics = task_file.get("subsections", [])
if isinstance(legacy_topics, list):
raw_entries.extend(legacy_topics)
topics = task_file.get("topics", [])
if isinstance(topics, list):
raw_entries.extend(topics)
explicit_refs = task_file.get("topic_refs", [])
if isinstance(explicit_refs, list):
raw_entries.extend(explicit_refs)
......@@ -303,25 +280,7 @@ def _resolve_task_topic_refs(
return sorted(refs)
def _resolve_task_subsection_refs(
task_file: dict[str, Any],
subsection_map: dict[str, tuple[int, int, int]] | None = None,
) -> list[tuple[int, int, int]]:
mapping: dict[str, ParentRef]
if subsection_map is None:
mapping = load_topic_map()
else:
mapping = {key: (int(ref[0]), int(ref[1]), int(ref[2]), 0) for key, ref in subsection_map.items()}
refs = _resolve_task_topic_refs(task_file, topic_map=mapping)
normalized: list[tuple[int, int, int]] = []
for chap, sec, sub, subsub in refs:
if sub <= 0 or subsub > 0:
continue
normalized.append((chap, sec, sub))
return sorted(normalized)
def _format_subsection_label(value: str) -> str:
def _format_topic_label(value: str) -> str:
cleaned = re.sub(r"[-_]+", " ", value.strip())
cleaned = re.sub(r"\s+", " ", cleaned).strip()
if not cleaned:
......@@ -337,7 +296,7 @@ def build_topic_catalog(path: Path = TOPIC_MAP_PATH) -> list[dict[str, Any]]:
response.append(
{
"topic_key": key,
"label": _format_subsection_label(key),
"label": _format_topic_label(key),
"level": _parent_ref_level(ref),
"refs": [_parent_ref_to_list(ref)],
"summary": topic_summaries.get(key, ""),
......@@ -346,54 +305,6 @@ def build_topic_catalog(path: Path = TOPIC_MAP_PATH) -> list[dict[str, Any]]:
return response
def build_subsection_catalog(path: Path = SUBSECTION_MAP_PATH) -> list[dict[str, Any]]:
response: list[dict[str, Any]] = []
for item in build_topic_catalog(path):
refs = item.get("refs", [])
if not refs:
continue
ref = refs[0]
response.append(
{
"subsection_key": item["topic_key"],
"label": item["label"],
"level": item["level"],
"refs": [[int(ref[0]), int(ref[1]), int(ref[2]), int(ref[3])]],
"summary": item["summary"],
}
)
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: dict[str, ParentRef]
if subsection_map is None:
mapping = load_topic_map()
else:
mapping = {key: (int(ref[0]), int(ref[1]), int(ref[2]), 0) for key, ref in subsection_map.items()}
options = _resolve_task_topic_options(task_file, topic_map=mapping)
response: list[dict[str, Any]] = []
for option in options:
refs = option.get("refs", [])
if not refs:
continue
ref = refs[0]
response.append(
{
"subsection_key": option["topic_key"],
"label": option["label"],
"level": option["level"],
"refs": [[int(ref[0]), int(ref[1]), int(ref[2]), int(ref[3])]],
"summary": option.get("summary", ""),
}
)
return response
def _match_score(query_text: str, candidate_text: str) -> int:
query_tokens = _tokenize(query_text)
if not query_tokens:
......@@ -501,7 +412,7 @@ def _normalize_yaml_task_file(content: Any, path: Path) -> dict[str, Any] | None
intro = str(content.get("description", "")).strip()
file_id = str(content.get("id", "")).strip() or path.stem
slug = str(content.get("slug", "")).strip() or _slugify(title or file_id)
subsections = _normalize_exercise_topics(content.get("exercise_topic", []))
topics = _normalize_exercise_topics(content.get("exercise_topic", []))
topic_refs_raw = content.get("topic_refs", [])
if not title or not file_id or not slug:
......@@ -520,7 +431,7 @@ def _normalize_yaml_task_file(content: Any, path: Path) -> dict[str, Any] | None
"title": title,
"intro": intro,
"slug": slug,
"subsections": subsections,
"topics": topics,
"topic_refs": topic_refs_raw if isinstance(topic_refs_raw, list) else [],
"tasks": tasks,
}
......@@ -629,19 +540,17 @@ def set_selected_task(
sheet["task_id"] = str(task_entry.get("id", "")).zfill(2)
refs = _resolve_task_topic_refs(task_file)
sheet["task_parent_refs"] = [_parent_ref_to_list(ref) for ref in refs]
sheet.pop("task_subsection_refs", None)
sheet.pop("selected_subsection_key", None)
sheet.pop("selected_subsection_label", None)
sheet.pop("selected_subsection_parent_refs", None)
sheet.pop("selected_subsection_refs", None)
sheet.pop("selected_topic_key", None)
sheet.pop("selected_topic_label", None)
sheet.pop("selected_topic_parent_refs", None)
def set_selected_subsection(
def set_selected_topic(
sheet: dict[str, Any],
task_file: dict[str, Any],
subsection_option: dict[str, Any],
topic_option: dict[str, Any],
) -> None:
refs_raw = subsection_option.get("refs", [])
refs_raw = topic_option.get("refs", [])
refs: list[ParentRef] = []
if isinstance(refs_raw, list):
for item in refs_raw:
......@@ -651,21 +560,15 @@ def set_selected_subsection(
if not refs:
return
key = str(subsection_option.get("subsection_key") or subsection_option.get("topic_key") or "").strip()
sheet["selected_subsection_key"] = key
sheet["selected_subsection_label"] = str(subsection_option.get("label", "")).strip()
key = str(topic_option.get("topic_key") or "").strip()
sheet["selected_topic_key"] = key
sheet["selected_topic_label"] = str(topic_option.get("label", "")).strip()
parent_refs = sorted({_normalize_parent_ref(ref) for ref in refs})
sheet["selected_subsection_parent_refs"] = [_parent_ref_to_list(ref) for ref in parent_refs]
sheet["selected_subsection_refs"] = [
[chap, sec, sub]
for chap, sec, sub, subsub in parent_refs
if sub > 0 and subsub <= 0
]
sheet["selected_topic_parent_refs"] = [_parent_ref_to_list(ref) for ref in parent_refs]
sheet.pop("task_file_id", None)
sheet.pop("task_id", None)
sheet.pop("task_parent_refs", None)
sheet.pop("task_subsection_refs", None)
sheet.pop("task", None)
sheet.pop("hints", None)
sheet.pop("solution", None)
......@@ -688,10 +591,10 @@ def select_task_by_ids(
return True
def select_subsection_by_ids(
def select_topic_by_ids(
sheet: dict[str, Any],
file_id: str,
subsection_key: str,
topic_key: str,
task_files: list[dict[str, Any]] | None = None,
) -> bool:
catalog = task_files if task_files is not None else load_cached_task_files()
......@@ -699,48 +602,38 @@ def select_subsection_by_ids(
if not task_file:
return False
options = _resolve_task_subsection_options(task_file)
normalized_key = _normalize_subsection_key(subsection_key)
options = _resolve_task_topic_options(task_file)
normalized_key = _normalize_topic_key(topic_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)
if str(option.get("topic_key", "")).strip() == normalized_key:
set_selected_topic(sheet, task_file, option)
return True
return False
def select_subsection_by_key(
def select_topic_by_key(
sheet: dict[str, Any],
subsection_key: str,
subsection_map: dict[str, tuple[int, int, int]] | None = None,
topic_key: str,
topic_map: dict[str, ParentRef] | None = None,
) -> bool:
mapping: dict[str, ParentRef]
if subsection_map is None:
mapping = load_topic_map()
else:
mapping = {key: (int(ref[0]), int(ref[1]), int(ref[2]), 0) for key, ref in subsection_map.items()}
normalized_key = _normalize_subsection_key(subsection_key)
mapping = topic_map if topic_map is not None else load_topic_map()
normalized_key = _normalize_topic_key(topic_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_topic_key"] = normalized_key
sheet["selected_topic_label"] = _format_topic_label(normalized_key)
normalized_ref = _normalize_parent_ref(ref)
sheet["selected_subsection_parent_refs"] = [_parent_ref_to_list(normalized_ref)]
chap, sec, sub, subsub = normalized_ref
if sub > 0 and subsub <= 0:
sheet["selected_subsection_refs"] = [[chap, sec, sub]]
else:
sheet["selected_subsection_refs"] = []
sheet["selected_topic_parent_refs"] = [_parent_ref_to_list(normalized_ref)]
sheet.pop("task_file_id", None)
sheet.pop("task_id", None)
sheet.pop("task_parent_refs", None)
sheet.pop("task_subsection_refs", None)
sheet.pop("task", None)
sheet.pop("hints", None)
sheet.pop("solution", None)
......@@ -753,25 +646,13 @@ def get_selected_task_ids(sheet: dict[str, Any]) -> tuple[str | None, str | 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: list[tuple[int, int, int]] = []
for chap, sec, sub, subsub in get_selected_subsection_parent_refs(sheet):
if sub <= 0 or subsub > 0:
continue
refs.append((chap, sec, sub))
return sorted(set(refs))
def get_selected_topic_ids(sheet: dict[str, Any]) -> tuple[str | None, str | None]:
topic_key = str(sheet.get("selected_topic_key", "")).strip()
return (None, topic_key or None)
def get_selected_subsection_parent_refs(sheet: dict[str, Any]) -> list[ParentRef]:
refs_raw = sheet.get("selected_subsection_parent_refs")
if not isinstance(refs_raw, list):
refs_raw = sheet.get("selected_subsection_refs", [])
def get_selected_topic_parent_refs(sheet: dict[str, Any]) -> list[ParentRef]:
refs_raw = sheet.get("selected_topic_parent_refs", [])
refs: set[ParentRef] = set()
for item in refs_raw:
parsed = _parse_parent_ref(item)
......@@ -793,15 +674,6 @@ def get_selected_task_parent_refs(sheet: dict[str, Any]) -> list[ParentRef]:
return sorted(refs)
def get_selected_task_subsection_refs(sheet: dict[str, Any]) -> list[tuple[int, int, int]]:
refs: list[tuple[int, int, int]] = []
for chap, sec, sub, subsub in get_selected_task_parent_refs(sheet):
if sub <= 0 or subsub > 0:
continue
refs.append((chap, sec, sub))
return sorted(refs)
def select_task_for_context(
sheet: dict[str, Any],
query_text: str,
......@@ -880,7 +752,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()
topic_options = _resolve_task_topic_options(task_file)
topics_raw = task_file.get("subsections", [])
topics_raw = task_file.get("topics", [])
if isinstance(topics_raw, list) and topics_raw:
topics = [str(item).strip() for item in topics_raw if str(item).strip()]
else:
......
......@@ -38,7 +38,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
"description": "Pfeildiagramm mit einer fehlenden Abbildung.",
}
],
"hints": ["Prüfe jedes Element."],
"hints": ["Prüfe jedes Element."],
"solution": "Keine Funktion.",
}
......@@ -47,7 +47,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
self.assertIn("Grundlagen von Funktionen", task_text)
self.assertIn("Ordnen Sie zu.", task_text)
self.assertIn("Bildbeschreibung: Pfeildiagramm mit einer fehlenden Abbildung.", task_text)
self.assertEqual(hints, ["Prüfe jedes Element."])
self.assertEqual(hints, ["Prüfe jedes Element."])
self.assertEqual(solution, "Keine Funktion.")
self.assertEqual(
images,
......@@ -65,7 +65,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
"_file_id": "analysis_1",
"title": "Analysis",
"intro": "Intro",
"subsections": ["quadratische_gleichungen"],
"topics": ["quadratische_gleichungen"],
"topic_refs": [],
"tasks": [
{
......@@ -105,7 +105,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
"_file_id": "analysis_1",
"title": "Analysis",
"intro": "Intro",
"subsections": ["quadratische_gleichungen"],
"topics": ["quadratische_gleichungen"],
"topic_refs": [],
"tasks": [
{
......@@ -163,7 +163,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
self.assertEqual(normalized["hints"], ["Nutze den Graphen."])
self.assertEqual(normalized["images"], [])
def test_socratic_build_subsection_catalog_returns_response_shape(self) -> None:
def test_socratic_build_topic_catalog_returns_response_shape(self) -> None:
with patch(
"app.deterministic_services.socratic_oranisator.load_initial_prompt_items",
return_value={
......@@ -185,20 +185,20 @@ class TaskCatalogSocraticTest(unittest.TestCase):
"quadratische gleichungen": "Quadratische summary text",
},
):
catalog = socratic_oranisator.build_subsection_catalog()
catalog = socratic_oranisator.build_topic_catalog()
self.assertEqual(
catalog,
[
{
"subsection_key": "mengen",
"topic_key": "mengen",
"label": "Mengen",
"level": "section",
"refs": [[1, 1, 0, 0]],
"summary": "Mengen summary text",
},
{
"subsection_key": "quadratische gleichungen",
"topic_key": "quadratische gleichungen",
"label": "Quadratische Gleichungen",
"level": "subsection",
"refs": [[1, 3, 3, 0]],
......@@ -207,12 +207,12 @@ class TaskCatalogSocraticTest(unittest.TestCase):
],
)
def test_build_subsection_catalog_includes_summary(self) -> None:
def test_build_topic_catalog_includes_summary(self) -> None:
with patch(
"app.deterministic_services.task_catalog.load_topic_map",
return_value={
"quadratische gleichungen": (1, 3, 3, 0),
"mengen": (1, 1, 1, 0),
"mengen": (1, 1, 0, 0),
},
), patch(
"app.deterministic_services.task_catalog.load_topic_summaries",
......@@ -221,20 +221,20 @@ class TaskCatalogSocraticTest(unittest.TestCase):
"quadratische gleichungen": "Quadratische summary text",
},
):
catalog = task_catalog.build_subsection_catalog()
catalog = task_catalog.build_topic_catalog()
self.assertEqual(
catalog,
[
{
"subsection_key": "mengen",
"topic_key": "mengen",
"label": "Mengen",
"level": "section",
"refs": [[1, 1, 0, 0]],
"summary": "Mengen summary text",
},
{
"subsection_key": "quadratische gleichungen",
"topic_key": "quadratische gleichungen",
"label": "Quadratische Gleichungen",
"level": "subsection",
"refs": [[1, 3, 3, 0]],
......@@ -288,7 +288,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
],
)
def test_load_subsection_summaries_extracts_body(self) -> None:
def test_load_topic_summaries_extracts_body(self) -> None:
temp_dir = Path(__file__).resolve().parent / "_tmp_subsection_summaries"
summary_root = temp_dir / "child_lvl" / "subsections"
summary_root.mkdir(parents=True, exist_ok=True)
......@@ -315,7 +315,7 @@ Zweite Zeile.
)
try:
summaries = task_catalog.load_subsection_summaries(temp_dir)
summaries = task_catalog.load_topic_summaries(temp_dir)
finally:
if md_path.exists():
md_path.unlink()
......@@ -331,12 +331,12 @@ Zweite Zeile.
self.assertTrue(summaries["mengen"].startswith("### **Zusammenfassung: Mengen**"))
self.assertNotIn("Child-Chunks", summaries["mengen"])
def test_select_subsection_by_ids_sets_sheet_fields(self) -> None:
def test_select_topic_by_ids_sets_sheet_fields(self) -> None:
task_files = [
{
"_file_id": "analysis_1",
"title": "Analysis",
"subsections": ["quadratische_gleichungen"],
"topics": ["quadratische_gleichungen"],
"tasks": [],
}
]
......@@ -346,7 +346,7 @@ Zweite Zeile.
"app.deterministic_services.task_catalog.load_topic_map",
return_value={"quadratische gleichungen": (1, 3, 3, 1)},
):
updated = task_catalog.select_subsection_by_ids(
updated = task_catalog.select_topic_by_ids(
sheet,
"analysis_1",
"quadratische_gleichungen",
......@@ -354,30 +354,28 @@ Zweite Zeile.
)
self.assertTrue(updated)
self.assertEqual(sheet["selected_subsection_key"], "quadratische gleichungen")
self.assertEqual(sheet["selected_subsection_parent_refs"], [[1, 3, 3, 1]])
self.assertEqual(sheet["selected_subsection_refs"], [])
self.assertEqual(sheet["selected_topic_key"], "quadratische gleichungen")
self.assertEqual(sheet["selected_topic_parent_refs"], [[1, 3, 3, 1]])
def test_select_subsection_by_key_sets_sheet_fields(self) -> None:
def test_select_topic_by_key_sets_sheet_fields(self) -> None:
sheet: dict[str, object] = {}
with patch(
"app.deterministic_services.task_catalog.load_topic_map",
return_value={"quadratische gleichungen": (1, 3, 3, 1)},
):
updated = task_catalog.select_subsection_by_key(sheet, "quadratische_gleichungen")
updated = task_catalog.select_topic_by_key(sheet, "quadratische_gleichungen")
self.assertTrue(updated)
self.assertEqual(sheet["selected_subsection_key"], "quadratische gleichungen")
self.assertEqual(sheet["selected_subsection_parent_refs"], [[1, 3, 3, 1]])
self.assertEqual(sheet["selected_subsection_refs"], [])
self.assertEqual(sheet["selected_topic_key"], "quadratische gleichungen")
self.assertEqual(sheet["selected_topic_parent_refs"], [[1, 3, 3, 1]])
def test_get_selected_subsection_parent_refs_reads_normalized_refs(self) -> None:
def test_get_selected_topic_parent_refs_reads_normalized_refs(self) -> None:
sheet: dict[str, object] = {
"selected_subsection_parent_refs": [[1, 1, 0, 0], [1, 3, 2, 1], [1, 3, 2, 1]],
"selected_topic_parent_refs": [[1, 1, 0, 0], [1, 3, 2, 1], [1, 3, 2, 1]],
}
refs = task_catalog.get_selected_subsection_parent_refs(sheet)
refs = task_catalog.get_selected_topic_parent_refs(sheet)
self.assertEqual(refs, [(1, 1, 0, 0), (1, 3, 2, 1)])
......@@ -386,7 +384,7 @@ Zweite Zeile.
task_file = {
"_file_id": "analysis_1",
"title": "Analysis",
"subsections": ["mengen"],
"topics": ["mengen"],
"topic_refs": [
{
"chapter_index": 1,
......@@ -496,10 +494,10 @@ class TaskApiSocraticTest(unittest.TestCase):
self.assertEqual(response.status_code, 404)
def test_list_socratic_subsections_returns_catalog(self) -> None:
subsections = [
def test_list_socratic_topics_returns_catalog(self) -> None:
topics = [
{
"subsection_key": "quadratische gleichungen",
"topic_key": "quadratische gleichungen",
"label": "Quadratische Gleichungen",
"level": "subsubsection",
"refs": [[1, 3, 3, 1]],
......@@ -508,18 +506,18 @@ class TaskApiSocraticTest(unittest.TestCase):
]
with patch("app.api.tasks.config.get_orchestrator", return_value="socratic"), patch(
"app.api.tasks.socratic_oranisator.build_subsection_catalog",
return_value=subsections,
"app.api.tasks.socratic_oranisator.build_topic_catalog",
return_value=topics,
):
response = self.client.get("/api/tasks/socratic-subsections")
response = self.client.get("/api/tasks/socratic-topics")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["orchestrator"], "socratic")
self.assertTrue(body["enabled"])
self.assertEqual(body["subsections"], subsections)
self.assertEqual(body["topics"], topics)
def test_select_subsection_endpoint_returns_selected_key(self) -> None:
def test_select_topic_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(
......@@ -529,25 +527,25 @@ class TaskApiSocraticTest(unittest.TestCase):
"app.api.tasks.context_store.context_store_new.init_sheet",
return_value=sheet,
), patch(
"app.api.tasks.task_catalog.select_subsection_by_key",
"app.api.tasks.task_catalog.select_topic_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",
"app.api.tasks.task_catalog.get_selected_topic_ids",
return_value=(None, "quadratische gleichungen"),
):
response = self.client.post(
"/api/tasks/select-subsection",
"/api/tasks/select-topic",
json={
"draft": "chat-1",
"subsection_key": "quadratische_gleichungen",
"topic_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")
self.assertEqual(response.json()["topic_key"], "quadratische gleichungen")
select_mock.assert_called_once_with(sheet, topic_key="quadratische_gleichungen")
class ChatBootstrapSocraticTest(unittest.TestCase):
......@@ -585,28 +583,28 @@ class ChatBootstrapSocraticTest(unittest.TestCase):
"/api/chat/bootstrap-socratic",
json={
"draft": "chat-1",
"subsection_key": "quadratische_gleichungen",
"topic_key": "quadratische_gleichungen",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["reply"], "Was sind die Themen dieses Abschnitts?")
retrieve_mock.assert_called_once_with(pg_url="postgresql://localhost/test", parent_refs=[(1, 3, 3, 1)])
self.assertEqual(sheet["selected_subsection_key"], "quadratische gleichungen")
self.assertEqual(sheet["selected_subsection_parent_refs"], [[1, 3, 3, 1]])
self.assertEqual(sheet["selected_topic_key"], "quadratische gleichungen")
self.assertEqual(sheet["selected_topic_parent_refs"], [[1, 3, 3, 1]])
self.assertEqual(sheet["history"], [{"role": "assistant", "content": "Was sind die Themen dieses Abschnitts?"}])
class SessionStoreSocraticTest(unittest.TestCase):
def test_load_archive_restores_selected_subsection(self) -> None:
def test_load_archive_restores_selected_topic(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",
"selected_topic": {
"topic_key": "quadratische gleichungen",
},
}
......@@ -624,7 +622,7 @@ class SessionStoreSocraticTest(unittest.TestCase):
temp_dir.rmdir()
self.assertIsNotNone(archive)
self.assertEqual(archive["selected_subsection"], record["selected_subsection"])
self.assertEqual(archive["selected_topic"], record["selected_topic"])
if __name__ == "__main__":
......
......@@ -59,7 +59,7 @@ class TaskYamlMigrationTest(unittest.TestCase):
self.assertEqual(loaded[0]["_file_id"], "analysis_1")
self.assertEqual(loaded[0]["title"], "Analysis")
self.assertEqual(loaded[0]["intro"], "Intro")
self.assertEqual(loaded[0]["subsections"], ["quadratische-gleichungen"])
self.assertEqual(loaded[0]["topics"], ["quadratische-gleichungen"])
self.assertEqual(loaded[0]["tasks"][0]["id"], "01")
self.assertEqual(loaded[0]["tasks"][0]["statement"], "Bestimme f(x).")
self.assertEqual(loaded[0]["tasks"][0]["hints"], ["Nutze die Ableitung."])
......@@ -124,7 +124,7 @@ class TaskYamlMigrationTest(unittest.TestCase):
"_file_id": "analysis_1",
"title": "Analysis",
"intro": "Intro",
"subsections": ["quadratische-gleichungen"],
"topics": ["quadratische-gleichungen"],
"tasks": [
{
"id": "01",
......
......@@ -13,10 +13,10 @@ export type TaskFile = {
tasks: TaskListItem[];
};
export type SubsectionOption = {
subsection_key: string;
export type TopicOption = {
topic_key: string;
label: string;
refs: [number, number, number][];
refs: [number, number, number, number][];
summary: string;
};
......@@ -37,7 +37,7 @@ export type TaskDetailsResponse = {
export type SocraticResponse = {
orchestrator: string;
enabled: boolean;
subsections: SubsectionOption[];
topics: TopicOption[];
};
export type SelectedTaskRef = {
......@@ -45,8 +45,8 @@ export type SelectedTaskRef = {
taskId: string;
};
export type SelectedSubsectionRef = {
subsectionKey: string;
export type SelectedTopicRef = {
topicKey: string;
};
export type SelectTaskResponse = {
......@@ -55,9 +55,9 @@ export type SelectTaskResponse = {
task_id: string;
};
export type SelectSubsectionResponse = {
export type SelectTopicResponse = {
status: string;
subsection_key: string;
topic_key: string;
};
export async function fetchTasks(): Promise<TasksResponse> {
......@@ -81,10 +81,10 @@ export async function fetchTaskDetails(input: {
return response.json();
}
export async function fetchSocraticSubsections(): Promise<SocraticResponse> {
const response = await fetch("/api/tasks/socratic-subsections");
export async function fetchSocraticTopics(): Promise<SocraticResponse> {
const response = await fetch("/api/tasks/socratic-topics");
if (!response.ok) {
throw new Error(`Socratic subsections failed: ${response.status}`);
throw new Error(`Socratic topics failed: ${response.status}`);
}
return response.json();
}
......@@ -110,21 +110,21 @@ export async function selectTask(input: {
return response.json();
}
export async function selectSubsection(input: {
export async function selectTopic(input: {
draft: string;
subsectionKey: string;
}): Promise<SelectSubsectionResponse> {
const response = await fetch("/api/tasks/select-subsection", {
topicKey: string;
}): Promise<SelectTopicResponse> {
const response = await fetch("/api/tasks/select-topic", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
draft: input.draft,
subsection_key: input.subsectionKey,
topic_key: input.topicKey,
}),
});
if (!response.ok) {
throw new Error(`Subsection selection failed: ${response.status}`);
throw new Error(`Topic selection failed: ${response.status}`);
}
return response.json();
}
......@@ -10,11 +10,11 @@ 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 { selectSubsection, selectTask } from "../api/taskApi";
import { selectTask, selectTopic } from "../api/taskApi";
import { t } from "../i18n";
import { createSessionId, useTutorSession } from "../state/tutorSession";
import { getSelectionRouteForOrchestrator, isSocraticOrchestrator } from "../utils/orchestratorRoutes";
import { normalizeSubsectionKey } from "../utils/subsectionKey";
import { normalizeSubsectionKey as normalizeTopicKey } from "../utils/subsectionKey";
import sumintLogo from "../../SuMINT-Logo.png";
const initialMessages: ChatMessage[] = [];
......@@ -51,8 +51,8 @@ type ArchivedChatDetail = {
file_id: string;
task_id: string;
} | null;
selected_subsection?: {
subsection_key: string;
selected_topic?: {
topic_key: string;
} | null;
};
......@@ -191,8 +191,8 @@ export default function ChatPage() {
selectedTaskRef,
selectedTask,
selectedTaskFile,
selectedSubsectionRef,
selectedSubsection,
selectedTopicRef,
selectedTopic,
selectedOrchestrator,
availableOrchestrators,
setSelectedOrchestrator,
......@@ -202,7 +202,7 @@ export default function ChatPage() {
lockTask,
unlockTask,
setTaskRef,
setSubsectionRef,
setTopicRef,
resetForNewChat,
} = useTutorSession();
......@@ -239,31 +239,33 @@ 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 rawTopicKey = String(
searchParams.get("topic_key") || searchParams.get("subsection_key") || ""
).trim();
const taskId =
/^\d{1,2}$/.test(rawTaskId) && rawTaskId.length < 2
? rawTaskId.padStart(2, "0")
: rawTaskId;
const subsectionKey = rawSubsectionKey ? normalizeSubsectionKey(rawSubsectionKey) : "";
const topicKey = rawTopicKey ? normalizeTopicKey(rawTopicKey) : "";
const hasFileId = Boolean(fileId);
const hasTaskId = Boolean(taskId);
const hasSubsectionKey = Boolean(subsectionKey);
const hasTopicKey = Boolean(topicKey);
const isTaskOrchestrator = isTaskCoupledOrchestrator(orchestrator);
const isSocratic = isSocraticOrchestrator(orchestrator as OrchestratorName);
const hasAnyTaskParam = hasFileId || hasTaskId || hasSubsectionKey;
const hasAnyTaskParam = hasFileId || hasTaskId || hasTopicKey;
const hasRequiredTaskParams = hasFileId && hasTaskId;
const hasRequiredSubsectionParams = hasSubsectionKey;
const key = `${orchestrator}|${fileId}|${taskId}|${subsectionKey}`;
const hasRequiredTopicParams = hasTopicKey;
const key = `${orchestrator}|${fileId}|${taskId}|${topicKey}`;
return {
fileId,
taskId,
subsectionKey,
topicKey,
isTaskOrchestrator,
isSocratic,
hasAnyTaskParam,
hasRequiredTaskParams,
hasRequiredSubsectionParams,
hasRequiredTopicParams,
key,
};
}, [searchParams]);
......@@ -286,13 +288,13 @@ export default function ChatPage() {
if (
(deepLinkTarget.isTaskOrchestrator && !deepLinkTarget.hasRequiredTaskParams) ||
(deepLinkTarget.isSocratic && !deepLinkTarget.hasRequiredSubsectionParams)
(deepLinkTarget.isSocratic && !deepLinkTarget.hasRequiredTopicParams)
) {
setDeepLinkError(
deepLinkTarget.isSocratic ? t("deepLinkInvalidSubsection") : t("deepLinkInvalidTask")
);
setTaskRef(null);
setSubsectionRef(null);
setTopicRef(null);
unlockTask();
navigate(targetRoute, { replace: true });
return;
......@@ -301,13 +303,13 @@ export default function ChatPage() {
if (deepLinkTarget.isSocratic) {
setSelectedOrchestrator("socratic");
setTaskRef(null);
setSubsectionRef({ subsectionKey: deepLinkTarget.subsectionKey });
setTopicRef({ topicKey: deepLinkTarget.topicKey });
} else {
const selectedFile = taskFiles.find((file) => file.file_id === deepLinkTarget.fileId);
if (!selectedFile) {
setDeepLinkError(t("deepLinkInvalidTask"));
setTaskRef(null);
setSubsectionRef(null);
setTopicRef(null);
unlockTask();
navigate(targetRoute, { replace: true });
return;
......@@ -316,13 +318,13 @@ export default function ChatPage() {
if (!selectedTask) {
setDeepLinkError(t("deepLinkInvalidTask"));
setTaskRef(null);
setSubsectionRef(null);
setTopicRef(null);
unlockTask();
navigate(targetRoute, { replace: true });
return;
}
setSelectedOrchestrator("task");
setSubsectionRef(null);
setTopicRef(null);
setTaskRef({ fileId: selectedFile.file_id, taskId: selectedTask.task_id });
}
unlockTask();
......@@ -331,9 +333,9 @@ export default function ChatPage() {
void (async () => {
try {
if (deepLinkTarget.isSocratic) {
await selectSubsection({
await selectTopic({
draft: chatSessionId,
subsectionKey: deepLinkTarget.subsectionKey,
topicKey: deepLinkTarget.topicKey,
});
} else {
const selectedFile = taskFiles.find((file) => file.file_id === deepLinkTarget.fileId);
......@@ -356,7 +358,7 @@ export default function ChatPage() {
deepLinkTarget.isSocratic ? t("deepLinkInitFailedSubsection") : t("deepLinkInitFailed")
);
setTaskRef(null);
setSubsectionRef(null);
setTopicRef(null);
unlockTask();
navigate(targetRoute, { replace: true });
}
......@@ -375,7 +377,7 @@ export default function ChatPage() {
searchParams,
setSelectedOrchestrator,
setTaskRef,
setSubsectionRef,
setTopicRef,
taskFiles,
unlockTask,
]);
......@@ -384,13 +386,13 @@ export default function ChatPage() {
if (
!isTasksInitialized ||
selectedOrchestrator !== "socratic" ||
!selectedSubsectionRef ||
!selectedTopicRef ||
messages.length > 0
) {
return;
}
const bootstrapKey = `${chatSessionId}|${selectedSubsectionRef.subsectionKey}`;
const bootstrapKey = `${chatSessionId}|${selectedTopicRef.topicKey}`;
if (socraticBootstrapRef.current === bootstrapKey) {
return;
}
......@@ -402,11 +404,11 @@ export default function ChatPage() {
const response = await fetch(`/api/chat/bootstrap-socratic`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
draft: chatSessionId,
subsection_key: selectedSubsectionRef.subsectionKey,
}),
});
body: JSON.stringify({
draft: chatSessionId,
topic_key: selectedTopicRef.topicKey,
}),
});
if (!response.ok) {
throw new Error(`Bootstrap failed: ${response.status}`);
......@@ -464,7 +466,7 @@ export default function ChatPage() {
isTasksInitialized,
messages.length,
selectedOrchestrator,
selectedSubsectionRef,
selectedTopicRef,
]);
useEffect(() => {
......@@ -479,7 +481,7 @@ export default function ChatPage() {
return;
}
if (selectedOrchestrator === "socratic") {
if (!selectedSubsectionRef) {
if (!selectedTopicRef) {
navigate("/select-socratic", { replace: true });
}
return;
......@@ -493,7 +495,7 @@ export default function ChatPage() {
isTasksInitialized,
navigate,
selectedOrchestrator,
selectedSubsectionRef,
selectedTopicRef,
selectedTaskRef,
]);
......@@ -674,7 +676,7 @@ export default function ChatPage() {
draft: string;
orchestrator: OrchestratorName;
selected_task?: { file_id: string; task_id: string };
selected_subsection?: { subsection_key: string };
selected_topic?: { topic_key: string };
} = {
messages: [...messages, userMessage].map((message) => ({
role: message.role,
......@@ -684,9 +686,9 @@ export default function ChatPage() {
orchestrator: selectedOrchestrator,
};
if (isTaskModeEnabled) {
if (selectedOrchestrator === "socratic" && selectedSubsectionRef) {
chatPayload.selected_subsection = {
subsection_key: selectedSubsectionRef.subsectionKey,
if (selectedOrchestrator === "socratic" && selectedTopicRef) {
chatPayload.selected_topic = {
topic_key: selectedTopicRef.topicKey,
};
} else if (selectedTaskRef) {
chatPayload.selected_task = {
......@@ -872,27 +874,27 @@ export default function ChatPage() {
if (nextOrchestrator === "socratic" || isTaskCoupledOrchestrator(nextOrchestrator)) {
if (nextOrchestrator === "socratic") {
const restoredSubsection = payload.selected_subsection;
if (restoredSubsection?.subsection_key) {
const restoredSubsection = payload.selected_topic;
if (restoredSubsection?.topic_key) {
setTaskRef(null);
setSubsectionRef({ subsectionKey: restoredSubsection.subsection_key });
setTopicRef({ topicKey: restoredSubsection.topic_key });
lockTask();
try {
await selectSubsection({
await selectTopic({
draft: payload.chat_id,
subsectionKey: restoredSubsection.subsection_key,
topicKey: restoredSubsection.topic_key,
});
} catch (error) {
void error;
}
} else {
setTaskRef(null);
setSubsectionRef(null);
setTopicRef(null);
unlockTask();
navigate("/select-socratic", { replace: true });
}
} else {
setSubsectionRef(null);
setTopicRef(null);
const restoredTask = payload.selected_task;
if (restoredTask?.file_id && restoredTask?.task_id) {
setTaskRef({
......@@ -917,7 +919,7 @@ export default function ChatPage() {
}
} else {
setTaskRef(null);
setSubsectionRef(null);
setTopicRef(null);
unlockTask();
navigate("/chat", { replace: true });
}
......@@ -1268,11 +1270,11 @@ export default function ChatPage() {
</section>
<aside className={`retrieval-column ${isTaskModeEnabled ? "retrieval-column-task-mode" : ""}`}>
{selectedOrchestrator === "socratic" && selectedSubsection ? (
{selectedOrchestrator === "socratic" && selectedTopic ? (
<SocraticPanel
selectedSubsectionLabel={selectedSubsection.label}
selectedSubsectionKey={selectedSubsection.subsectionKey}
selectedSubsectionSummary={selectedSubsection.summary}
selectedSubsectionLabel={selectedTopic.label}
selectedSubsectionKey={selectedTopic.topicKey}
selectedSubsectionSummary={selectedTopic.summary}
onChangeSelection={handleChangeTaskArea}
/>
) : null}
......
import { useEffect, useMemo, useRef } from "react";
import { useNavigate } from "react-router-dom";
import type { OrchestratorName } from "../api/orchestratorApi";
import { selectSubsection } from "../api/taskApi";
import { selectTopic } from "../api/taskApi";
import OrchestratorSelect from "../components/Orchestrator/OrchestratorSelect";
import { t } from "../i18n";
import { useTutorSession } from "../state/tutorSession";
......@@ -18,11 +18,11 @@ export default function SocraticSelectionPage() {
switchOrchestrator,
isOrchestratorSelectable,
orchestratorError,
subsections,
selectedSubsectionRef,
selectedSubsection,
topics,
selectedTopicRef,
selectedTopic,
tasksError,
setSubsectionKey,
setTopicKey,
lockTask,
unlockTask,
isTasksInitialized,
......@@ -31,11 +31,11 @@ export default function SocraticSelectionPage() {
const subsectionMenuOptions = useMemo(
() =>
subsections.map((option) => ({
value: option.subsection_key,
label: option.label || option.subsection_key,
topics.map((option) => ({
value: option.topic_key,
label: option.label || option.topic_key,
})),
[subsections]
[topics]
);
useEffect(() => {
......@@ -50,7 +50,7 @@ export default function SocraticSelectionPage() {
}, [isTasksInitialized, navigate, selectedOrchestrator, unlockTask]);
useEffect(() => {
if (!selectedSubsection?.label || !subsectionDisplayRef.current) {
if (!selectedTopic?.label || !subsectionDisplayRef.current) {
return;
}
const mathjax = window.MathJax;
......@@ -58,30 +58,30 @@ export default function SocraticSelectionPage() {
return;
}
mathjax.typesetPromise([subsectionDisplayRef.current]).catch(() => undefined);
}, [selectedSubsection?.label]);
}, [selectedTopic?.label]);
useEffect(() => {
if (!subsectionMenuOptions.length) {
return;
}
if (
selectedSubsectionRef &&
subsectionMenuOptions.some((option) => option.value === selectedSubsectionRef.subsectionKey)
selectedTopicRef &&
subsectionMenuOptions.some((option) => option.value === selectedTopicRef.topicKey)
) {
return;
}
setSubsectionKey(subsectionMenuOptions[0].value);
}, [selectedSubsectionRef, setSubsectionKey, subsectionMenuOptions]);
setTopicKey(subsectionMenuOptions[0].value);
}, [selectedTopicRef, setTopicKey, subsectionMenuOptions]);
const handleStartSocratic = async () => {
if (!selectedSubsectionRef) {
if (!selectedTopicRef) {
return;
}
try {
await selectSubsection({
await selectTopic({
draft: chatSessionId,
subsectionKey: selectedSubsectionRef.subsectionKey,
topicKey: selectedTopicRef.topicKey,
});
lockTask();
navigate("/chat");
......@@ -137,8 +137,8 @@ export default function SocraticSelectionPage() {
<select
id="socratic-subsection-select"
className="task-select"
value={selectedSubsectionRef?.subsectionKey || ""}
onChange={(event) => setSubsectionKey(event.target.value)}
value={selectedTopicRef?.topicKey || ""}
onChange={(event) => setTopicKey(event.target.value)}
disabled={!subsectionMenuOptions.length}
>
{subsectionMenuOptions.length ? (
......@@ -159,7 +159,7 @@ export default function SocraticSelectionPage() {
type="button"
className="btn primary task-solve-btn"
onClick={handleStartSocratic}
disabled={!selectedSubsectionRef}
disabled={!selectedTopicRef}
>
{t("startSocratic")}
</button>
......
......@@ -17,11 +17,11 @@ import {
} from "../api/orchestratorApi";
import {
fetchTaskDetails,
fetchSocraticSubsections,
fetchSocraticTopics,
fetchTasks,
type SelectedSubsectionRef,
type SelectedTopicRef,
type SelectedTaskRef,
type SubsectionOption,
type TopicOption,
type TaskImage,
type TaskDetailsResponse,
type TaskFile,
......@@ -38,9 +38,9 @@ export type SelectedTask = SelectedTaskRef & {
images: TaskImage[];
};
export type SelectedSubsection = SelectedSubsectionRef & {
export type SelectedTopic = SelectedTopicRef & {
label: string;
refs: [number, number, number][];
refs: [number, number, number, number][];
refsText: string;
summary: string;
};
......@@ -52,10 +52,10 @@ export type TaskSelectionState = {
selectedTaskFile: TaskFile | null;
taskFileOptions: SelectOption[];
taskOptions: SelectOption[];
selectedSubsectionRef: SelectedSubsectionRef | null;
selectedSubsection: SelectedSubsection | null;
subsections: SubsectionOption[];
subsectionOptions: SelectOption[];
selectedTopicRef: SelectedTopicRef | null;
selectedTopic: SelectedTopic | null;
topics: TopicOption[];
topicOptions: SelectOption[];
tasksError: string | null;
isTaskModeEnabled: boolean;
isTasksInitialized: boolean;
......@@ -73,10 +73,10 @@ export type TutorSessionState = TaskSelectionState & {
isOrchestratorSelectable: boolean;
orchestratorError: string | null;
setTaskRef: (value: SelectedTaskRef | null) => void;
setSubsectionRef: (value: SelectedSubsectionRef | null) => void;
setTopicRef: (value: SelectedTopicRef | null) => void;
setTaskFile: (fileId: string) => void;
setTaskId: (taskId: string) => void;
setSubsectionKey: (subsectionKey: string) => void;
setTopicKey: (topicKey: string) => void;
lockTask: () => void;
unlockTask: () => void;
resetForNewChat: () => void;
......@@ -96,7 +96,7 @@ const isSelectableTaskFile = (file: TaskFile): boolean =>
const isTaskCoupledOrchestrator = (value: OrchestratorName): boolean =>
value === "task" || value === "socratic";
const formatSubsectionRefs = (refs: [number, number, number][]): string =>
const formatTopicRefs = (refs: [number, number, number, number][]): string =>
refs.map((ref) => ref.join(":")).join(", ");
export function TutorSessionProvider({ children }: PropsWithChildren) {
......@@ -110,10 +110,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 [topics, setTopics] = useState<TopicOption[]>([]);
const [selectedTaskRef, setSelectedTaskRef] = useState<SelectedTaskRef | null>(null);
const [selectedSubsectionRef, setSelectedSubsectionRef] =
useState<SelectedSubsectionRef | null>(null);
const [selectedTopicRef, setSelectedTopicRef] =
useState<SelectedTopicRef | null>(null);
const [tasksError, setTasksError] = useState<string | null>(null);
const [taskLocked, setTaskLocked] = useState(false);
const [selectedTaskDetails, setSelectedTaskDetails] = useState<TaskDetailsResponse | null>(null);
......@@ -168,34 +168,34 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
[selectedTaskFile]
);
const subsectionOptions = useMemo(
const topicOptions = useMemo(
() =>
subsections.map((option) => ({
value: option.subsection_key,
label: option.label || option.subsection_key,
topics.map((option) => ({
value: option.topic_key,
label: option.label || option.topic_key,
})),
[subsections]
[topics]
);
const selectedSubsection = useMemo<SelectedSubsection | null>(() => {
if (!selectedSubsectionRef) {
const selectedTopic = useMemo<SelectedTopic | null>(() => {
if (!selectedTopicRef) {
return null;
}
const option = subsections.find(
(item) => item.subsection_key === selectedSubsectionRef.subsectionKey
const option = topics.find(
(item) => item.topic_key === selectedTopicRef.topicKey
);
if (!option) {
return null;
}
const refs = option.refs ?? [];
return {
subsectionKey: option.subsection_key,
topicKey: option.topic_key,
label: option.label,
refs,
refsText: formatSubsectionRefs(refs),
refsText: formatTopicRefs(refs),
summary: option.summary || "",
};
}, [selectedSubsectionRef, subsections]);
}, [selectedTopicRef, topics]);
const loadTaskDetails = useCallback(async (taskRef: SelectedTaskRef | null) => {
if (!taskRef) {
......@@ -228,11 +228,11 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
try {
const isSocratic = orchestrator === "socratic";
let files: TaskFile[] = [];
let subsectionsPayload: SubsectionOption[] = [];
let topicsPayload: TopicOption[] = [];
if (isSocratic) {
const payload = await fetchSocraticSubsections();
subsectionsPayload = payload.subsections || [];
const payload = await fetchSocraticTopics();
topicsPayload = payload.topics || [];
} else {
const payload = await fetchTasks();
files = payload.task_files || [];
......@@ -240,10 +240,10 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
const selectableFiles = files.filter((file) => Array.isArray(file.tasks) && file.tasks.length > 0);
let nextTaskRef: SelectedTaskRef | null = null;
let nextSubsectionRef: SelectedSubsectionRef | null = null;
let nextTopicRef: SelectedTopicRef | null = null;
setTaskFiles(files);
setSubsections(subsectionsPayload);
setTopics(topicsPayload);
setSelectedTaskRef((prev) => {
if (isSocratic) {
return null;
......@@ -267,26 +267,26 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
nextTaskRef = { fileId: firstFile.file_id, taskId: defaultTaskId };
return nextTaskRef;
});
setSelectedSubsectionRef((prev) => {
setSelectedTopicRef((prev) => {
if (!isSocratic) {
return null;
}
if (prev) {
const option = subsectionsPayload.find(
(item) => item.subsection_key === prev.subsectionKey
const option = topicsPayload.find(
(item) => item.topic_key === prev.topicKey
);
if (option) {
nextSubsectionRef = prev;
nextTopicRef = prev;
return prev;
}
}
const firstOption = subsectionsPayload[0];
const firstOption = topicsPayload[0];
if (!firstOption) {
return null;
}
nextSubsectionRef = { subsectionKey: firstOption.subsection_key };
return nextSubsectionRef;
nextTopicRef = { topicKey: firstOption.topic_key };
return nextTopicRef;
});
if (isSocratic) {
taskDetailsRequestRef.current += 1;
......@@ -299,13 +299,13 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
void error;
}
}
void nextSubsectionRef;
void nextTopicRef;
} catch (error) {
setTasksError(t("failedLoadTasks"));
setTaskFiles([]);
setSubsections([]);
setTopics([]);
setSelectedTaskRef(null);
setSelectedSubsectionRef(null);
setSelectedTopicRef(null);
taskDetailsRequestRef.current += 1;
setSelectedTaskDetails(null);
void error;
......@@ -406,12 +406,12 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
[loadTaskDetails, selectedTaskRef]
);
const setSubsectionKey = useCallback(
(subsectionKey: string) => {
if (!subsectionKey) {
const setTopicKey = useCallback(
(topicKey: string) => {
if (!topicKey) {
return;
}
setSelectedSubsectionRef({ subsectionKey });
setSelectedTopicRef({ topicKey });
},
[]
);
......@@ -434,7 +434,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
setSelectedTaskDetails(null);
}
if (value !== "socratic") {
setSelectedSubsectionRef(null);
setSelectedTopicRef(null);
}
}, []);
......@@ -449,7 +449,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
const resetForNewChat = useCallback(() => {
setChatSessionId(createSessionId());
setSelectedTaskRef(null);
setSelectedSubsectionRef(null);
setSelectedTopicRef(null);
setTaskLocked(false);
taskDetailsRequestRef.current += 1;
setSelectedTaskDetails(null);
......@@ -466,20 +466,20 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
isOrchestratorSelectable,
orchestratorError,
taskFiles,
subsections,
topics,
selectedTaskRef,
selectedTask,
selectedTaskFile,
taskFileOptions,
taskOptions,
selectedSubsectionRef,
selectedSubsection,
subsectionOptions,
selectedTopicRef,
selectedTopic,
topicOptions,
setTaskRef,
setSubsectionRef: setSelectedSubsectionRef,
setTopicRef: setSelectedTopicRef,
setTaskFile,
setTaskId,
setSubsectionKey,
setTopicKey,
tasksError,
isTaskModeEnabled,
isTasksInitialized,
......
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