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 import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
@ -17,6 +17,7 @@ try:
from google.adk.sessions import InMemorySessionService from google.adk.sessions import InMemorySessionService
from google.genai.types import Content, Part from google.genai.types import Content, Part
from agent import root_agent from agent import root_agent
from agents.recommendations_engine import get_recommendations
except ImportError as e: except ImportError as e:
logger.error(f"Failed to import ADK dependencies: {e}") logger.error(f"Failed to import ADK dependencies: {e}")
raise raise
@ -165,3 +166,16 @@ async def billing_tokens_summary():
except Exception as e: except Exception as e:
logger.error(f"[/billing/tokens/summary] Failed: {e}", exc_info=True) logger.error(f"[/billing/tokens/summary] Failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) 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 // CG3e: hent tokenbruk
(function(){ (function(){
fetch('/billing/tokens/summary') fetch('/billing/tokens/summary')