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

test kommentiert

parent 82adf746
......@@ -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()
......
......@@ -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(
......
......@@ -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
......
......@@ -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\nsection_index: 1\ntitle: "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\nsection_index: 1\ntitle: "Mengen"',
......
......@@ -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"},
......
......@@ -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)."
......
......@@ -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",
......
......@@ -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"
......
......@@ -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\nb")
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,
......
......@@ -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"),
......
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