feat: add conversational RollCalc assistant

This commit is contained in:
2026-08-29 22:37:56 +02:00
parent 3b44048250
commit fa3000b325
25 changed files with 6820 additions and 48 deletions
+852
View File
@@ -0,0 +1,852 @@
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()