Implement fin-hub T24-T26: ingest, runway, coupling, RaaS packaging

Add CSV importers, runway calculator, budget alerts, cross-hub coupling
emitters, finhub CLI, and raas-mvp-packaging docs with full test coverage.
This commit is contained in:
tegwick 2026-07-08 00:59:39 +02:00
parent b6993d4a05
commit 1abbbe85e4
27 changed files with 835 additions and 3 deletions

126
src/fin_hub/cli.py Normal file
View file

@ -0,0 +1,126 @@
"""Fin-hub operator CLI."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from fin_hub.coupling.canon import emit_viability_alert
from fin_hub.coupling.dev_hub import emit_resource_pressure
from fin_hub.coupling.ops_hub import ServiceCostLine, build_service_cost_report
from fin_hub.ingest.anthropic import parse_anthropic_billing_csv
from fin_hub.ingest.cloud import parse_cloud_cost_csv
from fin_hub.ingest.hosteurope import parse_hosteurope_csv
from fin_hub.services.alerts import evaluate_budget_alerts
from fin_hub.services.runway import compute_runway
def _cmd_import_cloud(args: argparse.Namespace) -> int:
rows = parse_cloud_cost_csv(Path(args.path))
print(json.dumps([row.__dict__ for row in rows], indent=2, default=str))
return 0
def _cmd_import_anthropic(args: argparse.Namespace) -> int:
rows = parse_anthropic_billing_csv(Path(args.path))
payload = [
{
**row.__dict__,
"recorded_at": row.recorded_at.isoformat(),
}
for row in rows
]
print(json.dumps(payload, indent=2))
return 0
def _cmd_import_hosteurope(args: argparse.Namespace) -> int:
rows = parse_hosteurope_csv(Path(args.path))
print(json.dumps([row.__dict__ for row in rows], indent=2, default=str))
return 0
def _cmd_runway(args: argparse.Namespace) -> int:
burns = [float(v) for v in args.monthly_burn.split(",") if v.strip()]
runway = compute_runway(
current_balance=args.balance,
monthly_burns=burns,
alert_threshold_months=args.threshold,
currency=args.currency,
)
alerts = evaluate_budget_alerts(
runway=runway,
allocated=args.allocated,
spent=args.spent,
)
report = {
"runway": runway.as_dict(),
"alerts": [alert.as_dict() for alert in alerts],
}
if args.emit:
report["dev_hub"] = emit_resource_pressure(alerts, api_base=args.api_base)
report["canon"] = emit_viability_alert(runway, api_base=args.api_base)
print(json.dumps(report, indent=2, default=str))
return 0
def _cmd_ops_costs(args: argparse.Namespace) -> int:
lines = [
ServiceCostLine(
service_id=row.service_id,
environment=row.environment,
period_month=row.period_month,
amount=row.amount,
currency=row.currency,
source=row.source,
)
for row in parse_hosteurope_csv(Path(args.path))
]
print(json.dumps(build_service_cost_report(lines), indent=2))
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Fin Hub operator CLI")
sub = parser.add_subparsers(dest="command", required=True)
cloud = sub.add_parser("import-cloud", help="Parse generic cloud cost CSV")
cloud.add_argument("path")
cloud.set_defaults(func=_cmd_import_cloud)
anthropic = sub.add_parser("import-anthropic", help="Parse Anthropic billing CSV")
anthropic.add_argument("path")
anthropic.set_defaults(func=_cmd_import_anthropic)
hosteurope = sub.add_parser("import-hosteurope", help="Parse HostEurope invoice CSV")
hosteurope.add_argument("path")
hosteurope.set_defaults(func=_cmd_import_hosteurope)
runway = sub.add_parser("runway", help="Compute runway and optional alerts")
runway.add_argument("--balance", type=float, required=True)
runway.add_argument("--monthly-burn", required=True, help="Comma-separated monthly burn values")
runway.add_argument("--threshold", type=float, default=3.0)
runway.add_argument("--currency", default="EUR")
runway.add_argument("--allocated", type=float)
runway.add_argument("--spent", type=float)
runway.add_argument("--emit", action="store_true", help="Emit fin→dev and fin→canon signals")
runway.add_argument("--api-base")
runway.set_defaults(func=_cmd_runway)
ops = sub.add_parser("ops-costs", help="Build per-service cost attribution report")
ops.add_argument("path")
ops.set_defaults(func=_cmd_ops_costs)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,11 @@
"""FOS §9 cross-hub coupling emitters."""
from fin_hub.coupling.canon import emit_viability_alert
from fin_hub.coupling.dev_hub import emit_resource_pressure
from fin_hub.coupling.ops_hub import build_service_cost_report
__all__ = [
"build_service_cost_report",
"emit_resource_pressure",
"emit_viability_alert",
]

View file

@ -0,0 +1,38 @@
"""fin→canon: viability alerts when runway breaches System 5 threshold."""
from __future__ import annotations
from typing import Any
from hub_core.events import RISK_ESCALATED
from fin_hub.coupling.common import default_dev_hub_api_base, post_progress_event
from fin_hub.services.runway import RunwayResult
def emit_viability_alert(
runway: RunwayResult,
*,
api_base: str | None = None,
canon_workplan_id: str | None = None,
) -> dict[str, Any]:
if not runway.below_threshold or runway.monthly_burn <= 0:
return {"ok": True, "skipped": True, "reason": "runway above threshold"}
base = api_base or default_dev_hub_api_base()
return post_progress_event(
api_base=base,
event_type=RISK_ESCALATED,
summary=(
f"[fin→canon] Runway {runway.months_remaining:.1f} months below "
f"{runway.alert_threshold_months:.1f}-month viability threshold"
),
detail={
"source_hub": "fin-hub",
"target_hub": "canon",
"signal": "viability_alert",
"runway": runway.as_dict(),
"escalation_target": "system_5",
},
author="fin-hub",
workplan_id=canon_workplan_id,
)

View file

@ -0,0 +1,46 @@
"""HTTP helpers for cross-hub progress emission."""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from typing import Any
def post_progress_event(
*,
api_base: str,
event_type: str,
summary: str,
detail: dict[str, Any] | None = None,
author: str = "fin-hub",
workplan_id: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"event_type": event_type,
"summary": summary,
"author": author,
}
if detail:
payload["detail"] = detail
if workplan_id:
payload["workplan_id"] = workplan_id
url = f"{api_base.rstrip('/')}/progress/"
body = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
url,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.URLError as exc:
return {"ok": False, "error": str(exc), "queued": False}
def default_dev_hub_api_base() -> str:
return os.environ.get("STATE_HUB_API", os.environ.get("API_BASE", "http://127.0.0.1:8000"))

View file

@ -0,0 +1,37 @@
"""fin→dev: resource pressure signals to dev-hub (State Hub progress)."""
from __future__ import annotations
from typing import Any
from hub_core.events import ALERT_RAISED
from fin_hub.coupling.common import default_dev_hub_api_base, post_progress_event
from fin_hub.services.alerts import BudgetAlert
def emit_resource_pressure(
alerts: list[BudgetAlert],
*,
api_base: str | None = None,
domain_slug: str = "infotech",
) -> list[dict[str, Any]]:
base = api_base or default_dev_hub_api_base()
emitted: list[dict[str, Any]] = []
for alert in alerts:
if alert.code not in {"budget_pressure", "runway_below_threshold"}:
continue
result = post_progress_event(
api_base=base,
event_type=ALERT_RAISED,
summary=f"[fin→dev:{domain_slug}] {alert.summary}",
detail={
"source_hub": "fin-hub",
"target_hub": "dev-hub",
"signal": "resource_pressure",
"alert": alert.as_dict(),
},
author="fin-hub",
)
emitted.append(result)
return emitted

View file

@ -0,0 +1,47 @@
"""fin→ops: infrastructure cost attribution per service."""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from typing import Iterable
@dataclass(frozen=True)
class ServiceCostLine:
service_id: str
environment: str
period_month: str
amount: float
currency: str
source: str
def build_service_cost_report(
lines: Iterable[ServiceCostLine],
) -> dict:
by_service: dict[str, dict] = {}
totals_by_month: dict[str, float] = defaultdict(float)
for line in lines:
bucket = by_service.setdefault(
line.service_id,
{
"service_id": line.service_id,
"environment": line.environment,
"currency": line.currency,
"months": {},
"total": 0.0,
},
)
month_total = bucket["months"].get(line.period_month, 0.0) + line.amount
bucket["months"][line.period_month] = month_total
bucket["total"] += line.amount
totals_by_month[line.period_month] += line.amount
services = sorted(by_service.values(), key=lambda item: item["total"], reverse=True)
return {
"source_hub": "fin-hub",
"target_hub": "ops-hub",
"signal": "service_cost_attribution",
"services": services,
"totals_by_month": dict(sorted(totals_by_month.items())),
}

View file

@ -0,0 +1,11 @@
"""CSV and billing export ingestion for fin-hub."""
from fin_hub.ingest.anthropic import parse_anthropic_billing_csv
from fin_hub.ingest.cloud import parse_cloud_cost_csv
from fin_hub.ingest.hosteurope import parse_hosteurope_csv
__all__ = [
"parse_anthropic_billing_csv",
"parse_cloud_cost_csv",
"parse_hosteurope_csv",
]

View file

@ -0,0 +1,28 @@
"""Shared CSV helpers."""
from __future__ import annotations
import csv
from pathlib import Path
def read_csv_rows(path: Path) -> list[dict[str, str]]:
text = path.read_text(encoding="utf-8-sig")
reader = csv.DictReader(text.splitlines())
return [{k.strip(): (v or "").strip() for k, v in row.items() if k} for row in reader]
def pick(row: dict[str, str], *names: str) -> str:
lowered = {k.lower(): v for k, v in row.items()}
for name in names:
value = lowered.get(name.lower())
if value:
return value
return ""
def parse_amount(value: str) -> float:
cleaned = value.replace("", "").replace("EUR", "").replace(",", ".").strip()
if not cleaned:
return 0.0
return float(cleaned)

View file

@ -0,0 +1,52 @@
"""Anthropic billing export ingestion."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from pathlib import Path
from fin_hub.ingest._csv import parse_amount, pick, read_csv_rows
@dataclass(frozen=True)
class TokenSpendRow:
provider: str
model: str
tokens_in: int
tokens_out: int
cost: float
currency: str
session_id: str | None
recorded_at: datetime
def parse_anthropic_billing_csv(path: Path, *, default_currency: str = "USD") -> list[TokenSpendRow]:
rows: list[TokenSpendRow] = []
for row in read_csv_rows(path):
model = pick(row, "model", "model_name")
cost_raw = pick(row, "cost", "amount", "total_cost", "usage_cost_usd")
if not model or not cost_raw:
continue
tokens_in = int(pick(row, "input_tokens", "tokens_in", "prompt_tokens") or "0")
tokens_out = int(pick(row, "output_tokens", "tokens_out", "completion_tokens") or "0")
recorded_raw = pick(row, "date", "usage_date", "recorded_at", "timestamp")
recorded_at = (
datetime.fromisoformat(recorded_raw.replace("Z", "+00:00"))
if recorded_raw
else datetime.utcnow()
)
currency = pick(row, "currency") or default_currency
rows.append(
TokenSpendRow(
provider="anthropic",
model=model,
tokens_in=tokens_in,
tokens_out=tokens_out,
cost=parse_amount(cost_raw),
currency=currency.upper()[:3],
session_id=pick(row, "session_id", "request_id") or None,
recorded_at=recorded_at,
)
)
return rows

