diff --git a/agents/core-logic/app.py b/agents/core-logic/app.py index b462380..9362beb 100644 --- a/agents/core-logic/app.py +++ b/agents/core-logic/app.py @@ -5,7 +5,7 @@ import os import logging from contextlib import asynccontextmanager -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request from fastapi.responses import JSONResponse from pydantic import BaseModel @@ -17,6 +17,7 @@ try: from google.adk.sessions import InMemorySessionService from google.genai.types import Content, Part from agent import root_agent + from agents.recommendations_engine import get_recommendations except ImportError as e: logger.error(f"Failed to import ADK dependencies: {e}") raise @@ -165,3 +166,16 @@ async def billing_tokens_summary(): except Exception as e: logger.error(f"[/billing/tokens/summary] Failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/billing/recommendations") +async def billing_recommendations(budget: float = 500.0): + """ + CG4 — Kjører anbefalings- og anomali-motoren. + Returnerer en liste med anbefalinger og estimert besparelse. + """ + try: + recommendations = get_recommendations(budget) + return JSONResponse(recommendations) + except Exception as e: + logger.error(f"[/billing/recommendations] Failed: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) diff --git a/agents/recommendations_engine.py b/agents/recommendations_engine.py new file mode 100644 index 0000000..3c051ad --- /dev/null +++ b/agents/recommendations_engine.py @@ -0,0 +1,86 @@ +from google.cloud import bigquery +from datetime import datetime, timezone + +PROJECT_ID = "propane-will-491900-m5" +DATASET = "costguard_dataset" +BILLING_TABLE = f"{PROJECT_ID}.{DATASET}.gcp_billing_export" + +def get_recommendations(budget_nok: float = 500.0) -> dict: + client = bigquery.Client(project=PROJECT_ID) + recommendations = [] + + # 1. Anomali: tjenester der siste 3d-snitt >= 2x 30d-snitt + anomaly_query = f""" + WITH base AS ( + SELECT service.description AS service, + AVG(cost) AS avg_30d + FROM `{BILLING_TABLE}` + WHERE DATE(usage_start_time) >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY) + GROUP BY service + ), + recent AS ( + SELECT service.description AS service, + AVG(cost) AS avg_3d + FROM `{BILLING_TABLE}` + WHERE DATE(usage_start_time) >= DATE_SUB(CURRENT_DATE(), INTERVAL 3 DAY) + GROUP BY service + ) + SELECT r.service, r.avg_3d, b.avg_30d, + SAFE_DIVIDE(r.avg_3d, b.avg_30d) AS ratio + FROM recent r JOIN base b ON r.service = b.service + WHERE SAFE_DIVIDE(r.avg_3d, b.avg_30d) >= 2.0 + AND b.avg_30d > 0.001 + ORDER BY ratio DESC + """ + + # 2. Sløsing: tjenester med kostnad men 0 aktivitet siste 7d + waste_query = f""" + WITH active AS ( + SELECT DISTINCT service.description AS service + FROM `{BILLING_TABLE}` + WHERE DATE(usage_start_time) >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY) + AND cost > 0 + ), + all_svc AS ( + SELECT DISTINCT service.description AS service + FROM `{BILLING_TABLE}` + WHERE DATE(usage_start_time) >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY) + AND cost > 0 + ) + SELECT a.service FROM all_svc a + LEFT JOIN active b ON a.service = b.service + WHERE b.service IS NULL + """ + + try: + # Anomalier + for row in client.query(anomaly_query).result(): + ratio = row.ratio or 0 + severity = "high" if ratio >= 3 else "medium" + savings = round(float(row.avg_3d - row.avg_30d) * 30 * 10, 2) # rough NOK + recommendations.append({ + "type": "anomaly", + "severity": severity, + "service": row.service, + "message": f"Unormal kostnadsøkning på {row.service} — {ratio:.1f}x over normalt", + "estimated_savings_nok": max(savings, 0) + }) + + # Sløsing + for row in client.query(waste_query).result(): + recommendations.append({ + "type": "waste", + "severity": "low", + "service": row.service, + "message": f"{row.service} har hatt kostnad men ingen aktivitet siste 7 dager — vurder å skru av", + "estimated_savings_nok": 0 + }) + + except Exception as e: + return {"recommendations": [], "total_estimated_savings_nok": 0, "error": str(e)} + + total_savings = sum(r["estimated_savings_nok"] for r in recommendations) + return { + "recommendations": recommendations, + "total_estimated_savings_nok": round(total_savings, 2) + } diff --git a/static/billing-dashboard.html b/static/billing-dashboard.html index b5ff794..1ee38f8 100644 --- a/static/billing-dashboard.html +++ b/static/billing-dashboard.html @@ -835,6 +835,36 @@ if ('serviceWorker' in navigator) { }); }); } +// CG4: hent anbefalinger +(function(){ + const ICONS = {high:'🔴', medium:'🟡', low:'🟢', anomaly:'⚡', waste:'♻️', budget:'💸'}; + fetch('/billing/recommendations') + .then(r=>r.ok?r.json():Promise.reject(r.status)) + .then(data=>{ + const body=document.getElementById('recommendations-body'); + const sub=document.getElementById('rec-savings'); + if(!data.recommendations||!data.recommendations.length){ + body.innerHTML='