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
+812
View File
@@ -0,0 +1,812 @@
"""Application-owned conversational workflow around deterministic RollCalc APIs."""
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
import math
import secrets
from threading import Lock
from typing import Any
from core_presets import CorePreset, core_family, matching_core_presets
from ollama_nlu import (
ClarificationInterpretation,
NLUError,
NLUInterpretation,
NLUResponse,
OllamaNLUClient,
parse_clarification_reply,
)
from roll_calculation import calculate_roll, modify_calculation
class ConversationNotFoundError(KeyError):
"""Raised when an in-memory conversation ID is unknown."""
class ConversationReportNotFoundError(KeyError):
"""Raised when an in-memory report ID is unknown."""
@dataclass(frozen=True)
class ArticleCandidate:
article_number: str
name: str
def to_dict(self) -> dict[str, str]:
return {
"article_number": self.article_number,
"name": self.name,
}
@dataclass(frozen=True)
class PendingClarification:
field: str
reason: str
core_family: str | None = None
candidates: tuple[CorePreset, ...] = ()
article_candidates: tuple[ArticleCandidate, ...] = ()
requested_changes: dict[str, Any] = field(default_factory=dict)
@property
def allowed_values(self) -> tuple[float, ...]:
return tuple(preset.diameter_mm for preset in self.candidates)
def to_dict(self) -> dict[str, Any]:
result = {
"field": self.field,
"reason": self.reason,
"core_family": self.core_family,
"allowed_values": list(self.allowed_values),
"candidates": [
{
"key": preset.key,
"label": preset.label,
"diameter_mm": preset.diameter_mm,
"material": preset.material,
}
for preset in self.candidates
],
}
if self.requested_changes:
result["requested_changes"] = deepcopy(self.requested_changes)
if self.reason == "ambiguous_article":
result.pop("core_family")
result.pop("allowed_values")
result["candidates"] = [
candidate.to_dict() for candidate in self.article_candidates
]
return result
@dataclass
class ConversationRecord:
calculation_state: dict[str, Any] | None = None
last_result: dict[str, Any] | None = None
pending_clarification: PendingClarification | None = None
@property
def pending_fields(self) -> tuple[str, ...]:
if self.pending_clarification is None:
return ()
return (self.pending_clarification.field,)
@dataclass(frozen=True)
class StoredReport:
request: dict[str, Any]
conversation_id: str
class InMemoryConversationStore:
"""Process-local MVP storage; all values are copied across its boundary."""
def __init__(self) -> None:
self._conversations: dict[str, ConversationRecord] = {}
self._reports: dict[str, StoredReport] = {}
self._lock = Lock()
def create(self) -> str:
conversation_id = secrets.token_urlsafe(18)
with self._lock:
self._conversations[conversation_id] = ConversationRecord()
return conversation_id
def get(self, conversation_id: str) -> ConversationRecord:
with self._lock:
record = self._conversations.get(conversation_id)
if record is None:
raise ConversationNotFoundError(conversation_id)
return deepcopy(record)
def save(self, conversation_id: str, record: ConversationRecord) -> None:
with self._lock:
if conversation_id not in self._conversations:
raise ConversationNotFoundError(conversation_id)
self._conversations[conversation_id] = deepcopy(record)
def add_report(self, conversation_id: str, request: dict[str, Any]) -> str:
report_id = secrets.token_urlsafe(18)
with self._lock:
if conversation_id not in self._conversations:
raise ConversationNotFoundError(conversation_id)
self._reports[report_id] = StoredReport(
request=deepcopy(request),
conversation_id=conversation_id,
)
return report_id
def get_report(self, report_id: str) -> StoredReport:
with self._lock:
report = self._reports.get(report_id)
if report is None:
raise ConversationReportNotFoundError(report_id)
return deepcopy(report)
def _calculation_request(interpretation: NLUInterpretation) -> dict[str, Any]:
payload = interpretation.to_dict()
payload["intent"] = "calculate_roll_diameter"
return payload
def _has_valid_calculation(record: ConversationRecord) -> bool:
return (
record.calculation_state is not None
and isinstance(record.last_result, dict)
and record.last_result.get("status") == "success"
)
def _current_nlu_context(record: ConversationRecord) -> dict[str, Any] | None:
if not _has_valid_calculation(record):
return None
request = record.calculation_state.get("request", {})
if not isinstance(request, dict):
return None
fields = (
"article_number",
"article_name_hint",
"roll_length_m",
"width_m",
"core_type",
"core_diameter_mm",
"include_roll_weight",
)
resolved_article = record.last_result.get("resolved_article", {})
resolved_name = (
resolved_article.get("name")
if isinstance(resolved_article, dict)
else None
)
return {
"has_successful_calculation": True,
"resolved_article_name": resolved_name,
**{field: request.get(field) for field in fields},
}
def _new_calculation_followup_changes(
interpretation: NLUInterpretation,
record: ConversationRecord,
message: str,
) -> dict[str, Any] | None:
if interpretation.intent != "new_calculation" or not _has_valid_calculation(record):
return None
if interpretation.article_number is not None:
return None
if interpretation.article_name_hint is not None:
return None
normalized_message = " ".join(message.casefold().split())
if any(
marker in normalized_message
for marker in (
"neue berechnung",
"neue rollenberechnung",
"new calculation",
"start over",
)
):
return None
changes = {}
for field in (
"roll_length_m",
"width_m",
"core_type",
"core_diameter_mm",
):
value = getattr(interpretation, field)
if value is not None:
changes[field] = value
if interpretation.include_roll_weight:
changes["include_roll_weight"] = True
return changes or None
def _pending_clarification(
result: dict[str, Any],
*,
requested_changes: dict[str, Any] | None = None,
) -> PendingClarification | None:
missing = result.get("missing", [])
ambiguous = result.get("ambiguous", [])
for field in (
"width_m",
"core_diameter_mm",
"roll_length_m",
"thickness_mm",
"area_weight_g_m2",
):
if field in missing:
return PendingClarification(
field,
"missing_parameter",
requested_changes=deepcopy(requested_changes or {}),
)
if "core_type" in ambiguous:
request = result.get("request", {})
core_type = request.get("core_type") if isinstance(request, dict) else None
if isinstance(core_type, str):
candidates = matching_core_presets(core_type)
family = core_family(core_type)
if len(candidates) > 1 and family is not None:
return PendingClarification(
field="core_diameter_mm",
reason="ambiguous_core_preset",
core_family=family,
candidates=candidates,
requested_changes=deepcopy(requested_changes or {}),
)
return PendingClarification(
"core_diameter_mm",
"ambiguous_core_type",
requested_changes=deepcopy(requested_changes or {}),
)
if "article" in ambiguous or any(
field in ambiguous for field in ("article_name_hint", "article_number")
):
resolution = result.get("article_resolution", {})
raw_candidates = (
resolution.get("candidates", [])
if isinstance(resolution, dict)
else []
)
candidates = tuple(
ArticleCandidate(
article_number=item["article_number"],
name=item["name"],
)
for item in raw_candidates
if isinstance(item, dict)
and isinstance(item.get("article_number"), str)
and isinstance(item.get("name"), str)
)
return PendingClarification(
"article",
"ambiguous_article",
article_candidates=candidates,
requested_changes=deepcopy(requested_changes or {}),
)
return None
def _format_diameter_choices(values: tuple[float, ...]) -> str:
labels = [f"{value:g} mm" for value in values]
if len(labels) < 2:
return labels[0] if labels else ""
if len(labels) == 2:
return f"{labels[0]} oder {labels[1]}"
return f"{', '.join(labels[:-1])} und {labels[-1]}"
def _core_family_names(family: str | None) -> tuple[str, str]:
if family == "steel":
return "Stahlkern", "Stahlkerne"
if family == "pvc":
return "PVC-Kern", "PVC-Kerne"
return "Kern", "Kerne"
def ambiguous_core_message(pending: PendingClarification) -> str:
_, plural = _core_family_names(pending.core_family)
choices = _format_diameter_choices(pending.allowed_values)
return (
f"Es sind mehrere {plural} hinterlegt. "
f"Welchen Kerndurchmesser soll ich verwenden: {choices}?"
)
def unavailable_core_diameter_message(
pending: PendingClarification,
value: float,
) -> str:
singular, _ = _core_family_names(pending.core_family)
choices = _format_diameter_choices(pending.allowed_values).replace(
" oder ",
" und ",
)
return (
f"{value:g} mm ist für einen {singular} nicht hinterlegt. "
f"Verfügbar sind {choices}."
)
def ambiguous_article_message(pending: PendingClarification) -> str:
choices = "\noder\n".join(
f"{candidate.article_number} – {' '.join(candidate.name.split())}"
for candidate in pending.article_candidates
)
return (
"Ich habe mehrere passende Artikel gefunden. Welchen meinst Du:\n"
f"{choices}?"
)
def incompatible_clarification_message(field: str) -> str:
messages = {
"core_diameter_mm": (
"Ich benötige den Kerndurchmesser in Millimetern, z. B. 150 mm."
),
"roll_length_m": "Ich benötige die Rollenlänge in Metern, z. B. 80 m.",
"width_m": "Ich benötige die Breite in Metern, z. B. 4,90 m.",
"thickness_mm": "Ich benötige die Materialdicke in Millimetern.",
"area_weight_g_m2": "Ich benötige das Flächengewicht in g/m².",
"article_number": "Bitte gib die genaue Artikelnummer an.",
"article": "Bitte wähle eine der genannten Artikelnummern aus.",
}
return messages.get(field, "Die Antwort passt nicht zur angeforderten Angabe.")
def clarification_message(
result: dict[str, Any],
pending: PendingClarification | None = None,
) -> str:
"""Map deterministic result statuses to deterministic German messages."""
status = result.get("status")
if status == "article_not_found":
return "Der angegebene Artikel wurde nicht gefunden."
if status == "article_conflict":
return (
"Artikelnummer und Produktbezeichnung passen nicht zusammen. "
"Bitte prüfe die Angaben."
)
if status == "invalid_parameter":
invalid = result.get("invalid", [])
if invalid and isinstance(invalid[0], dict):
field = invalid[0].get("field", "Eingabe")
detail = invalid[0].get("message", "ungültiger Wert")
return f"Die Eingabe für {field} ist ungültig: {detail}."
return "Die Eingaben sind ungültig. Bitte prüfe die Angaben."
missing = result.get("missing", [])
ambiguous = result.get("ambiguous", [])
if "width_m" in missing:
return (
"Für diesen Artikel ist keine eindeutige Breite verfügbar. "
"Welche Breite soll ich verwenden?"
)
if "core_diameter_mm" in missing:
return "Welchen Kerndurchmesser soll ich verwenden?"
if "roll_length_m" in missing:
return "Welche Rollenlänge soll ich verwenden?"
if "thickness_mm" in missing:
return (
"Für diese Berechnung ist keine Materialdicke verfügbar. "
"Bitte gib die Materialdicke an."
)
if "area_weight_g_m2" in missing:
return (
"Für die Gewichtsberechnung ist kein Flächengewicht verfügbar. "
"Bitte gib das Flächengewicht an."
)
if "core_type" in ambiguous:
if pending is not None and pending.reason == "ambiguous_core_preset":
return ambiguous_core_message(pending)
request = result.get("request", {})
core_type = request.get("core_type") if isinstance(request, dict) else None
family = core_family(core_type) if isinstance(core_type, str) else None
if family == "steel":
return (
"Es sind mehrere Stahlkern-Presets vorhanden. "
"Bitte gib den Kerndurchmesser an."
)
if family == "pvc":
return (
"Es sind mehrere PVC-Kern-Presets vorhanden. "
"Bitte gib den Kerndurchmesser an."
)
return "Der Kerntyp ist nicht eindeutig. Bitte gib den Kerndurchmesser an."
if pending is not None and pending.reason == "ambiguous_article":
return ambiguous_article_message(pending)
if any(
field in ambiguous
for field in ("article", "article_name_hint", "article_number")
):
return "Der Artikel ist nicht eindeutig. Bitte gib die genaue Artikelnummer an."
return "Für die Berechnung fehlen eindeutige Angaben. Bitte ergänze die Eingaben."
def success_message(result: dict[str, Any], pdf_url: str) -> str:
"""Render only authoritative result values using a fixed response template."""
article = result.get("resolved_article") or {}
calculation = result["calculation"]
number = article.get("number") or "ohne Artikelnummer"
name = article.get("name") or "ohne Produktbezeichnung"
lines = [
"Berechnung abgeschlossen.",
"",
f"Artikel: {name} ({number})",
f"Rollenlänge: {calculation['effective_roll_length_m']:.2f} m",
]
effective_width = result.get("effective_inputs", {}).get("width_m")
if isinstance(effective_width, dict) and effective_width.get("value") is not None:
lines.append(f"Breite: {effective_width['value']:.2f} m")
lines.extend((
f"Minimum: {calculation['minimum_diameter_mm']:.1f} mm",
f"Mittelwert: {calculation['average_diameter_mm']:.1f} mm",
f"Maximum: {calculation['maximum_diameter_mm']:.1f} mm",
))
if calculation.get("roll_weight_kg") is not None:
lines.append(f"Rollengewicht: {calculation['roll_weight_kg']:.1f} kg")
for warning in result.get("warnings", []):
lines.append(f"Warnung: {warning['title']} – {warning['message']}")
for note in result.get("notes", []):
lines.append(f"Hinweis: {note}")
lines.extend(("", f"PDF: {pdf_url}"))
return "\n".join(lines)
class ConversationService:
def __init__(
self,
nlu_client: OllamaNLUClient,
*,
store: InMemoryConversationStore | None = None,
build_info: dict[str, str] | None = None,
) -> None:
self.nlu_client = nlu_client
self.store = store or InMemoryConversationStore()
self.build_info = build_info or {}
def create_conversation(self) -> str:
return self.store.create()
def conversation_state(self, conversation_id: str) -> ConversationRecord:
return self.store.get(conversation_id)
def report_request(self, report_id: str) -> dict[str, Any]:
return self.store.get_report(report_id).request
def handle_message(
self,
conversation_id: str,
message: str,
*,
include_diagnostics: bool = False,
) -> dict[str, Any]:
record = self.store.get(conversation_id)
clarification: ClarificationInterpretation | None = None
nlu: NLUResponse | None = None
applied_as_modification = False
pending = record.pending_clarification
had_valid_result = (
isinstance(record.last_result, dict)
and record.last_result.get("status") == "success"
)
if pending is not None:
expected_field = pending.field
selected_article: ArticleCandidate | None = None
if pending.reason == "ambiguous_article":
article_number = message.strip()
selected_article = next(
(
candidate
for candidate in pending.article_candidates
if candidate.article_number == article_number
),
None,
)
clarification = ClarificationInterpretation(
"parsed" if selected_article is not None else "incompatible",
expected_field,
selected_article.article_number if selected_article else None,
)
else:
clarification = parse_clarification_reply(message, expected_field)
if clarification.status != "parsed":
response = {
"status": "needs_clarification",
"message": (
ambiguous_article_message(pending)
if pending.reason == "ambiguous_article"
else incompatible_clarification_message(expected_field)
),
"clarification": clarification.to_dict(),
"pending_clarification": pending.to_dict(),
"result": record.last_result,
}
return self._with_clarification(
response,
clarification,
include_diagnostics,
)
if record.calculation_state is None:
return {
"status": "needs_calculation",
"message": (
"Es gibt noch keine Berechnung, die ergänzt werden kann. "
"Bitte starte zuerst eine neue Berechnung."
),
}
if pending.reason == "ambiguous_article":
changes = dict(pending.requested_changes)
changes["article_number"] = clarification.value
else:
changes = dict(pending.requested_changes)
changes[expected_field] = clarification.value
if pending.reason == "ambiguous_core_preset":
preset = next(
(
candidate
for candidate in pending.candidates
if math.isclose(
candidate.diameter_mm,
clarification.value,
)
),
None,
)
if preset is None:
response = {
"status": "needs_clarification",
"message": unavailable_core_diameter_message(
pending,
clarification.value,
),
"clarification": {
"status": "invalid_value",
"field": pending.field,
"value": clarification.value,
},
"pending_clarification": pending.to_dict(),
"result": record.last_result,
}
return self._with_clarification(
response,
clarification,
include_diagnostics,
)
changes.update({
"core_type": preset.label,
"core_diameter_mm": preset.diameter_mm,
})
elif expected_field == "core_diameter_mm":
current_request = record.calculation_state.get("request", {})
current_core_type = current_request.get("core_type")
if current_core_type is not None:
changes["core_type"] = current_core_type
result = modify_calculation(
record.calculation_state,
changes,
build_info=self.build_info,
)
applied_as_modification = True
else:
try:
nlu = self.nlu_client.interpret(
message,
has_state=record.calculation_state is not None,
expected_fields=(),
current_state=_current_nlu_context(record),
)
except NLUError as error:
return {
"status": "nlu_error",
"error": error.__class__.__name__,
"message": (
"Die Spracheingabe konnte nicht sicher verarbeitet werden. "
"Bitte versuche es erneut oder formuliere die Angaben expliziter."
),
}
interpretation = nlu.interpretation
if interpretation.intent == "unsupported":
response = {
"status": "unsupported",
"message": (
"Diese Anfrage wird derzeit nicht unterstützt. Möglich sind neue "
"Rollendurchmesser-Berechnungen und Änderungen einer bestehenden "
"Berechnung."
),
}
return self._with_nlu(response, nlu, include_diagnostics)
followup_changes = _new_calculation_followup_changes(
interpretation,
record,
message,
)
if followup_changes is not None:
result = modify_calculation(
record.calculation_state,
followup_changes,
build_info=self.build_info,
)
applied_as_modification = True
elif interpretation.intent == "new_calculation":
result = calculate_roll(
_calculation_request(interpretation),
build_info=self.build_info,
)
else:
if record.calculation_state is None:
response = {
"status": "needs_calculation",
"message": (
"Es gibt noch keine Berechnung, die geändert werden kann. "
"Bitte starte zuerst eine neue Berechnung."
),
}
return self._with_nlu(response, nlu, include_diagnostics)
changes = interpretation.changes or {}
current_request = record.calculation_state.get("request", {})
current_article = current_request.get("article_number")
different_article = (
"article_number" in changes
and changes["article_number"] != current_article
) or "article_name_hint" in changes
if different_article:
new_request = {
"intent": "calculate_roll_diameter",
**{
field: changes[field]
for field in ("article_number", "article_name_hint")
if field in changes
},
}
result = calculate_roll(
new_request,
build_info=self.build_info,
)
else:
result = modify_calculation(
record.calculation_state,
changes,
build_info=self.build_info,
)
applied_as_modification = True
if clarification is not None and result.get("status") == "invalid_parameter":
response = {
"status": "needs_clarification",
"message": incompatible_clarification_message(clarification.field),
"clarification": clarification.to_dict(),
"result": result,
}
return self._with_clarification(
response,
clarification,
include_diagnostics,
)
pending_changes = None
if applied_as_modification:
if clarification is not None:
pending_changes = changes
elif nlu is not None and nlu.interpretation.intent == "modify_calculation":
pending_changes = nlu.interpretation.changes
else:
pending_changes = followup_changes
new_pending = _pending_clarification(
result,
requested_changes=pending_changes,
)
preserve_valid_state = (
had_valid_result
and (
(
new_pending is not None
and new_pending.reason == "ambiguous_article"
)
or (
applied_as_modification
and (
(
new_pending is not None
and new_pending.reason
in {
"ambiguous_core_preset",
"ambiguous_core_type",
"ambiguous_article",
}
)
or result.get("status")
in {
"invalid_parameter",
"article_not_found",
"article_conflict",
}
)
)
)
)
if not preserve_valid_state:
state = result.get("state")
if isinstance(state, dict):
record.calculation_state = state
record.last_result = result
record.pending_clarification = new_pending
self.store.save(conversation_id, record)
if result.get("status") != "success":
response = {
"status": result.get("status", "invalid_parameter"),
"message": clarification_message(result, new_pending),
"result": result,
}
if new_pending is not None:
pending_context = new_pending.to_dict()
response["pending_clarification"] = pending_context
if new_pending.reason == "ambiguous_article":
response.update({
"field": pending_context["field"],
"reason": pending_context["reason"],
"candidates": pending_context["candidates"],
})
if clarification is not None:
return self._with_clarification(
response,
clarification,
include_diagnostics,
)
return self._with_nlu(response, nlu, include_diagnostics)
report_id = self.store.add_report(conversation_id, result["request"])
pdf_url = f"/api/conversations/reports/{report_id}.pdf"
response = {
"status": "success",
"message": success_message(result, pdf_url),
"result": result,
"pdf": {"report_id": report_id, "url": pdf_url},
}
if clarification is not None:
return self._with_clarification(
response,
clarification,
include_diagnostics,
)
return self._with_nlu(response, nlu, include_diagnostics)
@staticmethod
def _with_nlu(
response: dict[str, Any],
nlu: NLUResponse,
include_diagnostics: bool,
) -> dict[str, Any]:
response["interpretation"] = nlu.interpretation.to_dict()
response["nlu"] = {
"model": nlu.model,
"latency_ms": round(nlu.latency_ms, 1),
}
if include_diagnostics:
response["nlu"]["raw_model_json"] = nlu.raw_model_json
return response
@staticmethod
def _with_clarification(
response: dict[str, Any],
clarification: ClarificationInterpretation,
include_diagnostics: bool,
) -> dict[str, Any]:
response["interpretation"] = clarification.to_dict()
response["nlu"] = {
"model": "deterministic_clarification_parser",
"latency_ms": 0.0,
}
if include_diagnostics:
response["nlu"]["raw_model_json"] = None
return response