feat(CG4): add recommendations & anomaly engine

This commit is contained in:
Chris Christiansen 2026-06-09 20:41:59 +00:00
parent 93313ee830
commit 62e742b9ba
3 changed files with 131 additions and 1 deletions

View File

@ -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))

View File

@ -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)
}

View File

@ -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='<div class="empty-state"><span class="material-symbols-outlined">check_circle</span>Ingen anbefalinger — forbruket ser normalt ut</div>';
sub.textContent='Alt OK';
return;
}
sub.textContent=`Estimert besparelse: ${(data.total_estimated_savings_nok||0).toLocaleString('no-NO',{minimumFractionDigits:2})} kr/mnd`;
body.innerHTML=data.recommendations.map(r=>`
<div class="svc-row">
<span style="font-size:16px">${ICONS[r.severity]||'💡'}</span>
<span class="svc-name">${r.message}</span>
<span style="font-size:11px;color:var(--muted);background:var(--surface-3);padding:2px 8px;border-radius:99px;flex-shrink:0">${r.service}</span>
${r.estimated_savings_nok>0?`<span class="svc-cost">-${r.estimated_savings_nok.toLocaleString('no-NO',{minimumFractionDigits:2})} kr</span>`: ''}
</div>
`).join('');
})
.catch(()=>{
document.getElementById('recommendations-body').innerHTML=
'<div class="empty-state"><span class="material-symbols-outlined">check_circle</span>Ingen anbefalinger tilgjengelig</div>';
document.getElementById('rec-savings').textContent='';
});
})();
// CG3e: hent tokenbruk
(function(){
fetch('/billing/tokens/summary')