import unittest from unittest.mock import patch from conversation_service import ConversationService, InMemoryConversationStore from ollama_nlu import ( NLUResponse, OllamaUnavailableError, NLUValidationError, validate_nlu_payload, ) from roll_calculation import calculate_roll, modify_calculation def new_interpretation(**changes): payload = { "intent": "new_calculation", "article_number": "180205", "article_name_hint": "Bentofix NSP 4900", "roll_length_m": 65.0, "width_m": None, "core_type": "150mm PVC", "core_diameter_mm": None, "include_roll_weight": False, } payload.update(changes) return payload class QueueNLUClient: def __init__(self, *payloads): self.payloads = list(payloads) self.calls = [] def interpret( self, message, *, has_state=False, expected_fields=(), current_state=None, ): self.calls.append({ "message": message, "has_state": has_state, "expected_fields": expected_fields, "current_state": current_state, }) payload = self.payloads.pop(0) if isinstance(payload, Exception): raise payload interpretation = validate_nlu_payload(payload) return NLUResponse( interpretation=interpretation, raw_model_json="raw-json", latency_ms=12.34, model="test-model", ) class ConversationServiceTests(unittest.TestCase): def create_service(self, *payloads): client = QueueNLUClient(*payloads) service = ConversationService( client, store=InMemoryConversationStore(), build_info={"version": "test"}, ) return service, client, service.create_conversation() def test_valid_new_calculation_calls_the_deterministic_core(self): service, _, conversation_id = self.create_service(new_interpretation()) with patch( "conversation_service.calculate_roll", wraps=calculate_roll, ) as deterministic_calculator: response = service.handle_message(conversation_id, "Neue Berechnung") self.assertEqual(response["status"], "success") deterministic_calculator.assert_called_once() self.assertEqual( response["result"]["provenance"]["calculator"], "roll_calculation.calculate_roll", ) def test_follow_up_length_modification_calls_modify_calculation(self): service, client, conversation_id = self.create_service( new_interpretation(), { "intent": "modify_calculation", "changes": {"roll_length_m": 80.0}, }, ) service.handle_message(conversation_id, "Neue Berechnung") with patch( "conversation_service.modify_calculation", wraps=modify_calculation, ) as deterministic_modifier: response = service.handle_message(conversation_id, "Mach daraus 80 m.") deterministic_modifier.assert_called_once() self.assertEqual(response["status"], "success") self.assertEqual( response["result"]["calculation"]["effective_roll_length_m"], 80.0, ) self.assertTrue(client.calls[1]["has_state"]) def test_multi_field_follow_up_recalculates_once_and_creates_new_report(self): service, _, conversation_id = self.create_service( new_interpretation(core_type="194mm Stahl", core_diameter_mm=194.0), { "intent": "modify_calculation", "changes": {"roll_length_m": 80.0, "include_roll_weight": True}, }, ) initial = service.handle_message(conversation_id, "Neue Berechnung") with patch( "conversation_service.modify_calculation", wraps=modify_calculation, ) as deterministic_modifier: response = service.handle_message( conversation_id, "Ändere bitte die Rollenlänge auf 80m und berechne das Rollengewicht.", ) deterministic_modifier.assert_called_once() self.assertEqual(response["status"], "success") self.assertEqual(response["result"]["request"]["roll_length_m"], 80.0) self.assertTrue(response["result"]["request"]["include_roll_weight"]) self.assertIsNotNone(response["result"]["calculation"]["roll_weight_kg"]) self.assertNotEqual(response["pdf"]["report_id"], initial["pdf"]["report_id"]) def test_multi_field_length_and_width_follow_up(self): service, _, conversation_id = self.create_service( new_interpretation(), { "intent": "modify_calculation", "changes": {"roll_length_m": 65.0, "width_m": 5.0}, }, ) service.handle_message(conversation_id, "Neue Berechnung") response = service.handle_message(conversation_id, "Nimm 65 m Länge und 5,0 m Breite.") self.assertEqual(response["status"], "success") self.assertEqual(response["result"]["request"]["roll_length_m"], 65.0) self.assertEqual(response["result"]["request"]["width_m"], 5.0) def test_multi_field_core_preset_and_length_follow_up(self): service, _, conversation_id = self.create_service( new_interpretation(), { "intent": "modify_calculation", "changes": { "core_type": "194-mm-Stahlkern", "roll_length_m": 80.0, }, }, ) service.handle_message(conversation_id, "Neue Berechnung") response = service.handle_message( conversation_id, "Nimm den 194-mm-Stahlkern und 80 m Länge.", ) self.assertEqual(response["status"], "success") request = response["result"]["request"] self.assertEqual(request["roll_length_m"], 80.0) self.assertEqual(request["core_type"], "194mm Stahl") self.assertEqual(request["core_diameter_mm"], 194.0) def test_incomplete_new_weight_output_inherits_current_calculation(self): service, client, conversation_id = self.create_service( new_interpretation( roll_length_m=80.0, core_type="194mm Stahl", core_diameter_mm=194.0, ), new_interpretation( article_number=None, article_name_hint=None, roll_length_m=None, width_m=5.0, core_type=None, core_diameter_mm=None, include_roll_weight=True, ), ) service.handle_message(conversation_id, "Neue Berechnung") with patch( "conversation_service.modify_calculation", wraps=modify_calculation, ) as deterministic_modifier: response = service.handle_message( conversation_id, "Wieviel wiegt die Rolle bei 5,0 m Breite?", ) changes = deterministic_modifier.call_args.args[1] self.assertEqual( changes, {"width_m": 5.0, "include_roll_weight": True}, ) request = response["result"]["state"]["request"] self.assertEqual(response["status"], "success") self.assertEqual(request["article_number"], "180205") self.assertEqual(request["roll_length_m"], 80.0) self.assertEqual(request["core_type"], "194mm Stahl") self.assertEqual(request["core_diameter_mm"], 194.0) self.assertEqual(request["width_m"], 5.0) self.assertTrue(request["include_roll_weight"]) self.assertIsNotNone(response["result"]["calculation"]["roll_weight_kg"]) self.assertIn("pdf", response) current_context = client.calls[1]["current_state"] self.assertTrue(current_context["has_successful_calculation"]) self.assertEqual(current_context["article_number"], "180205") self.assertEqual( current_context["resolved_article_name"], "Bfix NSP 4900, 5,00 x 40 m", ) self.assertEqual(current_context["roll_length_m"], 80.0) self.assertNotIn("calculation", current_context) def test_incomplete_new_length_output_inherits_article_and_core(self): service, _, conversation_id = self.create_service( new_interpretation( roll_length_m=65.0, core_type="194mm Stahl", core_diameter_mm=194.0, ), new_interpretation( article_number=None, article_name_hint=None, roll_length_m=80.0, width_m=None, core_type=None, core_diameter_mm=None, ), ) service.handle_message(conversation_id, "Neue Berechnung") response = service.handle_message(conversation_id, "Wie sieht es bei 80 m aus?") request = response["result"]["state"]["request"] self.assertEqual(response["status"], "success") self.assertEqual(request["article_number"], "180205") self.assertEqual(request["roll_length_m"], 80.0) self.assertEqual(request["core_type"], "194mm Stahl") self.assertEqual(request["core_diameter_mm"], 194.0) def test_explicit_new_article_does_not_inherit_current_state(self): service, _, conversation_id = self.create_service( new_interpretation( roll_length_m=80.0, core_type="194mm Stahl", core_diameter_mm=194.0, ), new_interpretation( article_number="146900", article_name_hint=None, roll_length_m=50.0, width_m=None, core_type="150mm PVC", core_diameter_mm=150.0, ), ) service.handle_message(conversation_id, "Neue Berechnung") with patch( "conversation_service.calculate_roll", wraps=calculate_roll, ) as deterministic_calculator: response = service.handle_message( conversation_id, "Neue Berechnung für Artikel 146900.", ) deterministic_calculator.assert_called_once() request = response["result"]["state"]["request"] self.assertEqual(response["status"], "success") self.assertEqual(request["article_number"], "146900") self.assertEqual(request["roll_length_m"], 50.0) self.assertEqual(request["core_diameter_mm"], 150.0) def test_different_article_in_modify_output_starts_new_calculation(self): service, _, conversation_id = self.create_service( new_interpretation(), { "intent": "modify_calculation", "changes": {"article_number": "146900"}, }, ) service.handle_message(conversation_id, "Neue Berechnung") with patch( "conversation_service.calculate_roll", wraps=calculate_roll, ) as deterministic_calculator: response = service.handle_message( conversation_id, "Neue Berechnung für Artikel 146900.", ) deterministic_calculator.assert_called_once() self.assertEqual(response["status"], "needs_clarification") self.assertEqual(response["result"]["request"]["article_number"], "146900") self.assertIsNone(response["result"]["request"]["roll_length_m"]) self.assertIsNone(response["result"]["request"]["core_diameter_mm"]) def test_explicit_new_calculation_phrase_does_not_trigger_inheritance(self): service, _, conversation_id = self.create_service( new_interpretation(), new_interpretation( article_number=None, article_name_hint=None, roll_length_m=80.0, width_m=None, core_type=None, core_diameter_mm=None, ), ) service.handle_message(conversation_id, "Erste Berechnung") with patch("conversation_service.modify_calculation") as modifier: response = service.handle_message( conversation_id, "Neue Berechnung mit 80 m.", ) modifier.assert_not_called() self.assertEqual(response["status"], "needs_clarification") self.assertIsNone(response["result"]["request"]["article_number"]) self.assertIn("thickness_mm", response["result"]["missing"]) def test_no_current_state_does_not_inherit_incomplete_request(self): service, _, conversation_id = self.create_service( new_interpretation( article_number=None, article_name_hint=None, roll_length_m=80.0, width_m=None, core_type=None, core_diameter_mm=None, ) ) with patch("conversation_service.modify_calculation") as modifier: response = service.handle_message(conversation_id, "Rechne mit 80 m.") modifier.assert_not_called() self.assertEqual(response["status"], "needs_clarification") self.assertIn("thickness_mm", response["result"]["missing"]) self.assertIn("core_diameter_mm", response["result"]["missing"]) def test_failed_followup_nlu_does_not_mutate_valid_state(self): service, _, conversation_id = self.create_service( new_interpretation(), NLUValidationError("invalid follow-up"), ) service.handle_message(conversation_id, "Neue Berechnung") before = service.conversation_state(conversation_id) response = service.handle_message(conversation_id, "Ungültige Änderung") after = service.conversation_state(conversation_id) self.assertEqual(response["status"], "nlu_error") self.assertEqual(after.calculation_state, before.calculation_state) self.assertEqual(after.last_result, before.last_result) def test_ambiguous_steel_core_uses_deterministic_clarification(self): service, _, conversation_id = self.create_service( new_interpretation(), { "intent": "modify_calculation", "changes": {"core_type": "Stahlkern"}, }, ) service.handle_message(conversation_id, "Neue Berechnung") valid_state = service.conversation_state(conversation_id).calculation_state response = service.handle_message( conversation_id, "Bitte ändere den Kern auf einen Stahlkern.", ) record = service.conversation_state(conversation_id) self.assertEqual(response["status"], "needs_clarification") self.assertEqual( response["message"], "Es sind mehrere Stahlkerne hinterlegt. Welchen Kerndurchmesser " "soll ich verwenden: 133 mm oder 194 mm?", ) self.assertEqual( response["pending_clarification"]["allowed_values"], [133.0, 194.0], ) self.assertEqual( response["pending_clarification"]["core_family"], "steel", ) self.assertEqual(record.calculation_state, valid_state) self.assertNotIn( "core_diameter_mm", response["interpretation"]["changes"], ) def test_ambiguous_pvc_core_lists_all_canonical_diameters(self): service, _, conversation_id = self.create_service( new_interpretation(), { "intent": "modify_calculation", "changes": {"core_type": "plastic core"}, }, ) service.handle_message(conversation_id, "Neue Berechnung") response = service.handle_message(conversation_id, "Nimm einen PVC-Kern.") self.assertEqual(response["status"], "needs_clarification") self.assertEqual( response["message"], "Es sind mehrere PVC-Kerne hinterlegt. Welchen Kerndurchmesser " "soll ich verwenden: 150 mm oder 168 mm?", ) self.assertEqual( response["pending_clarification"]["allowed_values"], [150.0, 168.0], ) def test_valid_ambiguous_core_candidate_resolves_atomically(self): service, client, conversation_id = self.create_service( new_interpretation(), { "intent": "modify_calculation", "changes": {"core_type": "Stahlkern"}, }, ) service.handle_message(conversation_id, "Neue Berechnung") service.handle_message(conversation_id, "Nimm einen Stahlkern.") with patch( "conversation_service.modify_calculation", wraps=modify_calculation, ) as deterministic_modifier: response = service.handle_message(conversation_id, "194 mm") record = service.conversation_state(conversation_id) deterministic_modifier.assert_called_once() self.assertEqual(len(client.calls), 2) self.assertEqual(response["status"], "success") self.assertIsNone(record.pending_clarification) self.assertEqual( record.calculation_state["request"]["core_type"], "194mm Stahl", ) self.assertEqual( record.calculation_state["request"]["core_diameter_mm"], 194.0, ) self.assertEqual( response["result"]["effective_inputs"]["core_diameter_mm"]["value"], 194.0, ) report_request = service.report_request(response["pdf"]["report_id"]) self.assertEqual(report_request["core_type"], "194mm Stahl") self.assertEqual(report_request["core_diameter_mm"], 194.0) def test_ambiguous_multi_change_preserves_patch_until_core_selection(self): service, _, conversation_id = self.create_service( new_interpretation(), { "intent": "modify_calculation", "changes": {"roll_length_m": 80.0, "core_type": "Stahlkern"}, }, ) initial = service.handle_message(conversation_id, "Neue Berechnung") before = service.conversation_state(conversation_id) pending_response = service.handle_message( conversation_id, "Nimm 80 m und einen Stahlkern.", ) pending = service.conversation_state(conversation_id) self.assertEqual(pending_response["status"], "needs_clarification") self.assertEqual(pending.calculation_state, before.calculation_state) self.assertEqual( pending.pending_clarification.requested_changes, {"roll_length_m": 80.0, "core_type": "Stahlkern"}, ) self.assertEqual( pending_response["pending_clarification"]["requested_changes"], {"roll_length_m": 80.0, "core_type": "Stahlkern"}, ) with patch( "conversation_service.modify_calculation", wraps=modify_calculation, ) as deterministic_modifier: response = service.handle_message(conversation_id, "194 mm") deterministic_modifier.assert_called_once() self.assertEqual(response["status"], "success") request = response["result"]["request"] self.assertEqual(request["roll_length_m"], 80.0) self.assertEqual(request["core_type"], "194mm Stahl") self.assertEqual(request["core_diameter_mm"], 194.0) self.assertNotEqual(response["pdf"]["report_id"], initial["pdf"]["report_id"]) def test_invalid_ambiguous_core_candidate_keeps_valid_state_and_pending(self): service, client, conversation_id = self.create_service( new_interpretation(), { "intent": "modify_calculation", "changes": {"core_type": "Stahlkern"}, }, ) service.handle_message(conversation_id, "Neue Berechnung") valid_record = service.conversation_state(conversation_id) service.handle_message(conversation_id, "Nimm einen Stahlkern.") with patch("conversation_service.modify_calculation") as modifier: response = service.handle_message(conversation_id, "200 mm") record = service.conversation_state(conversation_id) modifier.assert_not_called() self.assertEqual(len(client.calls), 2) self.assertEqual(response["status"], "needs_clarification") self.assertEqual( response["message"], "200 mm ist für einen Stahlkern nicht hinterlegt. " "Verfügbar sind 133 mm und 194 mm.", ) self.assertNotIn("pdf", response) self.assertEqual(record.calculation_state, valid_record.calculation_state) self.assertEqual(record.last_result, valid_record.last_result) self.assertEqual(record.pending_fields, ("core_diameter_mm",)) self.assertEqual( record.pending_clarification.allowed_values, (133.0, 194.0), ) def test_explicit_steel_preset_alias_recalculates_and_updates_pdf_request(self): service, _, conversation_id = self.create_service( new_interpretation(), { "intent": "modify_calculation", "changes": {"core_type": "194 mm steel core"}, }, ) service.handle_message(conversation_id, "Neue Berechnung") with patch( "conversation_service.modify_calculation", wraps=modify_calculation, ) as deterministic_modifier: response = service.handle_message( conversation_id, "Bitte ändere den Kern auf 194 mm steel core.", ) deterministic_modifier.assert_called_once() self.assertEqual(response["status"], "success") request = response["result"]["state"]["request"] self.assertEqual(request["core_type"], "194mm Stahl") self.assertEqual(request["core_diameter_mm"], 194.0) self.assertEqual( response["result"]["effective_inputs"]["core_type"]["value"], "194mm Stahl", ) report_request = service.report_request(response["pdf"]["report_id"]) self.assertEqual(report_request["core_type"], "194mm Stahl") self.assertEqual(report_request["core_diameter_mm"], 194.0) def test_missing_width_is_filled_by_field_locked_clarification(self): service, client, conversation_id = self.create_service( new_interpretation( article_number="6001316", article_name_hint=None, include_roll_weight=True, ), ) clarification = service.handle_message(conversation_id, "Mit Gewicht") self.assertEqual(clarification["status"], "needs_clarification") self.assertIn("Welche Breite", clarification["message"]) self.assertIsNone(clarification["interpretation"]["width_m"]) response = service.handle_message(conversation_id, "4,9 m") self.assertEqual(response["status"], "success") self.assertEqual( response["interpretation"], {"status": "parsed", "field": "width_m", "value": 4.9}, ) self.assertEqual(len(client.calls), 1) def test_pending_core_diameter_accepts_mm_reply_without_calling_ollama(self): service, client, conversation_id = self.create_service( new_interpretation(core_type=None) ) initial = service.handle_message(conversation_id, "Neue Berechnung") response = service.handle_message(conversation_id, "150 mm") self.assertEqual(initial["status"], "needs_clarification") self.assertEqual(response["status"], "success") self.assertEqual( response["result"]["state"]["request"]["core_diameter_mm"], 150.0, ) self.assertEqual(len(client.calls), 1) def test_pending_core_diameter_accepts_unitless_reply(self): service, _, conversation_id = self.create_service( new_interpretation(core_type=None) ) service.handle_message(conversation_id, "Neue Berechnung") response = service.handle_message(conversation_id, "150") self.assertEqual(response["status"], "success") self.assertEqual( response["result"]["state"]["request"]["core_diameter_mm"], 150.0, ) def test_incompatible_core_reply_keeps_state_result_and_pending_field(self): service, client, conversation_id = self.create_service( new_interpretation(core_type=None) ) service.handle_message(conversation_id, "Neue Berechnung") before = service.conversation_state(conversation_id) with patch("conversation_service.modify_calculation") as modifier: response = service.handle_message(conversation_id, "4,90 m") after = service.conversation_state(conversation_id) self.assertEqual(response["status"], "needs_clarification") self.assertEqual( response["clarification"], {"status": "incompatible", "field": "core_diameter_mm"}, ) self.assertIn("Millimetern", response["message"]) self.assertEqual(after.calculation_state, before.calculation_state) self.assertEqual(after.last_result, before.last_result) self.assertEqual(after.pending_fields, ("core_diameter_mm",)) self.assertEqual(len(client.calls), 1) modifier.assert_not_called() def test_pending_roll_length_accepts_meter_reply(self): service, _, conversation_id = self.create_service( new_interpretation(roll_length_m=None) ) service.handle_message(conversation_id, "Neue Berechnung") response = service.handle_message(conversation_id, "80 m") self.assertEqual(response["status"], "success") self.assertEqual( response["result"]["calculation"]["effective_roll_length_m"], 80.0, ) def test_article_conflict_remains_deterministic(self): service, _, conversation_id = self.create_service( new_interpretation(article_name_hint="Stex R 1801") ) response = service.handle_message(conversation_id, "Konflikt") self.assertEqual(response["status"], "article_conflict") self.assertIn("passen nicht zusammen", response["message"]) def test_ambiguous_article_lists_repository_candidates_with_dimensions(self): service, _, conversation_id = self.create_service( new_interpretation( article_number=None, article_name_hint="Stex R 1801", ) ) response = service.handle_message(conversation_id, "Berechnen") self.assertEqual(response["status"], "needs_clarification") self.assertEqual(response["field"], "article") self.assertEqual(response["reason"], "ambiguous_article") self.assertEqual( response["candidates"], response["pending_clarification"]["candidates"], ) self.assertEqual(response["pending_clarification"], { "field": "article", "reason": "ambiguous_article", "candidates": [ { "article_number": "146900", "name": "Stex R 1801, 5,80 x 50 m", }, { "article_number": "146910", "name": "Stex R 1801, 6,00 x 50 m", }, ], }) self.assertEqual( response["message"], "Ich habe mehrere passende Artikel gefunden. Welchen meinst Du:\n" "146900 – Stex R 1801, 5,80 x 50 m\n" "oder\n" "146910 – Stex R 1801, 6,00 x 50 m?", ) def test_product_alias_clarification_uses_repository_candidates_only(self): service, _, conversation_id = self.create_service( new_interpretation( article_number=None, article_name_hint="Secutex R501", ) ) response = service.handle_message( conversation_id, "Welchen Durchmesser hat eine Secutex R501 mit 100m Länge?", ) self.assertEqual(response["status"], "needs_clarification") candidates = response["pending_clarification"]["candidates"] self.assertIn( { "article_number": "214507", "name": "Stex R 501 (Betonvlies), 5,20 x 100 m", }, candidates, ) self.assertTrue( all(candidate["article_number"].isdigit() for candidate in candidates) ) def test_article_candidate_number_followup_skips_ollama(self): service, client, conversation_id = self.create_service( new_interpretation( article_number=None, article_name_hint="Stex R 1801", ) ) service.handle_message(conversation_id, "Berechnen") response = service.handle_message(conversation_id, "146900") self.assertEqual(len(client.calls), 1) self.assertEqual(response["status"], "success") self.assertEqual( response["interpretation"], {"status": "parsed", "field": "article", "value": "146900"}, ) self.assertEqual(response["result"]["resolved_article"]["number"], "146900") self.assertIsNone( service.conversation_state(conversation_id).pending_clarification ) def test_invalid_article_candidate_keeps_pending_state_without_ollama(self): service, client, conversation_id = self.create_service( new_interpretation( article_number=None, article_name_hint="Stex R 1801", ) ) service.handle_message(conversation_id, "Berechnen") before = service.conversation_state(conversation_id) response = service.handle_message(conversation_id, "999999") after = service.conversation_state(conversation_id) self.assertEqual(len(client.calls), 1) self.assertEqual(response["status"], "needs_clarification") self.assertEqual( response["interpretation"], {"status": "incompatible", "field": "article"}, ) self.assertEqual(after, before) def test_unresolved_article_ambiguity_does_not_mutate_valid_state(self): service, _, conversation_id = self.create_service( new_interpretation(), { "intent": "modify_calculation", "changes": {"article_name_hint": "Stex R 1801"}, }, ) service.handle_message(conversation_id, "Berechnen") before = service.conversation_state(conversation_id) response = service.handle_message(conversation_id, "Nimm Stex R 1801") after = service.conversation_state(conversation_id) self.assertEqual(response["status"], "needs_clarification") self.assertEqual(after.calculation_state, before.calculation_state) self.assertEqual(after.last_result, before.last_result) self.assertEqual(after.pending_fields, ("article",)) def test_unknown_article_name_has_deterministic_not_found_message(self): service, _, conversation_id = self.create_service( new_interpretation( article_number=None, article_name_hint="Nicht vorhandenes Produkt XYZ", ) ) response = service.handle_message(conversation_id, "Berechnen") self.assertEqual(response["status"], "article_not_found") self.assertEqual( response["message"], "Der angegebene Artikel wurde nicht gefunden.", ) def test_numeric_message_values_come_from_calculation_result(self): service, _, conversation_id = self.create_service(new_interpretation()) response = service.handle_message(conversation_id, "Berechnen") calculation = response["result"]["calculation"] self.assertIn( f"Minimum: {calculation['minimum_diameter_mm']:.1f} mm", response["message"], ) self.assertIn( f"Mittelwert: {calculation['average_diameter_mm']:.1f} mm", response["message"], ) self.assertIn( f"Maximum: {calculation['maximum_diameter_mm']:.1f} mm", response["message"], ) def test_ollama_failure_is_controlled_and_does_not_mutate_state(self): service, _, conversation_id = self.create_service( OllamaUnavailableError("offline") ) response = service.handle_message(conversation_id, "Berechnen") record = service.conversation_state(conversation_id) self.assertEqual(response["status"], "nlu_error") self.assertEqual(response["error"], "OllamaUnavailableError") self.assertIsNone(record.calculation_state) self.assertIsNone(record.last_result) if __name__ == "__main__": unittest.main()