Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
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.
2026-08-01 15:44:40 -07:00

172 lines
6.8 KiB
Python

"""Who a scanner's findings affected: counted from observations, exportable as a static cohort."""
from dataclasses import dataclass
from datetime import timedelta
from django.db.models import Case, CharField, Count, FloatField, Func, Q, QuerySet, Value, When
from django.db.models.fields.json import KeyTextTransform, KeyTransform
from django.db.models.functions import Cast, Coalesce
from django.utils import timezone
from insights.models.user import User
from products.cohorts.backend.models.cohort import Cohort
from products.replay_vision.backend.models.replay_observation import ObservationStatus, ReplayObservation
from products.replay_vision.backend.models.replay_scanner import ReplayScanner, ScannerType
DEFAULT_IMPACT_WINDOW_DAYS = 30
# Bounds the synchronous in-request insert.
MAX_COHORT_DISTINCT_IDS = 10_000
# Stable prefix so Vision can re-identify its own cohorts.
COHORT_NAME_PREFIX = "Matched by "
# A distinct_id may still be an anonymous device id, not an identified person.
_HAS_USER = ~Q(distinct_id__isnull=True) & ~Q(distinct_id="")
@dataclass(frozen=True)
class ScannerImpact:
affected_sessions: int
affected_users: int
sessions_without_user: int
window_days: int
def affected_observations(
scanner: ReplayScanner,
window_days: int,
*,
tag: str | None = None,
min_score: float | None = None,
max_score: float | None = None,
) -> QuerySet[ReplayObservation]:
"""Succeeded observations matching the type's impact predicate; raises ValueError on invalid qualifiers."""
since = timezone.now() - timedelta(days=window_days)
base = ReplayObservation.objects.annotate(
# Session time, not scan time: backfills must not count as current impact.
_affected_at=Coalesce("session_started_at", "created_at"),
).filter(
scanner=scanner,
team_id=scanner.team_id,
status=ObservationStatus.SUCCEEDED,
_affected_at__gte=since,
)
scanner_type = scanner.scanner_type
has_scores = min_score is not None or max_score is not None
if scanner_type == ScannerType.MONITOR:
if tag or has_scores:
raise ValueError("Monitor impact is verdict-based; tag and score filters don't apply.")
return base.filter(scanner_result__model_output__verdict="yes")
if scanner_type == ScannerType.CLASSIFIER:
if has_scores:
raise ValueError("Classifiers don't produce scores; use `tag` instead.")
if not tag:
raise ValueError("Classifier impact requires `tag`: the tag whose affected users you want.")
# Same predicate as the observations list filter.
return base.filter(
Q(scanner_result__model_output__tags__contains=[tag])
| Q(scanner_result__model_output__tags_freeform__contains=[tag])
)
if scanner_type == ScannerType.SCORER:
if tag:
raise ValueError("Scorers don't produce tags; use `min_score`/`max_score` instead.")
if not has_scores:
raise ValueError("Scorer impact requires `min_score` and/or `max_score`.")
score_jsonb = KeyTransform("score", KeyTransform("model_output", "scanner_result"))
score_text = KeyTextTransform("score", KeyTextTransform("model_output", "scanner_result"))
# CASE-guard the cast so a non-numeric score (schema drift) doesn't 500 the query.
qs = base.annotate(
_score_type=Func(score_jsonb, function="jsonb_typeof", output_field=CharField()),
_score=Case(
When(_score_type="number", then=Cast(score_text, FloatField())),
default=Value(None),
output_field=FloatField(),
),
)
if min_score is not None:
qs = qs.filter(_score__gte=min_score)
if max_score is not None:
qs = qs.filter(_score__lte=max_score)
return qs
raise ValueError(f"Impact is not available for {scanner_type} scanners.")
def compute_scanner_impact(
scanner: ReplayScanner,
window_days: int = DEFAULT_IMPACT_WINDOW_DAYS,
*,
tag: str | None = None,
min_score: float | None = None,
max_score: float | None = None,
) -> ScannerImpact:
aggregates = affected_observations(
scanner, window_days, tag=tag, min_score=min_score, max_score=max_score
).aggregate(
affected_sessions=Count("session_id", distinct=True),
affected_users=Count("distinct_id", filter=_HAS_USER, distinct=True),
sessions_without_user=Count("session_id", filter=~_HAS_USER, distinct=True),
)
return ScannerImpact(window_days=window_days, **aggregates)
def _qualifier_label(tag: str | None, min_score: float | None, max_score: float | None) -> str:
if tag:
return f", tag {tag}"
if min_score is not None and max_score is not None:
return f", score {min_score:g} to {max_score:g}"
if min_score is not None:
return f", score {min_score:g}+"
if max_score is not None:
return f", score up to {max_score:g}"
return ""
def create_affected_cohort(
scanner: ReplayScanner,
user: User | None,
window_days: int = DEFAULT_IMPACT_WINDOW_DAYS,
*,
tag: str | None = None,
min_score: float | None = None,
max_score: float | None = None,
) -> tuple[Cohort, int]:
"""Static cohort of matched users; returns (cohort, real member count). Raises ValueError when not creatable."""
distinct_ids = list(
affected_observations(scanner, window_days, tag=tag, min_score=min_score, max_score=max_score)
.filter(_HAS_USER)
.values_list("distinct_id", flat=True)
.distinct()[: MAX_COHORT_DISTINCT_IDS + 1]
)
if not distinct_ids:
raise ValueError("No users in the window to save as a cohort.")
if len(distinct_ids) > MAX_COHORT_DISTINCT_IDS:
raise ValueError(f"Too many users to save as one cohort (over {MAX_COHORT_DISTINCT_IDS:,}). Narrow the window.")
qualifier = _qualifier_label(tag, min_score, max_score)
cohort = Cohort.objects.create(
team_id=scanner.team_id,
name=f"{COHORT_NAME_PREFIX}{scanner.name}{qualifier} ({timezone.now().date().isoformat()})"[:400],
description=(
f"Users matched by the '{scanner.name}' scanner{qualifier} in the last {window_days} days. Static snapshot."
),
is_static=True,
created_by=user,
)
try:
cohort.insert_users_by_list(distinct_ids, team_id=scanner.team_id, raise_on_error=True)
except Exception:
# Don't leave a partial cohort behind.
cohort.delete()
raise
cohort.refresh_from_db()
# Person-less ids are dropped and merged persons dedupe during insert.
inserted = cohort.count or 0
if inserted == 0:
cohort.delete()
raise ValueError("None of the matched users have a person profile to add to a cohort.")
return cohort, inserted