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
3c67b18f
Commit
3c67b18f
authored
Mar 31, 2026
by
Kantz
Browse files
Auswahl von Unterthemen
parent
13288a8e
Changes
17
Hide whitespace changes
Inline
Side-by-side
math-tutor/backend/app/api/chat.py
View file @
3c67b18f
...
...
@@ -26,6 +26,7 @@ class ChatRequest(BaseModel):
messages
:
List
[
ChatMessage
]
draft
:
Optional
[
str
]
=
None
selected_task
:
Optional
[
dict
[
str
,
str
]]
=
None
selected_subsection
:
Optional
[
dict
[
str
,
str
]]
=
None
orchestrator
:
Optional
[
str
]
=
None
...
...
@@ -52,11 +53,16 @@ class SelectedTaskRef(BaseModel):
task_id
:
str
class
SelectedSubsectionRef
(
BaseModel
):
subsection_key
:
str
class
ChatArchiveDetail
(
BaseModel
):
chat_id
:
str
saved_at
:
str
history
:
List
[
ChatMessage
]
selected_task
:
Optional
[
SelectedTaskRef
]
=
None
selected_subsection
:
Optional
[
SelectedSubsectionRef
]
=
None
orchestrator
:
str
...
...
@@ -80,6 +86,7 @@ def chat(request: ChatRequest) -> ChatResponse:
payload_messages
,
draft
=
request
.
draft
,
selected_task
=
request
.
selected_task
,
selected_subsection
=
request
.
selected_subsection
,
)
else
:
result
=
orchestrator_impl
.
run_chat
(
...
...
@@ -125,11 +132,19 @@ def get_archive(chat_id: str = Path(..., min_length=1)) -> ChatArchiveDetail:
if
file_id
and
task_id
:
selected_task
=
SelectedTaskRef
(
file_id
=
file_id
,
task_id
=
task_id
)
selected_subsection_raw
=
record
.
get
(
"selected_subsection"
)
selected_subsection
:
Optional
[
SelectedSubsectionRef
]
=
None
if
isinstance
(
selected_subsection_raw
,
dict
):
subsection_key
=
str
(
selected_subsection_raw
.
get
(
"subsection_key"
,
""
)).
strip
()
if
subsection_key
:
selected_subsection
=
SelectedSubsectionRef
(
subsection_key
=
subsection_key
)
return
ChatArchiveDetail
(
chat_id
=
record
[
"chat_id"
],
saved_at
=
record
.
get
(
"saved_at"
,
""
),
history
=
[
ChatMessage
(
role
=
item
[
"role"
],
text
=
item
[
"text"
])
for
item
in
record
[
"history"
]],
selected_task
=
selected_task
,
selected_subsection
=
selected_subsection
,
orchestrator
=
record
.
get
(
"orchestrator"
)
or
get_default_orchestrator
(),
)
...
...
math-tutor/backend/app/api/tasks.py
View file @
3c67b18f
...
...
@@ -19,6 +19,12 @@ class TaskItem(BaseModel):
full_text
:
str
class
SubsectionEntry
(
BaseModel
):
subsection_key
:
str
label
:
str
refs
:
List
[
List
[
int
]]
class
TaskFile
(
BaseModel
):
file_id
:
str
title
:
str
...
...
@@ -31,6 +37,7 @@ class TasksResponse(BaseModel):
orchestrator
:
str
enabled
:
bool
task_files
:
List
[
TaskFile
]
subsections
:
List
[
SubsectionEntry
]
=
Field
(
default_factory
=
list
)
class
SelectTaskRequest
(
BaseModel
):
...
...
@@ -45,6 +52,16 @@ class SelectTaskResponse(BaseModel):
task_id
:
str
class
SelectSubsectionRequest
(
BaseModel
):
draft
:
str
=
Field
(...,
min_length
=
1
)
subsection_key
:
str
=
Field
(...,
min_length
=
1
)
class
SelectSubsectionResponse
(
BaseModel
):
status
:
str
subsection_key
:
str
@
router
.
get
(
"/api/tasks/config"
)
def
get_task_config
()
->
dict
[
str
,
object
]:
orchestrator
=
config
.
get_orchestrator
()
...
...
@@ -55,10 +72,12 @@ def get_task_config() -> dict[str, object]:
def
list_tasks
()
->
TasksResponse
:
orchestrator
=
config
.
get_orchestrator
()
task_files
=
task_catalog
.
build_task_catalog
()
subsections
=
task_catalog
.
build_subsection_catalog
()
return
TasksResponse
(
orchestrator
=
orchestrator
,
enabled
=
orchestrator
in
TASK_ORCHESTRATORS
,
task_files
=
task_files
,
subsections
=
subsections
,
)
# Eigentlich sollte die Context-Selection erst passieren wen das schon fest steht
...
...
@@ -85,3 +104,22 @@ def select_task(request: SelectTaskRequest) -> SelectTaskResponse:
file_id
=
file_id
or
request
.
file_id
,
task_id
=
task_id
or
request
.
task_id
,
)
@
router
.
post
(
"/api/tasks/select-subsection"
,
response_model
=
SelectSubsectionResponse
)
def
select_subsection
(
request
:
SelectSubsectionRequest
)
->
SelectSubsectionResponse
:
chat_id
=
context_store
.
get_chat_id
([],
draft
=
request
.
draft
)
sheet
=
context_store
.
load_sheet
(
chat_id
)
if
not
sheet
:
sheet
=
context_store
.
context_store_new
.
init_sheet
(
chat_id
,
[])
updated
=
task_catalog
.
select_subsection_by_key
(
sheet
,
subsection_key
=
request
.
subsection_key
)
if
not
updated
:
raise
HTTPException
(
status_code
=
404
,
detail
=
"subsection not found"
)
context_store
.
save_sheet
(
sheet
)
_
,
subsection_key
=
task_catalog
.
get_selected_subsection_ids
(
sheet
)
return
SelectSubsectionResponse
(
status
=
"ok"
,
subsection_key
=
subsection_key
or
request
.
subsection_key
,
)
math-tutor/backend/app/deterministic_services/orchestrators/orchestrator_feedback.py
View file @
3c67b18f
...
...
@@ -120,6 +120,7 @@ def run_chat(
messages
:
list
[
dict
],
draft
:
str
|
None
=
None
,
selected_task
:
dict
|
None
=
None
,
selected_subsection
:
dict
|
None
=
None
,
)
->
dict
:
def
_apply_selected_task
(
state
:
base
.
ChatState
)
->
None
:
if
not
selected_task
:
...
...
math-tutor/backend/app/deterministic_services/orchestrators/orchestrator_socratic.py
View file @
3c67b18f
...
...
@@ -2,60 +2,26 @@ from __future__ import annotations
from
app.LLM_services
import
socratic_LLM
import
app.config
as
config
from
app.deterministic_services
import
(
context_store
,
retrieval_store
,
task_catalog
,
)
from
app.deterministic_services
import
context_store
,
retrieval_store
,
task_catalog
from
app.deterministic_services.orchestrators
import
orchestrator_base
as
base
def
_ensure_context_task_fields
(
state
:
base
.
ChatState
,
query_text
:
str
)
->
tuple
[
str
,
str
]
|
None
:
store_new
=
context_store
.
context_store_new
has_task
=
bool
(
store_new
.
get_task
(
state
.
sheet
))
has_hints
=
bool
(
store_new
.
get_hints
(
state
.
sheet
))
has_solution
=
bool
(
store_new
.
get_solution
(
state
.
sheet
))
if
has_task
and
has_hints
and
has_solution
:
selected
=
task_catalog
.
get_selected_task_ids
(
state
.
sheet
)
if
selected
[
0
]
and
selected
[
1
]:
was_selected
=
task_catalog
.
select_task_by_ids
(
state
.
sheet
,
selected
[
0
],
selected
[
1
],
)
if
was_selected
:
return
selected
[
0
],
selected
[
1
]
sources_text
=
"
\n
"
.
join
([
source
.
to_string
()
for
source
in
context_store
.
get_retrieval
(
state
.
sheet
)])
selection
=
task_catalog
.
select_task_for_context
(
state
.
sheet
,
query_text
=
query_text
,
sources_text
=
sources_text
,
history
=
context_store
.
get_history_turns
(
state
.
sheet
),
)
if
not
selection
:
return
None
task_file
,
task_entry
=
selection
selected_file_id
=
str
(
task_file
.
get
(
"_file_id"
,
""
))
selected_task_id
=
str
(
task_entry
.
get
(
"id"
,
""
)).
zfill
(
2
)
def
_apply_selected_subsection
(
state
:
base
.
ChatState
,
selected_subsection
:
dict
|
None
,
)
->
None
:
if
not
selected_subsection
:
return
base
.
append_tool_log
(
state
.
tool_log
,
"task_json_selected"
,
{
"tasks_dir"
:
str
(
task_catalog
.
TASKS_DIR
)},
{
"file"
:
task_file
.
get
(
"_path"
,
""
),
"file_id"
:
selected_file_id
,
"task_id"
:
selected_task_id
,
"hint_count"
:
len
(
store_new
.
get_hints
(
state
.
sheet
)),
"has_solution"
:
bool
(
store_new
.
get_solution
(
state
.
sheet
)),
},
)
return
selected_file_id
,
selected_task_id
subsection_key
=
str
(
selected_subsection
.
get
(
"subsection_key"
,
""
)).
strip
()
if
not
subsection_key
:
return
task_catalog
.
select_subsection_by_key
(
state
.
sheet
,
subsection_key
)
def
_retrieve_context_for_
task
(
state
:
base
.
ChatState
,
query_text
:
str
)
->
int
:
refs
=
task_catalog
.
get_selected_
task_
subsection_refs
(
state
.
sheet
)
def
_retrieve_context_for_
subsection
(
state
:
base
.
ChatState
,
query_text
:
str
)
->
int
:
refs
=
task_catalog
.
get_selected_subsection_refs
(
state
.
sheet
)
if
not
refs
:
return
0
...
...
@@ -72,7 +38,7 @@ def _retrieve_context_for_task(state: base.ChatState, query_text: str) -> int:
result
=
base
.
log_timed_call
(
state
.
tool_log
,
"retrieve_context_with_
task
_subsections"
,
"retrieve_context_with_
socratic
_subsections"
,
{
"query"
:
query_text
,
"subsection_refs"
:
refs
,
...
...
@@ -83,17 +49,17 @@ def _retrieve_context_for_task(state: base.ChatState, query_text: str) -> int:
def
_on_bootstrap
(
state
:
base
.
ChatState
,
query_text
:
str
)
->
None
:
_ensure_context_task_fields
(
state
,
query_text
)
_retrieve_context_for_task
(
state
,
query_text
)
_retrieve_context_for_subsection
(
state
,
query_text
)
def
_on_turn_logic
(
state
:
base
.
ChatState
)
->
None
:
_ensure_context_task_fields
(
state
,
state
.
last_user
)
if
not
task_catalog
.
get_selected_subsection_refs
(
state
.
sheet
):
return
def
_on_build_reply
(
state
:
base
.
ChatState
)
->
str
|
None
:
history_turns
=
context_store
.
get_history_turns
(
state
.
sheet
)
subsection_refs
=
task_catalog
.
get_selected_
task_
subsection_refs
(
state
.
sheet
)
subsection_refs
=
task_catalog
.
get_selected_subsection_refs
(
state
.
sheet
)
args
=
{
"query"
:
state
.
last_user
,
"subsection_refs"
:
subsection_refs
,
...
...
@@ -111,26 +77,18 @@ def _on_build_reply(state: base.ChatState) -> str | None:
def
run_chat
(
messages
:
list
[
dict
],
draft
:
str
|
None
=
None
,
selected_subsection
:
dict
|
None
=
None
,
selected_task
:
dict
|
None
=
None
,
)
->
dict
:
def
_apply_selected_task
(
state
:
base
.
ChatState
)
->
None
:
if
not
selected_task
:
return
selected_file_id
=
str
(
selected_task
.
get
(
"file_id"
,
""
)).
strip
()
selected_task_id
=
str
(
selected_task
.
get
(
"task_id"
,
""
)).
strip
()
if
selected_file_id
and
selected_task_id
:
task_catalog
.
select_task_by_ids
(
state
.
sheet
,
selected_file_id
,
selected_task_id
,
)
def
_apply_selected_context
(
state
:
base
.
ChatState
)
->
None
:
_apply_selected_subsection
(
state
,
selected_subsection
)
def
on_bootstrap
(
state
:
base
.
ChatState
,
query_text
:
str
)
->
None
:
_apply_selected_
task
(
state
)
_apply_selected_
context
(
state
)
_on_bootstrap
(
state
,
query_text
)
def
on_turn_logic
(
state
:
base
.
ChatState
)
->
None
:
_apply_selected_
task
(
state
)
_apply_selected_
context
(
state
)
_on_turn_logic
(
state
)
return
base
.
run_chat_common
(
...
...
math-tutor/backend/app/deterministic_services/orchestrators/orchestrator_task.py
View file @
3c67b18f
...
...
@@ -123,6 +123,7 @@ def run_chat(
messages
:
list
[
dict
],
draft
:
str
|
None
=
None
,
selected_task
:
dict
|
None
=
None
,
selected_subsection
:
dict
|
None
=
None
,
)
->
dict
:
def
_apply_selected_task
(
state
:
base
.
ChatState
)
->
None
:
if
not
selected_task
:
...
...
math-tutor/backend/app/deterministic_services/session_store.py
View file @
3c67b18f
...
...
@@ -26,6 +26,13 @@ def _extract_selected_task(sheet: dict[str, Any]) -> dict[str, str] | None:
return
{
"file_id"
:
file_id
,
"task_id"
:
task_id
}
def
_extract_selected_subsection
(
sheet
:
dict
[
str
,
Any
])
->
dict
[
str
,
str
]
|
None
:
subsection_key
=
str
(
sheet
.
get
(
"selected_subsection_key"
,
""
)).
strip
()
if
not
subsection_key
:
return
None
return
{
"subsection_key"
:
subsection_key
}
def
archive_chat
(
messages
:
list
[
dict
[
str
,
Any
]],
draft
:
str
|
None
=
None
,
...
...
@@ -49,6 +56,7 @@ def archive_chat(
"math_solutions"
:
sheet
.
get
(
"math_solutions"
,
[]),
"sources"
:
sheet
.
get
(
"sources"
,
[]),
"selected_task"
:
_extract_selected_task
(
sheet
),
"selected_subsection"
:
_extract_selected_subsection
(
sheet
),
}
os
.
makedirs
(
_LOG_DIR
,
exist_ok
=
True
)
...
...
@@ -126,11 +134,19 @@ def load_archive(chat_id: str) -> dict[str, Any] | None:
if
file_id
and
task_id
:
selected_task
=
{
"file_id"
:
file_id
,
"task_id"
:
task_id
}
selected_subsection_raw
=
record
.
get
(
"selected_subsection"
)
selected_subsection
:
dict
[
str
,
str
]
|
None
=
None
if
isinstance
(
selected_subsection_raw
,
dict
):
subsection_key
=
str
(
selected_subsection_raw
.
get
(
"subsection_key"
,
""
)).
strip
()
if
subsection_key
:
selected_subsection
=
{
"subsection_key"
:
subsection_key
}
return
{
"chat_id"
:
record
.
get
(
"chat_id"
,
chat_id
),
"saved_at"
:
record
.
get
(
"saved_at"
,
""
),
"orchestrator"
:
record
.
get
(
"orchestrator"
),
"history"
:
history
,
"selected_task"
:
selected_task
,
"selected_subsection"
:
selected_subsection
,
}
return
None
math-tutor/backend/app/deterministic_services/task_catalog.py
View file @
3c67b18f
...
...
@@ -71,6 +71,61 @@ def _resolve_task_subsection_refs(
return
sorted
(
refs
)
def
_format_subsection_label
(
value
:
str
)
->
str
:
cleaned
=
re
.
sub
(
r
"[-_]+"
,
" "
,
value
.
strip
())
cleaned
=
re
.
sub
(
r
"\s+"
,
" "
,
cleaned
).
strip
()
if
not
cleaned
:
return
""
return
cleaned
.
title
()
def
build_subsection_catalog
(
path
:
Path
=
SUBSECTION_MAP_PATH
)
->
list
[
dict
[
str
,
Any
]]:
subsection_map
=
load_subsection_map
(
path
)
response
:
list
[
dict
[
str
,
Any
]]
=
[]
for
key
,
ref
in
sorted
(
subsection_map
.
items
(),
key
=
lambda
item
:
(
item
[
0
],
item
[
1
])):
response
.
append
(
{
"subsection_key"
:
key
,
"label"
:
_format_subsection_label
(
key
),
"refs"
:
[[
int
(
ref
[
0
]),
int
(
ref
[
1
]),
int
(
ref
[
2
])]],
}
)
return
response
def
_resolve_task_subsection_options
(
task_file
:
dict
[
str
,
Any
],
subsection_map
:
dict
[
str
,
tuple
[
int
,
int
,
int
]]
|
None
=
None
,
)
->
list
[
dict
[
str
,
Any
]]:
mapping
=
subsection_map
if
subsection_map
is
not
None
else
load_subsection_map
()
subsections
=
task_file
.
get
(
"subsections"
,
[])
if
not
isinstance
(
subsections
,
list
):
return
[]
options
:
list
[
dict
[
str
,
Any
]]
=
[]
seen_refs
:
set
[
tuple
[
int
,
int
,
int
]]
=
set
()
for
subsection
in
subsections
:
raw_label
=
str
(
subsection
).
strip
()
key
=
_normalize_subsection_key
(
raw_label
)
if
not
key
:
continue
ref
=
mapping
.
get
(
key
)
if
ref
is
None
:
continue
normalized_ref
=
(
int
(
ref
[
0
]),
int
(
ref
[
1
]),
int
(
ref
[
2
]))
if
normalized_ref
in
seen_refs
:
continue
seen_refs
.
add
(
normalized_ref
)
options
.
append
(
{
"subsection_key"
:
key
,
"label"
:
_format_subsection_label
(
raw_label
)
or
raw_label
,
"refs"
:
[[
normalized_ref
[
0
],
normalized_ref
[
1
],
normalized_ref
[
2
]]],
}
)
return
options
def
_match_score
(
query_text
:
str
,
candidate_text
:
str
)
->
int
:
query_tokens
=
_tokenize
(
query_text
)
if
not
query_tokens
:
...
...
@@ -142,6 +197,37 @@ def set_selected_task(
sheet
[
"task_id"
]
=
str
(
task_entry
.
get
(
"id"
,
""
)).
zfill
(
2
)
refs
=
_resolve_task_subsection_refs
(
task_file
)
sheet
[
"task_subsection_refs"
]
=
[[
chap
,
sec
,
sub
]
for
chap
,
sec
,
sub
in
refs
]
sheet
.
pop
(
"selected_subsection_key"
,
None
)
sheet
.
pop
(
"selected_subsection_label"
,
None
)
sheet
.
pop
(
"selected_subsection_refs"
,
None
)
def
set_selected_subsection
(
sheet
:
dict
[
str
,
Any
],
task_file
:
dict
[
str
,
Any
],
subsection_option
:
dict
[
str
,
Any
],
)
->
None
:
refs_raw
=
subsection_option
.
get
(
"refs"
,
[])
refs
:
list
[
tuple
[
int
,
int
,
int
]]
=
[]
if
isinstance
(
refs_raw
,
list
):
for
item
in
refs_raw
:
if
isinstance
(
item
,
(
list
,
tuple
))
and
len
(
item
)
>=
3
:
try
:
refs
.
append
((
int
(
item
[
0
]),
int
(
item
[
1
]),
int
(
item
[
2
])))
except
Exception
:
continue
if
not
refs
:
return
sheet
[
"selected_subsection_key"
]
=
str
(
subsection_option
.
get
(
"subsection_key"
,
""
)).
strip
()
sheet
[
"selected_subsection_label"
]
=
str
(
subsection_option
.
get
(
"label"
,
""
)).
strip
()
sheet
[
"selected_subsection_refs"
]
=
[[
chap
,
sec
,
sub
]
for
chap
,
sec
,
sub
in
sorted
({
*
refs
})]
sheet
.
pop
(
"task_id"
,
None
)
sheet
.
pop
(
"task_subsection_refs"
,
None
)
sheet
.
pop
(
"task"
,
None
)
sheet
.
pop
(
"hints"
,
None
)
sheet
.
pop
(
"solution"
,
None
)
def
select_task_by_ids
(
...
...
@@ -161,12 +247,81 @@ def select_task_by_ids(
return
True
def
select_subsection_by_ids
(
sheet
:
dict
[
str
,
Any
],
file_id
:
str
,
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
()
task_file
=
_find_task_file
(
catalog
,
file_id
)
if
not
task_file
:
return
False
subsection_map
=
load_subsection_map
()
options
=
_resolve_task_subsection_options
(
task_file
,
subsection_map
=
subsection_map
)
normalized_key
=
_normalize_subsection_key
(
subsection_key
)
if
not
normalized_key
:
return
False
for
option
in
options
:
if
str
(
option
.
get
(
"subsection_key"
,
""
)).
strip
()
==
normalized_key
:
set_selected_subsection
(
sheet
,
task_file
,
option
)
return
True
return
False
def
select_subsection_by_key
(
sheet
:
dict
[
str
,
Any
],
subsection_key
:
str
,
subsection_map
:
dict
[
str
,
tuple
[
int
,
int
,
int
]]
|
None
=
None
,
)
->
bool
:
mapping
=
subsection_map
if
subsection_map
is
not
None
else
load_subsection_map
()
normalized_key
=
_normalize_subsection_key
(
subsection_key
)
if
not
normalized_key
:
return
False
ref
=
mapping
.
get
(
normalized_key
)
if
ref
is
None
:
return
False
sheet
[
"selected_subsection_key"
]
=
normalized_key
sheet
[
"selected_subsection_label"
]
=
_format_subsection_label
(
normalized_key
)
sheet
[
"selected_subsection_refs"
]
=
[[
int
(
ref
[
0
]),
int
(
ref
[
1
]),
int
(
ref
[
2
])]]
sheet
.
pop
(
"task_file_id"
,
None
)
sheet
.
pop
(
"task_id"
,
None
)
sheet
.
pop
(
"task_subsection_refs"
,
None
)
sheet
.
pop
(
"task"
,
None
)
sheet
.
pop
(
"hints"
,
None
)
sheet
.
pop
(
"solution"
,
None
)
return
True
def
get_selected_task_ids
(
sheet
:
dict
[
str
,
Any
])
->
tuple
[
str
|
None
,
str
|
None
]:
file_id
=
str
(
sheet
.
get
(
"task_file_id"
,
""
)).
strip
()
task_id
=
str
(
sheet
.
get
(
"task_id"
,
""
)).
strip
()
return
(
file_id
or
None
,
task_id
or
None
)
def
get_selected_subsection_ids
(
sheet
:
dict
[
str
,
Any
])
->
tuple
[
str
|
None
,
str
|
None
]:
subsection_key
=
str
(
sheet
.
get
(
"selected_subsection_key"
,
""
)).
strip
()
return
(
None
,
subsection_key
or
None
)
def
get_selected_subsection_refs
(
sheet
:
dict
[
str
,
Any
])
->
list
[
tuple
[
int
,
int
,
int
]]:
refs_raw
=
sheet
.
get
(
"selected_subsection_refs"
,
[])
if
not
isinstance
(
refs_raw
,
list
):
return
[]
refs
:
set
[
tuple
[
int
,
int
,
int
]]
=
set
()
for
item
in
refs_raw
:
if
isinstance
(
item
,
(
list
,
tuple
))
and
len
(
item
)
>=
3
:
try
:
refs
.
add
((
int
(
item
[
0
]),
int
(
item
[
1
]),
int
(
item
[
2
])))
except
Exception
:
continue
return
sorted
(
refs
)
def
get_selected_task_subsection_refs
(
sheet
:
dict
[
str
,
Any
])
->
list
[
tuple
[
int
,
int
,
int
]]:
refs_raw
=
sheet
.
get
(
"task_subsection_refs"
,
[])
if
not
isinstance
(
refs_raw
,
list
):
...
...
@@ -260,6 +415,7 @@ def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[d
title
=
str
(
task_file
.
get
(
"title"
,
""
)).
strip
()
intro
=
str
(
task_file
.
get
(
"intro"
,
""
)).
strip
()
subsections
=
task_file
.
get
(
"subsections"
,
[])
subsection_options
=
_resolve_task_subsection_options
(
task_file
)
tasks
:
list
[
dict
[
str
,
str
]]
=
[]
for
item
in
task_file
.
get
(
"tasks"
,
[]):
if
not
isinstance
(
item
,
dict
):
...
...
@@ -282,6 +438,7 @@ def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[d
"intro"
:
intro
,
"tasks"
:
tasks
,
"subsections"
:
subsections
,
"subsection_options"
:
subsection_options
,
}
)
return
response
math-tutor/backend/test/task_catalog_socratic_test.py
0 → 100644
View file @
3c67b18f
from
__future__
import
annotations
import
json
import
os
import
unittest
from
pathlib
import
Path
from
unittest.mock
import
patch
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
fastapi
import
FastAPI
from
fastapi.testclient
import
TestClient
from
app.api
import
tasks
from
app.deterministic_services
import
session_store
,
task_catalog
class
TaskCatalogSocraticTest
(
unittest
.
TestCase
):
def
test_build_subsection_catalog_uses_only_map
(
self
)
->
None
:
with
patch
(
"app.deterministic_services.task_catalog.load_subsection_map"
,
return_value
=
{
"quadratische gleichungen"
:
(
1
,
3
,
3
),
"mengen"
:
(
1
,
1
,
1
),
},
):
catalog
=
task_catalog
.
build_subsection_catalog
()
self
.
assertEqual
(
catalog
,
[
{
"subsection_key"
:
"mengen"
,
"label"
:
"Mengen"
,
"refs"
:
[[
1
,
1
,
1
]],
},
{
"subsection_key"
:
"quadratische gleichungen"
,
"label"
:
"Quadratische Gleichungen"
,
"refs"
:
[[
1
,
3
,
3
]],
},
],
)
def
test_select_subsection_by_ids_sets_sheet_fields
(
self
)
->
None
:
task_files
=
[
{
"_file_id"
:
"analysis_1"
,
"title"
:
"Analysis"
,
"subsections"
:
[
"quadratische_gleichungen"
],
"tasks"
:
[],
}
]
sheet
:
dict
[
str
,
object
]
=
{}
with
patch
(
"app.deterministic_services.task_catalog.load_subsection_map"
,
return_value
=
{
"quadratische gleichungen"
:
(
1
,
3
,
3
)},
):
updated
=
task_catalog
.
select_subsection_by_ids
(
sheet
,
"analysis_1"
,
"quadratische_gleichungen"
,
task_files
=
task_files
,
)
self
.
assertTrue
(
updated
)
self
.
assertEqual
(
sheet
[
"selected_subsection_key"
],
"quadratische gleichungen"
)
self
.
assertEqual
(
sheet
[
"selected_subsection_refs"
],
[[
1
,
3
,
3
]])
def
test_select_subsection_by_key_sets_sheet_fields
(
self
)
->
None
:
sheet
:
dict
[
str
,
object
]
=
{}
with
patch
(
"app.deterministic_services.task_catalog.load_subsection_map"
,
return_value
=
{
"quadratische gleichungen"
:
(
1
,
3
,
3
)},
):
updated
=
task_catalog
.
select_subsection_by_key
(
sheet
,
"quadratische_gleichungen"
)
self
.
assertTrue
(
updated
)
self
.
assertEqual
(
sheet
[
"selected_subsection_key"
],
"quadratische gleichungen"
)
self
.
assertEqual
(
sheet
[
"selected_subsection_refs"
],
[[
1
,
3
,
3
]])
class
TaskApiSocraticTest
(
unittest
.
TestCase
):
def
setUp
(
self
)
->
None
:
app
=
FastAPI
()
app
.
include_router
(
tasks
.
router
)
self
.
client
=
TestClient
(
app
)
def
test_list_tasks_includes_subsection_options
(
self
)
->
None
:
payload
=
[
{
"file_id"
:
"analysis_1"
,
"title"
:
"Analysis"
,
"intro"
:
"Intro"
,
"tasks"
:
[],
}
]
subsections
=
[
{
"subsection_key"
:
"quadratische gleichungen"
,
"label"
:
"Quadratische Gleichungen"
,
"refs"
:
[[
1
,
3
,
3
]],
}
]
with
patch
(
"app.api.tasks.config.get_orchestrator"
,
return_value
=
"socratic"
),
patch
(
"app.api.tasks.task_catalog.build_task_catalog"
,
return_value
=
payload
,
),
patch
(
"app.api.tasks.task_catalog.build_subsection_catalog"
,
return_value
=
subsections
,
):
response
=
self
.
client
.
get
(
"/api/tasks"
)
self
.
assertEqual
(
response
.
status_code
,
200
)
body
=
response
.
json
()
self
.
assertEqual
(
body
[
"orchestrator"
],
"socratic"
)
self
.
assertEqual
(
body
[
"subsections"
][
0
][
"subsection_key"
],
"quadratische gleichungen"
)
def
test_select_subsection_endpoint_returns_selected_key
(
self
)
->
None
:
sheet
:
dict
[
str
,
object
]
=
{}
with
patch
(
"app.api.tasks.context_store.get_chat_id"
,
return_value
=
"chat-1"
),
patch
(
"app.api.tasks.context_store.load_sheet"
,
return_value
=
sheet
,
),
patch
(
"app.api.tasks.context_store.context_store_new.init_sheet"
,
return_value
=
sheet
,
),
patch
(
"app.api.tasks.task_catalog.select_subsection_by_key"
,
return_value
=
True
,
)
as
select_mock
,
patch
(
"app.api.tasks.context_store.save_sheet"
),
patch
(
"app.api.tasks.task_catalog.get_selected_subsection_ids"
,
return_value
=
(
None
,
"quadratische gleichungen"
),
):
response
=
self
.
client
.
post
(
"/api/tasks/select-subsection"
,
json
=
{
"draft"
:
"chat-1"
,
"subsection_key"
:
"quadratische_gleichungen"
,
},
)
self
.
assertEqual
(
response
.
status_code
,
200
)
self
.
assertEqual
(
response
.
json
()[
"subsection_key"
],
"quadratische gleichungen"
)
select_mock
.
assert_called_once_with
(
sheet
,
subsection_key
=
"quadratische_gleichungen"
)
class
SessionStoreSocraticTest
(
unittest
.
TestCase
):
def
test_load_archive_restores_selected_subsection
(
self
)
->
None
:
record
=
{
"chat_id"
:
"chat-1"
,
"saved_at"
:
"2026-03-31T10:00:00Z"
,
"orchestrator"
:
"socratic"
,
"history"
:
[],
"selected_task"
:
None
,
"selected_subsection"
:
{
"subsection_key"
:
"quadratische gleichungen"
,
},
}
temp_dir
=
Path
(
__file__
).
resolve
().
parent
/
"_tmp_socratic_archive"
temp_dir
.
mkdir
(
exist_ok
=
True
)
log_path
=
temp_dir
/
"archive.jsonl"
log_path
.
write_text
(
json
.
dumps
(
record
,
ensure_ascii
=
False
)
+
"
\n
"
,
encoding
=
"utf-8"
)
try
:
with
patch
.
object
(
session_store
,
"_LOG_PATH"
,
str
(
log_path
)):
archive
=
session_store
.
load_archive
(
"chat-1"
)
finally
:
if
log_path
.
exists
():
log_path
.
unlink
()
if
temp_dir
.
exists
():
temp_dir
.
rmdir
()
self
.
assertIsNotNone
(
archive
)
self
.
assertEqual
(
archive
[
"selected_subsection"
],
record
[
"selected_subsection"
])
if
__name__
==
"__main__"
:
unittest
.
main
()
math-tutor/frontend/src/api/taskApi.ts
View file @
3c67b18f
...
...
@@ -10,12 +10,20 @@ export type TaskFile = {
intro
:
string
;
tasks
:
TaskItem
[];
subsections
?:
string
[];
subsection_options
?:
SubsectionOption
[];
};
export
type
SubsectionOption
=
{
subsection_key
:
string
;
label
:
string
;
refs
:
[
number
,
number
,
number
][];
};
export
type
TasksResponse
=
{
orchestrator
:
string
;
enabled
:
boolean
;
task_files
:
TaskFile
[];
subsections
:
SubsectionOption
[];
};
export
type
SelectedTaskRef
=
{
...
...
@@ -23,12 +31,21 @@ export type SelectedTaskRef = {
taskId
:
string
;
};
export
type
SelectedSubsectionRef
=
{
subsectionKey
:
string
;
};
export
type
SelectTaskResponse
=
{
status
:
string
;
file_id
:
string
;
task_id
:
string
;
};
export
type
SelectSubsectionResponse
=
{
status
:
string
;
subsection_key
:
string
;
};
export
async
function
fetchTasks
():
Promise
<
TasksResponse
>
{
const
response
=
await
fetch
(
"
/api/tasks
"
);
if
(
!
response
.
ok
)
{
...
...
@@ -57,3 +74,22 @@ export async function selectTask(input: {
}
return
response
.
json
();
}
export
async
function
selectSubsection
(
input
:
{
draft
:
string
;
subsectionKey
:
string
;
}):
Promise
<
SelectSubsectionResponse
>
{
const
response
=
await
fetch
(
"
/api/tasks/select-subsection
"
,
{
method
:
"
POST
"
,
headers
:
{
"
Content-Type
"
:
"
application/json
"
},
body
:
JSON
.
stringify
({
draft
:
input
.
draft
,
subsection_key
:
input
.
subsectionKey
,
}),
});
if
(
!
response
.
ok
)
{
throw
new
Error
(
`Subsection selection failed:
${
response
.
status
}
`
);
}
return
response
.
json
();
}
math-tutor/frontend/src/components/Task/SocraticPanel.tsx
0 → 100644
View file @
3c67b18f
import
{
useEffect
,
useRef
}
from
"
react
"
;
import
{
t
}
from
"
../../i18n
"
;
type
SocraticPanelProps
=
{
selectedSubsectionLabel
?:
string
;
selectedSubsectionKey
?:
string
;
selectedSubsectionRefsText
?:
string
;
onChangeSelection
?:
()
=>
void
;
};
export
default
function
SocraticPanel
({
selectedSubsectionLabel
,
selectedSubsectionKey
,
selectedSubsectionRefsText
,
onChangeSelection
,
}:
SocraticPanelProps
)
{
const
contentRef
=
useRef
<
HTMLDivElement
|
null
>
(
null
);
useEffect
(()
=>
{
if
(
!
contentRef
.
current
)
{
return
;
}
const
mathjax
=
window
.
MathJax
;
if
(
!
mathjax
?.
typesetPromise
)
{
return
;
}
mathjax
.
typesetPromise
([
contentRef
.
current
]).
catch
(()
=>
undefined
);
},
[
selectedSubsectionLabel
,
selectedSubsectionRefsText
]);
return
(
<
section
className
=
"task-panel"
>
<
div
className
=
"task-panel-header"
>
<
div
className
=
"task-panel-title"
>
{
t
(
"
orchestratorModeSocraticLabel
"
)
}
</
div
>
{
onChangeSelection
?
(
<
button
type
=
"button"
className
=
"btn task-panel-change-btn"
onClick
=
{
onChangeSelection
}
>
{
t
(
"
changeSubsectionArea
"
)
}
</
button
>
)
:
null
}
</
div
>
<
div
className
=
"task-panel-meta"
>
<
div
>
{
selectedSubsectionKey
?
`
${
t
(
"
subsectionKey
"
)}
:
${
selectedSubsectionKey
}
`
:
""
}
</
div
>
</
div
>
<
div
className
=
"task-panel-content"
ref
=
{
contentRef
}
>
{
selectedSubsectionLabel
||
t
(
"
noSubsectionSelected
"
)
}
{
selectedSubsectionRefsText
?
<
div
>
{
selectedSubsectionRefsText
}
</
div
>
:
null
}
</
div
>
</
section
>
);
}
math-tutor/frontend/src/i18n.ts
View file @
3c67b18f
...
...
@@ -48,6 +48,8 @@
hideThinking
:
"
Hide thinking
"
,
noTasksAvailable
:
"
No tasks available
"
,
noTaskSelected
:
"
No task selected.
"
,
noSubsectionsAvailable
:
"
No subsections available
"
,
noSubsectionSelected
:
"
No subsection selected.
"
,
savedChats
:
"
Saved Chats
"
,
saving
:
"
Saving...
"
,
noSavedChatsYet
:
"
No saved chats yet.
"
,
...
...
@@ -70,10 +72,17 @@
"
Saving the canvas failed. Please check backend logs.
"
,
taskSelectionTitle
:
"
Select a Task
"
,
taskSelectionSubtitle
:
"
Choose a task and start a tutor session
"
,
socraticSelectionTitle
:
"
Select a Subsection
"
,
socraticSelectionSubtitle
:
"
Choose a subsection and start a socratic session
"
,
subsection
:
"
Subsection
"
,
taskFile
:
"
Task Set
"
,
taskId
:
"
Task ID
"
,
subsectionFile
:
"
Subsection Set
"
,
subsectionKey
:
"
Subsection Key
"
,
solveWithTutor
:
"
Solve with Tutor
"
,
startSocratic
:
"
Start Socratic
"
,
changeTaskArea
:
"
Change Task Area
"
,
changeSubsectionArea
:
"
Change Subsection Area
"
,
previousTask
:
"
Previous Task
"
,
nextTask
:
"
Next Task
"
,
backendChecking
:
"
Checking backend availability...
"
,
...
...
@@ -84,6 +93,9 @@
lastCheckFailed
:
"
Last check: {detail}
"
,
deepLinkInvalidTask
:
"
Invalid task link. Please choose a task manually.
"
,
deepLinkInitFailed
:
"
Task link initialization failed. Please choose a task manually.
"
,
deepLinkInvalidSubsection
:
"
Invalid subsection link. Please choose a subsection manually.
"
,
deepLinkInitFailedSubsection
:
"
Subsection link initialization failed. Please choose a subsection manually.
"
,
},
de
:
{
chats
:
"
Chats
"
,
...
...
@@ -135,6 +147,8 @@
hideThinking
:
"
Thinking ausblenden
"
,
noTasksAvailable
:
"
Keine Aufgaben verfügbar
"
,
noTaskSelected
:
"
Keine Aufgabe ausgewählt.
"
,
noSubsectionsAvailable
:
"
Keine Unterabschnitte verfügbar
"
,
noSubsectionSelected
:
"
Kein Unterabschnitt ausgewählt.
"
,
savedChats
:
"
Gespeicherte Chats
"
,
saving
:
"
Speichere...
"
,
noSavedChatsYet
:
"
Noch keine gespeicherten Chats.
"
,
...
...
@@ -162,10 +176,17 @@
"
Da ist wohl das Speichern des Canvas fehlgeschlagen. Gib gerne deinem Dozenten bescheid. In vielen Fällen hilft es die Seite neu zu laden.
"
,
taskSelectionTitle
:
"
Aufgabe auswählen
"
,
taskSelectionSubtitle
:
"
Wähle eine Aufgabe und starte den Tutor-Chat
"
,
socraticSelectionTitle
:
"
Unterabschnitt auswählen
"
,
socraticSelectionSubtitle
:
"
Wähle einen Unterabschnitt und starte den sokratischen Chat
"
,
subsection
:
"
Unterabschnitt
"
,
taskFile
:
"
Aufgabenset
"
,
taskId
:
"
Aufgaben-ID
"
,
subsectionFile
:
"
Unterabschnitt-Set
"
,
subsectionKey
:
"
Unterabschnitt-Schlüssel
"
,
solveWithTutor
:
"
Mit Tutor lösen
"
,
startSocratic
:
"
Sokratisch starten
"
,
changeTaskArea
:
"
Aufgabengebiet ändern
"
,
changeSubsectionArea
:
"
Unterabschnitt ändern
"
,
previousTask
:
"
Vorherige Aufgabe
"
,
nextTask
:
"
Nächste Aufgabe
"
,
backendChecking
:
"
Backend-Verbindung wird geprüft...
"
,
...
...
@@ -178,6 +199,10 @@
"
Ungültiger Aufgaben-Link. Bitte wähle die Aufgabe manuell aus.
"
,
deepLinkInitFailed
:
"
Der Aufgaben-Link konnte nicht initialisiert werden. Bitte wähle die Aufgabe manuell aus.
"
,
deepLinkInvalidSubsection
:
"
Ungültiger Unterabschnitt-Link. Bitte wähle den Unterabschnitt manuell aus.
"
,
deepLinkInitFailedSubsection
:
"
Der Unterabschnitt-Link konnte nicht initialisiert werden. Bitte wähle den Unterabschnitt manuell aus.
"
,
},
}
as
const
;
...
...
math-tutor/frontend/src/pages/App.tsx
View file @
3c67b18f
import
{
Navigate
,
Route
,
Routes
}
from
"
react-router-dom
"
;
import
{
Navigate
,
Route
,
Routes
}
from
"
react-router-dom
"
;
import
{
t
}
from
"
../i18n
"
;
import
ChatPage
from
"
./ChatPage
"
;
import
SocraticSelectionPage
from
"
./SocraticSelectionPage
"
;
import
TaskSelectionPage
from
"
./TaskSelectionPage
"
;
import
{
TutorSessionProvider
,
useTutorSession
}
from
"
../state/tutorSession
"
;
import
{
getSelectionRouteForOrchestrator
}
from
"
../utils/orchestratorRoutes
"
;
function
StartRoute
()
{
const
{
isTasksInitialized
,
isTaskModeEnabled
}
=
useTutorSession
();
const
{
isTasksInitialized
,
selectedOrchestrator
}
=
useTutorSession
();
if
(
!
isTasksInitialized
)
{
return
<
div
className
=
"app-loading"
>
{
t
(
"
loading
"
)
}
</
div
>;
}
return
<
Navigate
to
=
{
isTaskModeEnabled
?
"
/select-task
"
:
"
/chat
"
}
replace
/>;
return
<
Navigate
to
=
{
getSelectionRouteForOrchestrator
(
selectedOrchestrator
)
}
replace
/>;
}
export
default
function
App
()
{
...
...
@@ -20,6 +22,7 @@ export default function App() {
<
Routes
>
<
Route
path
=
"/"
element
=
{
<
StartRoute
/>
}
/>
<
Route
path
=
"/select-task"
element
=
{
<
TaskSelectionPage
/>
}
/>
<
Route
path
=
"/select-socratic"
element
=
{
<
SocraticSelectionPage
/>
}
/>
<
Route
path
=
"/chat"
element
=
{
<
ChatPage
/>
}
/>
<
Route
path
=
"*"
element
=
{
<
StartRoute
/>
}
/>
</
Routes
>
...
...
math-tutor/frontend/src/pages/ChatPage.tsx
View file @
3c67b18f
...
...
@@ -5,13 +5,15 @@ import ChatWindow from "../components/Chat/ChatWindow";
import
CanvasDrawer
from
"
../components/Canvas/CanvasDrawer
"
;
import
OrchestratorSelect
from
"
../components/Orchestrator/OrchestratorSelect
"
;
import
DocPanel
from
"
../components/Retrieval/DocPanel
"
;
import
SocraticPanel
from
"
../components/Task/SocraticPanel
"
;
import
TaskPanel
from
"
../components/Task/TaskPanel
"
;
import
type
{
ChatMessage
}
from
"
../components/Chat/MessageList
"
;
import
type
{
RetrievedDoc
}
from
"
../components/Retrieval/DocPanel
"
;
import
type
{
OrchestratorName
}
from
"
../api/orchestratorApi
"
;
import
{
selectTask
}
from
"
../api/taskApi
"
;
import
{
selectSubsection
,
selectTask
}
from
"
../api/taskApi
"
;
import
{
t
}
from
"
../i18n
"
;
import
{
createSessionId
,
useTutorSession
}
from
"
../state/tutorSession
"
;
import
{
getSelectionRouteForOrchestrator
,
isSocraticOrchestrator
}
from
"
../utils/orchestratorRoutes
"
;
import
sumintLogo
from
"
../../SuMINT-Logo.png
"
;
const
initialMessages
:
ChatMessage
[]
=
[];
...
...
@@ -30,7 +32,7 @@ const normalizeOrchestrator = (value: string | null | undefined): OrchestratorNa
};
const
isTaskCoupledOrchestrator
=
(
value
:
string
|
null
|
undefined
):
boolean
=>
value
===
"
task
"
||
value
===
"
feedback
"
||
value
===
"
socratic
"
;
value
===
"
task
"
||
value
===
"
feedback
"
;
type
ArchivedChatSummary
=
{
chat_id
:
string
;
...
...
@@ -49,6 +51,9 @@ type ArchivedChatDetail = {
file_id
:
string
;
task_id
:
string
;
}
|
null
;
selected_subsection
?:
{
subsection_key
:
string
;
}
|
null
;
};
type
ContextSource
=
{
...
...
@@ -126,6 +131,8 @@ export default function ChatPage() {
selectedTaskRef
,
selectedTask
,
selectedTaskFile
,
selectedSubsectionRef
,
selectedSubsection
,
selectedOrchestrator
,
availableOrchestrators
,
setSelectedOrchestrator
,
...
...
@@ -135,6 +142,7 @@ export default function ChatPage() {
lockTask
,
unlockTask
,
setTaskRef
,
setSubsectionRef
,
resetForNewChat
,
}
=
useTutorSession
();
...
...
@@ -168,23 +176,31 @@ export default function ChatPage() {
.
toLowerCase
();
const
fileId
=
String
(
searchParams
.
get
(
"
file_id
"
)
||
""
).
trim
();
const
rawTaskId
=
String
(
searchParams
.
get
(
"
task_id
"
)
||
""
).
trim
();
const
rawSubsectionKey
=
String
(
searchParams
.
get
(
"
subsection_key
"
)
||
""
).
trim
();
const
taskId
=
/^
\d{1,2}
$/
.
test
(
rawTaskId
)
&&
rawTaskId
.
length
<
2
?
rawTaskId
.
padStart
(
2
,
"
0
"
)
:
rawTaskId
;
const
subsectionKey
=
rawSubsectionKey
;
const
hasFileId
=
Boolean
(
fileId
);
const
hasTaskId
=
Boolean
(
taskId
);
const
hasSubsectionKey
=
Boolean
(
subsectionKey
);
const
isTaskOrchestrator
=
isTaskCoupledOrchestrator
(
orchestrator
);
const
hasAnyTaskParam
=
hasFileId
||
hasTaskId
;
const
hasRequiredParams
=
hasFileId
&&
hasTaskId
;
const
key
=
`
${
orchestrator
}
|
${
fileId
}
|
${
taskId
}
`
;
const
isSocratic
=
isSocraticOrchestrator
(
orchestrator
as
OrchestratorName
);
const
hasAnyTaskParam
=
hasFileId
||
hasTaskId
||
hasSubsectionKey
;
const
hasRequiredTaskParams
=
hasFileId
&&
hasTaskId
;
const
hasRequiredSubsectionParams
=
hasSubsectionKey
;
const
key
=
`
${
orchestrator
}
|
${
fileId
}
|
${
taskId
}
|
${
subsectionKey
}
`
;
return
{
fileId
,
taskId
,
subsectionKey
,
isTaskOrchestrator
,
isSocratic
,
hasAnyTaskParam
,
hasRequiredParams
,
hasRequiredTaskParams
,
hasRequiredSubsectionParams
,
key
,
};
},
[
searchParams
]);
...
...
@@ -193,7 +209,7 @@ export default function ChatPage() {
if
(
!
isTasksInitialized
)
{
return
;
}
if
(
!
deepLinkTarget
.
isTaskOrchestrator
||
!
deepLinkTarget
.
hasAnyTaskParam
)
{
if
(
(
!
deepLinkTarget
.
isTaskOrchestrator
&&
!
deepLinkTarget
.
isSocratic
)
||
!
deepLinkTarget
.
hasAnyTaskParam
)
{
return
;
}
if
(
processedDeepLinkRef
.
current
===
deepLinkTarget
.
key
)
{
...
...
@@ -201,51 +217,93 @@ export default function ChatPage() {
}
processedDeepLinkRef
.
current
=
deepLinkTarget
.
key
;
if
(
!
deepLinkTarget
.
hasRequiredParams
)
{
se
tDeepLinkError
(
t
(
"
deepLinkInvalidTask
"
));
setTaskRef
(
null
);
unlockTask
();
navigate
(
"
/select-task
"
,
{
replace
:
true
});
return
;
}
const
targetRoute
=
getSelectionRouteForOrchestrator
(
se
archParams
.
get
(
"
orchestrator
"
)
===
"
feedback
"
?
"
feedback
"
:
searchParams
.
get
(
"
orchestrator
"
)
===
"
socratic
"
?
"
socratic
"
:
"
task
"
);
const
selectedFile
=
taskFiles
.
find
((
file
)
=>
file
.
file_id
===
deepLinkTarget
.
fileId
);
const
selectedTask
=
selectedFile
?.
tasks
.
find
((
task
)
=>
task
.
task_id
===
deepLinkTarget
.
taskId
);
if
(
!
selectedFile
||
!
selectedTask
)
{
setDeepLinkError
(
t
(
"
deepLinkInvalidTask
"
));
if
(
(
deepLinkTarget
.
isTaskOrchestrator
&&
!
deepLinkTarget
.
hasRequiredTaskParams
)
||
(
deepLinkTarget
.
isSocratic
&&
!
deepLinkTarget
.
hasRequiredSubsectionParams
)
)
{
setDeepLinkError
(
deepLinkTarget
.
isSocratic
?
t
(
"
deepLinkInvalidSubsection
"
)
:
t
(
"
deepLinkInvalidTask
"
)
);
setTaskRef
(
null
);
setSubsectionRef
(
null
);
unlockTask
();
navigate
(
"
/select-task
"
,
{
replace
:
true
});
navigate
(
targetRoute
,
{
replace
:
true
});
return
;
}
setSelectedOrchestrator
(
searchParams
.
get
(
"
orchestrator
"
)
===
"
feedback
"
?
"
feedback
"
:
searchParams
.
get
(
"
orchestrator
"
)
===
"
socratic
"
?
"
socratic
"
if
(
deepLinkTarget
.
isSocratic
)
{
setSelectedOrchestrator
(
"
socratic
"
);
setTaskRef
(
null
);
setSubsectionRef
({
subsectionKey
:
deepLinkTarget
.
subsectionKey
});
}
else
{
const
selectedFile
=
taskFiles
.
find
((
file
)
=>
file
.
file_id
===
deepLinkTarget
.
fileId
);
if
(
!
selectedFile
)
{
setDeepLinkError
(
t
(
"
deepLinkInvalidTask
"
));
setTaskRef
(
null
);
setSubsectionRef
(
null
);
unlockTask
();
navigate
(
targetRoute
,
{
replace
:
true
});
return
;
}
const
selectedTask
=
selectedFile
.
tasks
.
find
((
task
)
=>
task
.
task_id
===
deepLinkTarget
.
taskId
);
if
(
!
selectedTask
)
{
setDeepLinkError
(
t
(
"
deepLinkInvalidTask
"
));
setTaskRef
(
null
);
setSubsectionRef
(
null
);
unlockTask
();
navigate
(
targetRoute
,
{
replace
:
true
});
return
;
}
setSelectedOrchestrator
(
searchParams
.
get
(
"
orchestrator
"
)
===
"
feedback
"
?
"
feedback
"
:
"
task
"
);
setTaskRef
({
fileId
:
selectedFile
.
file_id
,
taskId
:
selectedTask
.
task_id
});
);
setSubsectionRef
(
null
);
setTaskRef
({
fileId
:
selectedFile
.
file_id
,
taskId
:
selectedTask
.
task_id
});
}
unlockTask
();
let
cancelled
=
false
;
void
(
async
()
=>
{
try
{
await
selectTask
({
draft
:
chatSessionId
,
fileId
:
selectedFile
.
file_id
,
taskId
:
selectedTask
.
task_id
,
});
if
(
deepLinkTarget
.
isSocratic
)
{
await
selectSubsection
({
draft
:
chatSessionId
,
subsectionKey
:
deepLinkTarget
.
subsectionKey
,
});
}
else
{
const
selectedFile
=
taskFiles
.
find
((
file
)
=>
file
.
file_id
===
deepLinkTarget
.
fileId
);
const
selectedTask
=
selectedFile
?.
tasks
.
find
((
task
)
=>
task
.
task_id
===
deepLinkTarget
.
taskId
);
if
(
!
selectedFile
||
!
selectedTask
)
{
throw
new
Error
(
"
invalid task deep link
"
);
}
await
selectTask
({
draft
:
chatSessionId
,
fileId
:
selectedFile
.
file_id
,
taskId
:
selectedTask
.
task_id
,
});
}
if
(
!
cancelled
)
{
setDeepLinkError
(
null
);
}
}
catch
(
error
)
{
if
(
!
cancelled
)
{
setDeepLinkError
(
t
(
"
deepLinkInitFailed
"
));
setDeepLinkError
(
deepLinkTarget
.
isSocratic
?
t
(
"
deepLinkInitFailedSubsection
"
)
:
t
(
"
deepLinkInitFailed
"
)
);
setTaskRef
(
null
);
setSubsectionRef
(
null
);
unlockTask
();
navigate
(
"
/select-task
"
,
{
replace
:
true
});
navigate
(
targetRoute
,
{
replace
:
true
});
}
void
error
;
}
...
...
@@ -261,6 +319,7 @@ export default function ChatPage() {
navigate
,
setSelectedOrchestrator
,
setTaskRef
,
setSubsectionRef
,
taskFiles
,
unlockTask
,
]);
...
...
@@ -276,10 +335,24 @@ export default function ChatPage() {
)
{
return
;
}
if
(
selectedOrchestrator
===
"
socratic
"
)
{
if
(
!
selectedSubsectionRef
)
{
navigate
(
"
/select-socratic
"
,
{
replace
:
true
});
}
return
;
}
if
(
!
selectedTaskRef
)
{
navigate
(
"
/select-task
"
,
{
replace
:
true
});
}
},
[
deepLinkTarget
,
isTaskModeEnabled
,
isTasksInitialized
,
navigate
,
selectedTaskRef
]);
},
[
deepLinkTarget
,
isTaskModeEnabled
,
isTasksInitialized
,
navigate
,
selectedOrchestrator
,
selectedSubsectionRef
,
selectedTaskRef
,
]);
const
docIndexes
=
useMemo
(()
=>
{
const
bySourceKey
:
Record
<
string
,
RetrievedDoc
>
=
{};
...
...
@@ -432,6 +505,7 @@ export default function ChatPage() {
draft
:
string
;
orchestrator
:
OrchestratorName
;
selected_task
?:
{
file_id
:
string
;
task_id
:
string
};
selected_subsection
?:
{
subsection_key
:
string
};
}
=
{
messages
:
[...
messages
,
userMessage
].
map
((
message
)
=>
({
role
:
message
.
role
,
...
...
@@ -440,11 +514,17 @@ export default function ChatPage() {
draft
:
chatSessionId
,
orchestrator
:
selectedOrchestrator
,
};
if
(
isTaskModeEnabled
&&
selectedTaskRef
)
{
chatPayload
.
selected_task
=
{
file_id
:
selectedTaskRef
.
fileId
,
task_id
:
selectedTaskRef
.
taskId
,
};
if
(
isTaskModeEnabled
)
{
if
(
selectedOrchestrator
===
"
socratic
"
&&
selectedSubsectionRef
)
{
chatPayload
.
selected_subsection
=
{
subsection_key
:
selectedSubsectionRef
.
subsectionKey
,
};
}
else
if
(
selectedTaskRef
)
{
chatPayload
.
selected_task
=
{
file_id
:
selectedTaskRef
.
fileId
,
task_id
:
selectedTaskRef
.
taskId
,
};
}
}
const
response
=
await
fetch
(
`/api/chat`
,
{
...
...
@@ -607,30 +687,54 @@ export default function ChatPage() {
:
selectedOrchestrator
;
setSelectedOrchestrator
(
nextOrchestrator
);
if
(
isTaskCoupledOrchestrator
(
nextOrchestrator
))
{
const
restoredTask
=
payload
.
selected_task
;
if
(
restoredTask
?.
file_id
&&
restoredTask
?.
task_id
)
{
setTaskRef
({
fileId
:
restoredTask
.
file_id
,
taskId
:
restoredTask
.
task_id
,
});
lockTask
();
try
{
await
selectTask
({
draft
:
payload
.
chat_id
,
if
(
nextOrchestrator
===
"
socratic
"
||
isTaskCoupledOrchestrator
(
nextOrchestrator
))
{
if
(
nextOrchestrator
===
"
socratic
"
)
{
const
restoredSubsection
=
payload
.
selected_subsection
;
if
(
restoredSubsection
?.
subsection_key
)
{
setTaskRef
(
null
);
setSubsectionRef
({
subsectionKey
:
restoredSubsection
.
subsection_key
});
lockTask
();
try
{
await
selectSubsection
({
draft
:
payload
.
chat_id
,
subsectionKey
:
restoredSubsection
.
subsection_key
,
});
}
catch
(
error
)
{
void
error
;
}
}
else
{
setTaskRef
(
null
);
setSubsectionRef
(
null
);
unlockTask
();
navigate
(
"
/select-socratic
"
,
{
replace
:
true
});
}
}
else
{
setSubsectionRef
(
null
);
const
restoredTask
=
payload
.
selected_task
;
if
(
restoredTask
?.
file_id
&&
restoredTask
?.
task_id
)
{
setTaskRef
({
fileId
:
restoredTask
.
file_id
,
taskId
:
restoredTask
.
task_id
,
});
}
catch
(
error
)
{
void
error
;
lockTask
();
try
{
await
selectTask
({
draft
:
payload
.
chat_id
,
fileId
:
restoredTask
.
file_id
,
taskId
:
restoredTask
.
task_id
,
});
}
catch
(
error
)
{
void
error
;
}
}
else
{
setTaskRef
(
null
);
unlockTask
();
navigate
(
"
/select-task
"
,
{
replace
:
true
});
}
}
else
{
setTaskRef
(
null
);
unlockTask
();
navigate
(
"
/select-task
"
,
{
replace
:
true
});
}
}
else
{
setTaskRef
(
null
);
setSubsectionRef
(
null
);
unlockTask
();
navigate
(
"
/chat
"
,
{
replace
:
true
});
}
...
...
@@ -666,7 +770,7 @@ export default function ChatPage() {
resetChatState
();
resetForNewChat
();
await
loadArchives
();
navigate
(
isTaskModeEnabled
?
"
/select-task
"
:
"
/chat
"
);
navigate
(
getSelectionRouteForOrchestrator
(
selectedOrchestrator
)
);
};
const
handleSwitchTask
=
async
(
target
:
{
fileId
:
string
;
taskId
:
string
}
|
null
)
=>
{
...
...
@@ -727,8 +831,9 @@ export default function ChatPage() {
};
const
handleChangeTaskArea
=
()
=>
{
resetForNewChat
();
unlockTask
();
navigate
(
"
/s
elect
-task
"
);
navigate
(
getS
elect
ionRouteForOrchestrator
(
selectedOrchestrator
)
);
};
const
handleOpenSidebar
=
()
=>
{
...
...
@@ -742,7 +847,7 @@ export default function ChatPage() {
}
resetChatState
();
switchOrchestrator
(
next
);
navigate
(
isTaskCoupledOrchestrator
(
next
)
?
"
/select-task
"
:
"
/chat
"
);
navigate
(
getSelectionRouteForOrchestrator
(
next
)
);
};
const
handleCanvasSave
=
async
(
...
...
@@ -975,7 +1080,16 @@ export default function ChatPage() {
</
section
>
<
aside
className
=
{
`retrieval-column
${
isTaskModeEnabled
?
"
retrieval-column-task-mode
"
:
""
}
`
}
>
{
isTaskModeEnabled
&&
selectedTask
?
(
{
selectedOrchestrator
===
"
socratic
"
&&
selectedSubsection
?
(
<
SocraticPanel
selectedSubsectionLabel
=
{
selectedSubsection
.
label
}
selectedSubsectionKey
=
{
selectedSubsection
.
subsectionKey
}
selectedSubsectionRefsText
=
{
selectedSubsection
.
refsText
}
onChangeSelection
=
{
handleChangeTaskArea
}
/>
)
:
null
}
{
selectedOrchestrator
!==
"
socratic
"
&&
isTaskModeEnabled
&&
selectedTask
?
(
<
TaskPanel
readOnly
selectedTaskText
=
{
selectedTask
.
fullText
}
...
...
math-tutor/frontend/src/pages/SocraticSelectionPage.tsx
0 → 100644
View file @
3c67b18f
import
{
useEffect
,
useMemo
,
useRef
}
from
"
react
"
;
import
{
useNavigate
}
from
"
react-router-dom
"
;
import
type
{
OrchestratorName
}
from
"
../api/orchestratorApi
"
;
import
{
selectSubsection
}
from
"
../api/taskApi
"
;
import
OrchestratorSelect
from
"
../components/Orchestrator/OrchestratorSelect
"
;
import
{
t
}
from
"
../i18n
"
;
import
{
useTutorSession
}
from
"
../state/tutorSession
"
;
import
{
getSelectionRouteForOrchestrator
}
from
"
../utils/orchestratorRoutes
"
;
import
"
../styles/theme.css
"
;
import
sumintLogo
from
"
../../SuMINT-Logo.png
"
;
export
default
function
SocraticSelectionPage
()
{
const
navigate
=
useNavigate
();
const
{
chatSessionId
,
selectedOrchestrator
,
availableOrchestrators
,
switchOrchestrator
,
isOrchestratorSelectable
,
orchestratorError
,
subsections
,
selectedSubsectionRef
,
selectedSubsection
,
tasksError
,
setSubsectionKey
,
lockTask
,
unlockTask
,
isTasksInitialized
,
}
=
useTutorSession
();
const
subsectionDisplayRef
=
useRef
<
HTMLDivElement
|
null
>
(
null
);
const
subsectionMenuOptions
=
useMemo
(
()
=>
subsections
.
map
((
option
)
=>
({
value
:
option
.
subsection_key
,
label
:
option
.
label
||
option
.
subsection_key
,
})),
[
subsections
]
);
useEffect
(()
=>
{
if
(
!
isTasksInitialized
)
{
return
;
}
if
(
selectedOrchestrator
!==
"
socratic
"
)
{
navigate
(
getSelectionRouteForOrchestrator
(
selectedOrchestrator
),
{
replace
:
true
});
return
;
}
unlockTask
();
},
[
isTasksInitialized
,
navigate
,
selectedOrchestrator
,
unlockTask
]);
useEffect
(()
=>
{
if
(
!
selectedSubsection
?.
label
||
!
subsectionDisplayRef
.
current
)
{
return
;
}
const
mathjax
=
window
.
MathJax
;
if
(
!
mathjax
?.
typesetPromise
)
{
return
;
}
mathjax
.
typesetPromise
([
subsectionDisplayRef
.
current
]).
catch
(()
=>
undefined
);
},
[
selectedSubsection
?.
label
]);
useEffect
(()
=>
{
if
(
!
subsectionMenuOptions
.
length
)
{
return
;
}
if
(
selectedSubsectionRef
&&
subsectionMenuOptions
.
some
((
option
)
=>
option
.
value
===
selectedSubsectionRef
.
subsectionKey
)
)
{
return
;
}
setSubsectionKey
(
subsectionMenuOptions
[
0
].
value
);
},
[
selectedSubsectionRef
,
setSubsectionKey
,
subsectionMenuOptions
]);
const
handleStartSocratic
=
async
()
=>
{
if
(
!
selectedSubsectionRef
)
{
return
;
}
try
{
await
selectSubsection
({
draft
:
chatSessionId
,
subsectionKey
:
selectedSubsectionRef
.
subsectionKey
,
});
lockTask
();
navigate
(
"
/chat
"
);
}
catch
(
error
)
{
void
error
;
}
};
const
handleSwitchOrchestrator
=
(
next
:
OrchestratorName
)
=>
{
if
(
next
===
selectedOrchestrator
)
{
return
;
}
switchOrchestrator
(
next
);
navigate
(
getSelectionRouteForOrchestrator
(
next
),
{
replace
:
true
});
};
if
(
!
isTasksInitialized
)
{
return
<
div
className
=
"app-loading"
>
{
t
(
"
loading
"
)
}
</
div
>;
}
return
(
<
div
className
=
"app-shell"
>
<
header
className
=
"app-header"
>
<
div
className
=
"brand"
>
<
img
className
=
"brand-logo"
src
=
{
sumintLogo
}
alt
=
"SuMINT Logo"
/>
<
div
className
=
"brand-text"
>
<
div
className
=
"brand-title"
>
Mathe Tutor
</
div
>
<
div
className
=
"brand-subtitle"
>
{
t
(
"
socraticSelectionSubtitle
"
)
}
</
div
>
</
div
>
</
div
>
</
header
>
{
orchestratorError
?
<
div
className
=
"chat-archive-error"
>
{
orchestratorError
}
</
div
>
:
null
}
<
main
className
=
"task-select-main"
>
<
div
className
=
"task-select-wrap"
>
<
div
className
=
"task-mode-row"
>
<
OrchestratorSelect
value
=
{
selectedOrchestrator
}
options
=
{
availableOrchestrators
}
onChange
=
{
handleSwitchOrchestrator
}
disabled
=
{
!
isOrchestratorSelectable
}
/>
</
div
>
<
section
className
=
"task-select-card"
>
<
div
className
=
"task-select-header"
>
<
h2
className
=
"task-select-title"
>
{
t
(
"
socraticSelectionTitle
"
)
}
</
h2
>
</
div
>
<
div
className
=
"task-select-controls"
>
<
label
className
=
"task-select-label"
htmlFor
=
"socratic-subsection-select"
>
{
t
(
"
subsection
"
)
}
</
label
>
<
select
id
=
"socratic-subsection-select"
className
=
"task-select"
value
=
{
selectedSubsectionRef
?.
subsectionKey
||
""
}
onChange
=
{
(
event
)
=>
setSubsectionKey
(
event
.
target
.
value
)
}
disabled
=
{
!
subsectionMenuOptions
.
length
}
>
{
subsectionMenuOptions
.
length
?
(
subsectionMenuOptions
.
map
((
option
)
=>
(
<
option
key
=
{
option
.
value
}
value
=
{
option
.
value
}
>
{
option
.
label
}
</
option
>
))
)
:
(
<
option
value
=
""
>
{
t
(
"
noSubsectionsAvailable
"
)
}
</
option
>
)
}
</
select
>
</
div
>
<
div
className
=
"task-panel-content"
ref
=
{
subsectionDisplayRef
}
>
{
selectedSubsection
?
(
<
div
>
<
div
>
{
selectedSubsection
.
label
}
</
div
>
<
div
>
{
selectedSubsection
.
refsText
}
</
div
>
</
div
>
)
:
(
t
(
"
noSubsectionSelected
"
)
)
}
</
div
>
{
tasksError
?
<
div
className
=
"task-panel-error"
>
{
tasksError
}
</
div
>
:
null
}
<
button
type
=
"button"
className
=
"btn primary task-solve-btn"
onClick
=
{
handleStartSocratic
}
disabled
=
{
!
selectedSubsectionRef
}
>
{
t
(
"
startSocratic
"
)
}
</
button
>
</
section
>
</
div
>
</
main
>
</
div
>
);
}
math-tutor/frontend/src/pages/TaskSelectionPage.tsx
View file @
3c67b18f
...
...
@@ -5,12 +5,10 @@ import OrchestratorSelect from "../components/Orchestrator/OrchestratorSelect";
import
{
selectTask
}
from
"
../api/taskApi
"
;
import
{
t
}
from
"
../i18n
"
;
import
{
useTutorSession
}
from
"
../state/tutorSession
"
;
import
{
getSelectionRouteForOrchestrator
}
from
"
../utils/orchestratorRoutes
"
;
import
"
../styles/theme.css
"
;
import
sumintLogo
from
"
../../SuMINT-Logo.png
"
;
const
isTaskCoupledOrchestrator
=
(
value
:
OrchestratorName
):
boolean
=>
value
===
"
task
"
||
value
===
"
feedback
"
||
value
===
"
socratic
"
;
export
default
function
TaskSelectionPage
()
{
const
navigate
=
useNavigate
();
const
{
...
...
@@ -42,8 +40,12 @@ export default function TaskSelectionPage() {
navigate
(
"
/chat
"
,
{
replace
:
true
});
return
;
}
if
(
selectedOrchestrator
===
"
socratic
"
)
{
navigate
(
"
/select-socratic
"
,
{
replace
:
true
});
return
;
}
unlockTask
();
},
[
isTaskModeEnabled
,
isTasksInitialized
,
navigate
,
unlockTask
]);
},
[
isTaskModeEnabled
,
isTasksInitialized
,
navigate
,
selectedOrchestrator
,
unlockTask
]);
useEffect
(()
=>
{
if
(
!
taskFileOptions
.
length
)
{
...
...
@@ -89,7 +91,7 @@ export default function TaskSelectionPage() {
return
;
}
switchOrchestrator
(
next
);
navigate
(
isTaskCoupledOrchestrator
(
next
)
?
"
/select-task
"
:
"
/chat
"
,
{
replace
:
true
});
navigate
(
getSelectionRouteForOrchestrator
(
next
)
,
{
replace
:
true
});
};
if
(
!
isTasksInitialized
)
{
...
...
math-tutor/frontend/src/state/tutorSession.tsx
View file @
3c67b18f
...
...
@@ -14,7 +14,13 @@ import {
getFallbackOrchestrators
,
type
OrchestratorName
,
}
from
"
../api/orchestratorApi
"
;
import
{
fetchTasks
,
type
SelectedTaskRef
,
type
TaskFile
}
from
"
../api/taskApi
"
;
import
{
fetchTasks
,
type
SelectedSubsectionRef
,
type
SelectedTaskRef
,
type
SubsectionOption
,
type
TaskFile
,
}
from
"
../api/taskApi
"
;
export
type
SelectOption
=
{
value
:
string
;
...
...
@@ -26,6 +32,12 @@ export type SelectedTask = SelectedTaskRef & {
fullText
:
string
;
};
export
type
SelectedSubsection
=
SelectedSubsectionRef
&
{
label
:
string
;
refs
:
[
number
,
number
,
number
][];
refsText
:
string
;
};
export
type
TaskSelectionState
=
{
taskFiles
:
TaskFile
[];
selectedTaskRef
:
SelectedTaskRef
|
null
;
...
...
@@ -33,6 +45,10 @@ export type TaskSelectionState = {
selectedTaskFile
:
TaskFile
|
null
;
taskFileOptions
:
SelectOption
[];
taskOptions
:
SelectOption
[];
selectedSubsectionRef
:
SelectedSubsectionRef
|
null
;
selectedSubsection
:
SelectedSubsection
|
null
;
subsections
:
SubsectionOption
[];
subsectionOptions
:
SelectOption
[];
tasksError
:
string
|
null
;
isTaskModeEnabled
:
boolean
;
isTasksInitialized
:
boolean
;
...
...
@@ -50,8 +66,10 @@ export type TutorSessionState = TaskSelectionState & {
isOrchestratorSelectable
:
boolean
;
orchestratorError
:
string
|
null
;
setTaskRef
:
(
value
:
SelectedTaskRef
|
null
)
=>
void
;
setSubsectionRef
:
(
value
:
SelectedSubsectionRef
|
null
)
=>
void
;
setTaskFile
:
(
fileId
:
string
)
=>
void
;
setTaskId
:
(
taskId
:
string
)
=>
void
;
setSubsectionKey
:
(
subsectionKey
:
string
)
=>
void
;
lockTask
:
()
=>
void
;
unlockTask
:
()
=>
void
;
resetForNewChat
:
()
=>
void
;
...
...
@@ -71,6 +89,9 @@ const isSelectableTaskFile = (file: TaskFile): boolean =>
const
isTaskCoupledOrchestrator
=
(
value
:
OrchestratorName
):
boolean
=>
value
===
"
task
"
||
value
===
"
feedback
"
||
value
===
"
socratic
"
;
const
formatSubsectionRefs
=
(
refs
:
[
number
,
number
,
number
][]):
string
=>
refs
.
map
((
ref
)
=>
ref
.
join
(
"
:
"
)).
join
(
"
,
"
);
export
function
TutorSessionProvider
({
children
}:
PropsWithChildren
)
{
const
[
chatSessionId
,
setChatSessionId
]
=
useState
<
string
>
(()
=>
createSessionId
());
const
[
selectedOrchestrator
,
setSelectedOrchestratorState
]
=
...
...
@@ -82,7 +103,10 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
const
[
orchestratorError
,
setOrchestratorError
]
=
useState
<
string
|
null
>
(
null
);
const
[
isTasksInitialized
,
setIsTasksInitialized
]
=
useState
(
false
);
const
[
taskFiles
,
setTaskFiles
]
=
useState
<
TaskFile
[]
>
([]);
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
);
...
...
@@ -136,6 +160,34 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
[
selectedTaskFile
]
);
const
subsectionOptions
=
useMemo
(
()
=>
subsections
.
map
((
option
)
=>
({
value
:
option
.
subsection_key
,
label
:
option
.
label
||
option
.
subsection_key
,
})),
[
subsections
]
);
const
selectedSubsection
=
useMemo
<
SelectedSubsection
|
null
>
(()
=>
{
if
(
!
selectedSubsectionRef
)
{
return
null
;
}
const
option
=
subsections
.
find
(
(
item
)
=>
item
.
subsection_key
===
selectedSubsectionRef
.
subsectionKey
);
if
(
!
option
)
{
return
null
;
}
const
refs
=
option
.
refs
??
[];
return
{
subsectionKey
:
option
.
subsection_key
,
label
:
option
.
label
,
refs
,
refsText
:
formatSubsectionRefs
(
refs
),
};
},
[
selectedSubsectionRef
,
subsections
]);
const
initTasks
=
useCallback
(
async
()
=>
{
setTasksError
(
null
);
setOrchestratorError
(
null
);
...
...
@@ -160,9 +212,11 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
try
{
const
payload
=
await
fetchTasks
();
const
files
=
payload
.
task_files
||
[];
const
subsectionsPayload
=
payload
.
subsections
||
[];
const
selectableFiles
=
files
.
filter
((
file
)
=>
isSelectableTaskFile
(
file
));
setTaskFiles
(
files
);
setSubsections
(
subsectionsPayload
);
setSelectedTaskRef
((
prev
)
=>
{
if
(
prev
)
{
const
file
=
selectableFiles
.
find
((
item
)
=>
item
.
file_id
===
prev
.
fileId
);
...
...
@@ -181,10 +235,26 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
}
return
{
fileId
:
firstFile
.
file_id
,
taskId
:
defaultTaskId
};
});
setSelectedSubsectionRef
((
prev
)
=>
{
if
(
prev
)
{
const
option
=
subsectionsPayload
.
find
((
item
)
=>
item
.
subsection_key
===
prev
.
subsectionKey
);
if
(
option
)
{
return
prev
;
}
}
const
firstOption
=
subsectionsPayload
[
0
];
if
(
!
firstOption
)
{
return
null
;
}
return
{
subsectionKey
:
firstOption
.
subsection_key
};
});
}
catch
(
error
)
{
setTasksError
(
t
(
"
failedLoadTasks
"
));
setTaskFiles
([]);
setSubsections
([]);
setSelectedTaskRef
(
null
);
setSelectedSubsectionRef
(
null
);
void
error
;
}
finally
{
setIsTasksInitialized
(
true
);
...
...
@@ -223,6 +293,16 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
[
selectedTaskRef
]
);
const
setSubsectionKey
=
useCallback
(
(
subsectionKey
:
string
)
=>
{
if
(
!
subsectionKey
)
{
return
;
}
setSelectedSubsectionRef
({
subsectionKey
});
},
[]
);
const
setSelectedOrchestrator
=
useCallback
((
value
:
OrchestratorName
)
=>
{
setSelectedOrchestratorState
(
value
);
},
[]);
...
...
@@ -231,9 +311,14 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
setSelectedOrchestratorState
(
value
);
setChatSessionId
(
createSessionId
());
setTaskLocked
(
false
);
if
(
!
isTaskCoupledOrchestrator
(
value
))
{
if
(
value
===
"
socratic
"
)
{
setSelectedTaskRef
(
null
);
}
else
if
(
!
isTaskCoupledOrchestrator
(
value
))
{
setSelectedTaskRef
(
null
);
}
if
(
value
!==
"
socratic
"
)
{
setSelectedSubsectionRef
(
null
);
}
},
[]);
const
lockTask
=
useCallback
(()
=>
{
...
...
@@ -247,6 +332,7 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
const
resetForNewChat
=
useCallback
(()
=>
{
setChatSessionId
(
createSessionId
());
setSelectedTaskRef
(
null
);
setSelectedSubsectionRef
(
null
);
setTaskLocked
(
false
);
},
[]);
...
...
@@ -261,14 +347,20 @@ export function TutorSessionProvider({ children }: PropsWithChildren) {
isOrchestratorSelectable
,
orchestratorError
,
taskFiles
,
subsections
,
selectedTaskRef
,
selectedTask
,
selectedTaskFile
,
taskFileOptions
,
taskOptions
,
selectedSubsectionRef
,
selectedSubsection
,
subsectionOptions
,
setTaskRef
:
setSelectedTaskRef
,
setSubsectionRef
:
setSelectedSubsectionRef
,
setTaskFile
,
setTaskId
,
setSubsectionKey
,
tasksError
,
isTaskModeEnabled
,
isTasksInitialized
,
...
...
math-tutor/frontend/src/utils/orchestratorRoutes.ts
0 → 100644
View file @
3c67b18f
import
type
{
OrchestratorName
}
from
"
../api/orchestratorApi
"
;
export
const
isTaskSelectionOrchestrator
=
(
value
:
OrchestratorName
):
boolean
=>
value
===
"
task
"
||
value
===
"
feedback
"
;
export
const
isSocraticOrchestrator
=
(
value
:
OrchestratorName
):
boolean
=>
value
===
"
socratic
"
;
export
const
getSelectionRouteForOrchestrator
=
(
value
:
OrchestratorName
):
string
=>
{
if
(
value
===
"
socratic
"
)
{
return
"
/select-socratic
"
;
}
if
(
value
===
"
task
"
||
value
===
"
feedback
"
)
{
return
"
/select-task
"
;
}
return
"
/chat
"
;
};
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