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 / routers / assistant.py 10762 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
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
"""Bid Assistant — an optional, admin-gated advisory tool.

Gating (all three required to run):
  1. AI Integration enabled by an administrator (SystemSetting 'ai_assistant').
  2. The bid's own Assistant toggle is on.
  3. (For real output) an Anthropic API key is configured — otherwise the
     Assistant returns a clearly-labelled placeholder.

The Assistant only advises. Enhancements are applied to a response ONLY after a
human approves the suggestion; every decision is written to the bid audit trail.
The self-check for hallucinations is surfaced as a guardrail indicator.
"""
from __future__ import annotations

from datetime import datetime, timezone

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session

from app.ai import load_prompt
from app.core.database import get_db
from app.core.deps import audit, get_current_user, require_roles
from app.models import (
    ROLE_ADMIN,
    ROLE_BID_MANAGER,
    ROLE_CONTRIBUTOR,
    AssistantSuggestion,
    Bid,
    BidRequirement,
    BidResponse,
    Capability,
    ComplianceFramework,
    SystemSetting,
    User,
)
from app.schemas import AssistantSuggestionOut
from app.services import ai

router = APIRouter(prefix="/api/bids", tags=["assistant"])
editor = require_roles(ROLE_ADMIN, ROLE_BID_MANAGER, ROLE_CONTRIBUTOR)


def _admin_ai_on(db: Session) -> bool:
    row = db.get(SystemSetting, "ai_assistant")
    return bool(row and row.value.get("enabled"))


def _require_assistant(db: Session, bid: Bid) -> None:
    if not _admin_ai_on(db):
        raise HTTPException(400, "AI Integration is disabled by the administrator")
    if not bid.assistant_enabled:
        raise HTTPException(400, "The Assistant is switched off for this bid")


def _guardrail(data: dict) -> dict:
    unsupported = data.get("unsupported_claims", []) or []
    return {
        "checked": bool(ai.ai_available()),
        "removed": len(unsupported),
        "claims": unsupported,
        "clean": len(unsupported) == 0,
    }


def _capability_summary(db: Session) -> str:
    caps = db.query(Capability).filter(Capability.status == "approved").all()
    return "\n".join(f"- {c.name} ({c.category}): {c.description}" for c in caps[:15]) or "(none approved)"


# --- per-bid toggle ---
@router.post("/{bid_id}/assistant/toggle")
def toggle_assistant(bid_id: int, payload: dict, db: Session = Depends(get_db), actor: User = Depends(require_roles(ROLE_ADMIN, ROLE_BID_MANAGER))):
    bid = db.get(Bid, bid_id)
    if not bid:
        raise HTTPException(404, "Not found")
    enabled = bool(payload.get("enabled"))
    if enabled and not _admin_ai_on(db):
        raise HTTPException(400, "AI Integration must be enabled by an administrator first")
    bid.assistant_enabled = enabled
    db.commit()
    audit(db, actor, "assistant_toggled", "bid", bid_id, {"enabled": enabled}, bid_id=bid_id)
    return {"assistant_enabled": bid.assistant_enabled}


