Commit 0d98206f authored by Kantz's avatar Kantz
Browse files

Bilder in Übungsaufgaben anzeigbar gemacht

parent d63d0e46
...@@ -3,6 +3,7 @@ from __future__ import annotations ...@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import List from typing import List
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import app.config as config import app.config as config
...@@ -13,10 +14,16 @@ router = APIRouter() ...@@ -13,10 +14,16 @@ router = APIRouter()
TASK_ORCHESTRATORS = {"task", "socratic"} TASK_ORCHESTRATORS = {"task", "socratic"}
class TaskImage(BaseModel):
src: str
description: str
class TaskItem(BaseModel): class TaskItem(BaseModel):
task_id: str task_id: str
statement: str statement: str
full_text: str full_text: str
images: List[TaskImage] = Field(default_factory=list)
class SubsectionEntry(BaseModel): class SubsectionEntry(BaseModel):
...@@ -68,6 +75,25 @@ class SelectSubsectionResponse(BaseModel): ...@@ -68,6 +75,25 @@ class SelectSubsectionResponse(BaseModel):
subsection_key: str subsection_key: str
@router.get("/api/tasks/assets/{asset_path:path}")
def get_task_asset(asset_path: str) -> FileResponse:
requested_path = str(asset_path).strip().replace("\\", "/")
if not requested_path:
raise HTTPException(status_code=404, detail="asset not found")
candidate = (task_catalog.TASK_IMAGES_DIR / requested_path).resolve()
base_dir = task_catalog.TASK_IMAGES_DIR.resolve()
try:
candidate.relative_to(base_dir)
except ValueError as exc:
raise HTTPException(status_code=404, detail="asset not found") from exc
if not candidate.is_file():
raise HTTPException(status_code=404, detail="asset not found")
return FileResponse(candidate)
@router.get("/api/tasks/config") @router.get("/api/tasks/config")
def get_task_config() -> dict[str, object]: def get_task_config() -> dict[str, object]:
orchestrator = config.get_orchestrator() orchestrator = config.get_orchestrator()
......
...@@ -5,14 +5,17 @@ import re ...@@ -5,14 +5,17 @@ import re
import unicodedata import unicodedata
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from urllib.parse import quote
from typing import Any from typing import Any
from app.deterministic_services import context_store from app.deterministic_services import context_store
from app.deterministic_services.vector_store import parse_markdown_with_frontmatter from app.deterministic_services.vector_store import parse_markdown_with_frontmatter
TASKS_DIR = Path(__file__).resolve().parents[2] / "sources" / "tasks" TASKS_DIR = Path(__file__).resolve().parents[2] / "sources" / "tasks"
TASK_IMAGES_DIR = TASKS_DIR / "images"
SOURCES_DIR = Path(__file__).resolve().parents[2] / "sources" SOURCES_DIR = Path(__file__).resolve().parents[2] / "sources"
SUBSECTION_MAP_PATH = TASKS_DIR / "_subsection_map.json" SUBSECTION_MAP_PATH = TASKS_DIR / "_subsection_map.json"
TASK_ASSET_URL_PREFIX = "/api/tasks/assets"
def _normalize_text(value: str) -> str: def _normalize_text(value: str) -> str:
return re.sub(r"\s+", " ", value.strip().lower()) return re.sub(r"\s+", " ", value.strip().lower())
...@@ -219,17 +222,49 @@ def _find_task_entry(task_file: dict[str, Any], task_id: str) -> dict[str, Any] ...@@ -219,17 +222,49 @@ def _find_task_entry(task_file: dict[str, Any], task_id: str) -> dict[str, Any]
return None return None
def _build_task_payload(task_file: dict[str, Any], task_entry: dict[str, Any]) -> tuple[str, list[str], str]: 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):
return []
normalized_images: list[dict[str, str]] = []
for item in images_raw:
if not isinstance(item, dict):
continue
src = str(item.get("src", "")).strip().replace("\\", "/")
description = str(item.get("description", "")).strip()
if not src or not description:
continue
if src.startswith("/") or Path(src).is_absolute():
continue
normalized_path = Path(src)
if any(part in {"", ".", ".."} for part in normalized_path.parts):
continue
encoded_src = "/".join(quote(part) for part in normalized_path.parts)
normalized_images.append(
{
"src": f"{TASK_ASSET_URL_PREFIX}/{encoded_src}",
"description": description,
}
)
return normalized_images
def _build_task_payload(
task_file: dict[str, Any], task_entry: dict[str, Any]
) -> tuple[str, list[str], str, list[dict[str, str]]]:
title = str(task_file.get("title", "")).strip() title = str(task_file.get("title", "")).strip()
intro = str(task_file.get("intro", "")).strip() intro = str(task_file.get("intro", "")).strip()
statement = str(task_entry.get("statement", "")).strip() statement = str(task_entry.get("statement", "")).strip()
hints_raw = task_entry.get("hints", []) hints_raw = task_entry.get("hints", [])
hints = [str(item).strip() for item in hints_raw if str(item).strip()] if isinstance(hints_raw, list) else [] hints = [str(item).strip() for item in hints_raw if str(item).strip()] if isinstance(hints_raw, list) else []
solution = str(task_entry.get("solution", "")).strip() solution = str(task_entry.get("solution", "")).strip()
images = _normalize_task_images(task_entry)
task_parts = [part for part in [title, intro, statement] if part] task_parts = [part for part in [title, intro, statement] if part]
task_parts.extend([f"Bildbeschreibung: {image['description']}" for image in images])
task_text = "\n".join(task_parts) task_text = "\n".join(task_parts)
return task_text, hints, solution return task_text, hints, solution, images
def set_selected_task( def set_selected_task(
...@@ -237,7 +272,7 @@ def set_selected_task( ...@@ -237,7 +272,7 @@ def set_selected_task(
task_file: dict[str, Any], task_file: dict[str, Any],
task_entry: dict[str, Any], task_entry: dict[str, Any],
) -> None: ) -> None:
task_text, hints, solution = _build_task_payload(task_file, task_entry) task_text, hints, solution, _ = _build_task_payload(task_file, task_entry)
store_new = context_store.context_store_new store_new = context_store.context_store_new
store_new.set_task(sheet, task_text) store_new.set_task(sheet, task_text)
store_new.set_hints(sheet, hints) store_new.set_hints(sheet, hints)
...@@ -471,12 +506,14 @@ def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[d ...@@ -471,12 +506,14 @@ def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[d
continue continue
task_id = str(item.get("id", "")).zfill(2) task_id = str(item.get("id", "")).zfill(2)
statement = str(item.get("statement", "")).strip() statement = str(item.get("statement", "")).strip()
images = _normalize_task_images(item)
full_text_parts = [part for part in [title, intro, statement] if part] full_text_parts = [part for part in [title, intro, statement] if part]
tasks.append( tasks.append(
{ {
"task_id": task_id, "task_id": task_id,
"statement": statement, "statement": statement,
"full_text": "\n".join(full_text_parts), "full_text": "\n".join(full_text_parts),
"images": images,
} }
) )
tasks.sort(key=lambda item: item["task_id"]) tasks.sort(key=lambda item: item["task_id"])
......
...@@ -2,6 +2,8 @@ from __future__ import annotations ...@@ -2,6 +2,8 @@ from __future__ import annotations
import json import json
import os import os
import shutil
import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
...@@ -18,6 +20,79 @@ from app.deterministic_services import session_store, socratic_oranisator, task_ ...@@ -18,6 +20,79 @@ from app.deterministic_services import session_store, socratic_oranisator, task_
class TaskCatalogSocraticTest(unittest.TestCase): class TaskCatalogSocraticTest(unittest.TestCase):
def test_build_task_payload_appends_image_descriptions(self) -> None:
task_file = {
"title": "Grundlagen von Funktionen",
"intro": "Ordnen Sie zu.",
}
task_entry = {
"id": "01",
"statement": "",
"images": [
{
"src": "grundlagen-von-funktionen/01.svg",
"description": "Pfeildiagramm mit einer fehlenden Abbildung.",
}
],
"hints": ["Prüfe jedes Element."],
"solution": "Keine Funktion.",
}
task_text, hints, solution, images = task_catalog._build_task_payload(task_file, task_entry)
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(solution, "Keine Funktion.")
self.assertEqual(
images,
[
{
"src": "/api/tasks/assets/grundlagen-von-funktionen/01.svg",
"description": "Pfeildiagramm mit einer fehlenden Abbildung.",
}
],
)
def test_build_task_catalog_includes_normalized_images(self) -> None:
task_files = [
{
"_file_id": "analysis_1",
"title": "Analysis",
"intro": "Intro",
"subsections": ["quadratische_gleichungen"],
"tasks": [
{
"id": "01",
"statement": "",
"images": [
{
"src": "analysis/01.png",
"description": "Graph einer Parabel.",
},
{
"src": "../invalid.png",
"description": "Invalid",
},
],
}
],
}
]
catalog = task_catalog.build_task_catalog(task_files)
self.assertEqual(
catalog[0]["tasks"][0]["images"],
[
{
"src": "/api/tasks/assets/analysis/01.png",
"description": "Graph einer Parabel.",
}
],
)
def test_socratic_build_subsection_catalog_returns_response_shape(self) -> None: def test_socratic_build_subsection_catalog_returns_response_shape(self) -> None:
with patch( with patch(
"app.deterministic_services.socratic_oranisator.load_initial_prompt_items", "app.deterministic_services.socratic_oranisator.load_initial_prompt_items",
...@@ -212,6 +287,36 @@ class TaskApiSocraticTest(unittest.TestCase): ...@@ -212,6 +287,36 @@ class TaskApiSocraticTest(unittest.TestCase):
self.assertEqual(body["orchestrator"], "socratic") self.assertEqual(body["orchestrator"], "socratic")
self.assertEqual(body["subsections"][0]["subsection_key"], "quadratische gleichungen") self.assertEqual(body["subsections"][0]["subsection_key"], "quadratische gleichungen")
def test_task_asset_endpoint_serves_files_from_task_image_dir(self) -> None:
temp_dir = Path(tempfile.mkdtemp(prefix="task-assets-"))
image_root = temp_dir / "images"
image_root.mkdir(parents=True, exist_ok=True)
image_path = image_root / "functions" / "01.svg"
image_path.parent.mkdir(parents=True, exist_ok=True)
image_path.write_text("<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>", encoding="utf-8")
try:
with patch.object(task_catalog, "TASK_IMAGES_DIR", image_root):
response = self.client.get("/api/tasks/assets/functions/01.svg")
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
self.assertEqual(response.status_code, 200)
self.assertIn("<svg", response.text)
def test_task_asset_endpoint_rejects_path_traversal(self) -> None:
temp_dir = Path(tempfile.mkdtemp(prefix="task-assets-"))
image_root = temp_dir / "images"
image_root.mkdir(parents=True, exist_ok=True)
try:
with patch.object(task_catalog, "TASK_IMAGES_DIR", image_root):
response = self.client.get("/api/tasks/assets/%2E%2E/secret.txt")
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
self.assertEqual(response.status_code, 404)
def test_list_socratic_subsections_returns_catalog(self) -> None: def test_list_socratic_subsections_returns_catalog(self) -> None:
subsections = [ subsections = [
{ {
...@@ -293,7 +398,7 @@ class ChatBootstrapSocraticTest(unittest.TestCase): ...@@ -293,7 +398,7 @@ class ChatBootstrapSocraticTest(unittest.TestCase):
"app.api.chat.retrieval_store.retrieve_for_subsections", "app.api.chat.retrieval_store.retrieve_for_subsections",
return_value=[], return_value=[],
) as retrieve_mock, patch( ) as retrieve_mock, patch(
"app.api.chat.socratic_LLM.generate_dialog", "app.api.chat.socratic_oranisator.get_initial_message",
return_value="Was sind die Themen dieses Abschnitts?", return_value="Was sind die Themen dieses Abschnitts?",
): ):
response = self.client.post( response = self.client.post(
......
export type TaskItem = { export type TaskImage = {
src: string;
description: string;
};
export type TaskItem = {
task_id: string; task_id: string;
statement: string; statement: string;
full_text: string; full_text: string;
images: TaskImage[];
}; };
export type TaskFile = { export type TaskFile = {
......
import type { RefObject } from "react";
import type { TaskImage } from "../../api/taskApi";
type TaskContentProps = {
text: string;
images?: TaskImage[];
textRef?: RefObject<HTMLDivElement | null>;
};
export default function TaskContent({ text, images = [], textRef }: TaskContentProps) {
return (
<div className="task-content-stack">
<div className="task-panel-content" ref={textRef}>
{text}
</div>
{images.length ? (
<div className="task-image-list">
{images.map((image) => (
<div className="task-image-wrap" key={`${image.src}|${image.description}`}>
<img className="task-image" src={image.src} alt={image.description} />
</div>
))}
</div>
) : null}
</div>
);
}
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import type { TaskImage } from "../../api/taskApi";
import { t } from "../../i18n"; import { t } from "../../i18n";
import TaskContent from "./TaskContent";
type SelectOption = { type SelectOption = {
value: string; value: string;
...@@ -14,6 +16,7 @@ type TaskPanelProps = { ...@@ -14,6 +16,7 @@ type TaskPanelProps = {
selectedTaskId?: string; selectedTaskId?: string;
taskOptions?: SelectOption[]; taskOptions?: SelectOption[];
selectedTaskText: string; selectedTaskText: string;
selectedTaskImages?: TaskImage[];
tasksError?: string | null; tasksError?: string | null;
onTaskFileChange?: (fileId: string) => void; onTaskFileChange?: (fileId: string) => void;
onTaskChange?: (taskId: string) => void; onTaskChange?: (taskId: string) => void;
...@@ -32,6 +35,7 @@ export default function TaskPanel({ ...@@ -32,6 +35,7 @@ export default function TaskPanel({
selectedTaskId = "", selectedTaskId = "",
taskOptions = [], taskOptions = [],
selectedTaskText, selectedTaskText,
selectedTaskImages = [],
tasksError, tasksError,
onTaskFileChange, onTaskFileChange,
onTaskChange, onTaskChange,
...@@ -111,9 +115,11 @@ export default function TaskPanel({ ...@@ -111,9 +115,11 @@ export default function TaskPanel({
</div> </div>
) : null} ) : null}
<div className="task-panel-content" ref={taskDisplayRef}> <TaskContent
{selectedTaskText || t("noTaskSelected")} text={selectedTaskText || t("noTaskSelected")}
</div> images={selectedTaskImages}
textRef={taskDisplayRef}
/>
{readOnly ? ( {readOnly ? (
<div className="task-panel-nav"> <div className="task-panel-nav">
......
...@@ -1265,6 +1265,7 @@ export default function ChatPage() { ...@@ -1265,6 +1265,7 @@ export default function ChatPage() {
<TaskPanel <TaskPanel
readOnly readOnly
selectedTaskText={selectedTask.fullText} selectedTaskText={selectedTask.fullText}
selectedTaskImages={selectedTask.images}
selectedFileLabel={selectedTask.title || selectedTask.fileId} selectedFileLabel={selectedTask.title || selectedTask.fileId}
selectedTaskId={selectedTask.taskId} selectedTaskId={selectedTask.taskId}
onChangeTaskArea={handleChangeTaskArea} onChangeTaskArea={handleChangeTaskArea}
......
...@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; ...@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import type { OrchestratorName } from "../api/orchestratorApi"; import type { OrchestratorName } from "../api/orchestratorApi";
import OrchestratorSelect from "../components/Orchestrator/OrchestratorSelect"; import OrchestratorSelect from "../components/Orchestrator/OrchestratorSelect";
import TaskContent from "../components/Task/TaskContent";
import { selectTask } from "../api/taskApi"; import { selectTask } from "../api/taskApi";
import { t } from "../i18n"; import { t } from "../i18n";
import { useTutorSession } from "../state/tutorSession"; import { useTutorSession } from "../state/tutorSession";
...@@ -170,9 +171,11 @@ export default function TaskSelectionPage() { ...@@ -170,9 +171,11 @@ export default function TaskSelectionPage() {
</select> </select>
</div> </div>
<div className="task-panel-content" ref={taskDisplayRef}> <TaskContent
{selectedTask?.fullText || t("noTaskSelected")} text={selectedTask?.fullText || t("noTaskSelected")}
</div> images={selectedTask?.images || []}
textRef={taskDisplayRef}
/>
{tasksError ? <div className="task-panel-error">{tasksError}</div> : null} {tasksError ? <div className="task-panel-error">{tasksError}</div> : null}
......
...@@ -20,6 +20,7 @@ import { ...@@ -20,6 +20,7 @@ import {
type SelectedSubsectionRef, type SelectedSubsectionRef,
type SelectedTaskRef, type SelectedTaskRef,
type SubsectionOption, type SubsectionOption,
type TaskImage,
type TaskFile, type TaskFile,
} from "../api/taskApi"; } from "../api/taskApi";
...@@ -31,6 +32,7 @@ export type SelectOption = { ...@@ -31,6 +32,7 @@ export type SelectOption = {
export type SelectedTask = SelectedTaskRef & { export type SelectedTask = SelectedTaskRef & {
title: string; title: string;
fullText: string; fullText: string;
images: TaskImage[];
}; };
export type SelectedSubsection = SelectedSubsectionRef & { export type SelectedSubsection = SelectedSubsectionRef & {
...@@ -131,6 +133,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) { ...@@ -131,6 +133,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
taskId: task.task_id, taskId: task.task_id,
title: file.title, title: file.title,
fullText: task.full_text, fullText: task.full_text,
images: task.images || [],
}; };
}, [selectedTaskRef, taskFiles]); }, [selectedTaskRef, taskFiles]);
......
...@@ -260,6 +260,34 @@ body { ...@@ -260,6 +260,34 @@ body {
overflow-x: auto; overflow-x: auto;
} }
.task-content-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.task-image-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.task-image-wrap {
background: #fff;
border: 1px solid #e2ded5;
border-radius: 12px;
padding: 10px;
}
.task-image {
display: block;
width: 100%;
max-width: 100%;
height: auto;
border-radius: 8px;
object-fit: contain;
}
.socratic-summary-content { .socratic-summary-content {
max-height: 30vh; max-height: 30vh;
overflow-y: auto; overflow-y: auto;
......
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