Commit 4091f2a9 authored by Kantz's avatar Kantz
Browse files

Merge branch 'dev' into 'main'

ladenzeiten von Tasks verschoben und verkürzt

See merge request kantz/tutor_react!26
parents 307b3f09 5ce4995d
......@@ -21,16 +21,6 @@ class TaskImage(BaseModel):
class TaskItem(BaseModel):
task_id: str
statement: str
full_text: str
images: List[TaskImage] = Field(default_factory=list)
class TopicEntry(BaseModel):
topic_key: str
label: str
refs: List[List[int]]
summary: str = ""
class SubsectionEntry(BaseModel):
......@@ -44,17 +34,21 @@ class SubsectionEntry(BaseModel):
class TaskFile(BaseModel):
file_id: str
title: str
intro: str
tasks: List[TaskItem]
topics: List[str] = Field(default_factory=list)
topic_options: List[TopicEntry] = Field(default_factory=list)
class TasksResponse(BaseModel):
orchestrator: str
enabled: bool
task_files: List[TaskFile]
topics: List[TopicEntry] = Field(default_factory=list)
class TaskDetailsResponse(BaseModel):
file_id: str
task_id: str
title: str
full_text: str
images: List[TaskImage] = Field(default_factory=list)
class SocraticResponse(BaseModel):
......@@ -112,16 +106,22 @@ def get_task_config() -> dict[str, object]:
@router.get("/api/tasks", response_model=TasksResponse)
def list_tasks() -> TasksResponse:
orchestrator = config.get_orchestrator()
task_files = task_catalog.build_task_catalog()
topics = task_catalog.build_topic_catalog()
task_files = list(task_catalog.build_cached_task_metadata_catalog())
return TasksResponse(
orchestrator=orchestrator,
enabled=orchestrator in TASK_ORCHESTRATORS,
task_files=task_files,
topics=topics,
)
@router.get("/api/tasks/{file_id}/{task_id}", response_model=TaskDetailsResponse)
def get_task_details(file_id: str, task_id: str) -> TaskDetailsResponse:
payload = task_catalog.find_task_details(file_id=file_id, task_id=task_id)
if payload is None:
raise HTTPException(status_code=404, detail="task not found")
return TaskDetailsResponse(**payload)
@router.get("/api/tasks/socratic-subsections", response_model=SocraticResponse)
def list_socratic_subsections() -> SocraticResponse:
orchestrator = config.get_orchestrator()
......
......@@ -2,6 +2,7 @@ from __future__ import annotations
import re
import unicodedata
from copy import deepcopy
from functools import lru_cache
from pathlib import Path
from typing import Any
......@@ -542,6 +543,11 @@ def load_task_files(tasks_dir: Path = TASKS_DIR) -> list[dict[str, Any]]:
return loaded
@lru_cache(maxsize=1)
def load_cached_task_files() -> list[dict[str, Any]]:
return load_task_files()
def _find_task_file(task_files: list[dict[str, Any]], file_id: str) -> dict[str, Any] | None:
for task_file in task_files:
if str(task_file.get("_file_id", "")) == file_id:
......@@ -558,6 +564,33 @@ def _find_task_entry(task_file: dict[str, Any], task_id: str) -> dict[str, Any]
return None
def find_task_details(
file_id: str,
task_id: str,
task_files: list[dict[str, Any]] | None = None,
) -> dict[str, Any] | None:
catalog = task_files if task_files is not None else load_cached_task_files()
task_file = _find_task_file(catalog, file_id)
if not task_file:
return None
task_entry = _find_task_entry(task_file, task_id)
if not task_entry:
return None
title = str(task_file.get("title", "")).strip()
intro = str(task_file.get("intro", "")).strip()
statement = str(task_entry.get("statement", "")).strip()
images = _normalize_task_images(task_entry)
full_text_parts = [part for part in [title, intro, statement] if part]
return {
"file_id": str(task_file.get("_file_id", "")),
"task_id": str(task_entry.get("id", "")).zfill(2),
"title": title,
"full_text": "\n".join(full_text_parts),
"images": images,
}
def _normalize_task_images(task_entry: dict[str, Any]) -> list[dict[str, str]]:
images_raw = task_entry.get("images", [])
if not isinstance(images_raw, list):
......@@ -644,7 +677,7 @@ def select_task_by_ids(
task_id: str,
task_files: list[dict[str, Any]] | None = None,
) -> bool:
catalog = task_files if task_files is not None else load_task_files()
catalog = task_files if task_files is not None else load_cached_task_files()
task_file = _find_task_file(catalog, file_id)
if not task_file:
return False
......@@ -661,7 +694,7 @@ def select_subsection_by_ids(
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()
catalog = task_files if task_files is not None else load_cached_task_files()
task_file = _find_task_file(catalog, file_id)
if not task_file:
return False
......@@ -776,7 +809,7 @@ def select_task_for_context(
history: list[dict],
task_files: list[dict[str, Any]] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]] | None:
catalog = task_files if task_files is not None else load_task_files()
catalog = task_files if task_files is not None else load_cached_task_files()
if not catalog:
return None
......@@ -833,7 +866,7 @@ def select_task_for_context(
def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]:
catalog = task_files if task_files is not None else load_task_files()
catalog = task_files if task_files is not None else load_cached_task_files()
catalog = sorted(
catalog,
key=lambda item: (
......@@ -880,3 +913,35 @@ def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[d
}
)
return response
def build_task_metadata_catalog(task_files: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]:
catalog = task_files if task_files is not None else load_cached_task_files()
catalog = sorted(
catalog,
key=lambda item: (
str(item.get("title", "")).strip().lower(),
str(item.get("_file_id", "")).strip().lower(),
),
)
response: list[dict[str, Any]] = []
for task_file in catalog:
tasks: list[dict[str, str]] = []
for item in task_file.get("tasks", []):
if not isinstance(item, dict):
continue
tasks.append({"task_id": str(item.get("id", "")).zfill(2)})
tasks.sort(key=lambda item: item["task_id"])
response.append(
{
"file_id": str(task_file.get("_file_id", "")),
"title": str(task_file.get("title", "")).strip(),
"tasks": tasks,
}
)
return response
@lru_cache(maxsize=1)
def build_cached_task_metadata_catalog() -> tuple[dict[str, Any], ...]:
return tuple(deepcopy(build_task_metadata_catalog()))
......@@ -20,6 +20,10 @@ from app.deterministic_services import session_store, socratic_oranisator, task_
class TaskCatalogSocraticTest(unittest.TestCase):
def tearDown(self) -> None:
task_catalog.build_cached_task_metadata_catalog.cache_clear()
task_catalog.load_cached_task_files.cache_clear()
def test_build_task_payload_appends_image_descriptions(self) -> None:
task_file = {
"title": "Grundlagen von Funktionen",
......@@ -95,6 +99,50 @@ class TaskCatalogSocraticTest(unittest.TestCase):
)
self.assertEqual(catalog[0]["topics"], ["quadratische_gleichungen"])
def test_build_task_metadata_catalog_only_returns_titles_and_task_ids(self) -> None:
task_files = [
{
"_file_id": "analysis_1",
"title": "Analysis",
"intro": "Intro",
"subsections": ["quadratische_gleichungen"],
"topic_refs": [],
"tasks": [
{
"id": "01",
"statement": "Bestimme f(x).",
"images": [{"src": "analysis/01.png", "description": "Graph"}],
}
],
}
]
catalog = task_catalog.build_task_metadata_catalog(task_files)
self.assertEqual(
catalog,
[
{
"file_id": "analysis_1",
"title": "Analysis",
"tasks": [{"task_id": "01"}],
}
],
)
def test_build_cached_task_metadata_catalog_reuses_cache(self) -> None:
task_catalog.build_cached_task_metadata_catalog.cache_clear()
with patch(
"app.deterministic_services.task_catalog.build_task_metadata_catalog",
return_value=[{"file_id": "analysis_1", "title": "Analysis", "tasks": [{"task_id": "01"}]}],
) as build_mock:
first = task_catalog.build_cached_task_metadata_catalog()
second = task_catalog.build_cached_task_metadata_catalog()
self.assertEqual(first, second)
build_mock.assert_called_once()
def test_normalize_yaml_task_entry_excludes_images_from_hints(self) -> None:
task_entry = {
"aufgabe": [
......@@ -376,39 +424,47 @@ class TaskApiSocraticTest(unittest.TestCase):
app.include_router(tasks.router)
self.client = TestClient(app)
def test_list_tasks_includes_topic_options(self) -> None:
def test_list_tasks_returns_lightweight_catalog(self) -> None:
payload = [
{
"file_id": "analysis_1",
"title": "Analysis",
"intro": "Intro",
"tasks": [],
"topics": ["quadratische gleichungen"],
"topic_options": [],
}
]
topics = [
{
"topic_key": "quadratische gleichungen",
"label": "Quadratische Gleichungen",
"refs": [[1, 3, 3, 0]],
"level": "subsection",
"tasks": [{"task_id": "01"}],
}
]
with patch("app.api.tasks.config.get_orchestrator", return_value="socratic"), patch(
"app.api.tasks.task_catalog.build_task_catalog",
"app.api.tasks.task_catalog.build_cached_task_metadata_catalog",
return_value=payload,
), patch(
"app.api.tasks.task_catalog.build_topic_catalog",
return_value=topics,
):
response = self.client.get("/api/tasks")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["orchestrator"], "socratic")
self.assertEqual(body["topics"][0]["topic_key"], "quadratische gleichungen")
self.assertEqual(body["task_files"], payload)
self.assertNotIn("topics", body)
def test_get_task_details_returns_task_payload(self) -> None:
payload = {
"file_id": "analysis_1",
"task_id": "01",
"title": "Analysis",
"full_text": "Analysis\nIntro\nBestimme f(x).",
"images": [{"src": "/api/tasks/assets/analysis/01.png", "description": "Graph"}],
}
with patch("app.api.tasks.task_catalog.find_task_details", return_value=payload):
response = self.client.get("/api/tasks/analysis_1/01")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), payload)
def test_get_task_details_returns_404_for_invalid_task(self) -> None:
with patch("app.api.tasks.task_catalog.find_task_details", return_value=None):
response = self.client.get("/api/tasks/analysis_1/99")
self.assertEqual(response.status_code, 404)
def test_task_asset_endpoint_serves_files_from_task_image_dir(self) -> None:
temp_dir = Path(tempfile.mkdtemp(prefix="task-assets-"))
......
......@@ -3,27 +3,14 @@ export type TaskImage = {
description: string;
};
export type TaskItem = {
export type TaskListItem = {
task_id: string;
statement: string;
full_text: string;
images: TaskImage[];
};
export type TaskFile = {
file_id: string;
title: string;
intro: string;
tasks: TaskItem[];
topics?: string[];
topic_options?: TopicOption[];
};
export type TopicOption = {
topic_key: string;
label: string;
refs: [number, number, number, number][];
summary: string;
tasks: TaskListItem[];
};
export type SubsectionOption = {
......@@ -37,7 +24,14 @@ export type TasksResponse = {
orchestrator: string;
enabled: boolean;
task_files: TaskFile[];
topics: TopicOption[];
};
export type TaskDetailsResponse = {
file_id: string;
task_id: string;
title: string;
full_text: string;
images: TaskImage[];
};
export type SocraticResponse = {
......@@ -74,6 +68,19 @@ export async function fetchTasks(): Promise<TasksResponse> {
return response.json();
}
export async function fetchTaskDetails(input: {
fileId: string;
taskId: string;
}): Promise<TaskDetailsResponse> {
const response = await fetch(
`/api/tasks/${encodeURIComponent(input.fileId)}/${encodeURIComponent(input.taskId)}`
);
if (!response.ok) {
throw new Error(`Task details failed: ${response.status}`);
}
return response.json();
}
export async function fetchSocraticSubsections(): Promise<SocraticResponse> {
const response = await fetch("/api/tasks/socratic-subsections");
if (!response.ok) {
......
......@@ -5,6 +5,7 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
} from "react";
......@@ -15,13 +16,14 @@ import {
type OrchestratorName,
} from "../api/orchestratorApi";
import {
fetchTaskDetails,
fetchSocraticSubsections,
fetchTasks,
type SelectedSubsectionRef,
type SelectedTaskRef,
type SubsectionOption,
type TopicOption,
type TaskImage,
type TaskDetailsResponse,
type TaskFile,
} from "../api/taskApi";
......@@ -45,7 +47,6 @@ export type SelectedSubsection = SelectedSubsectionRef & {
export type TaskSelectionState = {
taskFiles: TaskFile[];
topics: TopicOption[];
selectedTaskRef: SelectedTaskRef | null;
selectedTask: SelectedTask | null;
selectedTaskFile: TaskFile | null;
......@@ -90,8 +91,7 @@ export const getDefaultTaskId = (tasks: Array<{ task_id: string }>): string =>
tasks.find((task) => task.task_id === "01")?.task_id || tasks[0]?.task_id || "";
const isSelectableTaskFile = (file: TaskFile): boolean =>
(Array.isArray(file.topics) && file.topics.length > 0) ||
(Array.isArray(file.topic_options) && file.topic_options.length > 0);
Array.isArray(file.tasks) && file.tasks.length > 0;
const isTaskCoupledOrchestrator = (value: OrchestratorName): boolean =>
value === "task" || value === "socratic";
......@@ -110,36 +110,36 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
const [orchestratorError, setOrchestratorError] = useState<string | null>(null);
const [isTasksInitialized, setIsTasksInitialized] = useState(false);
const [taskFiles, setTaskFiles] = useState<TaskFile[]>([]);
const [topics, setTopics] = useState<TopicOption[]>([]);
const [subsections, setSubsections] = useState<SubsectionOption[]>([]);
const [selectedTaskRef, setSelectedTaskRef] = useState<SelectedTaskRef | null>(null);
const [selectedSubsectionRef, setSelectedSubsectionRef] =
useState<SelectedSubsectionRef | null>(null);
const [tasksError, setTasksError] = useState<string | null>(null);
const [taskLocked, setTaskLocked] = useState(false);
const [selectedTaskDetails, setSelectedTaskDetails] = useState<TaskDetailsResponse | null>(null);
const taskDetailsRequestRef = useRef(0);
const didRunPostInitReloadRef = useRef(false);
const isTaskModeEnabled = isTaskCoupledOrchestrator(selectedOrchestrator);
const selectedTask = useMemo<SelectedTask | null>(() => {
if (!selectedTaskRef) {
if (!selectedTaskRef || !selectedTaskDetails) {
return null;
}
const file = taskFiles.find((item) => item.file_id === selectedTaskRef.fileId);
if (!file) {
return null;
}
const task = file.tasks.find((item) => item.task_id === selectedTaskRef.taskId);
if (!task) {
if (
selectedTaskDetails.file_id !== selectedTaskRef.fileId ||
selectedTaskDetails.task_id !== selectedTaskRef.taskId
) {
return null;
}
return {
fileId: file.file_id,
taskId: task.task_id,
title: file.title,
fullText: task.full_text,
images: task.images || [],
fileId: selectedTaskDetails.file_id,
taskId: selectedTaskDetails.task_id,
title: selectedTaskDetails.title,
fullText: selectedTaskDetails.full_text,
images: selectedTaskDetails.images || [],
};
}, [selectedTaskRef, taskFiles]);
}, [selectedTaskDetails, selectedTaskRef]);
const selectedTaskFile = useMemo(
() => taskFiles.find((file) => file.file_id === selectedTaskRef?.fileId) || null,
......@@ -198,12 +198,37 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
};
}, [selectedSubsectionRef, subsections]);
const loadTaskDetails = useCallback(async (taskRef: SelectedTaskRef | null) => {
if (!taskRef) {
setSelectedTaskDetails(null);
return;
}
const requestId = ++taskDetailsRequestRef.current;
try {
const payload = await fetchTaskDetails({
fileId: taskRef.fileId,
taskId: taskRef.taskId,
});
if (taskDetailsRequestRef.current !== requestId) {
return;
}
setSelectedTaskDetails(payload);
} catch (error) {
if (taskDetailsRequestRef.current !== requestId) {
return;
}
setSelectedTaskDetails(null);
setTasksError(t("failedLoadTasks"));
void error;
}
}, []);
const loadSelectionData = useCallback(async (orchestrator: OrchestratorName) => {
setTasksError(null);
try {
const isSocratic = orchestrator === "socratic";
let files: TaskFile[] = [];
let topicsPayload: TopicOption[] = [];
let subsectionsPayload: SubsectionOption[] = [];
if (isSocratic) {
......@@ -212,13 +237,13 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
} else {
const payload = await fetchTasks();
files = payload.task_files || [];
topicsPayload = payload.topics || [];
}
const selectableFiles = files.filter((file) => isSelectableTaskFile(file));
const selectableFiles = files.filter((file) => Array.isArray(file.tasks) && file.tasks.length > 0);
let nextTaskRef: SelectedTaskRef | null = null;
let nextSubsectionRef: SelectedSubsectionRef | null = null;
setTaskFiles(files);
setTopics(topicsPayload);
setSubsections(subsectionsPayload);
setSelectedTaskRef((prev) => {
if (isSocratic) {
......@@ -227,6 +252,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
if (prev) {
const file = selectableFiles.find((item) => item.file_id === prev.fileId);
if (file && file.tasks.some((task) => task.task_id === prev.taskId)) {
nextTaskRef = prev;
return prev;
}
}
......@@ -239,7 +265,8 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
if (!defaultTaskId) {
return null;
}
return { fileId: firstFile.file_id, taskId: defaultTaskId };
nextTaskRef = { fileId: firstFile.file_id, taskId: defaultTaskId };
return nextTaskRef;
});
setSelectedSubsectionRef((prev) => {
if (!isSocratic) {
......@@ -250,6 +277,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
(item) => item.subsection_key === prev.subsectionKey
);
if (option) {
nextSubsectionRef = prev;
return prev;
}
}
......@@ -258,18 +286,27 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
if (!firstOption) {
return null;
}
return { subsectionKey: firstOption.subsection_key };
nextSubsectionRef = { subsectionKey: firstOption.subsection_key };
return nextSubsectionRef;
});
if (isSocratic) {
taskDetailsRequestRef.current += 1;
setSelectedTaskDetails(null);
} else {
await loadTaskDetails(nextTaskRef);
}
void nextSubsectionRef;
} catch (error) {
setTasksError(t("failedLoadTasks"));
setTaskFiles([]);
setTopics([]);
setSubsections([]);
setSelectedTaskRef(null);
setSelectedSubsectionRef(null);
taskDetailsRequestRef.current += 1;
setSelectedTaskDetails(null);
void error;
}
}, []);
}, [loadTaskDetails]);
const initTasks = useCallback(async () => {
setOrchestratorError(null);
......@@ -302,12 +339,52 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
void initTasks();
}, [initTasks]);
useEffect(() => {
if (!isTasksInitialized) {
return;
}
if (!didRunPostInitReloadRef.current) {
didRunPostInitReloadRef.current = true;
return;
}
void loadSelectionData(selectedOrchestrator);
}, [isTasksInitialized, loadSelectionData, selectedOrchestrator]);
useEffect(() => {
if (!isTasksInitialized || selectedOrchestrator !== "task" || !selectedTaskRef) {
return;
}
if (
selectedTaskDetails?.file_id === selectedTaskRef.fileId &&
selectedTaskDetails?.task_id === selectedTaskRef.taskId
) {
return;
}
void loadTaskDetails(selectedTaskRef);
}, [
isTasksInitialized,
loadTaskDetails,
selectedOrchestrator,
selectedTaskDetails?.file_id,
selectedTaskDetails?.task_id,
selectedTaskRef,
]);
const setTaskRef = useCallback(
(value: SelectedTaskRef | null) => {
setSelectedTaskRef(value);
setTasksError(null);
void loadTaskDetails(value);
},
[loadTaskDetails]
);
const setTaskFile = useCallback(
(fileId: string) => {
if (!fileId) {
return;
}
const file = taskFiles.find((item) => item.file_id === fileId && isSelectableTaskFile(item));
const file = taskFiles.find((item) => item.file_id === fileId && item.tasks.length > 0);
if (!file) {
return;
}
......@@ -315,9 +392,12 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
if (!defaultTaskId) {
return;
}
setSelectedTaskRef({ fileId: file.file_id, taskId: defaultTaskId });
setTasksError(null);
const nextRef = { fileId: file.file_id, taskId: defaultTaskId };
setSelectedTaskRef(nextRef);
void loadTaskDetails(nextRef);
},
[taskFiles]
[loadTaskDetails, taskFiles]
);
const setTaskId = useCallback(
......@@ -325,9 +405,12 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
if (!taskId || !selectedTaskRef?.fileId) {
return;
}
setSelectedTaskRef({ fileId: selectedTaskRef.fileId, taskId });
setTasksError(null);
const nextRef = { fileId: selectedTaskRef.fileId, taskId };
setSelectedTaskRef(nextRef);
void loadTaskDetails(nextRef);
},
[selectedTaskRef]
[loadTaskDetails, selectedTaskRef]
);
const setSubsectionKey = useCallback(
......@@ -350,8 +433,12 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
setTaskLocked(false);
if (value === "socratic") {
setSelectedTaskRef(null);
taskDetailsRequestRef.current += 1;
setSelectedTaskDetails(null);
} else if (!isTaskCoupledOrchestrator(value)) {
setSelectedTaskRef(null);
taskDetailsRequestRef.current += 1;
setSelectedTaskDetails(null);
}
if (value !== "socratic") {
setSelectedSubsectionRef(null);
......@@ -371,6 +458,8 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
setSelectedTaskRef(null);
setSelectedSubsectionRef(null);
setTaskLocked(false);
taskDetailsRequestRef.current += 1;
setSelectedTaskDetails(null);
}, []);
const value: TutorSessionState = {
......@@ -384,7 +473,6 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
isOrchestratorSelectable,
orchestratorError,
taskFiles,
topics,
subsections,
selectedTaskRef,
selectedTask,
......@@ -394,7 +482,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
selectedSubsectionRef,
selectedSubsection,
subsectionOptions,
setTaskRef: setSelectedTaskRef,
setTaskRef,
setSubsectionRef: setSelectedSubsectionRef,
setTaskFile,
setTaskId,
......
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