Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- **Live operator console** – Monitor active analyses, revisit previous sessions, and triage follow-up questions without leaving the dashboard.
- **SaaS-ready controls** – Tweak Hafnia model parameters, feature flags, and theme preferences in a dedicated settings workspace.
- **Usage & health metrics** – Track request cadence, clip throughput, and latency warnings from a real-time metrics view.
- **Insight Layer** – New in this release: a trend-aware analytics feature that transforms individual clip analyses into actionable summaries and temporal trends, automatically explained via Hafnia's Vision-Language Model, and ready to share via read-only links for stakeholders.

The platform combines a FastAPI backend (Python 3.11 via `uv`) with a Vite + React frontend styled using Tailwind CSS, shadcn/ui, and Framer Motion. Production runs a managed Postgres instance on Neon, serves the frontend from Vercel, and deploys the backend on Render.

Expand Down Expand Up @@ -68,6 +69,13 @@ Visit `http://localhost:5173` to access the monitoring console. Vite proxies API
- Sparkline visualises hourly activity while the table summarises daily aggregates
- Feature flag gating (`ENABLE_GRAPH_VIEW`) allows environments to hide the dashboard when needed

### Shareable Insight Snapshots

- `/api/insights/share` mints signed tokens while returning the cached insight metadata and ttl information
- `/share/{token}` on the frontend renders a public, read-only layout that reuses the summary card and trend chart components
- Tokens refresh the cached payload automatically, keeping shared links up to date without exposing the operator dashboard
- Error banners guide recipients through expired or malformed link states, including optional retries when the backend is temporarily unavailable

## Deployment Notes

- Production database uses Neon Postgres; set `DATABASE_URL` accordingly for local development or staging.
Expand Down
23 changes: 23 additions & 0 deletions backend/app/api/deps.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import os
from functools import lru_cache

from backend.app.core.config import get_settings
Expand All @@ -12,6 +13,7 @@
from backend.app.services.conversation import ConversationService
from backend.app.services.hafnia import FakeHafniaClient, HafniaAnalysisClient, HafniaAnalysisClientProtocol
from backend.app.services.hafnia_client import FakeHafniaService, HafniaClient, HafniaClientProtocol
from backend.app.services.insights import InsightService
from backend.app.services.key_store import KeyStore
from backend.app.services.metrics_service import MetricsService
from backend.app.services.sessions import SessionRegistry
Expand Down Expand Up @@ -167,3 +169,24 @@ def _get_metrics_service() -> MetricsService:

def get_metrics_service() -> MetricsService:
return _get_metrics_service()


@lru_cache(maxsize=1)
def _get_insight_service() -> InsightService:
settings = get_settings()
raw_ttl = os.environ.get("INSIGHTS_CACHE_TTL_SECONDS", "60")
try:
ttl = int(raw_ttl)
except ValueError:
ttl = 60
share_salt = os.environ.get("INSIGHTS_SHARE_TOKEN_SALT")
return InsightService(
database_url=settings.database_url,
cache_ttl_seconds=ttl,
share_token_salt=share_salt,
share_base_url=str(settings.frontend_url),
)


def get_insight_service() -> InsightService:
return _get_insight_service()
151 changes: 151 additions & 0 deletions backend/app/api/insights.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
from __future__ import annotations

from fastapi import APIRouter, Depends, HTTPException, Query, Response, status

from backend.app.api.deps import get_insight_service
from backend.app.models.insights import (
InsightRegenerateRequest,
InsightResponse,
InsightShareRequest,
InsightShareResponse,
InsightWindow,
)
from backend.app.services.insights import InsightService
from backend.app.services.insights.share_store import ShareTokenNotFoundError

router = APIRouter(prefix="/insights", tags=["insights"])


@router.get("", response_model=InsightResponse, responses={400: {"description": "Invalid window"}})
async def get_insights(
response: Response,
window: str = Query(default="24h"),
service: InsightService = Depends(get_insight_service),
) -> InsightResponse:
window_value = _coerce_window(window)
try:
snapshot = await service.get_snapshot(window_value)
except ValueError as exc: # pragma: no cover - defensive guard until service validates internally
raise _invalid_window_exception(str(exc)) from exc
_apply_cache_headers(response, service.cache_ttl_seconds)
return snapshot


@router.post(
"/regenerate",
response_model=InsightResponse,
responses={400: {"description": "Invalid window"}},
)
async def regenerate_insights(
response: Response,
payload: InsightRegenerateRequest,
service: InsightService = Depends(get_insight_service),
) -> InsightResponse:
try:
snapshot = await service.regenerate_snapshot(payload.window)
except ValueError as exc: # pragma: no cover
raise _invalid_window_exception(str(exc)) from exc
_apply_cache_headers(response, service.cache_ttl_seconds)
return snapshot


@router.post(
"/share",
response_model=InsightShareResponse,
responses={
400: {"description": "Invalid window"},
503: {"description": "Share service unavailable"},
},
)
async def create_share(
payload: InsightShareRequest,
service: InsightService = Depends(get_insight_service),
) -> InsightShareResponse:
try:
return await service.create_share(payload.window)
except ValueError as exc:
raise _invalid_window_exception(str(exc)) from exc
except RuntimeError as exc:
raise _share_unavailable_exception(str(exc)) from exc


