Skip to content
GitLab
Projects
Groups
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in
Toggle navigation
Menu
Open sidebar
math_tutor_dev
public_math_tutor
Commits
0d98206f
Commit
0d98206f
authored
May 08, 2026
by
Kantz
Browse files
Bilder in Übungsaufgaben anzeigbar gemacht
parent
d63d0e46
Changes
10
Show whitespace changes
Inline
Side-by-side
math-tutor/backend/app/api/tasks.py
View file @
0d98206f
...
...
@@ -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
()
...
...
math-tutor/backend/app/deterministic_services/task_catalog.py
View file @
0d98206f
...
...
@@ -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"
])
...
...
math-tutor/backend/test/task_catalog_socratic_test.py
View file @
0d98206f
...
...
@@ -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
(
...
...
math-tutor/frontend/src/api/taskApi.ts
View file @
0d98206f
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
=
{
...
...
math-tutor/frontend/src/components/Task/TaskContent.tsx
0 → 100644
View file @
0d98206f
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
>
);
}
math-tutor/frontend/src/components/Task/TaskPanel.tsx
View file @
0d98206f
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"
>
...
...
math-tutor/frontend/src/pages/ChatPage.tsx
View file @
0d98206f
...
...
@@ -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
}
...
...
math-tutor/frontend/src/pages/TaskSelectionPage.tsx
View file @
0d98206f
...
...
@@ -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
}
...
...
math-tutor/frontend/src/state/tutorSession.tsx
View file @
0d98206f
...
...
@@ -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
]);
...
...
math-tutor/frontend/src/styles/theme.css
View file @
0d98206f
...
...
@@ -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
;
...
...
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment