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
from typing import List
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
import app.config as config
......@@ -13,10 +14,16 @@ router = APIRouter()
TASK_ORCHESTRATORS = {"task", "socratic"}
class TaskImage(BaseModel):
src: str
description: str
class TaskItem(BaseModel):
task_id: str
statement: str
full_text: str
images: List[TaskImage] = Field(default_factory=list)
class SubsectionEntry(BaseModel):
......@@ -68,6 +75,25 @@ class SelectSubsectionResponse(BaseModel):
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")
def get_task_config() -> dict[str, object]:
orchestrator = config.get_orchestrator()
......
......@@ -5,14 +5,17 @@ import re
import unicodedata
from functools import lru_cache
from pathlib import Path
from urllib.parse import quote
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"
TASK_IMAGES_DIR = TASKS_DIR / "images"
SOURCES_DIR = Path(__file__).resolve().parents[2] / "sources"
SUBSECTION_MAP_PATH = TASKS_DIR / "_subsection_map.json"
TASK_ASSET_URL_PREFIX = "/api/tasks/assets"
def _normalize_text(value: str) -> str:
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]
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()
intro = str(task_file.get("intro", "")).strip()
statement = str(task_entry.get("statement", "")).strip()
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 []
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.extend([f"Bildbeschreibung: {image['description']}" for image in images])
task_text = "\n".join(task_parts)
return task_text, hints, solution
return task_text, hints, solution, images
def set_selected_task(
......@@ -237,7 +272,7 @@ def set_selected_task(
task_file: dict[str, Any],
task_entry: dict[str, Any],
) -> 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.set_task(sheet, task_text)
store_new.set_hints(sheet, hints)
......@@ -471,12 +506,14 @@ def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[d
continue
task_id = str(item.get("id", "")).zfill(2)
statement = str(item.get("statement", "")).strip()
images = _normalize_task_images(item)
full_text_parts = [part for part in [title, intro, statement] if part]
tasks.append(
{
"task_id": task_id,
"statement": statement,
"full_text": "\n".join(full_text_parts),
"images": images,
}
)
tasks.sort(key=lambda item: item["task_id"])
......
......@@ -2,6 +2,8 @@ from __future__ import annotations
import json
import os
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
......@@ -18,6 +20,79 @@ from app.deterministic_services import session_store, socratic_oranisator, task_
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:
with patch(
"app.deterministic_services.socratic_oranisator.load_initial_prompt_items",
......@@ -212,6 +287,36 @@ class TaskApiSocraticTest(unittest.TestCase):
self.assertEqual(body["orchestrator"], "socratic")
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:
subsections = [
{
......@@ -293,7 +398,7 @@ class ChatBootstrapSocraticTest(unittest.TestCase):
"app.api.chat.retrieval_store.retrieve_for_subsections",
return_value=[],
) 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?",
):
response = self.client.post(
......
export type TaskItem = {
export type TaskImage = {
src: string;
description: string;
};
export type TaskItem = {
task_id: string;
statement: string;
full_text: string;
images: TaskImage[];
};
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 TaskContent from "./TaskContent";
type SelectOption = {
value: string;
......@@ -14,6 +16,7 @@ type TaskPanelProps = {
selectedTaskId?: string;
taskOptions?: SelectOption[];
selectedTaskText: string;
selectedTaskImages?: TaskImage[];
tasksError?: string | null;
onTaskFileChange?: (fileId: string) => void;
onTaskChange?: (taskId: string) => void;
......@@ -32,6 +35,7 @@ export default function TaskPanel({
selectedTaskId = "",
taskOptions = [],
selectedTaskText,
selectedTaskImages = [],
tasksError,
onTaskFileChange,
onTaskChange,
......@@ -111,9 +115,11 @@ export default function TaskPanel({
</div>
) : null}
<div className="task-panel-content" ref={taskDisplayRef}>
{selectedTaskText || t("noTaskSelected")}
</div>
<TaskContent
text={selectedTaskText || t("noTaskSelected")}
images={selectedTaskImages}
textRef={taskDisplayRef}
/>
{readOnly ? (
<div className="task-panel-nav">
......
......@@ -1265,6 +1265,7 @@ export default function ChatPage() {
<TaskPanel
readOnly
selectedTaskText={selectedTask.fullText}
selectedTaskImages={selectedTask.images}
selectedFileLabel={selectedTask.title || selectedTask.fileId}
selectedTaskId={selectedTask.taskId}
onChangeTaskArea={handleChangeTaskArea}
......
......@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import type { OrchestratorName } from "../api/orchestratorApi";
import OrchestratorSelect from "../components/Orchestrator/OrchestratorSelect";
import TaskContent from "../components/Task/TaskContent";
import { selectTask } from "../api/taskApi";
import { t } from "../i18n";
import { useTutorSession } from "../state/tutorSession";
......@@ -170,9 +171,11 @@ export default function TaskSelectionPage() {
</select>
</div>
<div className="task-panel-content" ref={taskDisplayRef}>
{selectedTask?.fullText || t("noTaskSelected")}
</div>
<TaskContent
text={selectedTask?.fullText || t("noTaskSelected")}
images={selectedTask?.images || []}
textRef={taskDisplayRef}
/>
{tasksError ? <div className="task-panel-error">{tasksError}</div> : null}
......
......@@ -20,6 +20,7 @@ import {
type SelectedSubsectionRef,
type SelectedTaskRef,
type SubsectionOption,
type TaskImage,
type TaskFile,
} from "../api/taskApi";
......@@ -31,6 +32,7 @@ export type SelectOption = {
export type SelectedTask = SelectedTaskRef & {
title: string;
fullText: string;
images: TaskImage[];
};
export type SelectedSubsection = SelectedSubsectionRef & {
......@@ -131,6 +133,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
taskId: task.task_id,
title: file.title,
fullText: task.full_text,
images: task.images || [],
};
}, [selectedTaskRef, taskFiles]);
......
......@@ -260,6 +260,34 @@ body {
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 {
max-height: 30vh;
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