Commit c09584f1 authored by Kantz's avatar Kantz
Browse files

Merge branch 'dev' into 'main'

sokrates

See merge request kantz/tutor_react!19
parents 2dc8ef37 dde1de26
......@@ -71,6 +71,24 @@ Notes:
- If the link is invalid, the frontend falls back to `/select-task`.
- The task is not locked by deep link, so users can still switch tasks afterwards.
### Socratic Deep Links
You can open a socratic chat directly with URL query parameters:
`/chat?orchestrator=socratic&subsection_key=<subsection_key>`
Example:
`http://localhost:5173/chat?orchestrator=socratic&subsection_key=quadratische-gleichungen`
Notes:
- `subsection_key` must match an existing subsection entry from the task catalog.
- The links use hyphenated subsection slugs in the URL, while the app resolves them back to the catalog key.
- The fallback `/select-socratic` page only shows the subsection dropdown and start button.
- If the link is invalid, the frontend falls back to `/select-socratic`.
- The socratic dialog is not locked by deep link, so users can still switch subsections afterwards.
To make it accessible over the network.
Add the frontend- and backend-adress in the `backend/.env`-file in the frontend- and backend-folder. Use the following command to run the front- and backend.
......
......@@ -3,7 +3,7 @@ from app.deterministic_services import llm_client
SYSTEM_PROMPT = """
Du bist ein didaktischer Mathe-Tutor. Du gibts Feedback zu den Lösungen des Nutzers zur Gegebenen Frage.
Du bist ein didaktischer Mathe-Tutor. Du gibst Feedback zu den Lösungen des Nutzers zur Gegebenen Frage.
Vergleiche das Ergebnis mit der korrekten Lösung.
Stelle Nachfragen wenn der Lösungsweg nicht vollständig ist.
......@@ -13,6 +13,7 @@ Die Reihenfolge in mathematischen Ausdrücken ist irrelevant wenn sie äquivalen
Zitiere 1 zu 1 aus der AKTUELLE Eingabe des Nutzers wenn du auf Fehler aufmerksam machst.
verwende die $ für mathematische Formeln, z.B. $a^2 + b^2 = c^2$
minf steht für minus unendlich.
Halte dich kurz und präzise.
Denke nach bevor du eine Ausgabe macht.
......
......@@ -8,6 +8,7 @@ Du bist ein didaktischer Mathe-Tutor.
3) Wenn der Nutzer etwas Falsches sagt, antworte: "Das ist noch nicht richtig. Prüfe [konkreter Aspekt]."
4) Falls eine 'Mathematische Lösung' (Formel/Algorithmus) existiert, gib NUR diese als Tipp an – selbst wenn der Nutzer nach Alternativen fragt.
5) Verwende IMMER die $-Notation für Formeln (z. B. $a^2 + b^2 = c^2$). Kein LaTeX außerhalb der $-Blöcke.
6) minf steht für minus unendlich.
Antworte IMMER in dieser Form:
- "Das war richtig. [Tipp in einem Satz]."
......
......@@ -7,6 +7,9 @@ Nutze ausschließlich den bereitgestellten Kontext.
Wenn dort nichts zu dem Thema steht, antworte nur mit:
„Dazu steht nichts im Material.“
minf steht für minus unendlich.
verwende die $ für mathematische Formeln, z.B. $a^2 + b^2 = c^2$
Liefer wenn möglich ein Zahlenbeispiel mit.
......
from __future__ import annotations
from typing import Iterable
from app.deterministic_services import llm_client
HINT_SYSTEM_PROMPT = """
Du bist ein didaktischer Mathe-Tutor.
Antworte auf Deutsch, kurz und präzise.
Nutze ausschließlich den bereitgestellten Kontext.
Stelle vor allem Verständnisfragen und leite den Studeten sokratisch.
Gib keine komplette Lösung aus.
Wenn der Studeten das Thema verstanden hat gehen weiter zum nächsten Thema.
Wenn der Student alle Themen verstanden hat sag im das.
Verwende für mathematische Formeln immer die $-Notation, z.B. $a^2 + b^2 = c^2$.
minf steht für minus unendlich.
"""
def _format_subsection_refs(subsection_refs: Iterable[tuple[int, int, int]] | None) -> str:
if not subsection_refs:
return "Keine Subsection-Referenzen übergeben."
formatted = [f"{chap}:{sec}:{sub}" for chap, sec, sub in subsection_refs]
return ", ".join(formatted)
def generate_dialog(
query: str | None,
subsection_refs: list[tuple[int, int, int]] | None = None,
history: list[dict] | None = None,
sources: str | None = None,
) -> str:
context_parts = [
f"Subsection-Referenzen:\n{_format_subsection_refs(subsection_refs)}",
]
if sources:
context_parts.append(f"Kontext/Sources:\n{sources}")
messages = [{"role": "system", "content": HINT_SYSTEM_PROMPT}]
messages.append({"role": "user", "content": "\n\n".join(context_parts)})
if history:
messages.extend(history)
if query:
messages.append(
{
"role": "user",
"content": (
"AKTUELLE Eingabe des Studenten (höchste Priorität):\n"
f"{query}\n\n"
"Reagiere ausschließlich auf diese Eingabe und bleibe sokratisch."
),
}
)
result = llm_client.chat(messages=messages)
return llm_client.get_message_content(result)
......@@ -9,7 +9,9 @@ Du bist ein didaktischer Mathe-Tutor. Du beantwortest die mathematischen Fragen
3) Wenn der Nutzer etwas Falsches sagt weiße ihn darauf hin.
4) Die Übergebene Lösung ist IMMER korrekt.
5) Der Nutzer kenn die korrekte Lösung und den exemplarischer Lösungsweg nicht.
6) Die Reihnfolge in mathematischen Ausdrücken ist irrellevant wenn sie äquivalent sind also a+b = b+a und a^bc = a^cb.
6) Die Reihenfolge in mathematischen Ausdrücken ist irrelevant wenn sie äquivalent sind also a+b = b+a und a^bc = a^cb.
7) Zitiere 1 zu 1 aus der AKTUELLE Eingabe des Nutzers wenn du auf Fehler aufmerksam machst.
8) minf steht für minus unendlich.
verwende die $ für mathematische Formeln, z.B. $a^2 + b^2 = c^2$
......
......@@ -3,7 +3,10 @@ from __future__ import annotations
import logging
from typing import List, Optional
import app.config as config
from app.deterministic_services import session_store
from app.deterministic_services import context_store, retrieval_store, task_catalog
from app.LLM_services import socratic_LLM
from app.deterministic_services.orchestrators.registry import (
get_default_orchestrator,
is_valid_orchestrator,
......@@ -26,6 +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
orchestrator: Optional[str] = None
......@@ -52,14 +56,29 @@ class SelectedTaskRef(BaseModel):
task_id: str
class SelectedSubsectionRef(BaseModel):
subsection_key: str
class ChatArchiveDetail(BaseModel):
chat_id: str
saved_at: str
history: List[ChatMessage]
selected_task: Optional[SelectedTaskRef] = None
selected_subsection: Optional[SelectedSubsectionRef] = None
orchestrator: str
class SocraticBootstrapRequest(BaseModel):
draft: str = Field(..., min_length=1)
subsection_key: str = Field(..., min_length=1)
class SocraticBootstrapResponse(BaseModel):
reply: str
sources: List[dict] = []
@router.post("/api/chat", response_model=ChatResponse)
def chat(request: ChatRequest) -> ChatResponse:
if not request.messages:
......@@ -75,11 +94,12 @@ def chat(request: ChatRequest) -> ChatResponse:
try:
payload_messages = [{"role": m.role, "content": m.text} for m in request.messages]
if orchestrator_name in {"task", "feedback"}:
if orchestrator_name in {"task", "feedback", "socratic"}:
result = orchestrator_impl.run_chat(
payload_messages,
draft=request.draft,
selected_task=request.selected_task,
selected_subsection=request.selected_subsection,
)
else:
result = orchestrator_impl.run_chat(
......@@ -125,11 +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)
return ChatArchiveDetail(
chat_id=record["chat_id"],
saved_at=record.get("saved_at", ""),
history=[ChatMessage(role=item["role"], text=item["text"]) for item in record["history"]],
selected_task=selected_task,
selected_subsection=selected_subsection,
orchestrator=record.get("orchestrator") or get_default_orchestrator(),
)
......@@ -155,3 +183,41 @@ def archive_chat(request: ChatRequest) -> ChatArchiveResponse:
raise HTTPException(status_code=502, detail="chat archive failed") from exc
return ChatArchiveResponse(status="ok", chat_id=chat_id)
@router.post("/api/chat/bootstrap-socratic", response_model=SocraticBootstrapResponse)
def bootstrap_socratic(request: SocraticBootstrapRequest) -> SocraticBootstrapResponse:
chat_id = context_store.get_chat_id([], draft=request.draft)
sheet = context_store.load_sheet(chat_id)
if not sheet:
sheet = context_store.context_store_new.init_sheet(chat_id, [])
if not task_catalog.select_subsection_by_key(sheet, request.subsection_key):
raise HTTPException(status_code=404, detail="subsection not found")
refs = task_catalog.get_selected_subsection_refs(sheet)
sources = retrieval_store.retrieve_for_subsections(
pg_url=config.get_postgres_url(),
subsection_refs=refs,
)
context_store.set_sources(sheet, sources)
context_store.set_initialized(sheet, True)
context_store.save_sheet(sheet)
sources_text = "\n".join(source.to_string() for source in sources)
reply = socratic_LLM.generate_dialog(
query="Was sind die Themen dieses Abschnitts? Frage mich, mit welchem ich mich zuerst beschäftigen möchte.",
subsection_refs=refs,
history=None,
sources=sources_text,
)
if not reply:
reply = "Was sind die Themen dieses Abschnitts? Womit möchtest du anfangen?"
context_store.append_history_message(sheet, "assistant", reply)
context_store.save_sheet(sheet)
return SocraticBootstrapResponse(
reply=reply,
sources=[source.model_dump() for source in sources],
)
......@@ -10,7 +10,7 @@ from app.deterministic_services import context_store, task_catalog
router = APIRouter()
TASK_ORCHESTRATORS = {"task", "feedback"}
TASK_ORCHESTRATORS = {"task", "feedback", "socratic"}
class TaskItem(BaseModel):
......@@ -19,6 +19,13 @@ class TaskItem(BaseModel):
full_text: str
class SubsectionEntry(BaseModel):
subsection_key: str
label: str
refs: List[List[int]]
summary: str = ""
class TaskFile(BaseModel):
file_id: str
title: str
......@@ -31,6 +38,7 @@ class TasksResponse(BaseModel):
orchestrator: str
enabled: bool
task_files: List[TaskFile]
subsections: List[SubsectionEntry] = Field(default_factory=list)
class SelectTaskRequest(BaseModel):
......@@ -45,6 +53,16 @@ class SelectTaskResponse(BaseModel):
task_id: str
class SelectSubsectionRequest(BaseModel):
draft: str = Field(..., min_length=1)
subsection_key: str = Field(..., min_length=1)
class SelectSubsectionResponse(BaseModel):
status: str
subsection_key: str
@router.get("/api/tasks/config")
def get_task_config() -> dict[str, object]:
orchestrator = config.get_orchestrator()
......@@ -55,10 +73,12 @@ def get_task_config() -> dict[str, object]:
def list_tasks() -> TasksResponse:
orchestrator = config.get_orchestrator()
task_files = task_catalog.build_task_catalog()
subsections = task_catalog.build_subsection_catalog()
return TasksResponse(
orchestrator=orchestrator,
enabled=orchestrator in TASK_ORCHESTRATORS,
task_files=task_files,
subsections=subsections,
)
# Eigentlich sollte die Context-Selection erst passieren wen das schon fest steht
......@@ -85,3 +105,22 @@ def select_task(request: SelectTaskRequest) -> SelectTaskResponse:
file_id=file_id or request.file_id,
task_id=task_id or request.task_id,
)
@router.post("/api/tasks/select-subsection", response_model=SelectSubsectionResponse)
def select_subsection(request: SelectSubsectionRequest) -> SelectSubsectionResponse:
chat_id = context_store.get_chat_id([], draft=request.draft)
sheet = context_store.load_sheet(chat_id)
if not sheet:
sheet = context_store.context_store_new.init_sheet(chat_id, [])
updated = task_catalog.select_subsection_by_key(sheet, subsection_key=request.subsection_key)
if not updated:
raise HTTPException(status_code=404, detail="subsection not found")
context_store.save_sheet(sheet)
_, subsection_key = task_catalog.get_selected_subsection_ids(sheet)
return SelectSubsectionResponse(
status="ok",
subsection_key=subsection_key or request.subsection_key,
)
......@@ -120,6 +120,7 @@ def run_chat(
messages: list[dict],
draft: str | None = None,
selected_task: dict | None = None,
selected_subsection: dict | None = None,
) -> dict:
def _apply_selected_task(state: base.ChatState) -> None:
if not selected_task:
......
from __future__ import annotations
from app.LLM_services import socratic_LLM
import app.config as config
from app.deterministic_services import context_store, retrieval_store, task_catalog
from app.deterministic_services.orchestrators import orchestrator_base as base
def _apply_selected_subsection(
state: base.ChatState,
selected_subsection: dict | None,
) -> None:
if not selected_subsection:
return
subsection_key = str(selected_subsection.get("subsection_key", "")).strip()
if not subsection_key:
return
task_catalog.select_subsection_by_key(state.sheet, subsection_key)
def _retrieve_context_for_subsection(state: base.ChatState, query_text: str) -> int:
refs = task_catalog.get_selected_subsection_refs(state.sheet)
if not refs:
return 0
def _retrieve() -> dict:
sources = retrieval_store.retrieve_for_subsections(
pg_url=config.get_postgres_url(),
subsection_refs=refs,
)
context_store.update_retrieval_context(state.sheet, sources)
return {
"subsection_refs": refs,
"source_count": len(sources),
}
result = base.log_timed_call(
state.tool_log,
"retrieve_context_with_socratic_subsections",
{
"query": query_text,
"subsection_refs": refs,
},
_retrieve,
)
return int(result.get("source_count", 0))
def _on_bootstrap(state: base.ChatState, query_text: str) -> None:
_retrieve_context_for_subsection(state, query_text)
def _on_turn_logic(state: base.ChatState) -> None:
if not task_catalog.get_selected_subsection_refs(state.sheet):
return
def _on_build_reply(state: base.ChatState) -> str | None:
history_turns = context_store.get_history_turns(state.sheet)
subsection_refs = task_catalog.get_selected_subsection_refs(state.sheet)
args = {
"query": state.last_user,
"subsection_refs": subsection_refs,
"history": history_turns,
"sources": "\n".join([source.to_string() for source in context_store.get_retrieval(state.sheet)]),
}
return base.log_timed_call(
state.tool_log,
"socratic_generate_dialog",
args,
lambda: socratic_LLM.generate_dialog(**args),
)
def run_chat(
messages: list[dict],
draft: str | None = None,
selected_subsection: dict | None = None,
selected_task: dict | None = None,
) -> dict:
def _apply_selected_context(state: base.ChatState) -> None:
_apply_selected_subsection(state, selected_subsection)
def on_bootstrap(state: base.ChatState, query_text: str) -> None:
_apply_selected_context(state)
_on_bootstrap(state, query_text)
def on_turn_logic(state: base.ChatState) -> None:
_apply_selected_context(state)
_on_turn_logic(state)
return base.run_chat_common(
messages=messages,
draft=draft,
on_bootstrap=on_bootstrap,
on_turn_logic=on_turn_logic,
on_build_reply=_on_build_reply,
init_sheet_fn=context_store.context_store_new.init_sheet,
)
......@@ -123,6 +123,7 @@ def run_chat(
messages: list[dict],
draft: str | None = None,
selected_task: dict | None = None,
selected_subsection: dict | None = None,
) -> dict:
def _apply_selected_task(state: base.ChatState) -> None:
if not selected_task:
......
......@@ -6,17 +6,19 @@ import app.config as config
from app.deterministic_services.orchestrators import (
orchestrator_feedback,
orchestrator_qa,
orchestrator_socratic,
orchestrator_task,
orchestrator_tutor,
)
AVAILABLE_ORCHESTRATORS: tuple[str, ...] = ("qa", "tutor", "task", "feedback")
AVAILABLE_ORCHESTRATORS: tuple[str, ...] = ("qa", "tutor", "task", "feedback", "socratic")
_ORCHESTRATOR_MODULES: dict[str, Any] = {
"qa": orchestrator_qa,
"tutor": orchestrator_tutor,
"task": orchestrator_task,
"feedback": orchestrator_feedback,
"socratic": orchestrator_socratic,
}
......
......@@ -26,6 +26,13 @@ def _extract_selected_task(sheet: dict[str, Any]) -> dict[str, str] | None:
return {"file_id": file_id, "task_id": task_id}
def _extract_selected_subsection(sheet: dict[str, Any]) -> dict[str, str] | None:
subsection_key = str(sheet.get("selected_subsection_key", "")).strip()
if not subsection_key:
return None
return {"subsection_key": subsection_key}
def archive_chat(
messages: list[dict[str, Any]],
draft: str | None = None,
......@@ -49,6 +56,7 @@ def archive_chat(
"math_solutions": sheet.get("math_solutions", []),
"sources": sheet.get("sources", []),
"selected_task": _extract_selected_task(sheet),
"selected_subsection": _extract_selected_subsection(sheet),
}
os.makedirs(_LOG_DIR, exist_ok=True)
......@@ -126,11 +134,19 @@ def load_archive(chat_id: str) -> dict[str, Any] | None:
if file_id and task_id:
selected_task = {"file_id": file_id, "task_id": task_id}
selected_subsection_raw = record.get("selected_subsection")
selected_subsection: dict[str, str] | None = None
if isinstance(selected_subsection_raw, dict):
subsection_key = str(selected_subsection_raw.get("subsection_key", "")).strip()
if subsection_key:
selected_subsection = {"subsection_key": subsection_key}
return {
"chat_id": record.get("chat_id", chat_id),
"saved_at": record.get("saved_at", ""),
"orchestrator": record.get("orchestrator"),
"history": history,
"selected_task": selected_task,
"selected_subsection": selected_subsection,
}
return None
......@@ -2,15 +2,18 @@ from __future__ import annotations
import json
import re
import unicodedata
from functools import lru_cache
from pathlib import Path
from typing import Any
from app.deterministic_services import context_store
from app.deterministic_services.vector_store import parse_markdown_with_frontmatter
TASKS_DIR = Path(__file__).resolve().parents[2] / "sources" / "tasks"
SOURCES_DIR = Path(__file__).resolve().parents[2] / "sources"
SUBSECTION_MAP_PATH = TASKS_DIR / "_subsection_map.json"
def _normalize_text(value: str) -> str:
return re.sub(r"\s+", " ", value.strip().lower())
......@@ -20,8 +23,11 @@ def _tokenize(value: str) -> set[str]:
def _normalize_subsection_key(value: str) -> str:
collapsed = re.sub(r"[-_]+", " ", value.strip().lower())
return re.sub(r"\s+", " ", collapsed)
normalized = unicodedata.normalize("NFKD", str(value))
ascii_value = normalized.encode("ascii", "ignore").decode("ascii")
collapsed = re.sub(r"[-_]+", " ", ascii_value.strip().lower())
collapsed = re.sub(r"[^a-z0-9]+", " ", collapsed)
return re.sub(r"\s+", " ", collapsed).strip()
def _parse_subsection_ref(value: str) -> tuple[int, int, int] | None:
......@@ -51,6 +57,47 @@ def load_subsection_map(path: Path = SUBSECTION_MAP_PATH) -> dict[str, tuple[int
return mapped
def _extract_subsection_summary(text: str) -> str:
body = text.replace("\r\n", "\n").strip()
if not body:
return ""
header_match = re.search(r"(?im)^#\s+AI-Generierte Zusammenfassung\s*$", body)
if header_match:
body = body[header_match.end():].lstrip()
child_chunks_match = re.search(r"(?im)^#\s+Child-Chunks\s*$", body)
if child_chunks_match:
body = body[:child_chunks_match.start()].rstrip()
return body.strip()
@lru_cache(maxsize=1)
def load_subsection_summaries(base_dir: Path = SOURCES_DIR) -> dict[str, str]:
summaries: dict[str, str] = {}
for folder in (
base_dir / "with_chapters" / "subsections",
base_dir / "child_lvl" / "subsections",
base_dir / "subsection_lvl" / "subsections",
):
if not folder.exists():
continue
for path in sorted(folder.glob("*.md")):
try:
text = path.read_text(encoding="utf-8")
except Exception:
continue
meta, body = parse_markdown_with_frontmatter(text)
title = str(meta.get("title") or meta.get("subsection_title") or meta.get("section_title") or "").strip()
key = _normalize_subsection_key(title)
summary = _extract_subsection_summary(body)
if not key or not summary:
continue
summaries.setdefault(key, summary)
return summaries
def _resolve_task_subsection_refs(
task_file: dict[str, Any],
subsection_map: dict[str, tuple[int, int, int]] | None = None,
......@@ -71,6 +118,63 @@ def _resolve_task_subsection_refs(
return sorted(refs)
def _format_subsection_label(value: str) -> str:
cleaned = re.sub(r"[-_]+", " ", value.strip())
cleaned = re.sub(r"\s+", " ", cleaned).strip()
if not cleaned:
return ""
return cleaned.title()
def build_subsection_catalog(path: Path = SUBSECTION_MAP_PATH) -> list[dict[str, Any]]:
subsection_map = load_subsection_map(path)
subsection_summaries = load_subsection_summaries()
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])]],
"summary": subsection_summaries.get(key, ""),
}
)
return response
def _resolve_task_subsection_options(
task_file: dict[str, Any],
subsection_map: dict[str, tuple[int, int, int]] | None = None,
) -> list[dict[str, Any]]:
mapping = subsection_map if subsection_map is not None else load_subsection_map()
subsections = task_file.get("subsections", [])
if not isinstance(subsections, list):
return []
options: list[dict[str, Any]] = []
seen_refs: set[tuple[int, int, int]] = set()
for subsection in subsections:
raw_label = str(subsection).strip()
key = _normalize_subsection_key(raw_label)
if not key:
continue
ref = mapping.get(key)
if ref is None:
continue
normalized_ref = (int(ref[0]), int(ref[1]), int(ref[2]))
if normalized_ref in seen_refs:
continue
seen_refs.add(normalized_ref)
options.append(
{
"subsection_key": key,
"label": _format_subsection_label(raw_label) or raw_label,
"refs": [[normalized_ref[0], normalized_ref[1], normalized_ref[2]]],
}
)
return options
def _match_score(query_text: str, candidate_text: str) -> int:
query_tokens = _tokenize(query_text)
if not query_tokens:
......@@ -142,6 +246,37 @@ def set_selected_task(
sheet["task_id"] = str(task_entry.get("id", "")).zfill(2)
refs = _resolve_task_subsection_refs(task_file)
sheet["task_subsection_refs"] = [[chap, sec, sub] for chap, sec, sub in refs]
sheet.pop("selected_subsection_key", None)
sheet.pop("selected_subsection_label", None)
sheet.pop("selected_subsection_refs", None)
def set_selected_subsection(
sheet: dict[str, Any],
task_file: dict[str, Any],
subsection_option: dict[str, Any],
) -> None:
refs_raw = subsection_option.get("refs", [])
refs: list[tuple[int, int, int]] = []
if isinstance(refs_raw, list):
for item in refs_raw:
if isinstance(item, (list, tuple)) and len(item) >= 3:
try:
refs.append((int(item[0]), int(item[1]), int(item[2])))
except Exception:
continue
if not refs:
return
sheet["selected_subsection_key"] = str(subsection_option.get("subsection_key", "")).strip()
sheet["selected_subsection_label"] = str(subsection_option.get("label", "")).strip()
sheet["selected_subsection_refs"] = [[chap, sec, sub] for chap, sec, sub in sorted({*refs})]
sheet.pop("task_id", None)
sheet.pop("task_subsection_refs", None)
sheet.pop("task", None)
sheet.pop("hints", None)
sheet.pop("solution", None)
def select_task_by_ids(
......@@ -161,12 +296,81 @@ def select_task_by_ids(
return True
def select_subsection_by_ids(
sheet: dict[str, Any],
file_id: str,
subsection_key: str,
task_files: list[dict[str, Any]] | None = None,
) -> bool:
catalog = task_files if task_files is not None else load_task_files()
task_file = _find_task_file(catalog, file_id)
if not task_file:
return False
subsection_map = load_subsection_map()
options = _resolve_task_subsection_options(task_file, subsection_map=subsection_map)
normalized_key = _normalize_subsection_key(subsection_key)
if not normalized_key:
return False
for option in options:
if str(option.get("subsection_key", "")).strip() == normalized_key:
set_selected_subsection(sheet, task_file, option)
return True
return False
def select_subsection_by_key(
sheet: dict[str, Any],
subsection_key: str,
subsection_map: dict[str, tuple[int, int, int]] | None = None,
) -> bool:
mapping = subsection_map if subsection_map is not None else load_subsection_map()
normalized_key = _normalize_subsection_key(subsection_key)
if not normalized_key:
return False
ref = mapping.get(normalized_key)
if ref is None:
return False
sheet["selected_subsection_key"] = normalized_key
sheet["selected_subsection_label"] = _format_subsection_label(normalized_key)
sheet["selected_subsection_refs"] = [[int(ref[0]), int(ref[1]), int(ref[2])]]
sheet.pop("task_file_id", None)
sheet.pop("task_id", None)
sheet.pop("task_subsection_refs", None)
sheet.pop("task", None)
sheet.pop("hints", None)
sheet.pop("solution", None)
return True
def get_selected_task_ids(sheet: dict[str, Any]) -> tuple[str | None, str | None]:
file_id = str(sheet.get("task_file_id", "")).strip()
task_id = str(sheet.get("task_id", "")).strip()
return (file_id or None, task_id or None)
def get_selected_subsection_ids(sheet: dict[str, Any]) -> tuple[str | None, str | None]:
subsection_key = str(sheet.get("selected_subsection_key", "")).strip()
return (None, subsection_key or None)
def get_selected_subsection_refs(sheet: dict[str, Any]) -> list[tuple[int, int, int]]:
refs_raw = sheet.get("selected_subsection_refs", [])
if not isinstance(refs_raw, list):
return []
refs: set[tuple[int, int, int]] = set()
for item in refs_raw:
if isinstance(item, (list, tuple)) and len(item) >= 3:
try:
refs.add((int(item[0]), int(item[1]), int(item[2])))
except Exception:
continue
return sorted(refs)
def get_selected_task_subsection_refs(sheet: dict[str, Any]) -> list[tuple[int, int, int]]:
refs_raw = sheet.get("task_subsection_refs", [])
if not isinstance(refs_raw, list):
......@@ -260,6 +464,7 @@ def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[d
title = str(task_file.get("title", "")).strip()
intro = str(task_file.get("intro", "")).strip()
subsections = task_file.get("subsections", [])
subsection_options = _resolve_task_subsection_options(task_file)
tasks: list[dict[str, str]] = []
for item in task_file.get("tasks", []):
if not isinstance(item, dict):
......@@ -282,6 +487,7 @@ def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[d
"intro": intro,
"tasks": tasks,
"subsections": subsections,
"subsection_options": subsection_options,
}
)
return response
from __future__ import annotations
import json
import os
import unittest
from pathlib import Path
from unittest.mock import patch
os.environ.setdefault("OPENAI_BASE_URL", "http://localhost:9999")
os.environ.setdefault("OPENAI_API_KEY", "test-key")
os.environ.setdefault("POSTGRES_URL", "postgresql://localhost/test")
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api import chat, tasks
from app.deterministic_services import session_store, task_catalog
class TaskCatalogSocraticTest(unittest.TestCase):
def test_build_subsection_catalog_includes_summary(self) -> None:
with patch(
"app.deterministic_services.task_catalog.load_subsection_map",
return_value={
"quadratische gleichungen": (1, 3, 3),
"mengen": (1, 1, 1),
},
), patch(
"app.deterministic_services.task_catalog.load_subsection_summaries",
return_value={
"mengen": "Mengen summary text",
"quadratische gleichungen": "Quadratische summary text",
},
):
catalog = task_catalog.build_subsection_catalog()
self.assertEqual(
catalog,
[
{
"subsection_key": "mengen",
"label": "Mengen",
"refs": [[1, 1, 1]],
"summary": "Mengen summary text",
},
{
"subsection_key": "quadratische gleichungen",
"label": "Quadratische Gleichungen",
"refs": [[1, 3, 3]],
"summary": "Quadratische summary text",
},
],
)
def test_load_subsection_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)
md_path = summary_root / "s001-ss001-mengen.md"
md_path.write_text(
"""---
section_index: 1
subsection_index: 1
section_title: "Mengen"
title: "Mengen"
---
# AI-Generierte Zusammenfassung
### **Zusammenfassung: Mengen**
Erste Zeile der Zusammenfassung.
Zweite Zeile.
# Child-Chunks
- `childs/s001-ss001-c001-mengen.md`
""",
encoding="utf-8",
)
try:
summaries = task_catalog.load_subsection_summaries(temp_dir)
finally:
if md_path.exists():
md_path.unlink()
if summary_root.exists():
summary_root.rmdir()
child_lvl_dir = temp_dir / "child_lvl"
if child_lvl_dir.exists():
child_lvl_dir.rmdir()
if temp_dir.exists():
temp_dir.rmdir()
self.assertIn("mengen", summaries)
self.assertTrue(summaries["mengen"].startswith("### **Zusammenfassung: Mengen**"))
self.assertNotIn("Child-Chunks", summaries["mengen"])
def test_select_subsection_by_ids_sets_sheet_fields(self) -> None:
task_files = [
{
"_file_id": "analysis_1",
"title": "Analysis",
"subsections": ["quadratische_gleichungen"],
"tasks": [],
}
]
sheet: dict[str, object] = {}
with patch(
"app.deterministic_services.task_catalog.load_subsection_map",
return_value={"quadratische gleichungen": (1, 3, 3)},
):
updated = task_catalog.select_subsection_by_ids(
sheet,
"analysis_1",
"quadratische_gleichungen",
task_files=task_files,
)
self.assertTrue(updated)
self.assertEqual(sheet["selected_subsection_key"], "quadratische gleichungen")
self.assertEqual(sheet["selected_subsection_refs"], [[1, 3, 3]])
def test_select_subsection_by_key_sets_sheet_fields(self) -> None:
sheet: dict[str, object] = {}
with patch(
"app.deterministic_services.task_catalog.load_subsection_map",
return_value={"quadratische gleichungen": (1, 3, 3)},
):
updated = task_catalog.select_subsection_by_key(sheet, "quadratische_gleichungen")
self.assertTrue(updated)
self.assertEqual(sheet["selected_subsection_key"], "quadratische gleichungen")
self.assertEqual(sheet["selected_subsection_refs"], [[1, 3, 3]])
class TaskApiSocraticTest(unittest.TestCase):
def setUp(self) -> None:
app = FastAPI()
app.include_router(tasks.router)
self.client = TestClient(app)
def test_list_tasks_includes_subsection_options(self) -> None:
payload = [
{
"file_id": "analysis_1",
"title": "Analysis",
"intro": "Intro",
"tasks": [],
}
]
subsections = [
{
"subsection_key": "quadratische gleichungen",
"label": "Quadratische Gleichungen",
"refs": [[1, 3, 3]],
}
]
with patch("app.api.tasks.config.get_orchestrator", return_value="socratic"), patch(
"app.api.tasks.task_catalog.build_task_catalog",
return_value=payload,
), patch(
"app.api.tasks.task_catalog.build_subsection_catalog",
return_value=subsections,
):
response = self.client.get("/api/tasks")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["orchestrator"], "socratic")
self.assertEqual(body["subsections"][0]["subsection_key"], "quadratische gleichungen")
def test_select_subsection_endpoint_returns_selected_key(self) -> None:
sheet: dict[str, object] = {}
with patch("app.api.tasks.context_store.get_chat_id", return_value="chat-1"), patch(
"app.api.tasks.context_store.load_sheet",
return_value=sheet,
), patch(
"app.api.tasks.context_store.context_store_new.init_sheet",
return_value=sheet,
), patch(
"app.api.tasks.task_catalog.select_subsection_by_key",
return_value=True,
) as select_mock, patch(
"app.api.tasks.context_store.save_sheet"
), patch(
"app.api.tasks.task_catalog.get_selected_subsection_ids",
return_value=(None, "quadratische gleichungen"),
):
response = self.client.post(
"/api/tasks/select-subsection",
json={
"draft": "chat-1",
"subsection_key": "quadratische_gleichungen",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["subsection_key"], "quadratische gleichungen")
select_mock.assert_called_once_with(sheet, subsection_key="quadratische_gleichungen")
class ChatBootstrapSocraticTest(unittest.TestCase):
def setUp(self) -> None:
app = FastAPI()
app.include_router(chat.router)
self.client = TestClient(app)
def test_bootstrap_socratic_loads_sources_and_writes_only_assistant_history(self) -> None:
sheet: dict[str, object] = {}
with patch("app.api.chat.context_store.get_chat_id", return_value="chat-1"), patch(
"app.api.chat.context_store.load_sheet",
return_value=sheet,
), patch(
"app.api.chat.context_store.context_store_new.init_sheet",
return_value=sheet,
), patch(
"app.api.chat.context_store.save_sheet"
), patch(
"app.api.chat.context_store.set_sources"
), patch(
"app.api.chat.context_store.set_initialized"
), patch(
"app.api.chat.task_catalog.load_subsection_map",
return_value={"quadratische gleichungen": (1, 3, 3)},
), patch(
"app.api.chat.retrieval_store.retrieve_for_subsections",
return_value=[],
) as retrieve_mock, patch(
"app.api.chat.socratic_LLM.generate_dialog",
return_value="Was sind die Themen dieses Abschnitts?",
):
response = self.client.post(
"/api/chat/bootstrap-socratic",
json={
"draft": "chat-1",
"subsection_key": "quadratische_gleichungen",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["reply"], "Was sind die Themen dieses Abschnitts?")
retrieve_mock.assert_called_once_with(pg_url="postgresql://localhost/test", subsection_refs=[(1, 3, 3)])
self.assertEqual(sheet["selected_subsection_key"], "quadratische gleichungen")
self.assertEqual(sheet["history"], [{"role": "assistant", "content": "Was sind die Themen dieses Abschnitts?"}])
class SessionStoreSocraticTest(unittest.TestCase):
def test_load_archive_restores_selected_subsection(self) -> None:
record = {
"chat_id": "chat-1",
"saved_at": "2026-03-31T10:00:00Z",
"orchestrator": "socratic",
"history": [],
"selected_task": None,
"selected_subsection": {
"subsection_key": "quadratische gleichungen",
},
}
temp_dir = Path(__file__).resolve().parent / "_tmp_socratic_archive"
temp_dir.mkdir(exist_ok=True)
log_path = temp_dir / "archive.jsonl"
log_path.write_text(json.dumps(record, ensure_ascii=False) + "\n", encoding="utf-8")
try:
with patch.object(session_store, "_LOG_PATH", str(log_path)):
archive = session_store.load_archive("chat-1")
finally:
if log_path.exists():
log_path.unlink()
if temp_dir.exists():
temp_dir.rmdir()
self.assertIsNotNone(archive)
self.assertEqual(archive["selected_subsection"], record["selected_subsection"])
if __name__ == "__main__":
unittest.main()
export type OrchestratorName = "qa" | "tutor" | "task" | "feedback";
export type OrchestratorName = "qa" | "tutor" | "task" | "feedback" | "socratic";
export type OrchestratorConfigResponse = {
default_orchestrator: OrchestratorName;
available_orchestrators: OrchestratorName[];
};
const FALLBACK_ORCHESTRATORS: OrchestratorName[] = ["qa", "tutor", "task", "feedback"];
const FALLBACK_ORCHESTRATORS: OrchestratorName[] = ["qa", "tutor", "task", "feedback", "socratic"];
const normalizeOrchestrator = (value: string): OrchestratorName | null => {
if (value === "qa" || value === "tutor" || value === "task" || value === "feedback") {
if (
value === "qa" ||
value === "tutor" ||
value === "task" ||
value === "feedback" ||
value === "socratic"
) {
return value;
}
return null;
......
......@@ -10,12 +10,21 @@ export type TaskFile = {
intro: string;
tasks: TaskItem[];
subsections?: string[];
subsection_options?: SubsectionOption[];
};
export type SubsectionOption = {
subsection_key: string;
label: string;
refs: [number, number, number][];
summary: string;
};
export type TasksResponse = {
orchestrator: string;
enabled: boolean;
task_files: TaskFile[];
subsections: SubsectionOption[];
};
export type SelectedTaskRef = {
......@@ -23,12 +32,21 @@ export type SelectedTaskRef = {
taskId: string;
};
export type SelectedSubsectionRef = {
subsectionKey: string;
};
export type SelectTaskResponse = {
status: string;
file_id: string;
task_id: string;
};
export type SelectSubsectionResponse = {
status: string;
subsection_key: string;
};
export async function fetchTasks(): Promise<TasksResponse> {
const response = await fetch("/api/tasks");
if (!response.ok) {
......@@ -57,3 +75,22 @@ export async function selectTask(input: {
}
return response.json();
}
export async function selectSubsection(input: {
draft: string;
subsectionKey: string;
}): Promise<SelectSubsectionResponse> {
const response = await fetch("/api/tasks/select-subsection", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
draft: input.draft,
subsection_key: input.subsectionKey,
}),
});
if (!response.ok) {
throw new Error(`Subsection selection failed: ${response.status}`);
}
return response.json();
}
......@@ -9,6 +9,7 @@ type ChatWindowProps = {
draft: string;
onDraftChange: (value: string) => void;
onSend: () => void;
isSending?: boolean;
onHistoryNavigate?: (direction: "older" | "newer") => boolean;
onToggleCanvas?: () => void;
onUploadSolution?: (file: File) => void | Promise<void>;
......@@ -22,6 +23,7 @@ export default function ChatWindow({
draft,
onDraftChange,
onSend,
isSending = false,
onHistoryNavigate,
onToggleCanvas,
onUploadSolution,
......@@ -42,6 +44,7 @@ export default function ChatWindow({
value={draft}
onChange={onDraftChange}
onSend={onSend}
isSending={isSending}
onHistoryNavigate={onHistoryNavigate}
onToggleCanvas={onToggleCanvas}
onUploadSolution={onUploadSolution}
......
......@@ -50,10 +50,6 @@ export default function MessageBubble({
const hasThinking = Boolean(thinkingText);
useEffect(() => {
setIsThinkingExpanded(false);
}, [text]);
const renderMarkdown = (value: string) => (
<ReactMarkdown
urlTransform={(url) => {
......
......@@ -6,6 +6,7 @@ type MessageInputProps = {
value: string;
onChange: (value: string) => void;
onSend: () => void;
isSending?: boolean;
onHistoryNavigate?: (direction: "older" | "newer") => boolean;
onToggleCanvas?: () => void;
onUploadSolution?: (file: File) => void | Promise<void>;
......@@ -15,6 +16,7 @@ export default function MessageInput({
value,
onChange,
onSend,
isSending = false,
onHistoryNavigate,
onToggleCanvas,
onUploadSolution,
......@@ -44,6 +46,7 @@ export default function MessageInput({
className="btn"
type="button"
onClick={onToggleCanvas}
disabled={isSending}
aria-label={t("draw")}
title={t("draw")}
>
......@@ -53,6 +56,7 @@ export default function MessageInput({
className="btn"
type="button"
onClick={handleUploadClick}
disabled={isSending}
aria-label={t("uploadSolution")}
title={t("uploadSolution")}
>
......@@ -69,11 +73,19 @@ export default function MessageInput({
className="btn primary"
type="button"
onClick={onSend}
disabled={!canSend}
disabled={!canSend || isSending}
aria-label={t("send")}
title={t("send")}
>
{isSending ? (
<span className="composer-loading" aria-hidden="true">
<span />
<span />
<span />
</span>
) : (
<Send width={18} height={18} aria-hidden="true" />
)}
</button>
</div>
<textarea
......@@ -81,8 +93,14 @@ export default function MessageInput({
placeholder={t("typeQuestionOrLatex")}
rows={3}
value={value}
disabled={isSending}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (isSending) {
event.preventDefault();
return;
}
const isCursorAtStart =
event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd === 0;
......
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