Implement executing tasks

This commit is contained in:
2026-03-03 16:41:28 +00:00
parent d6bd56dace
commit 9c14e51b43
42 changed files with 3410 additions and 121 deletions

18
core/presence/__init__.py Normal file
View File

@@ -0,0 +1,18 @@
from .engine import (
AvailabilitySignal,
ensure_fading_state,
get_settings,
record_inferred_signal,
record_native_signal,
)
from .query import latest_state_for_people, spans_for_range
__all__ = [
"AvailabilitySignal",
"ensure_fading_state",
"get_settings",
"record_inferred_signal",
"record_native_signal",
"latest_state_for_people",
"spans_for_range",
]

175
core/presence/engine.py Normal file
View File

@@ -0,0 +1,175 @@
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"}
@dataclass(slots=True)
class AvailabilitySignal:
user: User
person: Person
person_identifier: PersonIdentifier | None
service: str
source_kind: str
availability_state: str
confidence: float = 0.8
ts: int = 0
payload: dict | None = None
def get_settings(user: User) -> ContactAvailabilitySettings:
settings_row, _ = ContactAvailabilitySettings.objects.get_or_create(user=user)
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 _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:
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
def record_inferred_signal(signal: AvailabilitySignal) -> ContactAvailabilityEvent | 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()
def ensure_fading_state(
*,
user: User,
person: Person,
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,
)
)

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
import time
def now_ms() -> int:
return int(time.time() * 1000)
def fade_confidence(elapsed_ms: int, fade_threshold_seconds: int) -> float:
"""
Convert elapsed inactivity to a confidence score for fading state.
Confidence starts at 0.7 when fading begins and decays toward 0.2 over
4x fade threshold windows.
"""
threshold_ms = max(1, int(fade_threshold_seconds or 900) * 1000)
if elapsed_ms <= threshold_ms:
return 0.7
over = min(4.0, float(elapsed_ms - threshold_ms) / float(threshold_ms))
return max(0.2, 0.7 - (over * 0.125))
def should_fade(last_event_ts: int, now_ts: int, fade_threshold_seconds: int) -> bool:
if last_event_ts <= 0:
return False
threshold_ms = max(1, int(fade_threshold_seconds or 900) * 1000)
return int(now_ts) - int(last_event_ts) >= threshold_ms

60
core/presence/query.py Normal file
View File

@@ -0,0 +1,60 @@
from __future__ import annotations
from django.db.models import Q
from core.models import ContactAvailabilityEvent, ContactAvailabilitySpan, Person, User
from .engine import ensure_fading_state
from .inference import now_ms
def spans_for_range(
*,
user: User,
person: Person,
start_ts: int,
end_ts: int,
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())
ensure_fading_state(
user=user,
person=person,
person_identifier=None,
service=(str(service or "").strip().lower() or "signal"),
at_ts=now_ms(),
)
return list(qs.order_by("-end_ts")[: 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"))
seen = set()
for row in rows:
person_key = str(row.person_id)
if person_key in seen:
continue
seen.add(person_key)
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 ""),
}
return out