853 lines
33 KiB
Python
853 lines
33 KiB
Python
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()
|