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