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
79a212a1
Commit
79a212a1
authored
Jun 24, 2026
by
Kantz
Browse files
test kommentiert
parent
82adf746
Changes
12
Hide whitespace changes
Inline
Side-by-side
math-tutor/backend/app/config.py
View file @
79a212a1
...
...
@@ -67,16 +67,6 @@ def get_embedding_provider() -> str:
f
"Unsupported EMBEDDING_PROVIDER:
{
value
}
. Expected one of:
{
supported
}
"
)
legacy_type
=
os
.
getenv
(
"EMBEDDING_TYPE"
,
"openai-like"
).
strip
().
lower
()
if
legacy_type
==
"sentence-transformer"
:
return
"sentence-transformer"
if
legacy_type
==
"openai-like"
:
return
"openai"
supported
=
", "
.
join
(
sorted
(
SUPPORTED_EMBEDDING_PROVIDERS
))
raise
ValueError
(
f
"Unsupported EMBEDDING_TYPE:
{
legacy_type
}
. Set EMBEDDING_PROVIDER to one of:
{
supported
}
"
)
def
get_embedding_settings
()
->
EmbeddingSettings
:
provider
=
get_embedding_provider
()
...
...
math-tutor/backend/test/context_sheet_history_test.py
View file @
79a212a1
...
...
@@ -31,6 +31,7 @@ def _build_state() -> ChatState:
class
FinalizeResponseHistoryTest
(
unittest
.
TestCase
):
def
test_finalize_response_appends_decoded_reply_to_history
(
self
)
->
None
:
"""Checks that decoded assistant replies are persisted to sheet history."""
state
=
_build_state
()
with
patch
(
...
...
@@ -54,6 +55,7 @@ class FinalizeResponseHistoryTest(unittest.TestCase):
save_sheet
.
assert_called_once_with
(
state
.
sheet
)
def
test_finalize_response_appends_fallback_reply_to_history
(
self
)
->
None
:
"""Checks that the fallback assistant reply is persisted when no answer exists."""
state
=
_build_state
()
with
patch
(
...
...
math-tutor/backend/test/embeddings_jina_v5_unit_test.py
View file @
79a212a1
...
...
@@ -35,6 +35,7 @@ embeddings = _load_module("backend_embeddings_test_module", "app/deterministic_s
class
SentenceTransformerJinaV5Test
(
unittest
.
TestCase
):
def
test_config_defaults_to_jina_v5
(
self
)
->
None
:
"""Checks that Jina v5 is the default sentence-transformer embedding setup. When non is set in .env."""
with
patch
.
dict
(
os
.
environ
,
{
"EMBEDDING_TYPE"
:
"sentence-transformer"
},
clear
=
True
):
settings
=
config
.
get_embedding_settings
()
...
...
@@ -42,6 +43,7 @@ class SentenceTransformerJinaV5Test(unittest.TestCase):
self
.
assertEqual
(
settings
.
target_dim
,
1024
)
def
test_embedder_uses_document_and_query_prompts
(
self
)
->
None
:
"""Checks that the embedder applies separate prompts for documents and queries."""
fake_model
=
MagicMock
()
fake_model
.
encode
.
side_effect
=
[
[[
0.1
,
0.2
,
0.3
,
0.4
]],
...
...
@@ -66,6 +68,7 @@ class SentenceTransformerJinaV5Test(unittest.TestCase):
self
.
assertEqual
(
len
(
query
),
4
)
def
test_openai_like_embedder_uses_openai_library
(
self
)
->
None
:
"""Checks that OpenAI-like embeddings route through the OpenAI client library."""
class
FakeEmbeddingsClient
:
create_kwargs
:
dict
|
None
=
None
...
...
math-tutor/backend/test/generate_socratic_chats_test.py
View file @
79a212a1
...
...
@@ -38,6 +38,7 @@ class GenerateSocraticChatsTest(unittest.TestCase):
shutil
.
rmtree
(
self
.
temp_dir
,
ignore_errors
=
True
)
def
test_build_initial_prompt_index_uses_lowest_available_leaf_level
(
self
)
->
None
:
"""Checks that prompt indexing chooses the lowest available leaf level."""
_write_markdown
(
self
.
source_root
/
"sections/section-leaf.md"
,
'chapter_index: 1
\n
section_index: 1
\n
title: "Mengen"'
,
...
...
@@ -118,6 +119,7 @@ class GenerateSocraticChatsTest(unittest.TestCase):
self
.
assertNotIn
(
"grundrechenarten bei reellen zahlen"
,
items
)
def
test_existing_message_reuse_matches_leaf_key
(
self
)
->
None
:
"""Checks that existing Socratic messages are reused by matching the leaf key."""
_write_markdown
(
self
.
source_root
/
"sections/section-leaf.md"
,
'chapter_index: 1
\n
section_index: 1
\n
title: "Mengen"'
,
...
...
math-tutor/backend/test/health_readiness_unit_test.py
View file @
79a212a1
...
...
@@ -15,6 +15,7 @@ class HealthReadinessUnitTest(unittest.TestCase):
health
.
set_readiness_starting
()
def
test_readiness_returns_503_while_starting
(
self
)
->
None
:
"""Checks that readiness reports 503 while the app is still starting."""
health
.
set_readiness_starting
()
response
=
health
.
readiness
()
...
...
@@ -24,6 +25,7 @@ class HealthReadinessUnitTest(unittest.TestCase):
self
.
assertEqual
(
json
.
loads
(
response
.
body
),
{
"status"
:
"starting"
})
def
test_readiness_returns_200_when_ready
(
self
)
->
None
:
"""Checks that readiness reports 200 once startup completed successfully."""
warmup
=
{
"total_warmup_ms"
:
123.45
}
health
.
set_readiness_ready
(
warmup
=
warmup
)
...
...
@@ -32,6 +34,7 @@ class HealthReadinessUnitTest(unittest.TestCase):
self
.
assertEqual
(
response
,
{
"status"
:
"ready"
,
"warmup"
:
warmup
})
def
test_readiness_returns_503_when_failed
(
self
)
->
None
:
"""Checks that readiness reports 503 when startup has failed."""
checks
=
{
"status"
:
"degraded"
}
health
.
set_readiness_failed
(
"Embedding warmup failed"
,
checks
=
checks
)
...
...
@@ -48,7 +51,8 @@ class HealthReadinessUnitTest(unittest.TestCase):
},
)
def
test_health_checks_only_selected_gwdg_provider
(
self
)
->
None
:
def
test_health_checks_only_selected_provider_calls
(
self
)
->
None
:
"""Checks that health validation only requires config for the selected provider. With the example of the GWDG provider."""
with
patch
.
dict
(
os
.
environ
,
{
"LLM_PROVIDER"
:
"gwdg"
}),
patch
(
"app.api.health._check_gwdg"
,
return_value
=
{
"status"
:
"ok"
,
"url"
:
"https://chat-ai.academiccloud.de/v1/models"
},
...
...
math-tutor/backend/test/hint_test.py
→
math-tutor/backend/test/hint_test
_harness
.py
View file @
79a212a1
File moved
math-tutor/backend/test/referenz_decoder_unit_test.py
View file @
79a212a1
...
...
@@ -57,6 +57,7 @@ def _mk_source() -> SourceStub:
class
ReferenzDecoderUnitTest
(
unittest
.
TestCase
):
def
test_decodes_reference_without_chapter
(
self
)
->
None
:
"""Checks that references without chapter info still decode to source links."""
source
=
_mk_source
()
text
=
"Siehe [Lineare Funktionen|Steigung|Definition|subsection]."
...
...
@@ -69,6 +70,7 @@ class ReferenzDecoderUnitTest(unittest.TestCase):
)
def
test_decodes_reference_with_extra_tail_segment
(
self
)
->
None
:
"""Checks that references still decode when they include extra trailing segments."""
source
=
_mk_source
()
text
=
"Siehe [Lineare Funktionen/Steigung/Definition | subsection | ...]."
...
...
@@ -81,6 +83,7 @@ class ReferenzDecoderUnitTest(unittest.TestCase):
)
def
test_skips_existing_markdown_links
(
self
)
->
None
:
"""Checks that already-linked markdown references are left unchanged."""
source
=
_mk_source
()
text
=
"Bereits verlinkt: [Definition](doc://foo)."
...
...
math-tutor/backend/test/retrieval_store_test.py
→
math-tutor/backend/test/retrieval_store_test
_harness
.py
View file @
79a212a1
File moved
math-tutor/backend/test/task_catalog_socratic_test.py
View file @
79a212a1
...
...
@@ -25,6 +25,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
task_catalog
.
load_cached_task_files
.
cache_clear
()
def
test_build_task_payload_appends_image_descriptions
(
self
)
->
None
:
"""Checks that task payloads include appended descriptions for attached images."""
task_file
=
{
"title"
:
"Grundlagen von Funktionen"
,
"intro"
:
"Ordnen Sie zu."
,
...
...
@@ -60,6 +61,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
)
def
test_build_task_catalog_includes_normalized_images
(
self
)
->
None
:
"""Checks that task catalog entries contain normalized image metadata."""
task_files
=
[
{
"_file_id"
:
"analysis_1"
,
...
...
@@ -100,6 +102,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
self
.
assertEqual
(
catalog
[
0
][
"topics"
],
[
"quadratische_gleichungen"
])
def
test_build_task_metadata_catalog_only_returns_titles_and_task_ids
(
self
)
->
None
:
"""Checks that the metadata catalog keeps only lightweight title and task ID fields."""
task_files
=
[
{
"_file_id"
:
"analysis_1"
,
...
...
@@ -131,6 +134,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
)
def
test_build_cached_task_metadata_catalog_reuses_cache
(
self
)
->
None
:
"""Checks that cached task metadata is reused instead of being rebuilt."""
task_catalog
.
build_cached_task_metadata_catalog
.
cache_clear
()
with
patch
(
...
...
@@ -144,6 +148,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
build_mock
.
assert_called_once
()
def
test_normalize_yaml_task_entry_excludes_images_from_hints
(
self
)
->
None
:
"""Checks that image data is not copied into normalized task hints."""
task_entry
=
{
"aufgabe"
:
[
{
"type"
:
"text"
,
"text"
:
"Bestimme f(x)."
},
...
...
@@ -164,6 +169,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
self
.
assertEqual
(
normalized
[
"images"
],
[])
def
test_socratic_build_topic_catalog_returns_response_shape
(
self
)
->
None
:
"""Checks that the Socratic topic catalog matches the expected response shape."""
with
patch
(
"app.deterministic_services.socratic_oranisator.load_initial_prompt_items"
,
return_value
=
{
...
...
@@ -208,6 +214,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
)
def
test_build_topic_catalog_includes_summary
(
self
)
->
None
:
"""Checks that topic catalog entries include loaded summary text."""
with
patch
(
"app.deterministic_services.task_catalog.load_topic_map"
,
return_value
=
{
...
...
@@ -244,6 +251,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
)
def
test_build_topic_catalog_supports_mixed_parent_levels
(
self
)
->
None
:
"""Checks that topic catalog building handles mixed parent-depth hierarchies."""
with
patch
(
"app.deterministic_services.task_catalog.load_topic_map"
,
return_value
=
{
...
...
@@ -289,6 +297,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
)
def
test_load_topic_summaries_extracts_body
(
self
)
->
None
:
"""Checks that topic summary loading strips frontmatter and returns the body."""
temp_dir
=
Path
(
__file__
).
resolve
().
parent
/
"_tmp_subsection_summaries"
summary_root
=
temp_dir
/
"child_lvl"
/
"subsections"
summary_root
.
mkdir
(
parents
=
True
,
exist_ok
=
True
)
...
...
@@ -332,6 +341,7 @@ Zweite Zeile.
self
.
assertNotIn
(
"Child-Chunks"
,
summaries
[
"mengen"
])
def
test_select_topic_by_ids_sets_sheet_fields
(
self
)
->
None
:
"""Checks that selecting a topic by IDs writes the expected sheet fields."""
task_files
=
[
{
"_file_id"
:
"analysis_1"
,
...
...
@@ -358,6 +368,7 @@ Zweite Zeile.
self
.
assertEqual
(
sheet
[
"selected_topic_parent_refs"
],
[[
1
,
3
,
3
,
1
]])
def
test_select_topic_by_key_sets_sheet_fields
(
self
)
->
None
:
"""Checks that selecting a topic by key writes the expected sheet fields."""
sheet
:
dict
[
str
,
object
]
=
{}
with
patch
(
...
...
@@ -371,6 +382,7 @@ Zweite Zeile.
self
.
assertEqual
(
sheet
[
"selected_topic_parent_refs"
],
[[
1
,
3
,
3
,
1
]])
def
test_get_selected_topic_parent_refs_reads_normalized_refs
(
self
)
->
None
:
"""Checks that selected topic parent refs are read from normalized sheet fields."""
sheet
:
dict
[
str
,
object
]
=
{
"selected_topic_parent_refs"
:
[[
1
,
1
,
0
,
0
],
[
1
,
3
,
2
,
1
],
[
1
,
3
,
2
,
1
]],
}
...
...
@@ -380,6 +392,7 @@ Zweite Zeile.
self
.
assertEqual
(
refs
,
[(
1
,
1
,
0
,
0
),
(
1
,
3
,
2
,
1
)])
def
test_set_selected_task_stores_generic_parent_refs
(
self
)
->
None
:
"""Checks that selected task parent refs are stored in the generic normalized format."""
sheet
:
dict
[
str
,
object
]
=
{}
task_file
=
{
"_file_id"
:
"analysis_1"
,
...
...
@@ -407,6 +420,7 @@ Zweite Zeile.
self
.
assertEqual
(
sheet
[
"task_parent_refs"
],
[[
1
,
1
,
0
,
0
],
[
1
,
3
,
2
,
1
]])
def
test_get_selected_task_parent_refs_reads_normalized_refs
(
self
)
->
None
:
"""Checks that selected task parent refs are read back from normalized sheet fields."""
sheet
:
dict
[
str
,
object
]
=
{
"task_parent_refs"
:
[[
1
,
1
,
0
,
0
],
[
1
,
3
,
2
,
1
],
[
1
,
3
,
2
,
1
]],
}
...
...
@@ -423,6 +437,7 @@ class TaskApiSocraticTest(unittest.TestCase):
self
.
client
=
TestClient
(
app
)
def
test_list_tasks_returns_lightweight_catalog
(
self
)
->
None
:
"""Checks that the task list endpoint returns the lightweight catalog shape."""
payload
=
[
{
"file_id"
:
"analysis_1"
,
...
...
@@ -443,6 +458,7 @@ class TaskApiSocraticTest(unittest.TestCase):
self
.
assertNotIn
(
"topics"
,
body
)
def
test_get_task_details_returns_task_payload
(
self
)
->
None
:
"""Checks that the task details endpoint returns the full task payload."""
payload
=
{
"file_id"
:
"analysis_1"
,
"task_id"
:
"01"
,
...
...
@@ -458,12 +474,14 @@ class TaskApiSocraticTest(unittest.TestCase):
self
.
assertEqual
(
response
.
json
(),
payload
)
def
test_get_task_details_returns_404_for_invalid_task
(
self
)
->
None
:
"""Checks that the task details endpoint returns 404 for unknown tasks."""
with
patch
(
"app.api.tasks.task_catalog.find_task_details"
,
return_value
=
None
):
response
=
self
.
client
.
get
(
"/api/tasks/analysis_1/99"
)
self
.
assertEqual
(
response
.
status_code
,
404
)
def
test_task_asset_endpoint_serves_files_from_task_image_dir
(
self
)
->
None
:
"""Checks that task asset requests are served from the task image directory."""
temp_dir
=
Path
(
tempfile
.
mkdtemp
(
prefix
=
"task-assets-"
))
image_root
=
temp_dir
/
"images"
image_root
.
mkdir
(
parents
=
True
,
exist_ok
=
True
)
...
...
@@ -481,6 +499,7 @@ class TaskApiSocraticTest(unittest.TestCase):
self
.
assertIn
(
"<svg"
,
response
.
text
)
def
test_task_asset_endpoint_rejects_path_traversal
(
self
)
->
None
:
"""Checks that the task asset endpoint blocks path traversal attempts."""
temp_dir
=
Path
(
tempfile
.
mkdtemp
(
prefix
=
"task-assets-"
))
image_root
=
temp_dir
/
"images"
image_root
.
mkdir
(
parents
=
True
,
exist_ok
=
True
)
...
...
@@ -494,6 +513,7 @@ class TaskApiSocraticTest(unittest.TestCase):
self
.
assertEqual
(
response
.
status_code
,
404
)
def
test_list_socratic_topics_returns_catalog
(
self
)
->
None
:
"""Checks that the Socratic topics endpoint returns the built topic catalog."""
topics
=
[
{
"topic_key"
:
"quadratische gleichungen"
,
...
...
@@ -515,6 +535,7 @@ class TaskApiSocraticTest(unittest.TestCase):
self
.
assertEqual
(
body
[
"topics"
],
topics
)
def
test_select_topic_endpoint_returns_selected_key
(
self
)
->
None
:
"""Checks that the topic selection endpoint returns the selected topic key."""
sheet
:
dict
[
str
,
object
]
=
{}
with
patch
(
"app.api.tasks.context_store_base.get_chat_id"
,
return_value
=
"chat-1"
),
patch
(
...
...
@@ -558,6 +579,7 @@ class ChatBootstrapSocraticTest(unittest.TestCase):
self
.
client
=
TestClient
(
app
)
def
test_bootstrap_socratic_loads_sources_and_writes_only_assistant_history
(
self
)
->
None
:
"""Checks that Socratic bootstrap loads sources and stores only assistant history entries."""
sheet
:
dict
[
str
,
object
]
=
{}
with
patch
(
"app.api.chat.context_store_base.get_chat_id"
,
return_value
=
"chat-1"
),
patch
(
...
...
@@ -608,6 +630,7 @@ class ChatBootstrapSocraticTest(unittest.TestCase):
class
SessionStoreSocraticTest
(
unittest
.
TestCase
):
def
test_load_archive_restores_selected_topic
(
self
)
->
None
:
"""Checks that archived sessions restore the selected Socratic topic fields."""
record
=
{
"chat_id"
:
"chat-1"
,
"saved_at"
:
"2026-03-31T10:00:00Z"
,
...
...
math-tutor/backend/test/task_yaml_migration_test.py
View file @
79a212a1
...
...
@@ -19,6 +19,7 @@ from scripts import convert_tasks_json_to_yaml
class
TaskYamlMigrationTest
(
unittest
.
TestCase
):
def
test_load_task_files_normalizes_yaml_schema
(
self
)
->
None
:
"""Checks that YAML task files are normalized into the internal task schema."""
temp_dir
=
Path
(
tempfile
.
mkdtemp
(
prefix
=
"task-yaml-"
))
yaml_path
=
temp_dir
/
"analysis.yaml"
yaml_path
.
write_text
(
...
...
@@ -73,6 +74,7 @@ class TaskYamlMigrationTest(unittest.TestCase):
)
def
test_load_task_files_skips_invalid_yaml
(
self
)
->
None
:
"""Checks that invalid YAML task files are ignored instead of crashing the loader."""
temp_dir
=
Path
(
tempfile
.
mkdtemp
(
prefix
=
"task-yaml-invalid-"
))
valid_path
=
temp_dir
/
"valid.yaml"
invalid_path
=
temp_dir
/
"invalid.yaml"
...
...
@@ -119,6 +121,7 @@ class TaskYamlMigrationTest(unittest.TestCase):
self
.
assertEqual
([
item
[
"_file_id"
]
for
item
in
loaded
],
[
"valid"
])
def
test_build_task_catalog_keeps_api_shape_for_yaml_sources
(
self
)
->
None
:
"""Checks that the task catalog API shape stays stable for YAML-backed tasks."""
task_files
=
[
{
"_file_id"
:
"analysis_1"
,
...
...
@@ -151,6 +154,7 @@ class TaskYamlMigrationTest(unittest.TestCase):
)
def
test_convert_task_payload_maps_json_shape_to_yaml
(
self
)
->
None
:
"""Checks that JSON task payloads are converted into the YAML field layout."""
payload
=
{
"title"
:
"Reelle Funktionen bestimmen"
,
"subsections"
:
[
"eigenschaften-reeller-funktionen"
],
...
...
@@ -185,6 +189,7 @@ class TaskYamlMigrationTest(unittest.TestCase):
self
.
assertEqual
(
converted
[
"tasks"
][
0
][
"loesung"
],
[{
"type"
:
"text"
,
"text"
:
"Loesung"
}])
def
test_convert_all_rewrites_json_and_moves_images
(
self
)
->
None
:
"""Checks that full conversion rewrites JSON tasks and relocates their images."""
temp_dir
=
Path
(
tempfile
.
mkdtemp
(
prefix
=
"task-convert-"
))
images_dir
=
temp_dir
/
"images"
/
"old-folder"
images_dir
.
mkdir
(
parents
=
True
,
exist_ok
=
True
)
...
...
@@ -230,6 +235,7 @@ class TaskYamlMigrationTest(unittest.TestCase):
shutil
.
rmtree
(
temp_dir
,
ignore_errors
=
True
)
def
test_convert_all_uses_distinct_yaml_filenames_for_duplicate_titles
(
self
)
->
None
:
"""Checks that duplicate task titles still produce unique YAML filenames."""
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"
...
...
math-tutor/backend/test/test_llm_provider.py
View file @
79a212a1
...
...
@@ -146,6 +146,7 @@ class _FakeSession:
class
LLMProviderConfigTest
(
unittest
.
TestCase
):
def
test_get_llm_provider_accepts_supported_values
(
self
)
->
None
:
"""Verifies that each supported LLM provider string is accepted."""
for
provider
in
(
"openai"
,
"gwdg"
,
"mistral"
,
"ollama"
):
with
self
.
subTest
(
provider
=
provider
),
patch
.
dict
(
os
.
environ
,
{
"LLM_PROVIDER"
:
provider
},
clear
=
True
...
...
@@ -153,24 +154,29 @@ class LLMProviderConfigTest(unittest.TestCase):
self
.
assertEqual
(
config
.
get_llm_provider
(),
provider
)
def
test_get_llm_provider_normalizes_case_and_space
(
self
)
->
None
:
"""Verifies that provider names are trimmed and lowercased."""
with
patch
.
dict
(
os
.
environ
,
{
"LLM_PROVIDER"
:
" OpenAI "
},
clear
=
True
):
self
.
assertEqual
(
config
.
get_llm_provider
(),
"openai"
)
def
test_get_llm_provider_rejects_missing_value
(
self
)
->
None
:
"""Verifies that reading the provider fails when the env var is missing."""
with
patch
.
dict
(
os
.
environ
,
{},
clear
=
True
):
with
self
.
assertRaisesRegex
(
ValueError
,
"Missing LLM_PROVIDER"
):
config
.
get_llm_provider
()
def
test_get_llm_provider_rejects_unknown_value
(
self
)
->
None
:
"""Verifies that unsupported provider names are rejected."""
with
patch
.
dict
(
os
.
environ
,
{
"LLM_PROVIDER"
:
"anthropic"
},
clear
=
True
):
with
self
.
assertRaisesRegex
(
ValueError
,
"Unsupported LLM_PROVIDER"
):
config
.
get_llm_provider
()
def
test_get_llm_tool_use_enabled_defaults_to_false
(
self
)
->
None
:
"""Verifies that tool use is off by default when not configured."""
with
patch
.
dict
(
os
.
environ
,
{},
clear
=
True
):
self
.
assertFalse
(
config
.
get_llm_tool_use_enabled
())
def
test_get_gwdg_chat_settings_reads_gwdg_keys
(
self
)
->
None
:
"""Verifies that GWDG chat settings are loaded from the expected env vars."""
env
=
{
"GWDG_BASE_URL"
:
"https://chat-ai.academiccloud.de/v1/"
,
"GWDG_API_KEY"
:
"gwdg-key"
,
...
...
@@ -190,22 +196,17 @@ class LLMProviderConfigTest(unittest.TestCase):
self
.
assertEqual
(
settings
.
timeout
,
60.0
)
def
test_get_embedding_provider_accepts_supported_values
(
self
)
->
None
:
"""Verifies that each supported embedding provider string is accepted."""
for
provider
in
(
"sentence-transformer"
,
"openai"
,
"gwdg"
):
with
self
.
subTest
(
provider
=
provider
),
patch
.
dict
(
os
.
environ
,
{
"EMBEDDING_PROVIDER"
:
provider
},
clear
=
True
):
self
.
assertEqual
(
config
.
get_embedding_provider
(),
provider
)
def
test_get_embedding_provider_uses_legacy_embedding_type_fallback
(
self
)
->
None
:
with
patch
.
dict
(
os
.
environ
,
{
"EMBEDDING_TYPE"
:
"openai-like"
},
clear
=
True
):
self
.
assertEqual
(
config
.
get_embedding_provider
(),
"openai"
)
with
patch
.
dict
(
os
.
environ
,
{
"EMBEDDING_TYPE"
:
"sentence-transformer"
},
clear
=
True
):
self
.
assertEqual
(
config
.
get_embedding_provider
(),
"sentence-transformer"
)
class
LLMClientProviderTest
(
unittest
.
TestCase
):
def
test_chat_uses_only_openai_provider
(
self
)
->
None
:
"""Checks that plain OpenAI chat stays on the non-tool provider path."""
settings
=
object
()
expected
=
{
"raw"
:
object
(),
"message"
:
{
"content"
:
"openai"
}}
with
patch
.
dict
(
...
...
@@ -235,6 +236,7 @@ class LLMClientProviderTest(unittest.TestCase):
ollama_chat
.
assert_not_called
()
def
test_chat_uses_openai_tool_path_when_enabled
(
self
)
->
None
:
"""Checks that OpenAI chat switches to the tool-enabled path when configured."""
settings
=
object
()
expected
=
{
"raw"
:
object
(),
"message"
:
{
"content"
:
"openai-tools"
}}
with
patch
.
dict
(
...
...
@@ -258,6 +260,7 @@ class LLMClientProviderTest(unittest.TestCase):
openai_chat
.
assert_not_called
()
def
test_chat_uses_only_gwdg_provider
(
self
)
->
None
:
"""Checks that plain GWDG chat stays on the OpenAI-compatible path."""
settings
=
object
()
expected
=
{
"raw"
:
object
(),
"message"
:
{
"content"
:
"gwdg"
}}
with
patch
.
dict
(
...
...
@@ -287,6 +290,7 @@ class LLMClientProviderTest(unittest.TestCase):
ollama_chat
.
assert_not_called
()
def
test_chat_uses_gwdg_tool_path_when_enabled
(
self
)
->
None
:
"""Checks that GWDG chat switches to the tool-enabled path when configured."""
settings
=
object
()
expected
=
{
"raw"
:
object
(),
"message"
:
{
"content"
:
"gwdg-tools"
}}
with
patch
.
dict
(
...
...
@@ -310,6 +314,7 @@ class LLMClientProviderTest(unittest.TestCase):
compatible_chat
.
assert_not_called
()
def
test_chat_uses_only_mistral_provider
(
self
)
->
None
:
"""Checks that plain Mistral chat uses only the Mistral-specific path."""
settings
=
object
()
expected
=
{
"raw"
:
object
(),
"message"
:
{
"content"
:
"mistral"
}}
with
patch
.
dict
(
...
...
@@ -339,6 +344,7 @@ class LLMClientProviderTest(unittest.TestCase):
ollama_chat
.
assert_not_called
()
def
test_chat_falls_back_to_mistral_plain_path_when_tools_enabled
(
self
)
->
None
:
"""Checks that Mistral ignores tool mode and still uses its plain chat path."""
settings
=
object
()
expected
=
{
"raw"
:
object
(),
"message"
:
{
"content"
:
"mistral"
}}
with
patch
.
dict
(
...
...
@@ -362,6 +368,7 @@ class LLMClientProviderTest(unittest.TestCase):
mistral_chat
.
assert_called_once_with
(
MESSAGES
,
settings
)
def
test_chat_uses_only_ollama_provider
(
self
)
->
None
:
"""Checks that plain Ollama chat uses only the Ollama-specific path."""
expected
=
{
"raw"
:
object
(),
"message"
:
{
"content"
:
"ollama"
}}
with
patch
.
dict
(
os
.
environ
,
...
...
@@ -388,6 +395,7 @@ class LLMClientProviderTest(unittest.TestCase):
ollama_chat
.
assert_called_once_with
(
MESSAGES
)
def
test_chat_uses_ollama_tool_path_when_enabled
(
self
)
->
None
:
"""Checks that Ollama switches to the tool-enabled path when configured."""
expected
=
{
"raw"
:
object
(),
"message"
:
{
"content"
:
"ollama-tools"
}}
with
patch
.
dict
(
os
.
environ
,
...
...
@@ -408,6 +416,7 @@ class LLMClientProviderTest(unittest.TestCase):
ollama_chat
.
assert_not_called
()
def
test_selected_gwdg_config_error_happens_before_quota
(
self
)
->
None
:
"""Checks that missing GWDG config fails before any quota accounting runs."""
with
patch
.
dict
(
os
.
environ
,
{
"LLM_PROVIDER"
:
"gwdg"
,
"LLM_TOOL_USE_ENABLED"
:
"true"
},
...
...
@@ -419,6 +428,7 @@ class LLMClientProviderTest(unittest.TestCase):
ensure_quota
.
assert_not_called
()
def
test_selected_provider_config_error_happens_before_quota
(
self
)
->
None
:
"""Checks that missing OpenAI config fails before any quota accounting runs."""
with
patch
.
dict
(
os
.
environ
,
{
"LLM_PROVIDER"
:
"openai"
,
"LLM_TOOL_USE_ENABLED"
:
"true"
},
...
...
@@ -430,6 +440,7 @@ class LLMClientProviderTest(unittest.TestCase):
ensure_quota
.
assert_not_called
()
def
test_chat_without_provider_does_not_fallback
(
self
)
->
None
:
"""Checks that chat raises on missing provider instead of guessing a fallback."""
with
patch
.
dict
(
os
.
environ
,
{},
clear
=
True
),
patch
.
object
(
llm_client
,
"_chat_openai_compatible"
)
as
openai_chat
,
patch
.
object
(
...
...
@@ -445,6 +456,7 @@ class LLMClientProviderTest(unittest.TestCase):
ollama_chat
.
assert_not_called
()
def
test_tool_enabled_chat_records_quota_once_on_success
(
self
)
->
None
:
"""Checks that successful tool-enabled chats record one quota event."""
expected
=
{
"raw"
:
object
(),
"message"
:
{
"content"
:
"ok"
}}
with
patch
.
dict
(
os
.
environ
,
...
...
@@ -463,6 +475,7 @@ class LLMClientProviderTest(unittest.TestCase):
record_call
.
assert_called_once_with
(
expected
)
def
test_tool_enabled_chat_records_zero_tokens_on_failure
(
self
)
->
None
:
"""Checks that failed tool-enabled chats still record a zero-token call."""
with
patch
.
dict
(
os
.
environ
,
{
"LLM_PROVIDER"
:
"ollama"
,
"LLM_TOOL_USE_ENABLED"
:
"true"
},
...
...
@@ -482,15 +495,18 @@ class LLMClientProviderTest(unittest.TestCase):
class
LLMClientToolHelpersTest
(
unittest
.
TestCase
):
def
test_extract_structured_tool_output_prefers_structured_content
(
self
)
->
None
:
"""Checks that structured tool payloads win over other output formats."""
result
=
_FakeToolResult
(
structured_content
=
{
"answer"
:
42
})
self
.
assertEqual
(
llm_tool_client
.
_extract_structured_tool_output
(
result
),
{
"answer"
:
42
})
def
test_extract_structured_tool_output_joins_text_blocks
(
self
)
->
None
:
"""Checks that text blocks are joined into one plain-text tool result."""
blocks
=
[
types
.
SimpleNamespace
(
text
=
"a"
),
types
.
SimpleNamespace
(
text
=
"b"
)]
result
=
_FakeToolResult
(
content
=
blocks
)
self
.
assertEqual
(
llm_tool_client
.
_extract_structured_tool_output
(
result
),
"a
\n
b"
)
def
test_run_provider_chat_appends_tool_trace_to_active_log
(
self
)
->
None
:
"""Checks that tool traces from provider chats are appended to the active log."""
active_log
:
list
[
dict
]
=
[]
token
=
tool_log_context
.
set_active_tool_log
(
active_log
)
result
=
{
...
...
@@ -525,6 +541,7 @@ class LLMClientToolHelpersTest(unittest.TestCase):
)
def
test_run_provider_chat_skips_log_when_no_tool_trace
(
self
)
->
None
:
"""Checks that provider chats do not log anything when no tool trace exists."""
active_log
:
list
[
dict
]
=
[]
token
=
tool_log_context
.
set_active_tool_log
(
active_log
)
result
=
{
"raw"
:
object
(),
"message"
:
{
"content"
:
"done"
}}
...
...
@@ -538,6 +555,7 @@ class LLMClientToolHelpersTest(unittest.TestCase):
self
.
assertEqual
(
active_log
,
[])
def
test_openai_tool_loop_returns_final_message_without_tool_calls
(
self
)
->
None
:
"""Checks that the OpenAI tool loop returns immediately when no tool call is requested."""
settings
=
config
.
OpenAIChatSettings
(
base_url
=
"https://example.com/v1"
,
api_key
=
"key"
,
...
...
@@ -567,6 +585,7 @@ class LLMClientToolHelpersTest(unittest.TestCase):
self
.
assertIsNone
(
asyncio_result
)
def
test_openai_tool_loop_executes_tool_and_continues
(
self
)
->
None
:
"""Checks that the OpenAI tool loop executes one tool call and resumes the chat."""
settings
=
config
.
OpenAIChatSettings
(
base_url
=
"https://example.com/v1"
,
api_key
=
"key"
,
...
...
@@ -620,6 +639,7 @@ class LLMClientToolHelpersTest(unittest.TestCase):
self
.
assertIsNone
(
asyncio_result
)
def
test_openai_tool_loop_accumulates_multiple_tool_calls
(
self
)
->
None
:
"""Checks that the OpenAI tool loop records and replays multiple tool calls in order."""
settings
=
config
.
OpenAIChatSettings
(
base_url
=
"https://example.com/v1"
,
api_key
=
"key"
,
...
...
@@ -679,11 +699,9 @@ class LLMClientToolHelpersTest(unittest.TestCase):
class
TutorOrchestratorLegacyModuleTest
(
unittest
.
TestCase
):
def
test_tutor_orchestrator_does_not_import_legacy_llm_modules
(
self
)
->
None
:
self
.
assertFalse
(
hasattr
(
orchestrator_tutor
,
"decision_LLM"
))
self
.
assertFalse
(
hasattr
(
orchestrator_tutor
,
"math_intent_LLM"
))
def
test_bootstrap_only_runs_retrieval
(
self
)
->
None
:
"""Checks that bootstrap only triggers retrieval and leaves the tool log untouched."""
state
=
ChatState
(
messages
=
MESSAGES
[:],
draft
=
None
,
...
...
@@ -701,6 +719,7 @@ class TutorOrchestratorLegacyModuleTest(unittest.TestCase):
self
.
assertEqual
(
state
.
tool_log
,
[])
def
test_non_new_turn_does_not_run_decision_llm
(
self
)
->
None
:
"""Checks that non-new turns skip the old decision-model flow."""
state
=
ChatState
(
messages
=
MESSAGES
[:]
+
[{
"role"
:
"assistant"
,
"content"
:
"Antwort"
}],
draft
=
None
,
...
...
math-tutor/backend/test/vector_store_pipeline_unit_test.py
View file @
79a212a1
...
...
@@ -58,10 +58,12 @@ def _mk_retrieved(
class
VectorStorePipelineUnitTest
(
unittest
.
TestCase
):
def
test_package_exports_point_to_retrieval_models
(
self
)
->
None
:
"""Checks that the package re-exports the retrieval pipeline models."""
self
.
assertIs
(
PackageSource
,
Source
)
self
.
assertIs
(
PackageSourceID
,
SourceID
)
def
test_merge_sources_prefers_task_childs_on_duplicate
(
self
)
->
None
:
"""Checks that duplicate source merges keep task child entries over other copies."""
child_direct
=
Source
(
source_id
=
SourceID
(
chapter_title
=
"Kapitel"
,
...
...
@@ -95,6 +97,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self
.
assertEqual
(
merged
[
0
].
retrieved_as
,
"task_childs"
)
def
test_build_default_pipeline_config
(
self
)
->
None
:
"""Checks that the default pipeline config is constructed with expected values."""
cfg
=
build_default_pipeline_config
(
k
=
4
,
expand_links
=
True
,
neighbor_expand
=
2
)
self
.
assertEqual
(
cfg
.
vector_k
,
20
)
self
.
assertEqual
(
cfg
.
scope_fill_k
,
5
)
...
...
@@ -104,6 +107,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self
.
assertTrue
(
cfg
.
enable_context_docs
)
def
test_select_dominant_scope_prefers_subsection_count
(
self
)
->
None
:
"""Checks that dominant scope selection prefers the scope with more subsection matches."""
children
=
[
_mk_retrieved
(
"a"
,
0.9
,
1
,
1
,
1
),
_mk_retrieved
(
"b"
,
0.8
,
1
,
1
,
1
),
...
...
@@ -116,6 +120,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self
.
assertEqual
((
scope
.
chapter_index
,
scope
.
section_index
,
scope
.
subsection_index
),
(
1
,
1
,
1
))
def
test_select_dominant_scope_resolves_section_parent
(
self
)
->
None
:
"""Checks that dominant scope resolution can collapse matches to a section parent."""
children
=
[
_mk_retrieved
(
"a"
,
0.9
,
1
,
1
,
0
,
0
),
_mk_retrieved
(
"b"
,
0.8
,
1
,
1
,
0
,
0
),
...
...
@@ -130,6 +135,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self
.
assertIsNone
(
scope
.
subsubsection_index
)
def
test_select_dominant_scope_resolves_subsubsection_parent
(
self
)
->
None
:
"""Checks that dominant scope resolution can collapse matches to a subsubsection parent."""
children
=
[
_mk_retrieved
(
"a"
,
0.9
,
1
,
1
,
2
,
1
),
_mk_retrieved
(
"b"
,
0.8
,
1
,
1
,
2
,
1
),
...
...
@@ -142,6 +148,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self
.
assertEqual
((
scope
.
chapter_index
,
scope
.
section_index
,
scope
.
subsection_index
,
scope
.
subsubsection_index
),
(
1
,
1
,
2
,
1
))
def
test_select_dominant_scope_tiebreak_avg_score
(
self
)
->
None
:
"""Checks that tied dominant scopes are broken by average score."""
children
=
[
_mk_retrieved
(
"a"
,
0.7
,
1
,
1
,
1
,
0
),
_mk_retrieved
(
"b"
,
0.8
,
1
,
1
,
2
,
0
),
...
...
@@ -153,6 +160,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self
.
assertEqual
((
scope
.
chapter_index
,
scope
.
section_index
,
scope
.
subsection_index
),
(
1
,
1
,
2
))
def
test_select_dominant_scope_tiebreak_lexicographic
(
self
)
->
None
:
"""Checks that fully tied dominant scopes fall back to lexicographic ordering."""
children
=
[
_mk_retrieved
(
"a"
,
0.8
,
2
,
1
,
1
,
0
),
_mk_retrieved
(
"b"
,
0.8
,
1
,
2
,
3
,
0
),
...
...
@@ -163,6 +171,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self
.
assertEqual
((
scope
.
chapter_index
,
scope
.
section_index
,
scope
.
subsection_index
),
(
1
,
2
,
3
))
def
test_select_dominant_scope_keeps_exact_parent_levels_separate
(
self
)
->
None
:
"""Checks that exact parent levels are not merged together during scope selection."""
children
=
[
_mk_retrieved
(
"section-hit"
,
0.91
,
1
,
1
,
2
,
0
),
_mk_retrieved
(
"subsub-hit"
,
0.92
,
1
,
1
,
2
,
1
),
...
...
@@ -175,6 +184,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self
.
assertEqual
(
scope
.
subsubsection_index
,
1
)
def
test_merge_retrieval_groups_dedup_max_score_and_trim_children
(
self
)
->
None
:
"""Checks that retrieval groups dedupe children, keep max scores, and trim overflow."""
groups
=
{
"children_direct"
:
[
_mk_retrieved
(
"u1"
,
0.5
,
1
,
1
,
1
),
...
...
@@ -198,11 +208,13 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self
.
assertEqual
(
merged
[
"neighbors"
],
[])
def
test_expand_neighbor_children_returns_empty_for_zero_expand
(
self
)
->
None
:
"""Checks that neighbor expansion is skipped when the requested radius is zero."""
children
=
[
_mk_retrieved
(
"u1"
,
0.8
,
1
,
1
,
1
,
4
,
child_index
=
3
)]
result
=
expand_neighbor_children
(
"postgresql://unused"
,
children
,
neighbor_expand
=
0
)
self
.
assertEqual
(
result
,
[])
def
test_source_id_to_string_includes_subsubsection_title
(
self
)
->
None
:
"""Checks that source IDs include subsubsection titles in their string form."""
source_id
=
SourceID
(
chapter_title
=
"Kapitel"
,
section_title
=
"Section"
,
...
...
@@ -215,6 +227,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self
.
assertEqual
(
source_id
.
to_string
(),
"[Kapitel|Section|Subsection|Subsubsection|Child|child]"
)
def
test_merge_sources_distinguishes_subsubsection_title
(
self
)
->
None
:
"""Checks that sources with different subsubsection titles stay distinct when merged."""
first
=
Source
(
source_id
=
SourceID
(
chapter_title
=
"Kapitel"
,
...
...
@@ -248,6 +261,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self
.
assertEqual
(
len
(
merged
),
2
)
def
test_retrieve_sorts_sources_by_score
(
self
)
->
None
:
"""Checks that retrieved sources are returned in descending score order."""
groups
=
{
"children_direct"
:
[
_mk_retrieved
(
"low"
,
0.2
,
1
,
1
,
1
),
...
...
@@ -267,6 +281,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self
.
assertEqual
([
source
.
source_id
.
title
for
source
in
result
],
[
"high"
,
"low"
])
def
test_retrieve_with_subsections_matches_retrieve_without_refs
(
self
)
->
None
:
"""Checks that subsection-aware retrieval matches the legacy retrieval output shape."""
expected
=
[
Source
(
source_id
=
SourceID
(
title
=
"Child"
,
doc_type
=
"child"
),
...
...
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