@router.get(
"/share/{token}",
response_model=InsightResponse,
responses={
400: {"description": "Invalid window"},
404: {"description": "Share token not found"},
503: {"description": "Share service unavailable"},
},
)
async def get_share(
response: Response,
token: str,
window: str | None = Query(default=None),
service: InsightService = Depends(get_insight_service),
) -> InsightResponse:
try:
window_value = _coerce_window(window) if window else None
snapshot = await service.get_shared_snapshot(token, window_value)
except ValueError as exc:
raise _invalid_window_exception(str(exc)) from exc
except ShareTokenNotFoundError:
raise _share_not_found_exception(token)
except RuntimeError as exc:
raise _share_unavailable_exception(str(exc)) from exc

_apply_cache_headers(response, service.cache_ttl_seconds)
return snapshot


def _coerce_window(raw: str) -> InsightWindow:
value = raw.strip().lower()
if value in {"24h", "7d"}:
return value # type: ignore[return-value]
raise _invalid_window_exception(f"Unsupported window '{raw}'.")


def _apply_cache_headers(response: Response, ttl: int) -> None:
if ttl <= 0:
response.headers["Cache-Control"] = "no-store"
else:
response.headers["Cache-Control"] = f"public, max-age={ttl}"


def _invalid_window_exception(detail: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": {
"code": "invalid_window",
"message": "Window parameter is invalid.",
"detail": detail,
}
},
)


def _share_unavailable_exception(detail: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"error": {
"code": "share_unavailable",
"message": "Insight sharing is temporarily unavailable.",
"detail": detail,
}
},
)


def _share_not_found_exception(token: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"error": {
"code": "share_not_found",
"message": "Share token was not found.",
"detail": token,
}
},
)
2 changes: 2 additions & 0 deletions backend/app/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,14 @@
from backend.app.services.summarizer import Summarizer
from backend.app.services.validators import UploadValidationError, validate_upload_file
from backend.app.store import ClipNotFoundError, ClipRecord, ClipStore
from backend.app.api.insights import router as insights_router
from backend.app.reasoning.router import router as reasoning_router

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api", tags=["analysis"])
router.include_router(reasoning_router)
router.include_router(insights_router)
system_router = APIRouter(tags=["system"])


Expand Down
1 change: 1 addition & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
# Import model modules so Base metadata is populated for Alembic autogenerate and runtime reflection.
from . import config # noqa: F401
from . import reasoning_history # noqa: F401
from . import insights # noqa: F401
119 changes: 119 additions & 0 deletions backend/app/models/insights.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
from __future__ import annotations

from datetime import datetime, timezone
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy import DateTime, JSON, String
from sqlalchemy.orm import Mapped, mapped_column

from backend.app.db import Base

InsightWindow = Literal["24h", "7d"]


class InsightSeverityTotals(BaseModel):
"""Aggregate severity counts for an insight window."""

low: int = Field(ge=0)
medium: int = Field(ge=0)
high: int = Field(ge=0)

model_config = ConfigDict(extra="forbid")


class InsightSeriesBucket(BaseModel):
"""Time bucket for charting insight data."""

bucket_start: datetime
total: int = Field(ge=0)
severity: InsightSeverityTotals

model_config = ConfigDict(extra="forbid")


class InsightTopLabel(BaseModel):
"""Frequently occurring label with optional severity heuristic."""

label: str = Field(max_length=80)
count: int = Field(ge=0)
avg_severity: float | None = Field(default=None, ge=0, le=2)

model_config = ConfigDict(extra="forbid")

@field_validator("label")
@classmethod
def _normalize_label(cls, value: str) -> str:
cleaned = value.strip()
if not cleaned:
raise ValueError("Label cannot be blank")
return cleaned


class InsightSnapshot(BaseModel):
"""Cached snapshot payload returned by insight endpoints."""

window: InsightWindow
generated_at: datetime
summary: str
summary_source: Literal["hafnia", "fallback"]
severity_totals: InsightSeverityTotals
series: list[InsightSeriesBucket]
top_labels: list[InsightTopLabel] = Field(default_factory=list)
delta: dict[str, int] | None = None
cache_expires_at: datetime | None = None

model_config = ConfigDict(extra="forbid")


class InsightResponse(InsightSnapshot):
"""Response schema for `/api/insights` and share endpoints."""

model_config = ConfigDict(extra="forbid")


class InsightRegenerateRequest(BaseModel):
"""Request payload to bust cache and regenerate an insight snapshot."""

window: InsightWindow = "24h"

model_config = ConfigDict(extra="forbid")


class InsightShareRequest(BaseModel):
"""Request payload to create a share token for an insight window."""

window: InsightWindow = "24h"

model_config = ConfigDict(extra="forbid")


class InsightShareResponse(BaseModel):
"""Response payload for a created share link."""

token: str
url: str
window: InsightWindow
generated_at: datetime
cache_expires_at: datetime | None = None

model_config = ConfigDict(extra="forbid")


class InsightShareModel(Base):
"""Persisted share token and payload for read-only access."""

__tablename__ = "insight_shares"

token_hash: Mapped[str] = mapped_column(String(128), primary_key=True)
window: Mapped[str] = mapped_column(String(8), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
last_accessed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
payload: Mapped[dict] = mapped_column(JSON, nullable=False)
5 changes: 5 additions & 0 deletions backend/app/services/insights/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from __future__ import annotations

from backend.app.services.insights.service import InsightService

__all__ = ["InsightService"]
Loading