Commit 79a212a1 authored by Kantz's avatar Kantz
Browse files

test kommentiert

parent 82adf746
...@@ -67,16 +67,6 @@ def get_embedding_provider() -> str: ...@@ -67,16 +67,6 @@ def get_embedding_provider() -> str:
f"Unsupported EMBEDDING_PROVIDER: {value}. Expected one of: {supported}" 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: def get_embedding_settings() -> EmbeddingSettings:
provider = get_embedding_provider() provider = get_embedding_provider()
......
...@@ -31,6 +31,7 @@ def _build_state() -> ChatState: ...@@ -31,6 +31,7 @@ def _build_state() -> ChatState:
class FinalizeResponseHistoryTest(unittest.TestCase): class FinalizeResponseHistoryTest(unittest.TestCase):
def test_finalize_response_appends_decoded_reply_to_history(self) -> None: def test_finalize_response_appends_decoded_reply_to_history(self) -> None:
"""Checks that decoded assistant replies are persisted to sheet history."""
state = _build_state() state = _build_state()
with patch( with patch(
...@@ -54,6 +55,7 @@ class FinalizeResponseHistoryTest(unittest.TestCase): ...@@ -54,6 +55,7 @@ class FinalizeResponseHistoryTest(unittest.TestCase):
save_sheet.assert_called_once_with(state.sheet) save_sheet.assert_called_once_with(state.sheet)
def test_finalize_response_appends_fallback_reply_to_history(self) -> None: 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() state = _build_state()
with patch( with patch(
......
...@@ -35,6 +35,7 @@ embeddings = _load_module("backend_embeddings_test_module", "app/deterministic_s ...@@ -35,6 +35,7 @@ embeddings = _load_module("backend_embeddings_test_module", "app/deterministic_s
class SentenceTransformerJinaV5Test(unittest.TestCase): class SentenceTransformerJinaV5Test(unittest.TestCase):
def test_config_defaults_to_jina_v5(self) -> None: 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): with patch.dict(os.environ, {"EMBEDDING_TYPE": "sentence-transformer"}, clear=True):
settings = config.get_embedding_settings() settings = config.get_embedding_settings()
...@@ -42,6 +43,7 @@ class SentenceTransformerJinaV5Test(unittest.TestCase): ...@@ -42,6 +43,7 @@ class SentenceTransformerJinaV5Test(unittest.TestCase):
self.assertEqual(settings.target_dim, 1024) self.assertEqual(settings.target_dim, 1024)
def test_embedder_uses_document_and_query_prompts(self) -> None: 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 = MagicMock()
fake_model.encode.side_effect = [ fake_model.encode.side_effect = [
[[0.1, 0.2, 0.3, 0.4]], [[0.1, 0.2, 0.3, 0.4]],
...@@ -66,6 +68,7 @@ class SentenceTransformerJinaV5Test(unittest.TestCase): ...@@ -66,6 +68,7 @@ class SentenceTransformerJinaV5Test(unittest.TestCase):
self.assertEqual(len(query), 4) self.assertEqual(len(query), 4)
def test_openai_like_embedder_uses_openai_library(self) -> None: def test_openai_like_embedder_uses_openai_library(self) -> None:
"""Checks that OpenAI-like embeddings route through the OpenAI client library."""
class FakeEmbeddingsClient: class FakeEmbeddingsClient:
create_kwargs: dict | None = None create_kwargs: dict | None = None
......
...@@ -38,6 +38,7 @@ class GenerateSocraticChatsTest(unittest.TestCase): ...@@ -38,6 +38,7 @@ class GenerateSocraticChatsTest(unittest.TestCase):
shutil.rmtree(self.temp_dir, ignore_errors=True) shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_build_initial_prompt_index_uses_lowest_available_leaf_level(self) -> None: 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( _write_markdown(
self.source_root / "sections/section-leaf.md", self.source_root / "sections/section-leaf.md",
'chapter_index: 1\nsection_index: 1\ntitle: "Mengen"', 'chapter_index: 1\nsection_index: 1\ntitle: "Mengen"',
...@@ -118,6 +119,7 @@ class GenerateSocraticChatsTest(unittest.TestCase): ...@@ -118,6 +119,7 @@ class GenerateSocraticChatsTest(unittest.TestCase):
self.assertNotIn("grundrechenarten bei reellen zahlen", items) self.assertNotIn("grundrechenarten bei reellen zahlen", items)
def test_existing_message_reuse_matches_leaf_key(self) -> None: def test_existing_message_reuse_matches_leaf_key(self) -> None:
"""Checks that existing Socratic messages are reused by matching the leaf key."""
_write_markdown( _write_markdown(
self.source_root / "sections/section-leaf.md", self.source_root / "sections/section-leaf.md",
'chapter_index: 1\nsection_index: 1\ntitle: "Mengen"', 'chapter_index: 1\nsection_index: 1\ntitle: "Mengen"',
......
...@@ -15,6 +15,7 @@ class HealthReadinessUnitTest(unittest.TestCase): ...@@ -15,6 +15,7 @@ class HealthReadinessUnitTest(unittest.TestCase):
health.set_readiness_starting() health.set_readiness_starting()
def test_readiness_returns_503_while_starting(self) -> None: def test_readiness_returns_503_while_starting(self) -> None:
"""Checks that readiness reports 503 while the app is still starting."""
health.set_readiness_starting() health.set_readiness_starting()
response = health.readiness() response = health.readiness()
...@@ -24,6 +25,7 @@ class HealthReadinessUnitTest(unittest.TestCase): ...@@ -24,6 +25,7 @@ class HealthReadinessUnitTest(unittest.TestCase):
self.assertEqual(json.loads(response.body), {"status": "starting"}) self.assertEqual(json.loads(response.body), {"status": "starting"})
def test_readiness_returns_200_when_ready(self) -> None: def test_readiness_returns_200_when_ready(self) -> None:
"""Checks that readiness reports 200 once startup completed successfully."""
warmup = {"total_warmup_ms": 123.45} warmup = {"total_warmup_ms": 123.45}
health.set_readiness_ready(warmup=warmup) health.set_readiness_ready(warmup=warmup)
...@@ -32,6 +34,7 @@ class HealthReadinessUnitTest(unittest.TestCase): ...@@ -32,6 +34,7 @@ class HealthReadinessUnitTest(unittest.TestCase):
self.assertEqual(response, {"status": "ready", "warmup": warmup}) self.assertEqual(response, {"status": "ready", "warmup": warmup})
def test_readiness_returns_503_when_failed(self) -> None: def test_readiness_returns_503_when_failed(self) -> None:
"""Checks that readiness reports 503 when startup has failed."""
checks = {"status": "degraded"} checks = {"status": "degraded"}
health.set_readiness_failed("Embedding warmup failed", checks=checks) health.set_readiness_failed("Embedding warmup failed", checks=checks)
...@@ -48,7 +51,8 @@ class HealthReadinessUnitTest(unittest.TestCase): ...@@ -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( with patch.dict(os.environ, {"LLM_PROVIDER": "gwdg"}), patch(
"app.api.health._check_gwdg", "app.api.health._check_gwdg",
return_value={"status": "ok", "url": "https://chat-ai.academiccloud.de/v1/models"}, return_value={"status": "ok", "url": "https://chat-ai.academiccloud.de/v1/models"},
......
...@@ -57,6 +57,7 @@ def _mk_source() -> SourceStub: ...@@ -57,6 +57,7 @@ def _mk_source() -> SourceStub:
class ReferenzDecoderUnitTest(unittest.TestCase): class ReferenzDecoderUnitTest(unittest.TestCase):
def test_decodes_reference_without_chapter(self) -> None: def test_decodes_reference_without_chapter(self) -> None:
"""Checks that references without chapter info still decode to source links."""
source = _mk_source() source = _mk_source()
text = "Siehe [Lineare Funktionen|Steigung|Definition|subsection]." text = "Siehe [Lineare Funktionen|Steigung|Definition|subsection]."
...@@ -69,6 +70,7 @@ class ReferenzDecoderUnitTest(unittest.TestCase): ...@@ -69,6 +70,7 @@ class ReferenzDecoderUnitTest(unittest.TestCase):
) )
def test_decodes_reference_with_extra_tail_segment(self) -> None: def test_decodes_reference_with_extra_tail_segment(self) -> None:
"""Checks that references still decode when they include extra trailing segments."""
source = _mk_source() source = _mk_source()
text = "Siehe [Lineare Funktionen/Steigung/Definition | subsection | ...]." text = "Siehe [Lineare Funktionen/Steigung/Definition | subsection | ...]."
...@@ -81,6 +83,7 @@ class ReferenzDecoderUnitTest(unittest.TestCase): ...@@ -81,6 +83,7 @@ class ReferenzDecoderUnitTest(unittest.TestCase):
) )
def test_skips_existing_markdown_links(self) -> None: def test_skips_existing_markdown_links(self) -> None:
"""Checks that already-linked markdown references are left unchanged."""
source = _mk_source() source = _mk_source()
text = "Bereits verlinkt: [Definition](doc://foo)." text = "Bereits verlinkt: [Definition](doc://foo)."
......
...@@ -25,6 +25,7 @@ class TaskCatalogSocraticTest(unittest.TestCase): ...@@ -25,6 +25,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
task_catalog.load_cached_task_files.cache_clear() task_catalog.load_cached_task_files.cache_clear()
def test_build_task_payload_appends_image_descriptions(self) -> None: def test_build_task_payload_appends_image_descriptions(self) -> None:
"""Checks that task payloads include appended descriptions for attached images."""
task_file = { task_file = {
"title": "Grundlagen von Funktionen", "title": "Grundlagen von Funktionen",
"intro": "Ordnen Sie zu.", "intro": "Ordnen Sie zu.",
...@@ -60,6 +61,7 @@ class TaskCatalogSocraticTest(unittest.TestCase): ...@@ -60,6 +61,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
) )
def test_build_task_catalog_includes_normalized_images(self) -> None: def test_build_task_catalog_includes_normalized_images(self) -> None:
"""Checks that task catalog entries contain normalized image metadata."""
task_files = [ task_files = [
{ {
"_file_id": "analysis_1", "_file_id": "analysis_1",
...@@ -100,6 +102,7 @@ class TaskCatalogSocraticTest(unittest.TestCase): ...@@ -100,6 +102,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
self.assertEqual(catalog[0]["topics"], ["quadratische_gleichungen"]) self.assertEqual(catalog[0]["topics"], ["quadratische_gleichungen"])
def test_build_task_metadata_catalog_only_returns_titles_and_task_ids(self) -> None: 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 = [ task_files = [
{ {
"_file_id": "analysis_1", "_file_id": "analysis_1",
...@@ -131,6 +134,7 @@ class TaskCatalogSocraticTest(unittest.TestCase): ...@@ -131,6 +134,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
) )
def test_build_cached_task_metadata_catalog_reuses_cache(self) -> None: 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() task_catalog.build_cached_task_metadata_catalog.cache_clear()
with patch( with patch(
...@@ -144,6 +148,7 @@ class TaskCatalogSocraticTest(unittest.TestCase): ...@@ -144,6 +148,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
build_mock.assert_called_once() build_mock.assert_called_once()
def test_normalize_yaml_task_entry_excludes_images_from_hints(self) -> None: 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 = { task_entry = {
"aufgabe": [ "aufgabe": [
{"type": "text", "text": "Bestimme f(x)."}, {"type": "text", "text": "Bestimme f(x)."},
...@@ -164,6 +169,7 @@ class TaskCatalogSocraticTest(unittest.TestCase): ...@@ -164,6 +169,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
self.assertEqual(normalized["images"], []) self.assertEqual(normalized["images"], [])
def test_socratic_build_topic_catalog_returns_response_shape(self) -> None: def test_socratic_build_topic_catalog_returns_response_shape(self) -> None:
"""Checks that the Socratic topic catalog matches the expected response shape."""
with patch( with patch(
"app.deterministic_services.socratic_oranisator.load_initial_prompt_items", "app.deterministic_services.socratic_oranisator.load_initial_prompt_items",
return_value={ return_value={
...@@ -208,6 +214,7 @@ class TaskCatalogSocraticTest(unittest.TestCase): ...@@ -208,6 +214,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
) )
def test_build_topic_catalog_includes_summary(self) -> None: def test_build_topic_catalog_includes_summary(self) -> None:
"""Checks that topic catalog entries include loaded summary text."""
with patch( with patch(
"app.deterministic_services.task_catalog.load_topic_map", "app.deterministic_services.task_catalog.load_topic_map",
return_value={ return_value={
...@@ -244,6 +251,7 @@ class TaskCatalogSocraticTest(unittest.TestCase): ...@@ -244,6 +251,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
) )
def test_build_topic_catalog_supports_mixed_parent_levels(self) -> None: def test_build_topic_catalog_supports_mixed_parent_levels(self) -> None:
"""Checks that topic catalog building handles mixed parent-depth hierarchies."""
with patch( with patch(
"app.deterministic_services.task_catalog.load_topic_map", "app.deterministic_services.task_catalog.load_topic_map",
return_value={ return_value={
...@@ -289,6 +297,7 @@ class TaskCatalogSocraticTest(unittest.TestCase): ...@@ -289,6 +297,7 @@ class TaskCatalogSocraticTest(unittest.TestCase):
) )
def test_load_topic_summaries_extracts_body(self) -> None: 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" temp_dir = Path(__file__).resolve().parent / "_tmp_subsection_summaries"
summary_root = temp_dir / "child_lvl" / "subsections" summary_root = temp_dir / "child_lvl" / "subsections"
summary_root.mkdir(parents=True, exist_ok=True) summary_root.mkdir(parents=True, exist_ok=True)
...@@ -332,6 +341,7 @@ Zweite Zeile. ...@@ -332,6 +341,7 @@ Zweite Zeile.
self.assertNotIn("Child-Chunks", summaries["mengen"]) self.assertNotIn("Child-Chunks", summaries["mengen"])
def test_select_topic_by_ids_sets_sheet_fields(self) -> None: 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 = [ task_files = [
{ {
"_file_id": "analysis_1", "_file_id": "analysis_1",
...@@ -358,6 +368,7 @@ Zweite Zeile. ...@@ -358,6 +368,7 @@ Zweite Zeile.
self.assertEqual(sheet["selected_topic_parent_refs"], [[1, 3, 3, 1]]) self.assertEqual(sheet["selected_topic_parent_refs"], [[1, 3, 3, 1]])
def test_select_topic_by_key_sets_sheet_fields(self) -> None: 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] = {} sheet: dict[str, object] = {}
with patch( with patch(
...@@ -371,6 +382,7 @@ Zweite Zeile. ...@@ -371,6 +382,7 @@ Zweite Zeile.
self.assertEqual(sheet["selected_topic_parent_refs"], [[1, 3, 3, 1]]) self.assertEqual(sheet["selected_topic_parent_refs"], [[1, 3, 3, 1]])
def test_get_selected_topic_parent_refs_reads_normalized_refs(self) -> None: 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] = { sheet: dict[str, object] = {
"selected_topic_parent_refs": [[1, 1, 0, 0], [1, 3, 2, 1], [1, 3, 2, 1]], "selected_topic_parent_refs": [[1, 1, 0, 0], [1, 3, 2, 1], [1, 3, 2, 1]],
} }
...@@ -380,6 +392,7 @@ Zweite Zeile. ...@@ -380,6 +392,7 @@ Zweite Zeile.
self.assertEqual(refs, [(1, 1, 0, 0), (1, 3, 2, 1)]) self.assertEqual(refs, [(1, 1, 0, 0), (1, 3, 2, 1)])
def test_set_selected_task_stores_generic_parent_refs(self) -> None: 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] = {} sheet: dict[str, object] = {}
task_file = { task_file = {
"_file_id": "analysis_1", "_file_id": "analysis_1",
...@@ -407,6 +420,7 @@ Zweite Zeile. ...@@ -407,6 +420,7 @@ Zweite Zeile.
self.assertEqual(sheet["task_parent_refs"], [[1, 1, 0, 0], [1, 3, 2, 1]]) 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: 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] = { sheet: dict[str, object] = {
"task_parent_refs": [[1, 1, 0, 0], [1, 3, 2, 1], [1, 3, 2, 1]], "task_parent_refs": [[1, 1, 0, 0], [1, 3, 2, 1], [1, 3, 2, 1]],
} }
...@@ -423,6 +437,7 @@ class TaskApiSocraticTest(unittest.TestCase): ...@@ -423,6 +437,7 @@ class TaskApiSocraticTest(unittest.TestCase):
self.client = TestClient(app) self.client = TestClient(app)
def test_list_tasks_returns_lightweight_catalog(self) -> None: def test_list_tasks_returns_lightweight_catalog(self) -> None:
"""Checks that the task list endpoint returns the lightweight catalog shape."""
payload = [ payload = [
{ {
"file_id": "analysis_1", "file_id": "analysis_1",
...@@ -443,6 +458,7 @@ class TaskApiSocraticTest(unittest.TestCase): ...@@ -443,6 +458,7 @@ class TaskApiSocraticTest(unittest.TestCase):
self.assertNotIn("topics", body) self.assertNotIn("topics", body)
def test_get_task_details_returns_task_payload(self) -> None: def test_get_task_details_returns_task_payload(self) -> None:
"""Checks that the task details endpoint returns the full task payload."""
payload = { payload = {
"file_id": "analysis_1", "file_id": "analysis_1",
"task_id": "01", "task_id": "01",
...@@ -458,12 +474,14 @@ class TaskApiSocraticTest(unittest.TestCase): ...@@ -458,12 +474,14 @@ class TaskApiSocraticTest(unittest.TestCase):
self.assertEqual(response.json(), payload) self.assertEqual(response.json(), payload)
def test_get_task_details_returns_404_for_invalid_task(self) -> None: 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): with patch("app.api.tasks.task_catalog.find_task_details", return_value=None):
response = self.client.get("/api/tasks/analysis_1/99") response = self.client.get("/api/tasks/analysis_1/99")
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_task_asset_endpoint_serves_files_from_task_image_dir(self) -> None: 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-")) temp_dir = Path(tempfile.mkdtemp(prefix="task-assets-"))
image_root = temp_dir / "images" image_root = temp_dir / "images"
image_root.mkdir(parents=True, exist_ok=True) image_root.mkdir(parents=True, exist_ok=True)
...@@ -481,6 +499,7 @@ class TaskApiSocraticTest(unittest.TestCase): ...@@ -481,6 +499,7 @@ class TaskApiSocraticTest(unittest.TestCase):
self.assertIn("<svg", response.text) self.assertIn("<svg", response.text)
def test_task_asset_endpoint_rejects_path_traversal(self) -> None: 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-")) temp_dir = Path(tempfile.mkdtemp(prefix="task-assets-"))
image_root = temp_dir / "images" image_root = temp_dir / "images"
image_root.mkdir(parents=True, exist_ok=True) image_root.mkdir(parents=True, exist_ok=True)
...@@ -494,6 +513,7 @@ class TaskApiSocraticTest(unittest.TestCase): ...@@ -494,6 +513,7 @@ class TaskApiSocraticTest(unittest.TestCase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_list_socratic_topics_returns_catalog(self) -> None: def test_list_socratic_topics_returns_catalog(self) -> None:
"""Checks that the Socratic topics endpoint returns the built topic catalog."""
topics = [ topics = [
{ {
"topic_key": "quadratische gleichungen", "topic_key": "quadratische gleichungen",
...@@ -515,6 +535,7 @@ class TaskApiSocraticTest(unittest.TestCase): ...@@ -515,6 +535,7 @@ class TaskApiSocraticTest(unittest.TestCase):
self.assertEqual(body["topics"], topics) self.assertEqual(body["topics"], topics)
def test_select_topic_endpoint_returns_selected_key(self) -> None: 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] = {} sheet: dict[str, object] = {}
with patch("app.api.tasks.context_store_base.get_chat_id", return_value="chat-1"), patch( with patch("app.api.tasks.context_store_base.get_chat_id", return_value="chat-1"), patch(
...@@ -558,6 +579,7 @@ class ChatBootstrapSocraticTest(unittest.TestCase): ...@@ -558,6 +579,7 @@ class ChatBootstrapSocraticTest(unittest.TestCase):
self.client = TestClient(app) self.client = TestClient(app)
def test_bootstrap_socratic_loads_sources_and_writes_only_assistant_history(self) -> None: 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] = {} sheet: dict[str, object] = {}
with patch("app.api.chat.context_store_base.get_chat_id", return_value="chat-1"), patch( with patch("app.api.chat.context_store_base.get_chat_id", return_value="chat-1"), patch(
...@@ -608,6 +630,7 @@ class ChatBootstrapSocraticTest(unittest.TestCase): ...@@ -608,6 +630,7 @@ class ChatBootstrapSocraticTest(unittest.TestCase):
class SessionStoreSocraticTest(unittest.TestCase): class SessionStoreSocraticTest(unittest.TestCase):
def test_load_archive_restores_selected_topic(self) -> None: def test_load_archive_restores_selected_topic(self) -> None:
"""Checks that archived sessions restore the selected Socratic topic fields."""
record = { record = {
"chat_id": "chat-1", "chat_id": "chat-1",
"saved_at": "2026-03-31T10:00:00Z", "saved_at": "2026-03-31T10:00:00Z",
......
...@@ -19,6 +19,7 @@ from scripts import convert_tasks_json_to_yaml ...@@ -19,6 +19,7 @@ from scripts import convert_tasks_json_to_yaml
class TaskYamlMigrationTest(unittest.TestCase): class TaskYamlMigrationTest(unittest.TestCase):
def test_load_task_files_normalizes_yaml_schema(self) -> None: 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-")) temp_dir = Path(tempfile.mkdtemp(prefix="task-yaml-"))
yaml_path = temp_dir / "analysis.yaml" yaml_path = temp_dir / "analysis.yaml"
yaml_path.write_text( yaml_path.write_text(
...@@ -73,6 +74,7 @@ class TaskYamlMigrationTest(unittest.TestCase): ...@@ -73,6 +74,7 @@ class TaskYamlMigrationTest(unittest.TestCase):
) )
def test_load_task_files_skips_invalid_yaml(self) -> None: 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-")) temp_dir = Path(tempfile.mkdtemp(prefix="task-yaml-invalid-"))
valid_path = temp_dir / "valid.yaml" valid_path = temp_dir / "valid.yaml"
invalid_path = temp_dir / "invalid.yaml" invalid_path = temp_dir / "invalid.yaml"
...@@ -119,6 +121,7 @@ class TaskYamlMigrationTest(unittest.TestCase): ...@@ -119,6 +121,7 @@ class TaskYamlMigrationTest(unittest.TestCase):
self.assertEqual([item["_file_id"] for item in loaded], ["valid"]) self.assertEqual([item["_file_id"] for item in loaded], ["valid"])
def test_build_task_catalog_keeps_api_shape_for_yaml_sources(self) -> None: 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 = [ task_files = [
{ {
"_file_id": "analysis_1", "_file_id": "analysis_1",
...@@ -151,6 +154,7 @@ class TaskYamlMigrationTest(unittest.TestCase): ...@@ -151,6 +154,7 @@ class TaskYamlMigrationTest(unittest.TestCase):
) )
def test_convert_task_payload_maps_json_shape_to_yaml(self) -> None: 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 = { payload = {
"title": "Reelle Funktionen bestimmen", "title": "Reelle Funktionen bestimmen",
"subsections": ["eigenschaften-reeller-funktionen"], "subsections": ["eigenschaften-reeller-funktionen"],
...@@ -185,6 +189,7 @@ class TaskYamlMigrationTest(unittest.TestCase): ...@@ -185,6 +189,7 @@ class TaskYamlMigrationTest(unittest.TestCase):
self.assertEqual(converted["tasks"][0]["loesung"], [{"type": "text", "text": "Loesung"}]) self.assertEqual(converted["tasks"][0]["loesung"], [{"type": "text", "text": "Loesung"}])
def test_convert_all_rewrites_json_and_moves_images(self) -> None: 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-")) temp_dir = Path(tempfile.mkdtemp(prefix="task-convert-"))
images_dir = temp_dir / "images" / "old-folder" images_dir = temp_dir / "images" / "old-folder"
images_dir.mkdir(parents=True, exist_ok=True) images_dir.mkdir(parents=True, exist_ok=True)
...@@ -230,6 +235,7 @@ class TaskYamlMigrationTest(unittest.TestCase): ...@@ -230,6 +235,7 @@ class TaskYamlMigrationTest(unittest.TestCase):
shutil.rmtree(temp_dir, ignore_errors=True) shutil.rmtree(temp_dir, ignore_errors=True)
def test_convert_all_uses_distinct_yaml_filenames_for_duplicate_titles(self) -> None: 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-")) temp_dir = Path(tempfile.mkdtemp(prefix="task-convert-duplicate-"))
first_json = temp_dir / "Auf Beschränktheit untersuchen.json" first_json = temp_dir / "Auf Beschränktheit untersuchen.json"
second_json = temp_dir / "Auf Beschränktheit untersuchen_images.json" second_json = temp_dir / "Auf Beschränktheit untersuchen_images.json"
......
...@@ -146,6 +146,7 @@ class _FakeSession: ...@@ -146,6 +146,7 @@ class _FakeSession:
class LLMProviderConfigTest(unittest.TestCase): class LLMProviderConfigTest(unittest.TestCase):
def test_get_llm_provider_accepts_supported_values(self) -> None: 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"): for provider in ("openai", "gwdg", "mistral", "ollama"):
with self.subTest(provider=provider), patch.dict( with self.subTest(provider=provider), patch.dict(
os.environ, {"LLM_PROVIDER": provider}, clear=True os.environ, {"LLM_PROVIDER": provider}, clear=True
...@@ -153,24 +154,29 @@ class LLMProviderConfigTest(unittest.TestCase): ...@@ -153,24 +154,29 @@ class LLMProviderConfigTest(unittest.TestCase):
self.assertEqual(config.get_llm_provider(), provider) self.assertEqual(config.get_llm_provider(), provider)
def test_get_llm_provider_normalizes_case_and_space(self) -> None: 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): with patch.dict(os.environ, {"LLM_PROVIDER": " OpenAI "}, clear=True):
self.assertEqual(config.get_llm_provider(), "openai") self.assertEqual(config.get_llm_provider(), "openai")
def test_get_llm_provider_rejects_missing_value(self) -> None: 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 patch.dict(os.environ, {}, clear=True):
with self.assertRaisesRegex(ValueError, "Missing LLM_PROVIDER"): with self.assertRaisesRegex(ValueError, "Missing LLM_PROVIDER"):
config.get_llm_provider() config.get_llm_provider()
def test_get_llm_provider_rejects_unknown_value(self) -> None: 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 patch.dict(os.environ, {"LLM_PROVIDER": "anthropic"}, clear=True):
with self.assertRaisesRegex(ValueError, "Unsupported LLM_PROVIDER"): with self.assertRaisesRegex(ValueError, "Unsupported LLM_PROVIDER"):
config.get_llm_provider() config.get_llm_provider()
def test_get_llm_tool_use_enabled_defaults_to_false(self) -> None: 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): with patch.dict(os.environ, {}, clear=True):
self.assertFalse(config.get_llm_tool_use_enabled()) self.assertFalse(config.get_llm_tool_use_enabled())
def test_get_gwdg_chat_settings_reads_gwdg_keys(self) -> None: def test_get_gwdg_chat_settings_reads_gwdg_keys(self) -> None:
"""Verifies that GWDG chat settings are loaded from the expected env vars."""
env = { env = {
"GWDG_BASE_URL": "https://chat-ai.academiccloud.de/v1/", "GWDG_BASE_URL": "https://chat-ai.academiccloud.de/v1/",
"GWDG_API_KEY": "gwdg-key", "GWDG_API_KEY": "gwdg-key",
...@@ -190,22 +196,17 @@ class LLMProviderConfigTest(unittest.TestCase): ...@@ -190,22 +196,17 @@ class LLMProviderConfigTest(unittest.TestCase):
self.assertEqual(settings.timeout, 60.0) self.assertEqual(settings.timeout, 60.0)
def test_get_embedding_provider_accepts_supported_values(self) -> None: 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"): for provider in ("sentence-transformer", "openai", "gwdg"):
with self.subTest(provider=provider), patch.dict( with self.subTest(provider=provider), patch.dict(
os.environ, {"EMBEDDING_PROVIDER": provider}, clear=True os.environ, {"EMBEDDING_PROVIDER": provider}, clear=True
): ):
self.assertEqual(config.get_embedding_provider(), provider) 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): class LLMClientProviderTest(unittest.TestCase):
def test_chat_uses_only_openai_provider(self) -> None: def test_chat_uses_only_openai_provider(self) -> None:
"""Checks that plain OpenAI chat stays on the non-tool provider path."""
settings = object() settings = object()
expected = {"raw": object(), "message": {"content": "openai"}} expected = {"raw": object(), "message": {"content": "openai"}}
with patch.dict( with patch.dict(
...@@ -235,6 +236,7 @@ class LLMClientProviderTest(unittest.TestCase): ...@@ -235,6 +236,7 @@ class LLMClientProviderTest(unittest.TestCase):
ollama_chat.assert_not_called() ollama_chat.assert_not_called()
def test_chat_uses_openai_tool_path_when_enabled(self) -> None: 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() settings = object()
expected = {"raw": object(), "message": {"content": "openai-tools"}} expected = {"raw": object(), "message": {"content": "openai-tools"}}
with patch.dict( with patch.dict(
...@@ -258,6 +260,7 @@ class LLMClientProviderTest(unittest.TestCase): ...@@ -258,6 +260,7 @@ class LLMClientProviderTest(unittest.TestCase):
openai_chat.assert_not_called() openai_chat.assert_not_called()
def test_chat_uses_only_gwdg_provider(self) -> None: def test_chat_uses_only_gwdg_provider(self) -> None:
"""Checks that plain GWDG chat stays on the OpenAI-compatible path."""
settings = object() settings = object()
expected = {"raw": object(), "message": {"content": "gwdg"}} expected = {"raw": object(), "message": {"content": "gwdg"}}
with patch.dict( with patch.dict(
...@@ -287,6 +290,7 @@ class LLMClientProviderTest(unittest.TestCase): ...@@ -287,6 +290,7 @@ class LLMClientProviderTest(unittest.TestCase):
ollama_chat.assert_not_called() ollama_chat.assert_not_called()
def test_chat_uses_gwdg_tool_path_when_enabled(self) -> None: 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() settings = object()
expected = {"raw": object(), "message": {"content": "gwdg-tools"}} expected = {"raw": object(), "message": {"content": "gwdg-tools"}}
with patch.dict( with patch.dict(
...@@ -310,6 +314,7 @@ class LLMClientProviderTest(unittest.TestCase): ...@@ -310,6 +314,7 @@ class LLMClientProviderTest(unittest.TestCase):
compatible_chat.assert_not_called() compatible_chat.assert_not_called()
def test_chat_uses_only_mistral_provider(self) -> None: def test_chat_uses_only_mistral_provider(self) -> None:
"""Checks that plain Mistral chat uses only the Mistral-specific path."""
settings = object() settings = object()
expected = {"raw": object(), "message": {"content": "mistral"}} expected = {"raw": object(), "message": {"content": "mistral"}}
with patch.dict( with patch.dict(
...@@ -339,6 +344,7 @@ class LLMClientProviderTest(unittest.TestCase): ...@@ -339,6 +344,7 @@ class LLMClientProviderTest(unittest.TestCase):
ollama_chat.assert_not_called() ollama_chat.assert_not_called()
def test_chat_falls_back_to_mistral_plain_path_when_tools_enabled(self) -> None: 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() settings = object()
expected = {"raw": object(), "message": {"content": "mistral"}} expected = {"raw": object(), "message": {"content": "mistral"}}
with patch.dict( with patch.dict(
...@@ -362,6 +368,7 @@ class LLMClientProviderTest(unittest.TestCase): ...@@ -362,6 +368,7 @@ class LLMClientProviderTest(unittest.TestCase):
mistral_chat.assert_called_once_with(MESSAGES, settings) mistral_chat.assert_called_once_with(MESSAGES, settings)
def test_chat_uses_only_ollama_provider(self) -> None: 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"}} expected = {"raw": object(), "message": {"content": "ollama"}}
with patch.dict( with patch.dict(
os.environ, os.environ,
...@@ -388,6 +395,7 @@ class LLMClientProviderTest(unittest.TestCase): ...@@ -388,6 +395,7 @@ class LLMClientProviderTest(unittest.TestCase):
ollama_chat.assert_called_once_with(MESSAGES) ollama_chat.assert_called_once_with(MESSAGES)
def test_chat_uses_ollama_tool_path_when_enabled(self) -> None: 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"}} expected = {"raw": object(), "message": {"content": "ollama-tools"}}
with patch.dict( with patch.dict(
os.environ, os.environ,
...@@ -408,6 +416,7 @@ class LLMClientProviderTest(unittest.TestCase): ...@@ -408,6 +416,7 @@ class LLMClientProviderTest(unittest.TestCase):
ollama_chat.assert_not_called() ollama_chat.assert_not_called()
def test_selected_gwdg_config_error_happens_before_quota(self) -> None: 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( with patch.dict(
os.environ, os.environ,
{"LLM_PROVIDER": "gwdg", "LLM_TOOL_USE_ENABLED": "true"}, {"LLM_PROVIDER": "gwdg", "LLM_TOOL_USE_ENABLED": "true"},
...@@ -419,6 +428,7 @@ class LLMClientProviderTest(unittest.TestCase): ...@@ -419,6 +428,7 @@ class LLMClientProviderTest(unittest.TestCase):
ensure_quota.assert_not_called() ensure_quota.assert_not_called()
def test_selected_provider_config_error_happens_before_quota(self) -> None: 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( with patch.dict(
os.environ, os.environ,
{"LLM_PROVIDER": "openai", "LLM_TOOL_USE_ENABLED": "true"}, {"LLM_PROVIDER": "openai", "LLM_TOOL_USE_ENABLED": "true"},
...@@ -430,6 +440,7 @@ class LLMClientProviderTest(unittest.TestCase): ...@@ -430,6 +440,7 @@ class LLMClientProviderTest(unittest.TestCase):
ensure_quota.assert_not_called() ensure_quota.assert_not_called()
def test_chat_without_provider_does_not_fallback(self) -> None: 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( with patch.dict(os.environ, {}, clear=True), patch.object(
llm_client, "_chat_openai_compatible" llm_client, "_chat_openai_compatible"
) as openai_chat, patch.object( ) as openai_chat, patch.object(
...@@ -445,6 +456,7 @@ class LLMClientProviderTest(unittest.TestCase): ...@@ -445,6 +456,7 @@ class LLMClientProviderTest(unittest.TestCase):
ollama_chat.assert_not_called() ollama_chat.assert_not_called()
def test_tool_enabled_chat_records_quota_once_on_success(self) -> None: 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"}} expected = {"raw": object(), "message": {"content": "ok"}}
with patch.dict( with patch.dict(
os.environ, os.environ,
...@@ -463,6 +475,7 @@ class LLMClientProviderTest(unittest.TestCase): ...@@ -463,6 +475,7 @@ class LLMClientProviderTest(unittest.TestCase):
record_call.assert_called_once_with(expected) record_call.assert_called_once_with(expected)
def test_tool_enabled_chat_records_zero_tokens_on_failure(self) -> None: 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( with patch.dict(
os.environ, os.environ,
{"LLM_PROVIDER": "ollama", "LLM_TOOL_USE_ENABLED": "true"}, {"LLM_PROVIDER": "ollama", "LLM_TOOL_USE_ENABLED": "true"},
...@@ -482,15 +495,18 @@ class LLMClientProviderTest(unittest.TestCase): ...@@ -482,15 +495,18 @@ class LLMClientProviderTest(unittest.TestCase):
class LLMClientToolHelpersTest(unittest.TestCase): class LLMClientToolHelpersTest(unittest.TestCase):
def test_extract_structured_tool_output_prefers_structured_content(self) -> None: 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}) result = _FakeToolResult(structured_content={"answer": 42})
self.assertEqual(llm_tool_client._extract_structured_tool_output(result), {"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: 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")] blocks = [types.SimpleNamespace(text="a"), types.SimpleNamespace(text="b")]
result = _FakeToolResult(content=blocks) result = _FakeToolResult(content=blocks)
self.assertEqual(llm_tool_client._extract_structured_tool_output(result), "a\nb") self.assertEqual(llm_tool_client._extract_structured_tool_output(result), "a\nb")
def test_run_provider_chat_appends_tool_trace_to_active_log(self) -> None: 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] = [] active_log: list[dict] = []
token = tool_log_context.set_active_tool_log(active_log) token = tool_log_context.set_active_tool_log(active_log)
result = { result = {
...@@ -525,6 +541,7 @@ class LLMClientToolHelpersTest(unittest.TestCase): ...@@ -525,6 +541,7 @@ class LLMClientToolHelpersTest(unittest.TestCase):
) )
def test_run_provider_chat_skips_log_when_no_tool_trace(self) -> None: 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] = [] active_log: list[dict] = []
token = tool_log_context.set_active_tool_log(active_log) token = tool_log_context.set_active_tool_log(active_log)
result = {"raw": object(), "message": {"content": "done"}} result = {"raw": object(), "message": {"content": "done"}}
...@@ -538,6 +555,7 @@ class LLMClientToolHelpersTest(unittest.TestCase): ...@@ -538,6 +555,7 @@ class LLMClientToolHelpersTest(unittest.TestCase):
self.assertEqual(active_log, []) self.assertEqual(active_log, [])
def test_openai_tool_loop_returns_final_message_without_tool_calls(self) -> None: 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( settings = config.OpenAIChatSettings(
base_url="https://example.com/v1", base_url="https://example.com/v1",
api_key="key", api_key="key",
...@@ -567,6 +585,7 @@ class LLMClientToolHelpersTest(unittest.TestCase): ...@@ -567,6 +585,7 @@ class LLMClientToolHelpersTest(unittest.TestCase):
self.assertIsNone(asyncio_result) self.assertIsNone(asyncio_result)
def test_openai_tool_loop_executes_tool_and_continues(self) -> None: 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( settings = config.OpenAIChatSettings(
base_url="https://example.com/v1", base_url="https://example.com/v1",
api_key="key", api_key="key",
...@@ -620,6 +639,7 @@ class LLMClientToolHelpersTest(unittest.TestCase): ...@@ -620,6 +639,7 @@ class LLMClientToolHelpersTest(unittest.TestCase):
self.assertIsNone(asyncio_result) self.assertIsNone(asyncio_result)
def test_openai_tool_loop_accumulates_multiple_tool_calls(self) -> None: 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( settings = config.OpenAIChatSettings(
base_url="https://example.com/v1", base_url="https://example.com/v1",
api_key="key", api_key="key",
...@@ -679,11 +699,9 @@ class LLMClientToolHelpersTest(unittest.TestCase): ...@@ -679,11 +699,9 @@ class LLMClientToolHelpersTest(unittest.TestCase):
class TutorOrchestratorLegacyModuleTest(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: def test_bootstrap_only_runs_retrieval(self) -> None:
"""Checks that bootstrap only triggers retrieval and leaves the tool log untouched."""
state = ChatState( state = ChatState(
messages=MESSAGES[:], messages=MESSAGES[:],
draft=None, draft=None,
...@@ -701,6 +719,7 @@ class TutorOrchestratorLegacyModuleTest(unittest.TestCase): ...@@ -701,6 +719,7 @@ class TutorOrchestratorLegacyModuleTest(unittest.TestCase):
self.assertEqual(state.tool_log, []) self.assertEqual(state.tool_log, [])
def test_non_new_turn_does_not_run_decision_llm(self) -> None: 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( state = ChatState(
messages=MESSAGES[:] + [{"role": "assistant", "content": "Antwort"}], messages=MESSAGES[:] + [{"role": "assistant", "content": "Antwort"}],
draft=None, draft=None,
......
...@@ -58,10 +58,12 @@ def _mk_retrieved( ...@@ -58,10 +58,12 @@ def _mk_retrieved(
class VectorStorePipelineUnitTest(unittest.TestCase): class VectorStorePipelineUnitTest(unittest.TestCase):
def test_package_exports_point_to_retrieval_models(self) -> None: 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(PackageSource, Source)
self.assertIs(PackageSourceID, SourceID) self.assertIs(PackageSourceID, SourceID)
def test_merge_sources_prefers_task_childs_on_duplicate(self) -> None: 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( child_direct = Source(
source_id=SourceID( source_id=SourceID(
chapter_title="Kapitel", chapter_title="Kapitel",
...@@ -95,6 +97,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase): ...@@ -95,6 +97,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self.assertEqual(merged[0].retrieved_as, "task_childs") self.assertEqual(merged[0].retrieved_as, "task_childs")
def test_build_default_pipeline_config(self) -> None: 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) cfg = build_default_pipeline_config(k=4, expand_links=True, neighbor_expand=2)
self.assertEqual(cfg.vector_k, 20) self.assertEqual(cfg.vector_k, 20)
self.assertEqual(cfg.scope_fill_k, 5) self.assertEqual(cfg.scope_fill_k, 5)
...@@ -104,6 +107,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase): ...@@ -104,6 +107,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self.assertTrue(cfg.enable_context_docs) self.assertTrue(cfg.enable_context_docs)
def test_select_dominant_scope_prefers_subsection_count(self) -> None: def test_select_dominant_scope_prefers_subsection_count(self) -> None:
"""Checks that dominant scope selection prefers the scope with more subsection matches."""
children = [ children = [
_mk_retrieved("a", 0.9, 1, 1, 1), _mk_retrieved("a", 0.9, 1, 1, 1),
_mk_retrieved("b", 0.8, 1, 1, 1), _mk_retrieved("b", 0.8, 1, 1, 1),
...@@ -116,6 +120,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase): ...@@ -116,6 +120,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self.assertEqual((scope.chapter_index, scope.section_index, scope.subsection_index), (1, 1, 1)) self.assertEqual((scope.chapter_index, scope.section_index, scope.subsection_index), (1, 1, 1))
def test_select_dominant_scope_resolves_section_parent(self) -> None: def test_select_dominant_scope_resolves_section_parent(self) -> None:
"""Checks that dominant scope resolution can collapse matches to a section parent."""
children = [ children = [
_mk_retrieved("a", 0.9, 1, 1, 0, 0), _mk_retrieved("a", 0.9, 1, 1, 0, 0),
_mk_retrieved("b", 0.8, 1, 1, 0, 0), _mk_retrieved("b", 0.8, 1, 1, 0, 0),
...@@ -130,6 +135,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase): ...@@ -130,6 +135,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self.assertIsNone(scope.subsubsection_index) self.assertIsNone(scope.subsubsection_index)
def test_select_dominant_scope_resolves_subsubsection_parent(self) -> None: def test_select_dominant_scope_resolves_subsubsection_parent(self) -> None:
"""Checks that dominant scope resolution can collapse matches to a subsubsection parent."""
children = [ children = [
_mk_retrieved("a", 0.9, 1, 1, 2, 1), _mk_retrieved("a", 0.9, 1, 1, 2, 1),
_mk_retrieved("b", 0.8, 1, 1, 2, 1), _mk_retrieved("b", 0.8, 1, 1, 2, 1),
...@@ -142,6 +148,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase): ...@@ -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)) 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: def test_select_dominant_scope_tiebreak_avg_score(self) -> None:
"""Checks that tied dominant scopes are broken by average score."""
children = [ children = [
_mk_retrieved("a", 0.7, 1, 1, 1, 0), _mk_retrieved("a", 0.7, 1, 1, 1, 0),
_mk_retrieved("b", 0.8, 1, 1, 2, 0), _mk_retrieved("b", 0.8, 1, 1, 2, 0),
...@@ -153,6 +160,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase): ...@@ -153,6 +160,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self.assertEqual((scope.chapter_index, scope.section_index, scope.subsection_index), (1, 1, 2)) self.assertEqual((scope.chapter_index, scope.section_index, scope.subsection_index), (1, 1, 2))
def test_select_dominant_scope_tiebreak_lexicographic(self) -> None: def test_select_dominant_scope_tiebreak_lexicographic(self) -> None:
"""Checks that fully tied dominant scopes fall back to lexicographic ordering."""
children = [ children = [
_mk_retrieved("a", 0.8, 2, 1, 1, 0), _mk_retrieved("a", 0.8, 2, 1, 1, 0),
_mk_retrieved("b", 0.8, 1, 2, 3, 0), _mk_retrieved("b", 0.8, 1, 2, 3, 0),
...@@ -163,6 +171,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase): ...@@ -163,6 +171,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self.assertEqual((scope.chapter_index, scope.section_index, scope.subsection_index), (1, 2, 3)) 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: 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 = [ children = [
_mk_retrieved("section-hit", 0.91, 1, 1, 2, 0), _mk_retrieved("section-hit", 0.91, 1, 1, 2, 0),
_mk_retrieved("subsub-hit", 0.92, 1, 1, 2, 1), _mk_retrieved("subsub-hit", 0.92, 1, 1, 2, 1),
...@@ -175,6 +184,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase): ...@@ -175,6 +184,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self.assertEqual(scope.subsubsection_index, 1) self.assertEqual(scope.subsubsection_index, 1)
def test_merge_retrieval_groups_dedup_max_score_and_trim_children(self) -> None: 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 = { groups = {
"children_direct": [ "children_direct": [
_mk_retrieved("u1", 0.5, 1, 1, 1), _mk_retrieved("u1", 0.5, 1, 1, 1),
...@@ -198,11 +208,13 @@ class VectorStorePipelineUnitTest(unittest.TestCase): ...@@ -198,11 +208,13 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self.assertEqual(merged["neighbors"], []) self.assertEqual(merged["neighbors"], [])
def test_expand_neighbor_children_returns_empty_for_zero_expand(self) -> None: 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)] children = [_mk_retrieved("u1", 0.8, 1, 1, 1, 4, child_index=3)]
result = expand_neighbor_children("postgresql://unused", children, neighbor_expand=0) result = expand_neighbor_children("postgresql://unused", children, neighbor_expand=0)
self.assertEqual(result, []) self.assertEqual(result, [])
def test_source_id_to_string_includes_subsubsection_title(self) -> None: 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( source_id = SourceID(
chapter_title="Kapitel", chapter_title="Kapitel",
section_title="Section", section_title="Section",
...@@ -215,6 +227,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase): ...@@ -215,6 +227,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self.assertEqual(source_id.to_string(), "[Kapitel|Section|Subsection|Subsubsection|Child|child]") self.assertEqual(source_id.to_string(), "[Kapitel|Section|Subsection|Subsubsection|Child|child]")
def test_merge_sources_distinguishes_subsubsection_title(self) -> None: def test_merge_sources_distinguishes_subsubsection_title(self) -> None:
"""Checks that sources with different subsubsection titles stay distinct when merged."""
first = Source( first = Source(
source_id=SourceID( source_id=SourceID(
chapter_title="Kapitel", chapter_title="Kapitel",
...@@ -248,6 +261,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase): ...@@ -248,6 +261,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self.assertEqual(len(merged), 2) self.assertEqual(len(merged), 2)
def test_retrieve_sorts_sources_by_score(self) -> None: def test_retrieve_sorts_sources_by_score(self) -> None:
"""Checks that retrieved sources are returned in descending score order."""
groups = { groups = {
"children_direct": [ "children_direct": [
_mk_retrieved("low", 0.2, 1, 1, 1), _mk_retrieved("low", 0.2, 1, 1, 1),
...@@ -267,6 +281,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase): ...@@ -267,6 +281,7 @@ class VectorStorePipelineUnitTest(unittest.TestCase):
self.assertEqual([source.source_id.title for source in result], ["high", "low"]) self.assertEqual([source.source_id.title for source in result], ["high", "low"])
def test_retrieve_with_subsections_matches_retrieve_without_refs(self) -> None: def test_retrieve_with_subsections_matches_retrieve_without_refs(self) -> None:
"""Checks that subsection-aware retrieval matches the legacy retrieval output shape."""
expected = [ expected = [
Source( Source(
source_id=SourceID(title="Child", doc_type="child"), source_id=SourceID(title="Child", doc_type="child"),
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment