feat(CG4): add recommendations & anomaly engine
This commit is contained in:
parent
93313ee830
commit
62e742b9ba
|
|
@ -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))
|
||||||
|
|
|
||||||
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
|
// CG3e: hent tokenbruk
|
||||||
(function(){
|
(function(){
|
||||||
fetch('/billing/tokens/summary')
|
fetch('/billing/tokens/summary')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user