"""Adapter: DODIL Models for draft steps (context map: a small adapter under the billed ignite.model-user role,
granted deliberately).
OpenAI-compatible chat completions at https://api.dodil.io/v1, authorised by the brick's service-account
client_credentials token from https://id.dodil.io/realms/dodil/protocol/openid-connect/token (DODIL_SERVICE_
ACCOUNT_ID / _SECRET in the runtime env; locally your login token, DODIL_TOKEN). The model is a settings row
(ai_model, default kimi-k2.6).
It is given ONLY the draft instruction's allowed fields (configuration.DRAFTABLE) — never a field from the
Sensitive aggregate: `draft()` refuses anything else before a byte leaves. Any failure or timeout raises
DraftFailed, which the engine turns into a manual task "write it by hand"; the run goes on.
"""
import json
import os
from typing import Optional
import httpx
from brick_hr.domain.configuration import DRAFTABLE
BASE_URL = os.environ.get("MODELS_BASE_URL", "https://api.dodil.io/v1").rstrip("/")
TIMEOUT = float(os.environ.get("MODELS_TIMEOUT", "30"))
class DraftFailed(Exception):
"""No draft: the step becomes a manual task. The message says why, in words."""
def _token() -> str:
from erp_platform.sa_token import get_pg_password
try:
return get_pg_password()
except KeyError:
raise DraftFailed("no Models credential (DODIL_SERVICE_ACCOUNT_ID/SECRET, or DODIL_TOKEN locally)") from None
class ModelsDrafter:
def draft(self, instructions: str, fields: dict, model: str) -> str:
bad = sorted(set(fields) - set(DRAFTABLE))
if bad:
raise DraftFailed(f"refused before sending: a draft is never given {', '.join(bad)}")
body = {"model": model, "temperature": 0.4, "messages": [
{"role": "system", "content": "You write short, warm, specific workplace messages. Use only the facts "
"given. Plain text, no placeholders, no invented details."},
{"role": "user", "content": f"{instructions}\n\nFacts:\n{json.dumps(fields, ensure_ascii=False, default=str)}"}]}
try:
r = httpx.post(f"{BASE_URL}/chat/completions", json=body, timeout=TIMEOUT,
headers={"Authorization": f"Bearer {_token()}"})
except httpx.HTTPError as e:
raise DraftFailed(f"Models unreachable: {type(e).__name__}") from None
if r.status_code >= 300:
raise DraftFailed(f"Models answered {r.status_code}")
data = r.json()
data = data.get("data", data) if isinstance(data, dict) else data # the gateway may wrap it in "data"
try:
text = data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError):
raise DraftFailed("Models answered without a draft") from None
text = (text or "").strip()
if not text:
raise DraftFailed("Models answered an empty draft")
return text
DRAFTER: Optional[object] = ModelsDrafter()
__all__ = ["ModelsDrafter", "DraftFailed", "DRAFTER"]