# --- bid-level advice ---
@router.post("/{bid_id}/assistant/advise", response_model=AssistantSuggestionOut)
def advise(bid_id: int, db: Session = Depends(get_db), actor: User = Depends(editor)):
    bid = db.get(Bid, bid_id)
    if not bid:
        raise HTTPException(404, "Not found")
    _require_assistant(db, bid)
    reqs = db.query(BidRequirement).filter(BidRequirement.bid_id == bid_id).all()
    coverage = []
    for r in reqs:
        resp = db.query(BidResponse).filter(BidResponse.requirement_id == r.id).first()
        coverage.append(f"- {r.ref}: {r.question_text[:80]}{(resp.status if resp else 'no response')}")
    fw_names = [db.get(ComplianceFramework, f).name for f in (bid.frameworks or []) if db.get(ComplianceFramework, f)]
    prompt = (
        f"OPPORTUNITY: {bid.title} for {bid.client}; value £{bid.opportunity_value}; "
        f"{bid.contract_duration_months} months.\nWIN THEMES: {bid.win_themes}\n"
        f"FRAMEWORKS: {', '.join(fw_names) or 'general'}\n\n"
        f"REQUIREMENT COVERAGE:\n" + "\n".join(coverage) + "\n\n"
        f"APPROVED CAPABILITIES:\n" + _capability_summary(db)
    )
    data, meta = ai.assistant_json(load_prompt("assistant_advise"), prompt)
    sug = AssistantSuggestion(
        bid_id=bid_id, response_id=None, kind="bid_advice", target_ref="Whole bid",
        advice=data.get("advice", []), proposed_text="", guardrail=_guardrail(data),
        status="proposed", model=meta.get("model", ""), created_by=actor.id,
    )
    db.add(sug)
    db.commit()
    db.refresh(sug)
    audit(db, actor, "assistant_advice_generated", "bid", bid_id, bid_id=bid_id)
    return sug


# --- response enhancement ---
@router.post("/{bid_id}/assistant/enhance/{response_id}", response_model=AssistantSuggestionOut)
def enhance(bid_id: int, response_id: int, db: Session = Depends(get_db), actor: User = Depends(editor)):
    bid = db.get(Bid, bid_id)
    if not bid:
        raise HTTPException(404, "Not found")
    _require_assistant(db, bid)
    resp = db.get(BidResponse, response_id)
    if not resp:
        raise HTTPException(404, "Response not found")
    req = db.get(BidRequirement, resp.requirement_id)
    fw_names = [db.get(ComplianceFramework, f).name for f in (bid.frameworks or []) if db.get(ComplianceFramework, f)]
    prompt = (
        f"REQUIREMENT ({req.ref}): {req.question_text}\n"
        f"WORD LIMIT: {req.word_limit or 'none'}\nFRAMEWORKS: {', '.join(fw_names) or 'general'}\n"
        f"WIN THEMES: {bid.win_themes}\n\n"
        f"CURRENT DRAFT RESPONSE:\n{resp.final_text or resp.draft_text}\n\n"
        f"APPROVED CAPABILITY EVIDENCE:\n" + _capability_summary(db)
    )
    data, meta = ai.assistant_json(load_prompt("assistant_enhance"), prompt)
    proposed = data.get("cleaned_text") or data.get("enhanced_text") or ""
    sug = AssistantSuggestion(
        bid_id=bid_id, response_id=response_id, kind="response_enhance", target_ref=req.ref,
        advice=data.get("advice", []), proposed_text=proposed, guardrail=_guardrail(data),
        status="proposed", model=meta.get("model", ""), created_by=actor.id,
    )
    db.add(sug)
    db.commit()
    db.refresh(sug)
    audit(db, actor, "assistant_enhance_generated", "response", response_id,
          {"ref": req.ref, "removed_claims": _guardrail(data)["removed"]}, bid_id=bid_id)
    return sug