View file

@ -0,0 +1,47 @@
"""Generic cloud cost CSV ingestion."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from pathlib import Path
from fin_hub.ingest._csv import parse_amount, pick, read_csv_rows
@dataclass(frozen=True)
class CloudCostRow:
service: str
amount: float
currency: str
period_month: str
incurred_on: date | None
source: str = "cloud_csv"
def parse_cloud_cost_csv(path: Path, *, default_currency: str = "EUR") -> list[CloudCostRow]:
rows: list[CloudCostRow] = []
for row in read_csv_rows(path):
service = pick(row, "service", "service_name", "resource", "description")
amount_raw = pick(row, "amount", "cost", "total", "spend")
if not service or not amount_raw:
continue
period = pick(row, "period_month", "month", "billing_period")
if not period:
incurred = pick(row, "date", "incurred_on", "usage_date")
period = incurred[:7] if len(incurred) >= 7 else datetime.utcnow().strftime("%Y-%m")
incurred_on = None
incurred_raw = pick(row, "date", "incurred_on", "usage_date")
if incurred_raw:
incurred_on = date.fromisoformat(incurred_raw[:10])
currency = pick(row, "currency") or default_currency
rows.append(
CloudCostRow(
service=service,
amount=parse_amount(amount_raw),
currency=currency.upper()[:3],
period_month=period[:7],
incurred_on=incurred_on,
)
)
return rows

View file

@ -0,0 +1,52 @@
"""HostEurope invoice CSV ingestion."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from fin_hub.ingest._csv import parse_amount, pick, read_csv_rows
@dataclass(frozen=True)
class HostEuropeCostRow:
service_id: str
title: str
amount: float
currency: str
period_month: str
incurred_on: date | None
environment: str = "production"
source: str = "hosteurope"
def parse_hosteurope_csv(path: Path, *, default_currency: str = "EUR") -> list[HostEuropeCostRow]:
rows: list[HostEuropeCostRow] = []
for row in read_csv_rows(path):
title = pick(row, "product", "description", "service", "title")
amount_raw = pick(row, "amount", "net", "total", "price")
if not title or not amount_raw:
continue
service_id = pick(row, "service_id", "product_id") or title.lower().replace(" ", "-")[:128]
period = pick(row, "period_month", "month", "billing_period")
incurred_raw = pick(row, "date", "invoice_date", "incurred_on")
incurred_on = date.fromisoformat(incurred_raw[:10]) if incurred_raw else None
if not period and incurred_on:
period = incurred_on.strftime("%Y-%m")
if not period:
continue
currency = pick(row, "currency") or default_currency
environment = pick(row, "environment", "env") or "production"
rows.append(
HostEuropeCostRow(
service_id=service_id,
title=title,
amount=parse_amount(amount_raw),
currency=currency.upper()[:3],
period_month=period[:7],
incurred_on=incurred_on,
environment=environment,
)
)
return rows

