Implement Manticore fully and re-theme
This commit is contained in:
@@ -2,25 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from core.models import (
|
||||
ContactAvailabilityEvent,
|
||||
ContactAvailabilitySettings,
|
||||
ContactAvailabilitySpan,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
User,
|
||||
)
|
||||
|
||||
from .inference import fade_confidence, now_ms, should_fade
|
||||
|
||||
POSITIVE_SOURCE_KINDS = {
|
||||
"native_presence",
|
||||
"read_receipt",
|
||||
"typing_start",
|
||||
"message_in",
|
||||
}
|
||||
from core.models import ContactAvailabilitySettings, Person, PersonIdentifier, User
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -41,95 +23,24 @@ def get_settings(user: User) -> ContactAvailabilitySettings:
|
||||
return settings_row
|
||||
|
||||
|
||||
def _normalize_ts(value: int | None) -> int:
|
||||
try:
|
||||
ts = int(value or 0)
|
||||
except Exception:
|
||||
ts = 0
|
||||
return ts if ts > 0 else now_ms()
|
||||
def record_native_signal(signal: AvailabilitySignal) -> AvailabilitySignal | None:
|
||||
"""
|
||||
Compatibility adapter for existing router call sites.
|
||||
|
||||
|
||||
def _upsert_spans_for_event(event: ContactAvailabilityEvent) -> None:
|
||||
prev = (
|
||||
ContactAvailabilitySpan.objects.filter(
|
||||
user=event.user,
|
||||
person=event.person,
|
||||
service=event.service,
|
||||
)
|
||||
.order_by("-end_ts", "-id")
|
||||
.first()
|
||||
)
|
||||
|
||||
if prev and prev.state == event.availability_state:
|
||||
prev.end_ts = max(int(prev.end_ts or 0), int(event.ts or 0))
|
||||
prev.confidence_end = float(event.confidence or 0.0)
|
||||
prev.closing_event = event
|
||||
prev.payload = dict(prev.payload or {})
|
||||
prev.payload.update({"extended_by": str(event.source_kind or "")})
|
||||
prev.save(
|
||||
update_fields=[
|
||||
"end_ts",
|
||||
"confidence_end",
|
||||
"closing_event",
|
||||
"payload",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
return
|
||||
|
||||
ContactAvailabilitySpan.objects.create(
|
||||
user=event.user,
|
||||
person=event.person,
|
||||
person_identifier=event.person_identifier,
|
||||
service=event.service,
|
||||
state=event.availability_state,
|
||||
start_ts=int(event.ts or 0),
|
||||
end_ts=int(event.ts or 0),
|
||||
confidence_start=float(event.confidence or 0.0),
|
||||
confidence_end=float(event.confidence or 0.0),
|
||||
opening_event=event,
|
||||
closing_event=event,
|
||||
payload=dict(event.payload or {}),
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def record_native_signal(signal: AvailabilitySignal) -> ContactAvailabilityEvent | None:
|
||||
Availability state is now derived from behavioral events in Manticore, so this
|
||||
function no longer persists a separate ORM availability row.
|
||||
"""
|
||||
settings_row = get_settings(signal.user)
|
||||
if not settings_row.enabled:
|
||||
return None
|
||||
|
||||
event = ContactAvailabilityEvent.objects.create(
|
||||
user=signal.user,
|
||||
person=signal.person,
|
||||
person_identifier=signal.person_identifier,
|
||||
service=str(signal.service or "").strip().lower() or "signal",
|
||||
source_kind=str(signal.source_kind or "").strip() or "native_presence",
|
||||
availability_state=str(signal.availability_state or "unknown").strip()
|
||||
or "unknown",
|
||||
confidence=float(signal.confidence or 0.0),
|
||||
ts=_normalize_ts(signal.ts),
|
||||
payload=dict(signal.payload or {}),
|
||||
)
|
||||
_upsert_spans_for_event(event)
|
||||
_prune_old_data(signal.user, settings_row.retention_days)
|
||||
return event
|
||||
return signal
|
||||
|
||||
|
||||
def record_inferred_signal(
|
||||
signal: AvailabilitySignal,
|
||||
) -> ContactAvailabilityEvent | None:
|
||||
def record_inferred_signal(signal: AvailabilitySignal) -> AvailabilitySignal | None:
|
||||
settings_row = get_settings(signal.user)
|
||||
if not settings_row.enabled or not settings_row.inference_enabled:
|
||||
return None
|
||||
return record_native_signal(signal)
|
||||
|
||||
|
||||
def _prune_old_data(user: User, retention_days: int) -> None:
|
||||
days = max(1, int(retention_days or 90))
|
||||
cutoff = now_ms() - (days * 24 * 60 * 60 * 1000)
|
||||
ContactAvailabilityEvent.objects.filter(user=user, ts__lt=cutoff).delete()
|
||||
ContactAvailabilitySpan.objects.filter(user=user, end_ts__lt=cutoff).delete()
|
||||
return signal
|
||||
|
||||
|
||||
def ensure_fading_state(
|
||||
@@ -139,48 +50,5 @@ def ensure_fading_state(
|
||||
person_identifier: PersonIdentifier | None,
|
||||
service: str,
|
||||
at_ts: int | None = None,
|
||||
) -> ContactAvailabilityEvent | None:
|
||||
settings_row = get_settings(user)
|
||||
if not settings_row.enabled or not settings_row.inference_enabled:
|
||||
return None
|
||||
|
||||
current_ts = _normalize_ts(at_ts)
|
||||
latest = (
|
||||
ContactAvailabilityEvent.objects.filter(
|
||||
user=user,
|
||||
person=person,
|
||||
service=str(service or "").strip().lower(),
|
||||
)
|
||||
.order_by("-ts", "-id")
|
||||
.first()
|
||||
)
|
||||
if latest is None:
|
||||
return None
|
||||
if latest.availability_state in {"fading", "unavailable"}:
|
||||
return None
|
||||
if latest.source_kind not in POSITIVE_SOURCE_KINDS:
|
||||
return None
|
||||
if not should_fade(
|
||||
int(latest.ts or 0), current_ts, settings_row.fade_threshold_seconds
|
||||
):
|
||||
return None
|
||||
|
||||
elapsed = max(0, current_ts - int(latest.ts or 0))
|
||||
payload = {
|
||||
"inferred_from": latest.source_kind,
|
||||
"last_signal_ts": int(latest.ts or 0),
|
||||
"elapsed_ms": elapsed,
|
||||
}
|
||||
return record_inferred_signal(
|
||||
AvailabilitySignal(
|
||||
user=user,
|
||||
person=person,
|
||||
person_identifier=person_identifier,
|
||||
service=service,
|
||||
source_kind="inferred_timeout",
|
||||
availability_state="fading",
|
||||
confidence=fade_confidence(elapsed, settings_row.fade_threshold_seconds),
|
||||
ts=current_ts,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db.models import Q
|
||||
from core.events.behavior import parse_payload
|
||||
from core.events.manticore import (
|
||||
get_behavioral_events_for_range,
|
||||
get_behavioral_latest_states,
|
||||
)
|
||||
from core.events.shadow import (
|
||||
get_shadow_behavioral_events_for_range,
|
||||
get_shadow_behavioral_latest_states,
|
||||
)
|
||||
from core.models import Person, User
|
||||
|
||||
from core.models import ContactAvailabilityEvent, ContactAvailabilitySpan, Person, User
|
||||
|
||||
from .engine import ensure_fading_state
|
||||
from .inference import now_ms
|
||||
def _behavioral_state_from_kind(kind: str) -> tuple[str, float]:
|
||||
normalized = str(kind or "").strip().lower()
|
||||
if normalized == "presence_unavailable":
|
||||
return ("unavailable", 0.95)
|
||||
if normalized == "composing_abandoned":
|
||||
return ("fading", 0.8)
|
||||
if normalized in {
|
||||
"presence_available",
|
||||
"message_read",
|
||||
"message_delivered",
|
||||
"composing_started",
|
||||
"composing_stopped",
|
||||
"message_sent",
|
||||
}:
|
||||
return ("available", 0.75)
|
||||
return ("unknown", 0.5)
|
||||
|
||||
|
||||
def spans_for_range(
|
||||
@@ -17,43 +39,98 @@ def spans_for_range(
|
||||
service: str = "",
|
||||
limit: int = 200,
|
||||
):
|
||||
qs = ContactAvailabilitySpan.objects.filter(
|
||||
user=user,
|
||||
person=person,
|
||||
).filter(Q(start_ts__lte=end_ts) & Q(end_ts__gte=start_ts))
|
||||
if service:
|
||||
qs = qs.filter(service=str(service).strip().lower())
|
||||
service_filter = str(service or "").strip().lower()
|
||||
try:
|
||||
rows = get_behavioral_events_for_range(
|
||||
user_id=int(user.id),
|
||||
person_id=str(person.id),
|
||||
start_ts=int(start_ts),
|
||||
end_ts=int(end_ts),
|
||||
transport=service_filter,
|
||||
)
|
||||
except Exception:
|
||||
rows = []
|
||||
if not rows:
|
||||
rows = get_shadow_behavioral_events_for_range(
|
||||
user=user,
|
||||
person_id=str(person.id),
|
||||
start_ts=int(start_ts),
|
||||
end_ts=int(end_ts),
|
||||
transport=service_filter,
|
||||
)
|
||||
|
||||
ensure_fading_state(
|
||||
user=user,
|
||||
person=person,
|
||||
person_identifier=None,
|
||||
service=(str(service or "").strip().lower() or "signal"),
|
||||
at_ts=now_ms(),
|
||||
)
|
||||
spans = []
|
||||
current = None
|
||||
for row in list(rows or []):
|
||||
transport = str(row.get("transport") or "").strip().lower()
|
||||
if service_filter and transport != service_filter:
|
||||
continue
|
||||
ts = int(row.get("ts") or 0)
|
||||
state, confidence = _behavioral_state_from_kind(str(row.get("kind") or ""))
|
||||
if current is None or str(current.get("state")) != state:
|
||||
if current is not None:
|
||||
spans.append(current)
|
||||
current = {
|
||||
"id": 0,
|
||||
"service": transport or service_filter,
|
||||
"state": state,
|
||||
"start_ts": ts,
|
||||
"end_ts": ts,
|
||||
"confidence_start": float(confidence),
|
||||
"confidence_end": float(confidence),
|
||||
"payload": {
|
||||
"source": "manticore_behavioral",
|
||||
"kind": str(row.get("kind") or "").strip().lower(),
|
||||
"raw_payload": parse_payload(row.get("payload")),
|
||||
},
|
||||
}
|
||||
continue
|
||||
current["end_ts"] = max(int(current.get("end_ts") or 0), ts)
|
||||
current["confidence_end"] = float(confidence)
|
||||
payload = dict(current.get("payload") or {})
|
||||
payload["kind"] = str(row.get("kind") or "").strip().lower()
|
||||
current["payload"] = payload
|
||||
|
||||
return list(qs.order_by("-end_ts")[: max(1, min(int(limit or 200), 500))])
|
||||
if current is not None:
|
||||
spans.append(current)
|
||||
return list(reversed(spans[: max(1, min(int(limit or 200), 500))]))
|
||||
|
||||
|
||||
def latest_state_for_people(*, user: User, person_ids: list, service: str = "") -> dict:
|
||||
out = {}
|
||||
if not person_ids:
|
||||
return out
|
||||
qs = ContactAvailabilityEvent.objects.filter(user=user, person_id__in=person_ids)
|
||||
if service:
|
||||
qs = qs.filter(service=str(service).strip().lower())
|
||||
rows = list(qs.order_by("person_id", "-ts", "-id"))
|
||||
service_filter = str(service or "").strip().lower()
|
||||
try:
|
||||
rows = get_behavioral_latest_states(
|
||||
user_id=int(user.id),
|
||||
person_ids=[str(value) for value in person_ids],
|
||||
transport=service_filter,
|
||||
)
|
||||
except Exception:
|
||||
rows = []
|
||||
if not rows:
|
||||
rows = get_shadow_behavioral_latest_states(
|
||||
user=user,
|
||||
person_ids=[str(value) for value in person_ids],
|
||||
transport=service_filter,
|
||||
)
|
||||
|
||||
seen = set()
|
||||
for row in rows:
|
||||
person_key = str(row.person_id)
|
||||
if person_key in seen:
|
||||
for row in list(rows or []):
|
||||
person_key = str(row.get("person_id") or "").strip()
|
||||
transport = str(row.get("transport") or "").strip().lower()
|
||||
if service_filter and transport != service_filter:
|
||||
continue
|
||||
if not person_key or person_key in seen:
|
||||
continue
|
||||
seen.add(person_key)
|
||||
state, confidence = _behavioral_state_from_kind(str(row.get("kind") or ""))
|
||||
out[person_key] = {
|
||||
"state": str(row.availability_state or "unknown"),
|
||||
"confidence": float(row.confidence or 0.0),
|
||||
"service": str(row.service or ""),
|
||||
"ts": int(row.ts or 0),
|
||||
"source_kind": str(row.source_kind or ""),
|
||||
"state": state,
|
||||
"confidence": float(confidence),
|
||||
"service": transport or service_filter,
|
||||
"ts": int(row.get("ts") or 0),
|
||||
"source_kind": f"behavioral:{str(row.get('kind') or '').strip().lower()}",
|
||||
}
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user