Files
RollCalcPython/tests/test_conversation_service.py
T

853 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()