mirror of
https://github.com/hanzoai/insights.git
synced 2026-11-02 14:32:45 +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.
127 lines
5.1 KiB
Python
127 lines
5.1 KiB
Python
from dataclasses import dataclass, field
|
|
from enum import StrEnum
|
|
from typing import Any, Protocol
|
|
|
|
from insights.models.team import Team
|
|
from insights.rbac.user_access_control import UserAccessControl
|
|
|
|
from products.pulse.backend.models import BriefConfig, ResourceLink, ResourceType
|
|
|
|
|
|
# Kept in sync with models.ResourceType (same members) — a cited evidence ref becomes a ResourceLink.
|
|
class EvidenceType(StrEnum):
|
|
INSIGHT = "insight"
|
|
DASHBOARD = "dashboard"
|
|
ANNOTATION = "annotation"
|
|
EXPERIMENT = "experiment"
|
|
ALERT = "alert"
|
|
SUBSCRIPTION = "subscription"
|
|
EVENT = "event"
|
|
|
|
|
|
class SourceItemKind(StrEnum):
|
|
MOVEMENT = "movement"
|
|
# Background that may explain movements (annotations, deploy markers); never an opportunity on its own.
|
|
CONTEXT = "context"
|
|
HEALTH = "health"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EvidenceRef:
|
|
"""A cited Insights resource. Owns its type-dispatch logic — its citation key, the ResourceLink
|
|
resource_type and FK field it maps to — so callers never branch on `type` themselves."""
|
|
|
|
type: EvidenceType
|
|
ref: str
|
|
label: str
|
|
url: str = "" # deep link into the app; "" when the ref has no navigable target
|
|
|
|
def __post_init__(self) -> None:
|
|
# The value crossing the Temporal boundary arrives as the enum's string (activity returns
|
|
# asdict(item); synthesize rebuilds via SourceItem(**item)). Coerce it back so `type` is
|
|
# always a real EvidenceType. `raw` is Any so the coercion isn't seen as dead code.
|
|
raw: Any = self.type
|
|
if not isinstance(raw, EvidenceType):
|
|
object.__setattr__(self, "type", EvidenceType(raw))
|
|
|
|
@property
|
|
def key(self) -> tuple[EvidenceType, str]:
|
|
"""Stable identity of the cited resource — dedup and citation-id lookups key on this."""
|
|
return (self.type, self.ref)
|
|
|
|
@property
|
|
def is_insight(self) -> bool:
|
|
return self.type == EvidenceType.INSIGHT
|
|
|
|
@property
|
|
def citation(self) -> dict[str, str]:
|
|
"""Structured citation the frontend renders directly — no client-side parsing of the ref."""
|
|
return {"type": self.type.value, "ref": self.ref, "label": self.label, "url": self.url}
|
|
|
|
@property
|
|
def metric_ref(self) -> dict[str, str] | None:
|
|
"""This ref's contribution to an opportunity's metric_ref, when it is the backing insight."""
|
|
return {"insight_short_id": self.ref} if self.is_insight else None
|
|
|
|
@property
|
|
def resource_type(self) -> ResourceType:
|
|
"""The ResourceLink resource_type; a type with no Django model is stored as an event."""
|
|
return ResourceType(self.type) if self.type in ResourceType.values else ResourceType.EVENT
|
|
|
|
@property
|
|
def fk_field(self) -> str | None:
|
|
"""Name of the ResourceLink FK to populate for this ref, or None for events (no model)."""
|
|
return ResourceLink.fk_field_for(self.resource_type)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SourceItem:
|
|
source: str
|
|
kind: SourceItemKind
|
|
title: str
|
|
description: str
|
|
# `metrics`, not `numbers`: these are the source's computed metric values (pct_change,
|
|
# totals, ...) that the LLM may reference and that become the opportunity baseline snapshot.
|
|
metrics: dict[str, float | int | str] = field(default_factory=dict)
|
|
evidence: list[EvidenceRef] = field(default_factory=list)
|
|
fingerprint_hint: str = ""
|
|
|
|
def __post_init__(self) -> None:
|
|
# Same Temporal-boundary coercion as EvidenceRef: rebuild the enum and nested EvidenceRefs
|
|
# from the strings/dicts that come back via asdict(item) -> SourceItem(**item). The `raw`
|
|
# locals are Any so the type checker doesn't treat the coercion as unreachable.
|
|
raw_kind: Any = self.kind
|
|
if not isinstance(raw_kind, SourceItemKind):
|
|
object.__setattr__(self, "kind", SourceItemKind(raw_kind))
|
|
raw_evidence: list[Any] = list(self.evidence)
|
|
if raw_evidence and not isinstance(raw_evidence[0], EvidenceRef):
|
|
object.__setattr__(self, "evidence", [EvidenceRef(**e) for e in raw_evidence])
|
|
|
|
|
|
def build_evidence_index(items: list[SourceItem]) -> dict[str, EvidenceRef]:
|
|
"""Assign stable citation ids (c1..cN) to the distinct evidence refs across all items.
|
|
|
|
Deduped by ref key in item then evidence order, so the same physical resource cited by two
|
|
items shares one id. Both synthesize (rendering ids for the LLM) and persist (resolving the
|
|
ids the LLM cited back to structured refs) call this on the same items, so the ids match
|
|
deterministically without passing the index across the Temporal boundary.
|
|
"""
|
|
index: dict[str, EvidenceRef] = {}
|
|
seen: dict[tuple[EvidenceType, str], str] = {}
|
|
for item in items:
|
|
for evidence in item.evidence:
|
|
if evidence.key in seen:
|
|
continue
|
|
citation_id = f"c{len(seen) + 1}"
|
|
seen[evidence.key] = citation_id
|
|
index[citation_id] = evidence
|
|
return index
|
|
|
|
|
|
class BriefSource(Protocol):
|
|
name: str
|
|
|
|
def gather(
|
|
self, team: Team, config: BriefConfig | None, lookback_days: int, user_access_control: UserAccessControl
|
|
) -> list[SourceItem]: ...
|