mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
135 lines
6.4 KiB
Python
135 lines
6.4 KiB
Python
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from typing import Any, Protocol
|
|
|
|
from insights.schema import AlertCondition, AlertConditionType, IntervalType
|
|
|
|
from insights.api.services.query import ExecutionMode
|
|
from insights.models.team import Team
|
|
from insights.models.user import User
|
|
|
|
from products.alerts.backend.models.alert import AlertConfiguration
|
|
from products.product_analytics.backend.models.insight import Insight
|
|
|
|
|
|
@dataclass
|
|
class SeriesPoint:
|
|
date: str | None # None for non-time-series (single aggregated value)
|
|
value: float | None # None = missing data point this interval
|
|
|
|
|
|
@dataclass
|
|
class ComparableSeries:
|
|
label: str # series / breakdown label, used in breach messages
|
|
points: list[SeriesPoint] # chronologically ascending; extractor guarantees order
|
|
current_index: int # comparison anchor; previous = current_index - 1
|
|
is_current_interval: bool = False # anchor is the ongoing (incomplete) interval — affects breach wording
|
|
|
|
|
|
@dataclass
|
|
class ExtractionResult:
|
|
"""Everything the comparator needs from an extractor, so the dispatcher stays kind-agnostic.
|
|
|
|
``subject`` and ``framed`` shape the breach message: framed trends messages read
|
|
"The insight value (label) for previous day (...)"; unframed messages (e.g. SQL insights,
|
|
which have no series label or interval) read "The SQL insight value (...)".
|
|
"""
|
|
|
|
series: list[ComparableSeries]
|
|
is_breakdown: bool = False # breakdown queries report no single no-breach value
|
|
interval_type: IntervalType | None = None # breach-message interval framing (time-series trends only)
|
|
subject: str = "The insight value" # breach-message subject
|
|
framed: bool = True # include the "(label) for current/previous interval" framing
|
|
unit: str = "" # appended to breach-message values (e.g. "%" for funnel conversion rates)
|
|
# Detector-path-only: True means the query returned zero rows, so the metric is genuinely 0. An
|
|
# empty ``series`` with this False instead means rows existed but none were long enough to score —
|
|
# the detector reports that as an uncomputed value (None). Threshold extractors never set this
|
|
# (they emit a zero sentinel series) and the comparator ignores it.
|
|
empty_query_result: bool = False
|
|
# When True, every breaching series is reported (capped) instead of stopping at the first —
|
|
# any-row SQL alerts use this so the notification names all violating rows. Trends breakdowns
|
|
# keep first-breach-only for parity with their historical messages.
|
|
aggregate_breaches: bool = False
|
|
# Name the (single) series in the breach message even when it isn't a breakdown — single-row SQL
|
|
# alerts set this so a resolved label column surfaces, e.g. "(Burn rate 24h)". Breakdowns already
|
|
# name every row via ``is_breakdown``; this covers the one-series case where the name is meaningful.
|
|
include_series_label: bool = False
|
|
# Optional per-value display formatter mirroring the insight's axis format (currency, prefix/postfix,
|
|
# decimals, duration, %). None → the comparator falls back to raw ``f"{value}{unit}"``. Only the
|
|
# trends extractor sets it today; the PERCENTAGE-threshold path ignores it (relative % ratios).
|
|
value_formatter: Callable[[float], str] | None = None
|
|
|
|
|
|
def zero_sentinel_series() -> ComparableSeries:
|
|
"""The shared empty-result sentinel: two zero points so relative conditions compute
|
|
0 - 0 = 0 rather than skipping for lack of a previous point; absolute reads 0 at the anchor."""
|
|
return ComparableSeries(
|
|
label="empty result",
|
|
points=[SeriesPoint(date=None, value=0.0), SeriesPoint(date=None, value=0.0)],
|
|
current_index=1,
|
|
)
|
|
|
|
|
|
class AlertExtractionError(Exception):
|
|
"""The alert cannot be evaluated as configured (wrong query shape, bad config).
|
|
|
|
Routed to the errored-alert notification path — distinct from "evaluated fine,
|
|
no data this interval", which is represented as SeriesPoint(value=None).
|
|
"""
|
|
|
|
|
|
def lookback_intervals_for(condition: AlertCondition) -> int:
|
|
"""How many trailing intervals an extractor must fetch for this condition.
|
|
|
|
Absolute needs the current + previous interval (2); relative needs three intervals
|
|
because, when the current interval is still accumulating, it compares the previous
|
|
interval against the one before it.
|
|
"""
|
|
match condition.type:
|
|
case AlertConditionType.ABSOLUTE_VALUE:
|
|
return 2
|
|
case AlertConditionType.RELATIVE_INCREASE | AlertConditionType.RELATIVE_DECREASE:
|
|
return 3
|
|
case _:
|
|
raise AlertExtractionError(f"Unsupported alert condition type: {condition.type}")
|
|
|
|
|
|
def execution_mode_for_alert(interval: IntervalType | None, *, high_frequency: bool) -> ExecutionMode:
|
|
"""Pick the query execution mode for an alert check, shared by every extractor."""
|
|
if interval == IntervalType.HOUR or high_frequency:
|
|
return ExecutionMode.CALCULATE_BLOCKING_ALWAYS
|
|
return ExecutionMode.RECENT_CACHE_CALCULATE_BLOCKING_IF_STALE
|
|
|
|
|
|
@dataclass
|
|
class SimulationContext:
|
|
"""Alert-less inputs for a read-only detector simulation. Each extractor reads only the fields its
|
|
kind needs: trends uses ``series_index``/``date_from``, SQL uses ``config``; both use ``team``,
|
|
``user``, and ``detector_config`` (the latter sizes the lookback window)."""
|
|
|
|
team: Team
|
|
detector_config: dict[str, Any]
|
|
user: User | None = None
|
|
series_index: int = 0
|
|
date_from: str | None = None
|
|
config: dict[str, Any] | None = None
|
|
|
|
|
|
class Extractor(Protocol):
|
|
# The dispatcher resolves execution_mode once (via execution_mode_for_alert) and passes it in, so
|
|
# the cache/recompute decision lives at one site instead of being re-derived in each extractor.
|
|
def extract(
|
|
self, alert: AlertConfiguration, insight: Insight, query: object, execution_mode: ExecutionMode
|
|
) -> ExtractionResult: ...
|
|
|
|
|
|
class DetectorExtractor(Extractor, Protocol):
|
|
"""An ``Extractor`` that can also build its series for a read-only simulation (no
|
|
``AlertConfiguration``). One implementation per detector-supported kind, registered in
|
|
``dispatcher.DETECTOR_EXTRACTORS`` — the single source of truth for both the alert-check path
|
|
(``extract``) and the simulation path (``simulate``)."""
|
|
|
|
def simulate(self, insight: Insight, query: object, ctx: SimulationContext) -> tuple[ExtractionResult, str | None]:
|
|
"""Return the extracted series plus the chart interval (None for kinds with no time interval)."""
|
|
...
|