Catalyst / admin/Apex 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Apex

public

Bid Management and Orchestration Tool with AI Capability

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
Apex / Synapse-Apexv2 / backend / app / services / ai.py 7274 B · main
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
"""Anthropic AI service with graceful degradation.

When ANTHROPIC_API_KEY is unset the service returns clearly-labelled
placeholder output so the whole app remains usable offline (blueprint §10).
All AI output is a *draft* for human review (blueprint §7 guardrails).
"""
from __future__ import annotations

import json
import time

from app.core.config import settings

PROMPT_VERSION = "v1"

try:  # SDK optional at import time
    import anthropic
except Exception:  # pragma: no cover
    anthropic = None


def ai_available() -> bool:
    return settings.ai_enabled and anthropic is not None


def _client():
    return anthropic.Anthropic(api_key=settings.anthropic_api_key)


def _extract_text(message) -> str:
    parts = []
    for block in message.content:
        if getattr(block, "type", None) == "text":
            parts.append(block.text)
    return "".join(parts).strip()


def _usage(message) -> dict:
    u = getattr(message, "usage", None)
    if not u:
        return {}
    return {
        "input_tokens": getattr(u, "input_tokens", 0),
        "output_tokens": getattr(u, "output_tokens", 0),
        "cache_read_input_tokens": getattr(u, "cache_read_input_tokens", 0),
    }


def _classify_error(exc: Exception) -> str:
    """Turn an SDK/API exception into a short human-readable reason."""
    if anthropic is not None:
        if isinstance(exc, getattr(anthropic, "AuthenticationError", ())):
            return "Invalid API key (authentication failed)."
        if isinstance(exc, getattr(anthropic, "PermissionDeniedError", ())):
            return "API key lacks permission for this model."
        if isinstance(exc, getattr(anthropic, "NotFoundError", ())):
            return f"Model '{settings.anthropic_model}' not found for this key."
        if isinstance(exc, getattr(anthropic, "RateLimitError", ())):
            return "Rate limited — try again shortly."
        if isinstance(exc, getattr(anthropic, "APIConnectionError", ())):
            return "Cannot reach the Anthropic API (network/connectivity)."
    return f"{type(exc).__name__}: {exc}"


# --- health / status ------------------------------------------------------
_status_cache: dict = {"ts": 0.0, "result": None}
_STATUS_TTL = 60.0


def check_status(force: bool = False) -> dict:
    """Actively verify the AI is connected and working with a tiny live call.

    Returns {status: connected|disabled|error, model, detail}. Cached ~60s.
    """
    if not settings.ai_enabled:
        return {"status": "disabled", "model": settings.anthropic_model,
                "detail": "No ANTHROPIC_API_KEY configured."}
    if anthropic is None:
        return {"status": "error", "model": settings.anthropic_model,
                "detail": "anthropic SDK is not installed."}

    now = time.time()
    if not force and _status_cache["result"] and (now - _status_cache["ts"] < _STATUS_TTL):
        return _status_cache["result"]

    try:
        client = _client()
        # Minimal, cheap live call — proves key + model + connectivity end to end.
        client.messages.create(
            model=settings.anthropic_model,
            max_tokens=8,
            messages=[{"role": "user", "content": "ping"}],
        )
        result = {"status": "connected", "model": settings.anthropic_model,
                  "detail": "Connected and responding."}
    except Exception as exc:  # noqa: BLE001
        result = {"status": "error", "model": settings.anthropic_model,
                  "detail": _classify_error(exc)}

    _status_cache.update(ts=now, result=result)
    return result


def invalidate_status() -> None:
    _status_cache.update(ts=0.0, result=None)


# --- generation -----------------------------------------------------------
def generate_text(system: str, prompt: str, max_tokens: int = 4000,
                  effort: str = "high") -> tuple[str, dict]:
    """Long-form generation. Returns (text, meta). Raises on real API errors."""
    if not ai_available():
        return (
            "[AI unavailable — placeholder draft. Configure ANTHROPIC_API_KEY to "
            "generate real content. A human author should complete this section.]",
            {"model": "none", "prompt_version": PROMPT_VERSION, "usage": {}},
        )
    client = _client()
    with client.messages.stream(
        model=settings.anthropic_model,
        max_tokens=max_tokens,
        system=system,
        thinking={"type": "adaptive"},
        output_config={"effort": effort},
        messages=[{"role": "user", "content": prompt}],
    ) as stream:
        message = stream.get_final_message()
    return _extract_text(message), {
        "model": settings.anthropic_model,
        "prompt_version": PROMPT_VERSION,
        "usage": _usage(message),
    }


def assistant_json(system: str, prompt: str, max_tokens: int = 6000) -> tuple[dict, dict]:
    """Assistant advisory call → JSON object. Placeholder when AI unavailable.

    Uses lower effort for snappier interactive responses.
    """
    if not ai_available():
        return (
            {
                "advice": [
                    "AI Assistant is unavailable because no ANTHROPIC_API_KEY is configured. "
                    "Guidance and hallucination self-checking cannot run until AI is connected."
                ],
                "enhanced_text": "",
                "unsupported_claims": [],
                "cleaned_text": "",
            },
            {"model": "none", "prompt_version": PROMPT_VERSION, "usage": {}},
        )
    data, meta = extract_json(system, prompt, max_tokens=max_tokens, effort="medium")
    if not isinstance(data, dict):
        data = {"advice": [], "enhanced_text": "", "unsupported_claims": [], "cleaned_text": ""}
    return data, meta


def extract_json(system: str, prompt: str, max_tokens: int = 8000,
                 effort: str = "high") -> tuple[list | dict, dict]:
    """Structured extraction. Asks the model for strict JSON and parses it.

    Streams so large max_tokens don't hit HTTP timeouts. Raises on real errors.
    """
    if not ai_available():
        return [], {"model": "none", "prompt_version": PROMPT_VERSION, "usage": {}}
    client = _client()
    guard = (
        system
        + "\n\nRespond with ONLY valid JSON — no prose, no markdown fences. "
        "If a list is requested, return a JSON array."
    )
    with client.messages.stream(
        model=settings.anthropic_model,
        max_tokens=max_tokens,
        system=guard,
        thinking={"type": "adaptive"},
        output_config={"effort": effort},
        messages=[{"role": "user", "content": prompt}],
    ) as stream:
        message = stream.get_final_message()
    raw = _extract_text(message)
    data: list | dict = []
    try:
        data = json.loads(raw)
    except Exception:
        start = raw.find("[")
        if start == -1:
            start = raw.find("{")
        end = max(raw.rfind("]"), raw.rfind("}"))
        if start != -1 and end != -1:
            try:
                data = json.loads(raw[start : end + 1])
            except Exception:
                data = []
    return data, {
        "model": settings.anthropic_model,
        "prompt_version": PROMPT_VERSION,
        "usage": _usage(message),
    }