View file

@ -1,11 +1,13 @@
"""Fin-specific SQLAlchemy models (T23 implementation target)."""
from fin_hub.models.budget import Budget, BurnRate, Commitment, RunwayProjection, TokenSpend
from fin_hub.models.service_cost import ServiceCost
__all__ = [
"Budget",
"Commitment",
"BurnRate",
"Commitment",
"RunwayProjection",
"ServiceCost",
"TokenSpend",
]

View file

@ -0,0 +1,26 @@
"""Per-service infrastructure cost attribution (fin→ops coupling)."""
from __future__ import annotations
import uuid
from datetime import date
from sqlalchemy import Date, Float, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from hub_core.models.base import Base, TimestampMixin
class ServiceCost(Base, TimestampMixin):
__tablename__ = "fin_service_costs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
service_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
environment: Mapped[str] = mapped_column(String(64), nullable=False, default="production")
period_month: Mapped[str] = mapped_column(String(7), nullable=False, index=True)
amount: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
source: Mapped[str] = mapped_column(String(32), nullable=False)
incurred_on: Mapped[date | None] = mapped_column(Date, nullable=True)
notes: Mapped[str | None] = mapped_column(String(512), nullable=True)

View file

@ -0,0 +1,6 @@
"""Fin-hub domain services."""
from fin_hub.services.alerts import evaluate_budget_alerts
from fin_hub.services.runway import RunwayResult, compute_runway
__all__ = ["RunwayResult", "compute_runway", "evaluate_budget_alerts"]

