Commit a27a4ff5 authored by Kantz's avatar Kantz
Browse files

Merge branch 'dev' into 'main'

Dev

See merge request kantz/tutor_react!23
parents 3a4e6b7b 9d944f7b
......@@ -4,6 +4,7 @@ venv/
.env-openai
.env-ollama
.env-gwdg
.env-stochastik
__pycache__/
drawings/
sources/
......
......@@ -10,7 +10,7 @@
if you want to use the task mode you need :
- task folder in sources with your tasks
- `_subsection_map.json`.
- `_subsection_map.yaml`.
## Setup
......@@ -217,7 +217,7 @@ python -m test.hint_test --chat-id draft_session_mlgmxxzc_avmjfb
python -m test.retrieval_store_test --query "Was ist eine Teilmenge?" --k 8 --expand
python -m test.retrieval_store_test --query "Was ist eine Teilmenge?" --k 3 --subsections 1:1:1
python -m test.retrieval_store_test --query "Was ist eine Teilmenge?" --k 3 --subsections 1-1-1
python -m test.math_intent_test --input "Integrate x^2" --input "Was ist 2+2?"
......
......@@ -9,6 +9,7 @@ FRONTEND_URL="http://frontend:3000"
ORCHESTRATOR="task" # "tutor", "task" or "qa"
RETRIEVAL_IMPL="child" # "child" or "subsection"
TASK_FOLDER="tasks"
LLM_PROVIDER="gwdg" # "openai", "gwdg", "mistral", or "ollama"
EMBEDDING_PROVIDER="sentence-transformer" # "sentence-transformer", "openai", or "gwdg"
......
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
from pydantic import BaseModel
......@@ -7,6 +8,9 @@ from typing import Optional
load_dotenv()
BACKEND_ROOT = Path(__file__).resolve().parents[1]
SOURCES_ROOT = BACKEND_ROOT / "sources"
SUPPORTED_LLM_PROVIDERS = {"openai", "gwdg", "mistral", "ollama"}
SUPPORTED_EMBEDDING_PROVIDERS = {"sentence-transformer", "openai", "gwdg"}
......@@ -30,6 +34,17 @@ def get_retrieval_impl() -> str:
return "child"
def get_task_folder() -> Path:
value = os.getenv("TASK_FOLDER", "tasks").strip()
if not value:
value = "tasks"
folder = Path(value)
if folder.is_absolute():
return folder
return SOURCES_ROOT / folder
def get_llm_provider() -> str:
value = os.getenv("LLM_PROVIDER")
if value is None or not value.strip():
......
......@@ -41,7 +41,7 @@ def _ensure_context_task_fields(state: base.ChatState, query_text: str) -> tuple
base.append_tool_log(
state.tool_log,
"task_json_selected",
"task_yaml_selected",
{"tasks_dir": str(task_catalog.TASKS_DIR)},
{
"file": task_file.get("_path", ""),
......
from __future__ import annotations
import json
import re
import unicodedata
from functools import lru_cache
from pathlib import Path
from urllib.parse import quote
from typing import Any
from urllib.parse import quote
import yaml
from app import config
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"
TASKS_DIR = config.get_task_folder()
TASK_IMAGES_DIR = TASKS_DIR / "images"
SOURCES_DIR = Path(__file__).resolve().parents[2] / "sources"
SUBSECTION_MAP_PATH = TASKS_DIR / "_subsection_map.json"
SUBSECTION_MAP_PATH = TASKS_DIR / "_subsection_map.yaml"
TASK_ASSET_URL_PREFIX = "/api/tasks/assets"
def _normalize_text(value: str) -> str:
......@@ -34,7 +36,7 @@ def _normalize_subsection_key(value: str) -> str:
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))
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))
......@@ -44,7 +46,7 @@ def load_subsection_map(path: Path = SUBSECTION_MAP_PATH) -> dict[str, tuple[int
if not path.exists():
return {}
try:
content = json.loads(path.read_text(encoding="utf-8"))
content = yaml.safe_load(path.read_text(encoding="utf-8"))
except Exception:
return {}
if not isinstance(content, dict):
......@@ -186,23 +188,144 @@ def _match_score(query_text: str, candidate_text: str) -> int:
return len(query_tokens.intersection(candidate_tokens))
def _slugify(value: str) -> str:
normalized = unicodedata.normalize("NFKD", str(value))
ascii_value = normalized.encode("ascii", "ignore").decode("ascii")
lowered = ascii_value.strip().lower()
collapsed = re.sub(r"[^a-z0-9]+", "-", lowered)
collapsed = re.sub(r"-{2,}", "-", collapsed)
return collapsed.strip("-")
def _normalize_block_images(images_raw: list[dict[str, str]]) -> list[dict[str, str]]:
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", "") or item.get("alt", "")).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 _extract_text_and_images(blocks: Any) -> tuple[list[str], list[dict[str, str]]]:
if not isinstance(blocks, list):
return [], []
text_parts: list[str] = []
images: list[dict[str, str]] = []
for block in blocks:
if not isinstance(block, dict):
continue
block_type = str(block.get("type", "")).strip().lower()
if block_type == "text":
text = str(block.get("text", "")).strip()
if text:
text_parts.append(text)
continue
if block_type == "image":
src = str(block.get("src", "")).strip()
alt = str(block.get("alt", "")).strip()
if src and alt:
images.append({"src": src, "description": alt})
return text_parts, images
def _normalize_yaml_task_entry(task_entry: Any, position: int) -> dict[str, Any] | None:
if not isinstance(task_entry, dict):
return None
statement_parts, statement_images = _extract_text_and_images(task_entry.get("aufgabe", []))
hint_parts, hint_images = _extract_text_and_images(task_entry.get("hinweise", []))
solution_parts, _ = _extract_text_and_images(task_entry.get("loesung", []))
return {
"id": str(position).zfill(2),
"statement": "\n".join(statement_parts).strip(),
"hints": hint_parts,
"solution": "\n".join(solution_parts).strip(),
"images": statement_images + hint_images,
}
def _normalize_exercise_topics(raw_topics: Any) -> list[str]:
if not isinstance(raw_topics, list):
return []
topics: list[str] = []
for item in raw_topics:
topic_id = ""
if isinstance(item, dict):
topic_id = str(item.get("topic-id", "")).strip()
elif isinstance(item, str):
topic_id = item.strip()
if topic_id:
topics.append(topic_id)
return topics
def _normalize_yaml_task_file(content: Any, path: Path) -> dict[str, Any] | None:
if not isinstance(content, dict):
return None
if not isinstance(content.get("tasks"), list):
return None
title = str(content.get("titel", "")).strip()
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)
level = str(content.get("level", "")).strip()
subsections = _normalize_exercise_topics(content.get("exercise_topic", []))
if not title or not file_id or not slug or not level:
return None
tasks: list[dict[str, Any]] = []
for index, raw_task in enumerate(content.get("tasks", []), start=1):
normalized_task = _normalize_yaml_task_entry(raw_task, index)
if normalized_task is None:
return None
tasks.append(normalized_task)
return {
"_path": str(path),
"_file_id": file_id,
"title": title,
"intro": intro,
"slug": slug,
"level": level,
"subsections": subsections,
"tasks": tasks,
}
def load_task_files(tasks_dir: Path = TASKS_DIR) -> list[dict[str, Any]]:
if not tasks_dir.exists():
return []
loaded: list[dict[str, Any]] = []
for path in sorted(tasks_dir.glob("*.json")):
for path in sorted([*tasks_dir.glob("*.yaml"), *tasks_dir.glob("*.yml")]):
try:
content = json.loads(path.read_text(encoding="utf-8"))
content = yaml.safe_load(path.read_text(encoding="utf-8"))
except Exception:
continue
if not isinstance(content, dict):
normalized = _normalize_yaml_task_file(content, path)
if normalized is None:
continue
if not isinstance(content.get("tasks"), list):
continue
content["_path"] = str(path)
content["_file_id"] = path.stem
loaded.append(content)
loaded.append(normalized)
return loaded
......@@ -226,28 +349,7 @@ 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
return _normalize_block_images(images_raw)
def _build_task_payload(
......
from __future__ import annotations
import argparse
import json
import shutil
import unicodedata
from pathlib import Path
from typing import Any
import yaml
BACKEND_ROOT = Path(__file__).resolve().parents[1]
TASKS_DIR = BACKEND_ROOT / "sources" / "tasks"
TASK_IMAGES_DIR = TASKS_DIR / "images"
DEFAULT_LEVEL = "Uebungen"
def slugify(value: str) -> str:
normalized = unicodedata.normalize("NFKD", str(value))
ascii_value = normalized.encode("ascii", "ignore").decode("ascii")
lowered = ascii_value.strip().lower()
collapsed = []
last_was_dash = False
for char in lowered:
if char.isalnum():
collapsed.append(char)
last_was_dash = False
elif not last_was_dash:
collapsed.append("-")
last_was_dash = True
return "".join(collapsed).strip("-")
def load_json_task(path: Path) -> dict[str, Any]:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"{path.name}: expected object")
if not isinstance(payload.get("tasks"), list):
raise ValueError(f"{path.name}: expected tasks list")
return payload
def _build_topic_entries(subsections: Any) -> list[dict[str, str]]:
if not isinstance(subsections, list):
return []
topics: list[dict[str, str]] = []
for item in subsections:
topic_id = str(item).strip()
if topic_id:
topics.append({"topic-id": topic_id})
return topics
def _build_text_block(text: str) -> dict[str, str]:
return {"type": "text", "text": text}
def _build_image_block(src: str, description: str) -> dict[str, str]:
return {"type": "image", "src": src, "alt": description}
def convert_task_payload(payload: dict[str, Any], source_file_id: str, image_path_map: dict[str, str]) -> dict[str, Any]:
title = str(payload.get("title", "")).strip()
intro = str(payload.get("intro", "")).strip()
slug = slugify(title or source_file_id)
converted_tasks: list[dict[str, Any]] = []
for raw_task in payload.get("tasks", []):
if not isinstance(raw_task, dict):
continue
aufgabe: list[dict[str, str]] = []
statement = str(raw_task.get("statement", "")).strip()
if statement:
aufgabe.append(_build_text_block(statement))
for raw_image in raw_task.get("images", []) if isinstance(raw_task.get("images"), list) else []:
if not isinstance(raw_image, dict):
continue
old_src = str(raw_image.get("src", "")).strip().replace("\\", "/")
description = str(raw_image.get("description", "")).strip()
new_src = image_path_map.get(old_src, old_src)
if old_src and description:
aufgabe.append(_build_image_block(new_src, description))
hinweise: list[dict[str, str]] = []
for hint in raw_task.get("hints", []) if isinstance(raw_task.get("hints"), list) else []:
hint_text = str(hint).strip()
if hint_text:
hinweise.append(_build_text_block(hint_text))
loesung_text = str(raw_task.get("solution", "")).strip()
loesung = [_build_text_block(loesung_text)] if loesung_text else []
converted_tasks.append(
{
"aufgabe": aufgabe,
"hinweise": hinweise,
"loesung": loesung,
}
)
return {
"id": source_file_id,
"titel": title,
"level": DEFAULT_LEVEL,
"slug": slug,
"description": intro,
"exercise_topic": _build_topic_entries(payload.get("subsections", [])),
"tasks": converted_tasks,
}
def build_image_path_map(payload: dict[str, Any], slug: str) -> dict[str, str]:
mapping: dict[str, str] = {}
used_targets: set[str] = set()
for raw_task in payload.get("tasks", []):
if not isinstance(raw_task, dict):
continue
for raw_image in raw_task.get("images", []) if isinstance(raw_task.get("images"), list) else []:
if not isinstance(raw_image, dict):
continue
old_src = str(raw_image.get("src", "")).strip().replace("\\", "/")
if not old_src:
continue
basename = Path(old_src).name
stem = Path(basename).stem
suffix = Path(basename).suffix
target = f"{slug}/{basename}"
counter = 2
while target in used_targets:
target = f"{slug}/{stem}-{counter}{suffix}"
counter += 1
used_targets.add(target)
mapping[old_src] = target
return mapping
def filter_existing_image_path_map(image_path_map: dict[str, str], images_root: Path) -> tuple[dict[str, str], list[str]]:
existing: dict[str, str] = {}
missing: list[str] = []
for old_src, new_src in image_path_map.items():
source_path = images_root / old_src
if source_path.exists():
existing[old_src] = new_src
else:
missing.append(old_src)
return existing, missing
def move_task_images(image_path_map: dict[str, str], images_root: Path, dry_run: bool = False) -> list[str]:
moved: list[str] = []
for old_src, new_src in image_path_map.items():
source_path = images_root / old_src
target_path = images_root / new_src
if source_path.resolve() == target_path.resolve():
continue
if dry_run:
moved.append(f"{source_path} -> {target_path}")
continue
target_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(source_path), str(target_path))
moved.append(f"{source_path} -> {target_path}")
return moved
def remove_empty_dirs(root: Path) -> None:
for path in sorted(root.rglob("*"), reverse=True):
if path.is_dir():
try:
next(path.iterdir())
except StopIteration:
path.rmdir()
def build_output_yaml_path(tasks_dir: Path, slug: str, source_file_id: str, used_names: set[str]) -> Path:
candidates = [slug, slugify(source_file_id)]
for candidate in candidates:
if candidate and candidate not in used_names:
used_names.add(candidate)
return tasks_dir / f"{candidate}.yaml"
base_name = slug or slugify(source_file_id) or "task"
counter = 2
candidate = f"{base_name}-{counter}"
while candidate in used_names:
counter += 1
candidate = f"{base_name}-{counter}"
used_names.add(candidate)
return tasks_dir / f"{candidate}.yaml"
def convert_file(
json_path: Path,
output_path: Path | None = None,
images_root: Path | None = None,
dry_run: bool = False,
) -> tuple[Path, list[str], list[str]]:
payload = load_json_task(json_path)
source_file_id = json_path.stem
slug = slugify(str(payload.get("title", "")).strip() or source_file_id)
yaml_path = output_path or json_path.with_name(f"{slug}.yaml")
resolved_images_root = images_root or TASK_IMAGES_DIR
image_path_map = build_image_path_map(payload, slug)
existing_image_map, missing_images = filter_existing_image_path_map(image_path_map, resolved_images_root)
converted = convert_task_payload(payload, source_file_id, existing_image_map)
moves = move_task_images(existing_image_map, resolved_images_root, dry_run=dry_run)
if not dry_run:
yaml_path.write_text(
yaml.safe_dump(converted, sort_keys=False, allow_unicode=True),
encoding="utf-8",
)
json_path.unlink()
return yaml_path, moves, missing_images
def convert_all(tasks_dir: Path = TASKS_DIR, dry_run: bool = False) -> tuple[list[Path], list[str]]:
converted_paths: list[Path] = []
missing_images: list[str] = []
images_root = tasks_dir / "images"
used_names: set[str] = set()
for json_path in sorted(tasks_dir.glob("*.json")):
if json_path.name == "_subsection_map.json":
continue
payload = load_json_task(json_path)
source_file_id = json_path.stem
slug = slugify(str(payload.get("title", "")).strip() or source_file_id)
yaml_path = build_output_yaml_path(tasks_dir, slug, source_file_id, used_names)
yaml_path, _, missing = convert_file(
json_path,
output_path=yaml_path,
images_root=images_root,
dry_run=dry_run,
)
converted_paths.append(yaml_path)
missing_images.extend(missing)
if not dry_run and images_root.exists():
remove_empty_dirs(images_root)
return converted_paths, missing_images
def main() -> int:
parser = argparse.ArgumentParser(description="Convert backend task sources from JSON to YAML.")
parser.add_argument("--dry-run", action="store_true", help="Preview conversion without writing files.")
args = parser.parse_args()
converted_paths, missing_images = convert_all(dry_run=args.dry_run)
print(f"Converted {len(converted_paths)} task files.")
if missing_images:
print(f"Missing images preserved with original src ({len(missing_images)}):")
for item in missing_images:
print(f"- {item}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
......@@ -144,7 +144,7 @@ def _subsection_key(subsection: MarkdownDoc) -> str:
def _format_ref(ref: SubsectionRef) -> str:
return f"{ref[0]}:{ref[1]}:{ref[2]}"
return f"{ref[0]}-{ref[1]}-{ref[2]}"
def build_llm_sources_text(subsection: MarkdownDoc, children: list[MarkdownDoc]) -> str:
......
......@@ -99,11 +99,11 @@ class TaskCatalogSocraticTest(unittest.TestCase):
return_value={
"quadratische gleichungen": {
"subsection": "Quadratische Gleichungen",
"index": "1:3:3",
"index": "1-3-3",
},
"mengen": {
"subsection": "Mengen",
"index": "1:1:1",
"index": "1-1-1",
},
},
), patch(
......
from __future__ import annotations
import json
import os
import shutil
import tempfile
import unittest
from pathlib import Path
import yaml
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 app.deterministic_services import task_catalog
from scripts import convert_tasks_json_to_yaml
class TaskYamlMigrationTest(unittest.TestCase):
def test_load_task_files_normalizes_yaml_schema(self) -> None:
temp_dir = Path(tempfile.mkdtemp(prefix="task-yaml-"))
yaml_path = temp_dir / "analysis.yaml"
yaml_path.write_text(
yaml.safe_dump(
{
"id": "analysis_1",
"titel": "Analysis",
"level": "Uebungen",
"slug": "analysis",
"description": "Intro",
"exercise_topic": [{"topic-id": "quadratische-gleichungen"}],
"tasks": [
{
"aufgabe": [
{"type": "text", "text": "Bestimme f(x)."},
{"type": "image", "src": "analysis/01.png", "alt": "Graph einer Parabel."},
],
"hinweise": [
{"type": "text", "text": "Nutze die Ableitung."},
{"type": "image", "src": "analysis/h1.png", "alt": "Hinweisgrafik."},
],
"loesung": [{"type": "text", "text": "f'(x)=2x"}],
}
],
},
sort_keys=False,
allow_unicode=True,
),
encoding="utf-8",
)
try:
loaded = task_catalog.load_task_files(temp_dir)
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
self.assertEqual(len(loaded), 1)
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]["tasks"][0]["id"], "01")
self.assertEqual(loaded[0]["tasks"][0]["statement"], "Bestimme f(x).")
self.assertEqual(loaded[0]["tasks"][0]["hints"], ["Nutze die Ableitung."])
self.assertEqual(loaded[0]["tasks"][0]["solution"], "f'(x)=2x")
self.assertEqual(
loaded[0]["tasks"][0]["images"],
[
{"src": "analysis/01.png", "description": "Graph einer Parabel."},
{"src": "analysis/h1.png", "description": "Hinweisgrafik."},
],
)
def test_load_task_files_skips_invalid_yaml(self) -> None:
temp_dir = Path(tempfile.mkdtemp(prefix="task-yaml-invalid-"))
valid_path = temp_dir / "valid.yaml"
invalid_path = temp_dir / "invalid.yaml"
malformed_path = temp_dir / "malformed.yaml"
valid_path.write_text(
yaml.safe_dump(
{
"id": "valid",
"titel": "Valid",
"level": "Uebungen",
"slug": "valid",
"description": "Intro",
"exercise_topic": [],
"tasks": [{"aufgabe": [], "hinweise": [], "loesung": []}],
},
sort_keys=False,
allow_unicode=True,
),
encoding="utf-8",
)
invalid_path.write_text(
yaml.safe_dump(
{
"id": "invalid",
"titel": "Invalid",
"slug": "invalid",
"description": "Intro",
"exercise_topic": [],
"tasks": [],
},
sort_keys=False,
allow_unicode=True,
),
encoding="utf-8",
)
malformed_path.write_text("tasks: [\n", encoding="utf-8")
try:
loaded = task_catalog.load_task_files(temp_dir)
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
self.assertEqual([item["_file_id"] for item in loaded], ["valid"])
def test_build_task_catalog_keeps_api_shape_for_yaml_sources(self) -> None:
task_files = [
{
"_file_id": "analysis_1",
"title": "Analysis",
"intro": "Intro",
"subsections": ["quadratische-gleichungen"],
"tasks": [
{
"id": "01",
"statement": "Bestimme f(x).",
"hints": [],
"solution": "f'(x)=2x",
"images": [{"src": "analysis/01.png", "description": "Graph einer Parabel."}],
}
],
}
]
catalog = task_catalog.build_task_catalog(task_files)
self.assertEqual(catalog[0]["file_id"], "analysis_1")
self.assertEqual(catalog[0]["title"], "Analysis")
self.assertEqual(catalog[0]["intro"], "Intro")
self.assertEqual(catalog[0]["tasks"][0]["task_id"], "01")
self.assertEqual(catalog[0]["tasks"][0]["statement"], "Bestimme f(x).")
self.assertEqual(
catalog[0]["tasks"][0]["images"],
[{"src": "/api/tasks/assets/analysis/01.png", "description": "Graph einer Parabel."}],
)
def test_convert_task_payload_maps_json_shape_to_yaml(self) -> None:
payload = {
"title": "Reelle Funktionen bestimmen",
"subsections": ["eigenschaften-reeller-funktionen"],
"intro": "Geben Sie Definitions- und Wertebereich der Funktion an:",
"tasks": [
{
"id": "01",
"statement": "$f(x)=x^2-3$",
"images": [{"src": "reelle_funktionen_bestimmen/Bild1.png", "description": "Graph"}],
"hints": ["Hinweis 1", ""],
"solution": "Loesung",
}
],
}
converted = convert_tasks_json_to_yaml.convert_task_payload(
payload,
source_file_id="Reelle Funktionen bestimmen",
image_path_map={"reelle_funktionen_bestimmen/Bild1.png": "reelle-funktionen-bestimmen/Bild1.png"},
)
self.assertEqual(converted["id"], "Reelle Funktionen bestimmen")
self.assertEqual(converted["slug"], "reelle-funktionen-bestimmen")
self.assertEqual(converted["level"], "Uebungen")
self.assertEqual(converted["exercise_topic"], [{"topic-id": "eigenschaften-reeller-funktionen"}])
self.assertEqual(converted["tasks"][0]["aufgabe"][0], {"type": "text", "text": "$f(x)=x^2-3$"})
self.assertEqual(
converted["tasks"][0]["aufgabe"][1],
{"type": "image", "src": "reelle-funktionen-bestimmen/Bild1.png", "alt": "Graph"},
)
self.assertEqual(converted["tasks"][0]["hinweise"], [{"type": "text", "text": "Hinweis 1"}])
self.assertEqual(converted["tasks"][0]["loesung"], [{"type": "text", "text": "Loesung"}])
def test_convert_all_rewrites_json_and_moves_images(self) -> None:
temp_dir = Path(tempfile.mkdtemp(prefix="task-convert-"))
images_dir = temp_dir / "images" / "old-folder"
images_dir.mkdir(parents=True, exist_ok=True)
(images_dir / "Bild1.png").write_text("image", encoding="utf-8")
json_path = temp_dir / "Reelle Funktionen bestimmen.json"
json_path.write_text(
json.dumps(
{
"title": "Reelle Funktionen bestimmen",
"subsections": ["eigenschaften-reeller-funktionen"],
"intro": "Intro",
"tasks": [
{
"id": "01",
"statement": "$f(x)=x^2-3$",
"images": [{"src": "old-folder/Bild1.png", "description": "Graph"}],
"hints": ["Hinweis 1"],
"solution": "Loesung",
}
],
},
ensure_ascii=False,
),
encoding="utf-8",
)
try:
converted, missing_images = convert_tasks_json_to_yaml.convert_all(temp_dir)
yaml_path = temp_dir / "reelle-funktionen-bestimmen.yaml"
self.assertEqual(converted, [yaml_path])
self.assertEqual(missing_images, [])
self.assertFalse(json_path.exists())
self.assertTrue(yaml_path.exists())
self.assertTrue((temp_dir / "images" / "reelle-funktionen-bestimmen" / "Bild1.png").exists())
payload = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
self.assertEqual(payload["id"], "Reelle Funktionen bestimmen")
self.assertEqual(
payload["tasks"][0]["aufgabe"][1]["src"],
"reelle-funktionen-bestimmen/Bild1.png",
)
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def test_convert_all_uses_distinct_yaml_filenames_for_duplicate_titles(self) -> None:
temp_dir = Path(tempfile.mkdtemp(prefix="task-convert-duplicate-"))
first_json = temp_dir / "Auf Beschränktheit untersuchen.json"
second_json = temp_dir / "Auf Beschränktheit untersuchen_images.json"
payload = {
"title": "Auf Beschränktheit untersuchen",
"subsections": [],
"intro": "Intro",
"tasks": [{"id": "01", "statement": "s", "hints": [], "solution": "l"}],
}
first_json.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
second_json.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
try:
converted, missing_images = convert_tasks_json_to_yaml.convert_all(temp_dir)
self.assertEqual(missing_images, [])
self.assertEqual(
sorted(path.name for path in converted),
[
"auf-beschranktheit-untersuchen-images.yaml",
"auf-beschranktheit-untersuchen.yaml",
],
)
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
unittest.main()
......@@ -25,12 +25,12 @@ services:
networks:
- web
backend-ollama:
backend-stochastik:
build:
context: ../backend
dockerfile: Dockerfile
env_file:
- ../backend/.env-ollama
- ../backend/.env-stochastik
expose:
- "8000"
healthcheck:
......@@ -63,14 +63,14 @@ services:
networks:
- web
frontend-ollama:
frontend-stochastik:
build:
context: ../frontend
dockerfile: Dockerfile
args:
VITE_API_BASE_URL: http://backend-ollama:8000
VITE_API_BASE_URL: http://backend-stochastik:8000
depends_on:
- backend-ollama
- backend-stochastik
restart: unless-stopped
networks:
- web
......
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