gateway/modules/datamodels/datamodelUtils.py
2026-04-11 19:44:58 +02:00

134 lines
5 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Utility datamodels: Prompt, TextMultilingual."""
import re as _re
from typing import Any, Dict, Optional
from pydantic import BaseModel, Field, field_validator
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.i18nRegistry import i18nModel
import uuid
@i18nModel("Prompt")
class Prompt(PowerOnModel):
"""Benutzer- oder System-Prompt fuer die KI."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
mandateId: str = Field(
default="",
description="ID of the mandate this prompt belongs to",
json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
isSystem: bool = Field(
default=False,
description="System prompt visible to all users (read-only for non-SysAdmin)",
json_schema_extra={"label": "System", "frontend_type": "boolean", "frontend_readonly": True, "frontend_required": False},
)
content: str = Field(
description="Content of the prompt",
json_schema_extra={"label": "Inhalt", "frontend_type": "textarea", "frontend_readonly": False, "frontend_required": True},
)
name: str = Field(
description="Name of the prompt",
json_schema_extra={"label": "Name", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True},
)
@field_validator('isSystem', mode='before')
@classmethod
def _coerceIsSystem(cls, v):
if v is None:
return False
return v
class TextMultilingual(BaseModel):
"""Multilingual text field stored as JSONB: {"xx": "source text", "de": "...", "en": "...", ...}.
- xx = source/default text (required). Same role as xx in the UI i18n system.
- All language codes (de, en, fr, ...) are dynamic, populated via batch translation.
- No hardcoded language fields. The DB column is JSONB with arbitrary keys.
"""
model_config = {"extra": "allow"}
xx: str = Field(description="Source/default text (required)")
@field_validator('xx')
@classmethod
def _validateXxRequired(cls, v):
if not v or not v.strip():
raise ValueError("Source text (xx) is required and cannot be empty")
return v
def model_dump(self, **kwargs) -> Dict[str, str]:
result = {"xx": self.xx}
if self.__pydantic_extra__:
for k, v in self.__pydantic_extra__.items():
if v is not None and isinstance(v, str):
result[k] = v
return result
@classmethod
def from_dict(cls, data: Dict[str, str]) -> 'TextMultilingual':
cleaned = {k: v for k, v in data.items() if v is not None and isinstance(v, str)}
if not cleaned.get('xx'):
cleaned['xx'] = cleaned.get('de') or next((v for v in cleaned.values() if v), '')
return cls(**cleaned)
def get_text(self, lang: str = 'de') -> str:
"""Get text for a language. Falls back to xx (source text)."""
if lang == 'xx':
return self.xx
extra = self.__pydantic_extra__ or {}
value = extra.get(lang)
if value and isinstance(value, str):
return value
return self.xx
@classmethod
def fromUniform(cls, text: str) -> "TextMultilingual":
"""Create with source text only. Languages are populated by batch translation."""
t = text.strip()
if not t:
raise ValueError("Text must be non-empty")
return cls(xx=t)
_REPR_PATTERN = _re.compile(r"(\w+)='([^']*)'")
def _parseReprString(s: str) -> Optional[Dict[str, str]]:
"""Parse a Pydantic repr string like "en='text' de=None fr=" into a dict."""
matches = _REPR_PATTERN.findall(s)
if not matches:
return None
result = {}
for code, text in matches:
if len(code) <= 5 and text:
result[code] = text
return result if result else None
def coerce_text_multilingual(val: Any) -> TextMultilingual:
"""Normalize str, dict, or TextMultilingual into a valid TextMultilingual instance."""
if isinstance(val, TextMultilingual):
return val
if isinstance(val, dict):
if not val:
return TextMultilingual.fromUniform("")
cleaned = {k: v for k, v in val.items() if v is not None and isinstance(v, str)}
if not cleaned.get("xx"):
cleaned["xx"] = cleaned.get("de") or next((v for v in cleaned.values() if v), "")
return TextMultilingual(**cleaned)
if isinstance(val, str) and val.strip():
parsed = _parseReprString(val)
if parsed:
if not parsed.get("xx"):
parsed["xx"] = parsed.get("de") or next((v for v in parsed.values() if v), val.strip())
return TextMultilingual(**parsed)
return TextMultilingual.fromUniform(val)
return TextMultilingual.fromUniform("")