feat: add conversational RollCalc assistant
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user