dachdigitalwerksorg/kanvaflow

KanvaFlow — Remixable team task board with RBAC, drag-and-drop Kanban, dependencies, Dodil Models AI, and MCP tools.

A remix template · 0 remixes · Remix dachdigitalwerksorg/kanvaflow

app/boards/infrastructure/dodil.py

"""Dodil Cloud Adapters: service-account OAuth2 token, DataK3 Postgres wire, and Dodil Models AI.

All credentials come from environment variables populated automatically by `.dodil/remix.yaml`
and `.dodil/deploy.yaml` (`DODIL_SERVICE_ACCOUNT_ID`, `DODIL_SERVICE_ACCOUNT_SECRET`, `BUCKET`).
"""
import os
import threading
import time
from typing import Optional

import httpx
import psycopg

TOKEN_URL = "https://id.dodil.io/realms/dodil/protocol/openid-connect/token"
PG_HOST = os.environ.get("PG_HOST", "pg.uk-lon-1.dodil.io")
MODELS_BASE = os.environ.get("MODELS_BASE", "https://api.dodil.io/v1").rstrip("/")
CHAT_MODEL = os.environ.get("CHAT_MODEL", "kimi-k2.6")
EMBED_MODEL = os.environ.get("EMBED_MODEL", "jina-embeddings-v4")

_lock, _tok = threading.Lock(), {"v": None, "exp": 0.0}


def token() -> str:
    """Short-lived service-account token (client_credentials), cached until 60s before expiry."""
    with _lock:
        if _tok["v"] and time.time() < _tok["exp"] - 60:
            return _tok["v"]
        r = httpx.post(
            TOKEN_URL,
            timeout=15,
            data={
                "grant_type": "client_credentials",
                "client_id": os.environ["DODIL_SERVICE_ACCOUNT_ID"],
                "client_secret": os.environ["DODIL_SERVICE_ACCOUNT_SECRET"],
            },
        )
        r.raise_for_status()
        d = r.json()
        _tok.update(v=d["access_token"], exp=time.time() + float(d.get("expires_in", 300)))
        return _tok["v"]


def connect() -> psycopg.Connection:
    """Connect to DataK3 over the Postgres wire (`sslmode=require`, database = bucket name)."""
    return psycopg.connect(
        host=PG_HOST,
        port=5432,
        dbname=os.environ["BUCKET"],
        user="token",
        password=token(),
        sslmode="require",
        autocommit=True,
    )


def chat(prompt: str) -> str:
    """Call Dodil Models chat completion (`kimi-k2.6`), unwrapping the gateway envelope."""
    r = httpx.post(
        f"{MODELS_BASE}/chat/completions",
        timeout=90,
        headers={"Authorization": f"Bearer {token()}"},
        json={"model": CHAT_MODEL, "messages": [{"role": "user", "content": prompt}]},
    )
    r.raise_for_status()
    body = r.json()
    inner = body.get("data")
    if isinstance(inner, dict) and "choices" in inner:
        body = inner
    return body["choices"][0]["message"]["content"] or ""


def embed_text(text: str) -> Optional[list[float]]:
    """Call Dodil Models embeddings (`jina-embeddings-v4`), unwrapping the gateway envelope."""
    clean = (text or "").strip()
    if not clean:
        return None
    r = httpx.post(
        f"{MODELS_BASE}/embeddings",
        timeout=25,
        headers={"Authorization": f"Bearer {token()}"},
        json={"model": EMBED_MODEL, "input": clean[:2000]},
    )
    r.raise_for_status()
    body = r.json()
    inner = body.get("data")
    if isinstance(inner, dict) and "data" in inner:
        body = inner
    arr = body.get("data") or []
    if arr and isinstance(arr[0], dict) and isinstance(arr[0].get("embedding"), list):
        return [float(x) for x in arr[0]["embedding"]]
    return None


class ModelsAssistant:
    def checklist(self, card_title: str, description: str = "") -> list[str]:
        ctx = f"Task: {card_title}"
        if description:
            ctx += f"\nContext: {description}"
        text = chat(
            "Break this task into a concise, actionable checklist of 3 to 6 concrete steps. "
            "Output one step per line, no numbering, no markdown intro or outro.\n\n" + ctx
        )
        return [line.strip("-*•0123456789.) ").strip() for line in text.splitlines() if line.strip()][:8]

    def summarize_board(self, board_title: str, snapshot: str) -> str:
        return chat(
            f"Write a crisp 3-5 sentence daily standup summary for the team board {board_title!r}. "
            "Highlight overall momentum, call out any overdue or blocked items with owners, "
            "and suggest the single highest-leverage focus for today.\n\n" + snapshot
        ).strip()

    def embed(self, text: str) -> Optional[list[float]]:
        return embed_text(text)