diff --git a/agents/core-logic/app.py b/agents/core-logic/app.py index 958c9b0..d60b9ae 100644 --- a/agents/core-logic/app.py +++ b/agents/core-logic/app.py @@ -13,7 +13,7 @@ from pydantic import BaseModel logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -# Gjor ml-pakken tilgjengelig uansett cwd (Cloud Run starter i agents/core-logic/) +# Gjor ml-pakken tilgjengelig uansett cwd sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) try: @@ -27,10 +27,13 @@ except ImportError as e: raise session_service = InMemorySessionService() -APP_NAME = os.environ.get("CLOUD_RUN_SERVICE", "gcp-orchestrator") -PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5") +APP_NAME = os.environ.get("CLOUD_RUN_SERVICE", "gcp-orchestrator") +PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5") BQ_BILLING_DATASET = os.environ.get("BQ_BILLING_DATASET", "billing_data") +# CG6: in-memory budget store (TODO Fase-C: migrate to Firestore per-user) +_budget_store: dict = {} # key: user_email | "default" + @asynccontextmanager async def lifespan(app: FastAPI): @@ -59,6 +62,11 @@ class RunResponse(BaseModel): response: str +class BudgetRequest(BaseModel): + budget: float + user: str = "default" + + async def _ensure_session(user_id: str, session_id: str): try: session = await session_service.get_session( @@ -75,13 +83,13 @@ async def _ensure_session(user_id: str, session_id: str): return session -# ── helse ────────────────────────────────────────────────────────────────────── +# ── helse ──────────────────────────────────────────────────────────────────── @app.get("/health") async def health(): return JSONResponse({"status": "ok", "service": APP_NAME}) -# ── agent run ────────────────────────────────────────────────────────────────── +# ── agent run ──────────────────────────────────────────────────────────────── @app.post("/run", response_model=RunResponse) async def run(req: RunRequest): try: @@ -103,12 +111,9 @@ async def run(req: RunRequest): raise HTTPException(status_code=500, detail=str(e)) -# ── billing: tokens ──────────────────────────────────────────────────────────── +# ── billing: tokens ────────────────────────────────────────────────────────── @app.get("/billing/tokens/summary") async def billing_tokens_summary(): - """ - CG3e — LLM token-bruk og estimert kostnad per agent siste 30 dager. - """ try: from google.cloud import bigquery client = bigquery.Client(project=PROJECT_ID) @@ -142,12 +147,9 @@ async def billing_tokens_summary(): raise HTTPException(status_code=500, detail=str(e)) -# ── billing: anbefalinger ────────────────────────────────────────────────────── +# ── billing: anbefalinger ──────────────────────────────────────────────────── @app.get("/billing/recommendations") async def billing_recommendations(budget: float = 500.0): - """ - CG4 — Anbefalings- og anomali-motor. - """ try: recommendations = get_recommendations(budget) return JSONResponse(recommendations) @@ -156,23 +158,95 @@ async def billing_recommendations(budget: float = 500.0): raise HTTPException(status_code=500, detail=str(e)) -# ── billing: tjenester med SKU-detaljer (CG5) ─────────────────────────────────── +# ── billing: tjenester med SKU-detaljer (CG5) ──────────────────────────────── @app.get("/billing/by-service") async def billing_by_service(days: int = 30): + try: + from ml.billing_agent import BillingAgent + agent = BillingAgent() + return JSONResponse(agent.get_service_totals(days)) + except Exception as e: + logger.error(f"[/billing/by-service] Failed: {e}", exc_info=True) + return JSONResponse([]) # fallback: tom liste, dashbordet krasjer ikke + + +# ── billing: anomalier (CG6) ───────────────────────────────────────────────── +@app.get("/billing/anomalies") +async def billing_anomalies(): """ - CG5 — Kostnad per tjeneste med SKU-detaljer for drill-down i dashbordet. - Returnerer liste sortert etter total_cost DESC. - Hvert element: {service, total_cost, skus: [{sku, sku_cost}]} - Fallback: hvis BillingAgent feiler returneres en tom liste (dashbordet - viser da fallback-visning istedenfor å krasje). + CG6 — Anomalideteksjon: sammenligner dagens kostnad mot 7-dagers snitt per tjeneste. + Returnerer tjenester der dagens kostnad > 2x snittet. + Fallback: tom liste ved feil slik at dashbordet ikke krasjer. """ try: from ml.billing_agent import BillingAgent agent = BillingAgent() - data = agent.get_service_totals(days) - return JSONResponse(data) + return JSONResponse(agent.get_anomalies()) except Exception as e: - logger.error(f"[/billing/by-service] Failed: {e}", exc_info=True) - # Returner tom liste istedenfor 500 — dashbordet faller da tilbake til - # den enkle buildSvc-visningen uten å miste all annen data. - return JSONResponse([]) + logger.error(f"[/billing/anomalies] Failed: {e}", exc_info=True) + return JSONResponse({"anomalies": []}) + + +# ── billing: historikk for bar-chart (CG6) ─────────────────────────────────── +@app.get("/billing/history") +async def billing_history(days: int = 30): + """ + CG6 — Daglig MTD-historikk for bar-chart i dashbordet. + Returnerer liste: [{date, mtd}] sortert ASC. + """ + try: + from ml.billing_agent import BillingAgent + agent = BillingAgent() + return JSONResponse({"history": agent.get_daily_history(days)}) + except Exception as e: + logger.error(f"[/billing/history] Failed: {e}", exc_info=True) + return JSONResponse({"history": []}) + + +# ── billing: summary ───────────────────────────────────────────────────────── +@app.get("/billing/summary") +async def billing_summary(): + try: + from ml.billing_agent import BillingAgent + agent = BillingAgent() + return JSONResponse(agent.get_summary()) + except Exception as e: + logger.error(f"[/billing/summary] Failed: {e}", exc_info=True) + return JSONResponse({"summary": []}) + + +# ── billing: live / forecast ───────────────────────────────────────────────── +@app.get("/billing/live") +async def billing_live(): + try: + from ml.billing_agent import BillingAgent + agent = BillingAgent() + return JSONResponse(agent.get_forecast()) + except Exception as e: + logger.error(f"[/billing/live] Failed: {e}", exc_info=True) + return JSONResponse({}) + + +# ── billing: budsjett GET + POST (CG6) ─────────────────────────────────────── +# TODO Fase-C: erstatt _budget_store med Firestore-dokument per bruker +@app.get("/billing/budget") +async def get_budget(user: str = "default"): + """ + CG6 — Hent lagret budsjett for bruker. + In-memory; nullstilles ved redeploy (Firestore i Fase C). + """ + budget = _budget_store.get(user, 500.0) + return JSONResponse({"budget": budget, "user": user}) + + +@app.post("/billing/budget") +async def set_budget(req: BudgetRequest): + """ + CG6 — Lagre budsjett for bruker. + In-memory; nullstilles ved redeploy (Firestore i Fase C). + """ + if req.budget <= 0: + raise HTTPException(status_code=400, detail="Budget must be > 0") + _budget_store[req.user] = req.budget + logger.info(f"Budget updated: {req.user} -> {req.budget}") + return JSONResponse({"budget": req.budget, "user": req.user}) diff --git a/ml/billing_agent.py b/ml/billing_agent.py index 0b2ec88..2b9a904 100644 --- a/ml/billing_agent.py +++ b/ml/billing_agent.py @@ -2,6 +2,7 @@ import os import datetime from google.cloud import bigquery + class BillingAgent: def __init__(self): self.project_id = os.environ.get("GOOGLE_CLOUD_PROJECT") @@ -14,10 +15,8 @@ class BillingAgent: self.bq_client = bigquery.Client(project=self.project_id) + # ── summary ────────────────────────────────────────────────────────────── def get_summary(self): - """ - Henter daglig billing-sammendrag per prosjekt og tjeneste siste 30 dager. - """ query = f""" SELECT DATE(usage_start_time) AS usage_date, @@ -35,7 +34,7 @@ class BillingAgent: { "usage_date": str(row.usage_date), "project_id": row.project_id, - "service": row.service, + "service": row.service, "daily_cost": row.daily_cost, } for row in results @@ -43,18 +42,17 @@ class BillingAgent: if not summary: return { "onboarding_status": { - "state": "awaiting_data", + "state": "awaiting_data", "message": "Fakturaeksport er aktiv, men ingen data for siste 30 dager ennå.", } } return {"summary": summary} + # ── forecast ───────────────────────────────────────────────────────────── def get_forecast(self): - """ - Prognose basert på siste 7 dager. - """ q7 = f""" - SELECT SUM(cost) + SUM(IFNULL((SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS total_cost + SELECT SUM(cost) + SUM(IFNULL( + (SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS total_cost FROM `{self.billing_table}` WHERE _PARTITIONTIME >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY) """ @@ -62,12 +60,15 @@ class BillingAgent: daily_average = total_7d / 7 today = datetime.date.today() - next_month = datetime.date(today.year + (1 if today.month == 12 else 0), - (today.month % 12) + 1, 1) + next_month = datetime.date( + today.year + (1 if today.month == 12 else 0), + (today.month % 12) + 1, 1 + ) remaining_days = (next_month - today).days q_mtd = f""" - SELECT SUM(cost) + SUM(IFNULL((SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS total_cost + SELECT SUM(cost) + SUM(IFNULL( + (SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS total_cost FROM `{self.billing_table}` WHERE EXTRACT(MONTH FROM _PARTITIONTIME) = EXTRACT(MONTH FROM CURRENT_DATE()) AND EXTRACT(YEAR FROM _PARTITIONTIME) = EXTRACT(YEAR FROM CURRENT_DATE()) @@ -75,24 +76,23 @@ class BillingAgent: mtd_cost = list(self.bq_client.query(q_mtd).result())[0].total_cost or 0 return { - "daily_average_last_7_days": daily_average, - "month_to_date_cost": mtd_cost, - "forecasted_remaining_cost": daily_average * remaining_days, - "total_monthly_forecast": mtd_cost + daily_average * remaining_days, - "remaining_days_in_month": remaining_days, + "daily_average_last_7_days": daily_average, + "month_to_date_cost": mtd_cost, + "forecasted_remaining_cost": daily_average * remaining_days, + "total_monthly_forecast": mtd_cost + daily_average * remaining_days, + "remaining_days_in_month": remaining_days, "data_note": "Prognose basert på siste 7 dager. BigQuery kan ha 24-48 timers forsinkelse.", } + # ── anomalier ───────────────────────────────────────────────────────────── def get_anomalies(self): - """ - Oppdager anomalier ved å sammenligne dagens kostnad mot 7-dagers snitt. - """ query = f""" WITH daily_costs AS ( SELECT service.description AS service, DATE(_PARTITIONTIME) AS usage_date, - SUM(cost) + SUM(IFNULL((SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS daily_cost + SUM(cost) + SUM(IFNULL( + (SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS daily_cost FROM `{self.billing_table}` WHERE _PARTITIONTIME >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 14 DAY) GROUP BY 1, 2 @@ -117,32 +117,51 @@ class BillingAgent: return { "anomalies": [ { - "service": row.service, + "service": row.service, "today_cost": row.today_cost, - "avg_7d": row.avg_7day, - "ratio": row.ratio, + "avg_7d": row.avg_7day, + "ratio": row.ratio, } for row in results ] } - def get_service_totals(self, days: int = 30): + # ── daglig historikk for bar-chart (CG6) ────────────────────────────────── + def get_daily_history(self, days: int = 30): """ - CG5 — Henter total kostnad per tjeneste gruppert med SKU-detaljer. - Returnerer en liste sortert etter total_cost DESC. - Hvert element har: - service : str - total_cost : float - skus : list av {sku: str, sku_cost: float} + Returnerer [{date: str, mtd: float}] for siste `days` dager, sortert ASC. + MTD (month-to-date) er kumulativ sum fra 1. i måneden til den dato. """ query = f""" SELECT - service.description AS service, - sku.description AS sku, - SUM(cost) - + SUM(IFNULL( - (SELECT SUM(c.amount) FROM UNNEST(credits) c), 0 - )) AS sku_cost + DATE(usage_start_time) AS usage_date, + SUM(cost) + SUM(IFNULL( + (SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS day_cost + FROM `{self.billing_table}` + WHERE _PARTITIONTIME >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), + INTERVAL {int(days)} DAY) + GROUP BY usage_date + ORDER BY usage_date ASC + """ + results = list(self.bq_client.query(query).result()) + history = [] + mtd = 0.0 + for row in results: + # Nullstill MTD ved månedsskifte + if history and row.usage_date.day == 1: + mtd = 0.0 + mtd += float(row.day_cost or 0) + history.append({"date": str(row.usage_date), "mtd": round(mtd, 6)}) + return history + + # ── tjenester med SKU-detaljer (CG5) ───────────────────────────────────── + def get_service_totals(self, days: int = 30): + query = f""" + SELECT + service.description AS service, + sku.description AS sku, + SUM(cost) + SUM(IFNULL( + (SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS sku_cost FROM `{self.billing_table}` WHERE _PARTITIONTIME >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {int(days)} DAY) @@ -151,26 +170,25 @@ class BillingAgent: ORDER BY service, sku_cost DESC """ results = self.bq_client.query(query).result() - - # Grupper SKU-er under tjeneste services: dict = {} for row in results: svc = row.service if svc not in services: services[svc] = {"service": svc, "total_cost": 0.0, "skus": []} - services[svc]["total_cost"] = round(services[svc]["total_cost"] + float(row.sku_cost), 6) + services[svc]["total_cost"] = round( + services[svc]["total_cost"] + float(row.sku_cost), 6 + ) services[svc]["skus"].append({ - "sku": row.sku, + "sku": row.sku, "sku_cost": round(float(row.sku_cost), 6), }) - - # Sorter etter total_cost DESC return sorted(services.values(), key=lambda x: x["total_cost"], reverse=True) if __name__ == '__main__': agent = BillingAgent() - print("Summary:", agent.get_summary()) - print("Forecast:", agent.get_forecast()) - print("Anomalies:", agent.get_anomalies()) - print("By-service:", agent.get_service_totals()) + print("Summary:", agent.get_summary()) + print("Forecast:", agent.get_forecast()) + print("Anomalies:", agent.get_anomalies()) + print("By-service:", agent.get_service_totals()) + print("History:", agent.get_daily_history()) diff --git a/static/billing-dashboard.html b/static/billing-dashboard.html index a0b1f1a..3a88ba5 100644 --- a/static/billing-dashboard.html +++ b/static/billing-dashboard.html @@ -12,8 +12,6 @@ - - @@ -227,7 +193,7 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
@@ -236,10 +202,6 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente propane-will-491900-m5
-
- GCP -
- check_circle @@ -250,10 +212,6 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente - @@ -264,15 +222,14 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
-
-