View file

@ -0,0 +1,60 @@
"""Budget and runway alert evaluation."""
from __future__ import annotations
from dataclasses import dataclass
from fin_hub.services.runway import RunwayResult
@dataclass(frozen=True)
class BudgetAlert:
code: str
severity: str
summary: str
detail: dict
def as_dict(self) -> dict:
return {
"code": self.code,
"severity": self.severity,
"summary": self.summary,
"detail": self.detail,
}
def evaluate_budget_alerts(
*,
runway: RunwayResult,
allocated: float | None = None,
spent: float | None = None,
) -> list[BudgetAlert]:
alerts: list[BudgetAlert] = []
if runway.below_threshold and runway.monthly_burn > 0:
alerts.append(
BudgetAlert(
code="runway_below_threshold",
severity="high",
summary=(
f"Projected runway {runway.months_remaining:.1f} months "
f"is below threshold {runway.alert_threshold_months:.1f}"
),
detail=runway.as_dict(),
)
)
if allocated is not None and spent is not None and allocated > 0:
utilisation = spent / allocated
if utilisation >= 0.8:
alerts.append(
BudgetAlert(
code="budget_pressure",
severity="medium" if utilisation < 1.0 else "high",
summary=f"Budget utilisation at {utilisation * 100:.0f}%",
detail={
"allocated": allocated,
"spent": spent,
"utilisation": utilisation,
},
)
)
return alerts

View file

@ -0,0 +1,56 @@
"""Runway calculator with burn-rate projection."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
@dataclass(frozen=True)
class RunwayResult:
current_balance: float
monthly_burn: float
months_remaining: float
alert_threshold_months: float
below_threshold: bool
currency: str
computed_at: datetime
def as_dict(self) -> dict:
return {
"current_balance": self.current_balance,
"monthly_burn": self.monthly_burn,
"months_remaining": self.months_remaining,
"alert_threshold_months": self.alert_threshold_months,
"below_threshold": self.below_threshold,
"currency": self.currency,
"computed_at": self.computed_at.isoformat(),
}
def compute_runway(
*,
current_balance: float,
monthly_burns: list[float],
alert_threshold_months: float = 3.0,
currency: str = "EUR",
) -> RunwayResult:
if not monthly_burns:
monthly_burn = 0.0
else:
recent = monthly_burns[-3:]
monthly_burn = sum(recent) / len(recent)
if monthly_burn <= 0:
months_remaining = float("inf") if current_balance > 0 else 0.0
else:
months_remaining = current_balance / monthly_burn
below_threshold = months_remaining < alert_threshold_months
return RunwayResult(
current_balance=current_balance,
monthly_burn=monthly_burn,
months_remaining=months_remaining,
alert_threshold_months=alert_threshold_months,
below_threshold=below_threshold,
currency=currency,
computed_at=datetime.now(timezone.utc),
)