# --- framework alignment ---
@router.post("/{bid_id}/assistant/align/{response_id}", response_model=AssistantSuggestionOut)
def align(bid_id: int, response_id: int, db: Session = Depends(get_db), actor: User = Depends(editor)):
    bid = db.get(Bid, bid_id)
    if not bid:
        raise HTTPException(404, "Not found")
    _require_assistant(db, bid)
    resp = db.get(BidResponse, response_id)
    if not resp:
        raise HTTPException(404, "Response not found")
    req = db.get(BidRequirement, resp.requirement_id)
    # only align to the bid's ACTIVE frameworks
    fw_blocks = []
    fw_names = []
    for f in bid.frameworks or []:
        fw = db.get(ComplianceFramework, f)
        if fw and fw.active:
            fw_names.append(fw.name)
            controls = "; ".join(str(c) for c in (fw.controls or [])[:30])
            fw_blocks.append(f"{fw.name}: {fw.description}" + (f"\n  Controls: {controls}" if controls else ""))
    if not fw_blocks:
        raise HTTPException(400, "This bid has no active frameworks to align to")
    prompt = (
        f"REQUIREMENT ({req.ref}): {req.question_text}\n"
        f"WORD LIMIT: {req.word_limit or 'none'}\n\n"
        f"FRAMEWORK(S) TO ALIGN TO:\n" + "\n".join(fw_blocks) + "\n\n"
        f"CURRENT DRAFT RESPONSE:\n{resp.final_text or resp.draft_text}\n\n"
        f"APPROVED CAPABILITY EVIDENCE:\n" + _capability_summary(db)
    )
    data, meta = ai.assistant_json(load_prompt("assistant_align"), prompt)
    proposed = data.get("cleaned_text") or data.get("enhanced_text") or ""
    sug = AssistantSuggestion(
        bid_id=bid_id, response_id=response_id, kind="framework_align",
        target_ref=f"{req.ref}{', '.join(fw_names)}",
        advice=data.get("advice", []), proposed_text=proposed, guardrail=_guardrail(data),
        status="proposed", model=meta.get("model", ""), created_by=actor.id,
    )
    db.add(sug)
    db.commit()
    db.refresh(sug)
    audit(db, actor, "assistant_align_generated", "response", response_id,
          {"ref": req.ref, "frameworks": fw_names, "removed_claims": _guardrail(data)["removed"]}, bid_id=bid_id)
    return sug


# --- list ---
@router.get("/{bid_id}/assistant/suggestions", response_model=list[AssistantSuggestionOut])
def list_suggestions(bid_id: int, db: Session = Depends(get_db), _: User = Depends(get_current_user)):
    return (
        db.query(AssistantSuggestion)
        .filter(AssistantSuggestion.bid_id == bid_id)
        .order_by(AssistantSuggestion.created_at.desc())
        .all()
    )


# --- human decision: apply / dismiss ---
@router.post("/assistant/suggestions/{sid}/apply", response_model=AssistantSuggestionOut)
def apply_suggestion(sid: int, db: Session = Depends(get_db), actor: User = Depends(editor)):
    sug = db.get(AssistantSuggestion, sid)
    if not sug:
        raise HTTPException(404, "Not found")
    if sug.status != "proposed":
        raise HTTPException(400, f"Suggestion already {sug.status}")
    if sug.kind not in ("response_enhance", "framework_align") or not sug.response_id:
        raise HTTPException(400, "This suggestion is advisory only and cannot be applied")
    if not sug.proposed_text:
        raise HTTPException(400, "No proposed text to apply")
    resp = db.get(BidResponse, sug.response_id)
    if not resp:
        raise HTTPException(404, "Response not found")
    resp.final_text = sug.proposed_text
    resp.edited_by = actor.id
    sug.status = "applied"
    sug.decided_by = actor.id
    sug.decided_at = datetime.now(timezone.utc)
    db.commit()
    db.refresh(sug)
    audit(db, actor, "assistant_suggestion_applied", "response", sug.response_id,
          {"ref": sug.target_ref, "suggestion_id": sid}, bid_id=sug.bid_id)
    return sug


@router.post("/assistant/suggestions/{sid}/dismiss", response_model=AssistantSuggestionOut)
def dismiss_suggestion(sid: int, db: Session = Depends(get_db), actor: User = Depends(editor)):
    sug = db.get(AssistantSuggestion, sid)
    if not sug:
        raise HTTPException(404, "Not found")
    if sug.status != "proposed":
        raise HTTPException(400, f"Suggestion already {sug.status}")
    sug.status = "dismissed"
    sug.decided_by = actor.id
    sug.decided_at = datetime.now(timezone.utc)
    db.commit()
    db.refresh(sug)
    audit(db, actor, "assistant_suggestion_dismissed", "bid", sug.bid_id,
          {"ref": sug.target_ref, "suggestion_id": sid}, bid_id=sug.bid_id)
    return sug