feat(CG4): add recommendations & anomaly engine
This commit is contained in:
parent
93313ee830
commit
62e742b9ba
|
|
@ -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))
|
||||
|
|
|
|||
86
agents/recommendations_engine.py
Normal file
86
agents/recommendations_engine.py
Normal 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)
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user