dodil/erp-brick-hr

src/brick_hr/infrastructure/models_adapter.py

"""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"]