Catalyst / admin/Bid-Sentinel 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Bid-Sentinel

public

Bid Scrape and Tracking Application with AI Capability

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
Bid-Sentinel / bid-sentinel-v2 / backend / app / ai.py 7656 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
"""Optional AI bolt-on: Claude Haiku analysis of opportunities.

Toggleable and degradable: when the toggle is off, the API key is missing, or a
call fails, callers transparently fall back to the deterministic engine in
``app.analysis``. Uses the official Anthropic SDK (low-cost model by default).
"""
from __future__ import annotations

import json
import logging
import re

from app import analysis
from app.config import settings

logger = logging.getLogger("ai")

# JSON shape we ask the model to return.
_SCHEMA_HINT = (
    '{"summary": string (<=2 sentences), '
    '"fit_score": integer 0-100, '
    '"fit_reason": string (one line), '
    '"certifications": string[], '
    '"security_clearances": string[], '
    '"technical_requirements": string[] (max 5)}'
)


def is_available() -> bool:
    """True if an API key is configured (AI can actually run)."""
    return bool(settings.anthropic_api_key.strip())


def _build_system(capability_terms: list[str]) -> str:
    profile = ", ".join(capability_terms) if capability_terms else "(no profile provided)"
    return (
        "You are an analyst for a UK cyber-security bid team. Analyse a public or "
        "private sector tender notice and respond with ONLY a JSON object, no prose, "
        "no markdown fences, matching exactly this shape:\n"
        f"{_SCHEMA_HINT}\n\n"
        "Guidance:\n"
        "- summary: a concise plain-English summary of what is being procured.\n"
        "- fit_score: how well this matches the supplier's capabilities below "
        "(0 = no fit, 100 = perfect fit); fit_reason: one short sentence explaining it.\n"
        "- certifications: required/expected certifications & accreditations using "
        "canonical UK names (e.g. 'Cyber Essentials Plus', 'ISO 27001', 'CREST').\n"
        "- security_clearances: e.g. 'SC Clearance', 'DV Clearance', 'BPSS'.\n"
        "- technical_requirements: the most important technical requirements, max 5, "
        "each a short phrase.\n"
        "Use empty arrays when nothing applies. Do not invent requirements.\n\n"
        f"Supplier capabilities: {profile}"
    )


def _parse_json(raw: str) -> dict | None:
    if not raw:
        return None
    match = re.search(r"\{.*\}", raw, re.S)
    if not match:
        return None
    try:
        return json.loads(match.group(0))
    except (ValueError, TypeError):
        return None


async def _ai_call(text: str, capability_terms: list[str]) -> dict | None:
    try:
        from anthropic import AsyncAnthropic

        client = AsyncAnthropic(api_key=settings.anthropic_api_key)
        resp = await client.messages.create(
            model=settings.ai_model,
            max_tokens=1024,
            system=_build_system(capability_terms),
            messages=[{"role": "user", "content": f"Tender notice:\n\n{(text or '')[:8000]}"}],
        )
        raw = "".join(getattr(b, "text", "") for b in resp.content if getattr(b, "type", "") == "text")
        data = _parse_json(raw)
        if not data:
            logger.warning("AI returned unparseable output; falling back")
            return None

        certs = []
        for c in [*(data.get("certifications") or []), *(data.get("security_clearances") or [])]:
            c = str(c).strip()
            if c and c not in certs:
                certs.append(c)

        score = data.get("fit_score")
        try:
            score = max(0, min(100, int(score)))
        except (TypeError, ValueError):
            score = None

        techs = [str(x).strip() for x in (data.get("technical_requirements") or []) if str(x).strip()][:5]

        return {
            "required_certs": ", ".join(certs)[:1024],
            "fit_score": score,
            "fit_matched": str(data.get("fit_reason") or "").strip()[:1024],
            "tech_requirements": "\n".join(techs),
            "ai_summary": str(data.get("summary") or "").strip()[:2000],
        }
    except Exception as exc:  # noqa: BLE001 - any failure -> deterministic fallback
        logger.warning("AI analysis failed (%s); falling back to deterministic", exc)
        return None


async def suggest_capabilities(text: str) -> list[str] | None:
    """Extract a company's service capabilities from a service-overview document.

    Returns a list of short capability phrases, or None on failure (caller falls
    back to deterministic keyphrase extraction).
    """
    if not is_available():
        return None
    try:
        from anthropic import AsyncAnthropic

        client = AsyncAnthropic(api_key=settings.anthropic_api_key)
        system = (
            "You extract a company's cyber-security service capabilities from a "
            "service-overview document. Respond with ONLY a JSON object, no prose, no "
            'markdown fences: {"capabilities": string[]}. Each item is a short noun '
            "phrase naming a service the company offers (e.g. 'Managed Detection and "
            "Response', 'Penetration Testing', 'Cloud Security', 'SOC Monitoring'). "
            "Return 5-25 de-duplicated items. Do not invent services not in the text."
        )
        resp = await client.messages.create(
            model=settings.ai_model,
            max_tokens=800,
            system=system,
            messages=[{"role": "user", "content": f"Document:\n\n{(text or '')[:12000]}"}],
        )
        raw = "".join(getattr(b, "text", "") for b in resp.content if getattr(b, "type", "") == "text")
        data = _parse_json(raw)
        if not data:
            return None
        seen, out = set(), []
        for c in data.get("capabilities") or []:
            c = str(c).strip()
            if c and c.lower() not in seen:
                seen.add(c.lower())
                out.append(c)
        return out[:30] or None
    except Exception as exc:  # noqa: BLE001
        logger.warning("AI capability extraction failed (%s); falling back", exc)
        return None


async def sales_angle(context_text: str) -> str | None:
    """Write a concise sales angle for a client organisation. None on failure."""
    if not is_available():
        return None
    try:
        from anthropic import AsyncAnthropic

        client = AsyncAnthropic(api_key=settings.anthropic_api_key)
        system = (
            "You are a UK cyber-security bid strategist. Given data about a client "
            "organisation's procurement activity and the supplier's capabilities, "
            "write a punchy 2-3 sentence sales angle: why the supplier is a strong "
            "fit, the specific gap or need to lead with, and any missing "
            "certification to mitigate (partner/roadmap). Plain text only, no "
            "preamble, no bullet points, no markdown."
        )
        resp = await client.messages.create(
            model=settings.ai_model,
            max_tokens=300,
            system=system,
            messages=[{"role": "user", "content": context_text}],
        )
        text = "".join(getattr(b, "text", "") for b in resp.content if getattr(b, "type", "") == "text").strip()
        return text or None
    except Exception as exc:  # noqa: BLE001
        logger.warning("AI sales angle failed (%s); falling back", exc)
        return None


async def analyze_opportunity(text: str, capability_terms: list[str], use_ai: bool) -> dict:
    """Return the analysis fields for one opportunity (AI if enabled, else rules)."""
    if use_ai and is_available():
        result = await _ai_call(text, capability_terms)
        if result is not None:
            return result
    fields = analysis.analyze(text, capability_terms)
    fields["ai_summary"] = ""
    return fields