Implement workspace history reconciliation
This commit is contained in:
@@ -1025,7 +1025,18 @@ class SignalClient(ClientBase):
|
|||||||
text=text,
|
text=text,
|
||||||
attachments=attachments,
|
attachments=attachments,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
|
detailed=True,
|
||||||
)
|
)
|
||||||
|
if isinstance(result, dict) and (not bool(result.get("ok"))):
|
||||||
|
status_value = int(result.get("status") or 0)
|
||||||
|
error_text = str(result.get("error") or "").strip()
|
||||||
|
recipient_value = str(result.get("recipient") or recipient).strip()
|
||||||
|
raise RuntimeError(
|
||||||
|
"signal_send_failed"
|
||||||
|
f" status={status_value or 'unknown'}"
|
||||||
|
f" recipient={recipient_value or 'unknown'}"
|
||||||
|
f" error={error_text or 'unknown'}"
|
||||||
|
)
|
||||||
if result is False or result is None:
|
if result is False or result is None:
|
||||||
raise RuntimeError("signal_send_failed")
|
raise RuntimeError("signal_send_failed")
|
||||||
transport.set_runtime_command_result(
|
transport.set_runtime_command_result(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import orjson
|
import orjson
|
||||||
@@ -9,6 +10,25 @@ from django.conf import settings
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
SIGNAL_UUID_PATTERN = re.compile(
|
||||||
|
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_signal_recipient(recipient: str) -> str:
|
||||||
|
raw = str(recipient or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
if SIGNAL_UUID_PATTERN.fullmatch(raw):
|
||||||
|
return raw
|
||||||
|
if raw.startswith("+"):
|
||||||
|
digits = re.sub(r"[^0-9]", "", raw)
|
||||||
|
return f"+{digits}" if digits else raw
|
||||||
|
digits_only = re.sub(r"[^0-9]", "", raw)
|
||||||
|
if digits_only and raw.isdigit():
|
||||||
|
return f"+{digits_only}"
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
async def start_typing(uuid):
|
async def start_typing(uuid):
|
||||||
@@ -73,7 +93,9 @@ async def download_and_encode_base64(file_url, filename, content_type, session=N
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def send_message_raw(recipient_uuid, text=None, attachments=None, metadata=None):
|
async def send_message_raw(
|
||||||
|
recipient_uuid, text=None, attachments=None, metadata=None, detailed=False
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Sends a message using the Signal REST API, ensuring attachment links are not included in the text body.
|
Sends a message using the Signal REST API, ensuring attachment links are not included in the text body.
|
||||||
|
|
||||||
@@ -88,8 +110,9 @@ async def send_message_raw(recipient_uuid, text=None, attachments=None, metadata
|
|||||||
base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/")
|
base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/")
|
||||||
url = f"{base}/v2/send"
|
url = f"{base}/v2/send"
|
||||||
|
|
||||||
|
normalized_recipient = normalize_signal_recipient(recipient_uuid)
|
||||||
data = {
|
data = {
|
||||||
"recipients": [recipient_uuid],
|
"recipients": [normalized_recipient],
|
||||||
"number": settings.SIGNAL_NUMBER,
|
"number": settings.SIGNAL_NUMBER,
|
||||||
"base64_attachments": [],
|
"base64_attachments": [],
|
||||||
}
|
}
|
||||||
@@ -168,8 +191,37 @@ async def send_message_raw(recipient_uuid, text=None, attachments=None, metadata
|
|||||||
ts = orjson.loads(response_text).get("timestamp", None)
|
ts = orjson.loads(response_text).get("timestamp", None)
|
||||||
return ts if ts else False
|
return ts if ts else False
|
||||||
if index == len(payloads) - 1:
|
if index == len(payloads) - 1:
|
||||||
|
log.warning(
|
||||||
|
"Signal send failed status=%s recipient=%s body=%s",
|
||||||
|
response_status,
|
||||||
|
normalized_recipient,
|
||||||
|
response_text[:300],
|
||||||
|
)
|
||||||
|
if detailed:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"status": int(response_status),
|
||||||
|
"error": str(response_text or "").strip()[:500],
|
||||||
|
"recipient": normalized_recipient,
|
||||||
|
}
|
||||||
return False
|
return False
|
||||||
if response_status not in {status.HTTP_400_BAD_REQUEST, status.HTTP_422_UNPROCESSABLE_ENTITY}:
|
if response_status not in {
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
}:
|
||||||
|
log.warning(
|
||||||
|
"Signal send failed early status=%s recipient=%s body=%s",
|
||||||
|
response_status,
|
||||||
|
normalized_recipient,
|
||||||
|
response_text[:300],
|
||||||
|
)
|
||||||
|
if detailed:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"status": int(response_status),
|
||||||
|
"error": str(response_text or "").strip()[:500],
|
||||||
|
"recipient": normalized_recipient,
|
||||||
|
}
|
||||||
return False
|
return False
|
||||||
log.warning(
|
log.warning(
|
||||||
"signal send quote payload rejected (%s), trying fallback shape: %s",
|
"signal send quote payload rejected (%s), trying fallback shape: %s",
|
||||||
@@ -305,7 +357,7 @@ def send_message_raw_sync(recipient_uuid, text=None, attachments=None):
|
|||||||
base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/")
|
base = getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080").rstrip("/")
|
||||||
url = f"{base}/v2/send"
|
url = f"{base}/v2/send"
|
||||||
data = {
|
data = {
|
||||||
"recipients": [recipient_uuid],
|
"recipients": [normalize_signal_recipient(recipient_uuid)],
|
||||||
"number": settings.SIGNAL_NUMBER,
|
"number": settings.SIGNAL_NUMBER,
|
||||||
"base64_attachments": [],
|
"base64_attachments": [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from core.models import (
|
|||||||
WorkspaceMetricSnapshot,
|
WorkspaceMetricSnapshot,
|
||||||
)
|
)
|
||||||
from core.views.workspace import _conversation_for_person
|
from core.views.workspace import _conversation_for_person
|
||||||
|
from core.workspace import compact_snapshot_rows
|
||||||
|
|
||||||
|
|
||||||
def _score_from_lag(lag_ms, target_hours=4):
|
def _score_from_lag(lag_ms, target_hours=4):
|
||||||
@@ -219,6 +220,7 @@ class Command(BaseCommand):
|
|||||||
parser.add_argument("--limit", type=int, default=200000)
|
parser.add_argument("--limit", type=int, default=200000)
|
||||||
parser.add_argument("--dry-run", action="store_true", default=False)
|
parser.add_argument("--dry-run", action="store_true", default=False)
|
||||||
parser.add_argument("--no-reset", action="store_true", default=False)
|
parser.add_argument("--no-reset", action="store_true", default=False)
|
||||||
|
parser.add_argument("--skip-compact", action="store_true", default=False)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
days = max(1, int(options.get("days") or 365))
|
days = max(1, int(options.get("days") or 365))
|
||||||
@@ -229,6 +231,7 @@ class Command(BaseCommand):
|
|||||||
limit = max(1, int(options.get("limit") or 200000))
|
limit = max(1, int(options.get("limit") or 200000))
|
||||||
dry_run = bool(options.get("dry_run"))
|
dry_run = bool(options.get("dry_run"))
|
||||||
reset = not bool(options.get("no_reset"))
|
reset = not bool(options.get("no_reset"))
|
||||||
|
compact_enabled = not bool(options.get("skip_compact"))
|
||||||
today_start = dj_timezone.now().astimezone(timezone.utc).replace(
|
today_start = dj_timezone.now().astimezone(timezone.utc).replace(
|
||||||
hour=0,
|
hour=0,
|
||||||
minute=0,
|
minute=0,
|
||||||
@@ -250,6 +253,7 @@ class Command(BaseCommand):
|
|||||||
deleted = 0
|
deleted = 0
|
||||||
snapshots_created = 0
|
snapshots_created = 0
|
||||||
checkpoints_total = 0
|
checkpoints_total = 0
|
||||||
|
compacted_deleted = 0
|
||||||
|
|
||||||
for person in people:
|
for person in people:
|
||||||
identifiers_qs = PersonIdentifier.objects.filter(user=person.user, person=person)
|
identifiers_qs = PersonIdentifier.objects.filter(user=person.user, person=person)
|
||||||
@@ -410,6 +414,28 @@ class Command(BaseCommand):
|
|||||||
"participant_feedback",
|
"participant_feedback",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
if compact_enabled:
|
||||||
|
snapshot_rows = list(
|
||||||
|
WorkspaceMetricSnapshot.objects.filter(conversation=conversation)
|
||||||
|
.order_by("computed_at", "id")
|
||||||
|
.values("id", "computed_at", "source_event_ts")
|
||||||
|
)
|
||||||
|
now_ts_ms = int(dj_timezone.now().timestamp() * 1000)
|
||||||
|
keep_ids = compact_snapshot_rows(
|
||||||
|
snapshot_rows=snapshot_rows,
|
||||||
|
now_ts_ms=now_ts_ms,
|
||||||
|
cutoff_ts_ms=cutoff_ts,
|
||||||
|
)
|
||||||
|
if keep_ids:
|
||||||
|
compacted_deleted += (
|
||||||
|
WorkspaceMetricSnapshot.objects.filter(conversation=conversation)
|
||||||
|
.exclude(id__in=list(keep_ids))
|
||||||
|
.delete()[0]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
compacted_deleted += WorkspaceMetricSnapshot.objects.filter(
|
||||||
|
conversation=conversation
|
||||||
|
).delete()[0]
|
||||||
|
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.SUCCESS(
|
self.style.SUCCESS(
|
||||||
@@ -418,6 +444,8 @@ class Command(BaseCommand):
|
|||||||
f"checkpoints={checkpoints_total} "
|
f"checkpoints={checkpoints_total} "
|
||||||
f"created={snapshots_created} "
|
f"created={snapshots_created} "
|
||||||
f"deleted={deleted} "
|
f"deleted={deleted} "
|
||||||
|
f"compacted_deleted={compacted_deleted} "
|
||||||
|
f"compact_enabled={compact_enabled} "
|
||||||
f"reset={reset} dry_run={dry_run} "
|
f"reset={reset} dry_run={dry_run} "
|
||||||
f"days={days} step_messages={step_messages} limit={limit}"
|
f"days={days} step_messages={step_messages} limit={limit}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,8 +14,25 @@
|
|||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Insight Graphs: {{ person.name }}</h1>
|
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Insight Graphs: {{ person.name }}</h1>
|
||||||
<p class="is-size-7 has-text-grey">
|
<p class="is-size-7 has-text-grey">
|
||||||
Historical metrics for workspace {{ workspace_conversation.id }}. Points come from deterministic message-history snapshots (not only mitigation runs).
|
Historical metrics for workspace {{ workspace_conversation.id }}. Points are range-downsampled server-side with high-resolution recent data and progressively sparser older ranges.
|
||||||
</p>
|
</p>
|
||||||
|
<div class="buttons are-small" style="margin: 0.5rem 0 0.25rem;">
|
||||||
|
<a
|
||||||
|
class="button {% if graph_density == 'low' %}is-dark{% else %}is-light{% endif %}"
|
||||||
|
href="?density=low">
|
||||||
|
Density: Low (max {{ graph_density_caps.low }})
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="button {% if graph_density == 'medium' %}is-dark{% else %}is-light{% endif %}"
|
||||||
|
href="?density=medium">
|
||||||
|
Density: Medium (max {{ graph_density_caps.medium }})
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="button {% if graph_density == 'high' %}is-dark{% else %}is-light{% endif %}"
|
||||||
|
href="?density=high">
|
||||||
|
Density: High (max {{ graph_density_caps.high }})
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
{% include "partials/ai-insight-nav.html" with active_tab="graphs" %}
|
{% include "partials/ai-insight-nav.html" with active_tab="graphs" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -26,7 +43,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="heading">{{ graph.group_title }}</p>
|
<p class="heading">{{ graph.group_title }}</p>
|
||||||
<p class="title is-6" style="margin-bottom: 0.2rem;">{{ graph.title }}</p>
|
<p class="title is-6" style="margin-bottom: 0.2rem;">{{ graph.title }}</p>
|
||||||
<p class="is-size-7 has-text-grey">{{ graph.count }} point{{ graph.count|pluralize }}</p>
|
<p class="is-size-7 has-text-grey">
|
||||||
|
{{ graph.count }} displayed of {{ graph.raw_count }} source point{{ graph.raw_count|pluralize }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="buttons are-small" style="margin: 0;">
|
<div class="buttons are-small" style="margin: 0;">
|
||||||
<a class="button is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric=graph.slug %}">
|
<a class="button is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric=graph.slug %}">
|
||||||
|
|||||||
@@ -66,13 +66,13 @@
|
|||||||
<table class="table is-fullwidth is-striped is-size-7" style="margin-bottom:0.9rem;">
|
<table class="table is-fullwidth is-striped is-size-7" style="margin-bottom:0.9rem;">
|
||||||
<thead><tr><th>Identifier</th><th>Service</th><th></th></tr></thead>
|
<thead><tr><th>Identifier</th><th>Service</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in person_identifiers %}
|
{% for row in person_identifier_rows %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>{{ row.identifier }}</code></td>
|
<td><code>{{ row.identifier }}</code></td>
|
||||||
<td>{{ row.service }}</td>
|
<td>{{ row.service }}</td>
|
||||||
<td class="has-text-right">
|
<td class="has-text-right">
|
||||||
{% if selected_project %}
|
{% if selected_project %}
|
||||||
{% if tuple(selected_project.id, row.service, row.identifier) in mapping_pairs %}
|
{% if row.mapped %}
|
||||||
<span class="tag task-stat-tag">Linked</span>
|
<span class="tag task-stat-tag">Linked</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
|||||||
@@ -130,7 +130,7 @@
|
|||||||
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
|
||||||
<span>Quick Insights</span>
|
<span>Quick Insights</span>
|
||||||
</button>
|
</button>
|
||||||
<a class="button is-light is-rounded" href="{{ tasks_group_url }}">
|
<a class="button is-light is-rounded" href="{{ tasks_hub_url }}">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-list-check"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-list-check"></i></span>
|
||||||
<span>Tasks</span>
|
<span>Tasks</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class ReconcileWorkspaceMetricHistoryCommandTests(TestCase):
|
|||||||
"36500",
|
"36500",
|
||||||
"--step-messages",
|
"--step-messages",
|
||||||
"2",
|
"2",
|
||||||
|
"--skip-compact",
|
||||||
)
|
)
|
||||||
conversation = _conversation_for_person(self.user, self.person)
|
conversation = _conversation_for_person(self.user, self.person)
|
||||||
first_count = WorkspaceMetricSnapshot.objects.filter(
|
first_count = WorkspaceMetricSnapshot.objects.filter(
|
||||||
@@ -72,6 +73,7 @@ class ReconcileWorkspaceMetricHistoryCommandTests(TestCase):
|
|||||||
"--step-messages",
|
"--step-messages",
|
||||||
"2",
|
"2",
|
||||||
"--no-reset",
|
"--no-reset",
|
||||||
|
"--skip-compact",
|
||||||
)
|
)
|
||||||
second_count = WorkspaceMetricSnapshot.objects.filter(
|
second_count = WorkspaceMetricSnapshot.objects.filter(
|
||||||
conversation=conversation
|
conversation=conversation
|
||||||
@@ -107,6 +109,7 @@ class ReconcileWorkspaceMetricHistoryCommandTests(TestCase):
|
|||||||
"36500",
|
"36500",
|
||||||
"--step-messages",
|
"--step-messages",
|
||||||
"2",
|
"2",
|
||||||
|
"--skip-compact",
|
||||||
)
|
)
|
||||||
conversation = _conversation_for_person(self.user, self.person)
|
conversation = _conversation_for_person(self.user, self.person)
|
||||||
first_count = WorkspaceMetricSnapshot.objects.filter(
|
first_count = WorkspaceMetricSnapshot.objects.filter(
|
||||||
@@ -124,8 +127,28 @@ class ReconcileWorkspaceMetricHistoryCommandTests(TestCase):
|
|||||||
"--step-messages",
|
"--step-messages",
|
||||||
"2",
|
"2",
|
||||||
"--no-reset",
|
"--no-reset",
|
||||||
|
"--skip-compact",
|
||||||
)
|
)
|
||||||
second_count = WorkspaceMetricSnapshot.objects.filter(
|
second_count = WorkspaceMetricSnapshot.objects.filter(
|
||||||
conversation=conversation
|
conversation=conversation
|
||||||
).count()
|
).count()
|
||||||
self.assertEqual(first_count, second_count)
|
self.assertEqual(first_count, second_count)
|
||||||
|
|
||||||
|
def test_reconcile_compacts_historical_snapshots_by_age_bucket(self):
|
||||||
|
call_command(
|
||||||
|
"reconcile_workspace_metric_history",
|
||||||
|
"--person-id",
|
||||||
|
str(self.person.id),
|
||||||
|
"--service",
|
||||||
|
"whatsapp",
|
||||||
|
"--days",
|
||||||
|
"36500",
|
||||||
|
"--step-messages",
|
||||||
|
"1",
|
||||||
|
)
|
||||||
|
conversation = _conversation_for_person(self.user, self.person)
|
||||||
|
count_after_compact = WorkspaceMetricSnapshot.objects.filter(
|
||||||
|
conversation=conversation
|
||||||
|
).count()
|
||||||
|
self.assertGreaterEqual(count_after_compact, 1)
|
||||||
|
self.assertLess(count_after_compact, 10)
|
||||||
|
|||||||
74
core/tests/test_signal_send_normalization.py
Normal file
74
core/tests/test_signal_send_normalization.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from django.test import TestCase
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from core.clients import signalapi
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, status_code: int, body: str):
|
||||||
|
self.status = int(status_code)
|
||||||
|
self._body = str(body)
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def text(self):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClientSession:
|
||||||
|
posted_payloads: list[dict] = []
|
||||||
|
next_status = 400
|
||||||
|
next_body = "invalid recipient"
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def post(self, url, json=None):
|
||||||
|
self.__class__.posted_payloads.append(dict(json or {}))
|
||||||
|
return _FakeResponse(self.__class__.next_status, self.__class__.next_body)
|
||||||
|
|
||||||
|
|
||||||
|
class SignalSendNormalizationTests(TestCase):
|
||||||
|
def test_normalize_signal_recipient_phone_and_uuid(self):
|
||||||
|
self.assertEqual("+447700900000", signalapi.normalize_signal_recipient("447700900000"))
|
||||||
|
self.assertEqual(
|
||||||
|
"+447700900000", signalapi.normalize_signal_recipient("+44 7700-900000")
|
||||||
|
)
|
||||||
|
uuid_value = "756078fd-d447-426d-a620-581a86d64f51"
|
||||||
|
self.assertEqual(uuid_value, signalapi.normalize_signal_recipient(uuid_value))
|
||||||
|
|
||||||
|
def test_send_message_raw_returns_detailed_error_with_normalized_recipient(self):
|
||||||
|
_FakeClientSession.posted_payloads = []
|
||||||
|
_FakeClientSession.next_status = 400
|
||||||
|
_FakeClientSession.next_body = "invalid recipient format"
|
||||||
|
with patch("core.clients.signalapi.aiohttp.ClientSession", _FakeClientSession):
|
||||||
|
result = async_to_sync(signalapi.send_message_raw)(
|
||||||
|
recipient_uuid="447700900000",
|
||||||
|
text="hello",
|
||||||
|
attachments=[],
|
||||||
|
metadata={},
|
||||||
|
detailed=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsInstance(result, dict)
|
||||||
|
self.assertFalse(bool(result.get("ok")))
|
||||||
|
self.assertEqual(400, int(result.get("status") or 0))
|
||||||
|
self.assertEqual("+447700900000", str(result.get("recipient") or ""))
|
||||||
|
self.assertIn("invalid recipient", str(result.get("error") or ""))
|
||||||
|
self.assertGreaterEqual(len(_FakeClientSession.posted_payloads), 1)
|
||||||
|
first_payload = _FakeClientSession.posted_payloads[0]
|
||||||
|
self.assertEqual(["+447700900000"], first_payload.get("recipients"))
|
||||||
|
|
||||||
@@ -3,60 +3,65 @@ from __future__ import annotations
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from core.models import ChatTaskSource, TaskEpic, TaskProject, User
|
from core.models import ChatTaskSource, Person, PersonIdentifier, TaskEpic, TaskProject, User
|
||||||
|
|
||||||
|
|
||||||
class TasksPagesManagementTests(TestCase):
|
class TasksPagesManagementTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user("tasks-pages-user", "tasks-pages@example.com", "x")
|
self.user = User.objects.create_user(
|
||||||
|
"tasks-pages-user", "tasks-pages@example.com", "x"
|
||||||
|
)
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
self.person = Person.objects.create(user=self.user, name="Scope Person")
|
||||||
|
self.pid_whatsapp = PersonIdentifier.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
person=self.person,
|
||||||
|
service="whatsapp",
|
||||||
|
identifier="120363402761690215@g.us",
|
||||||
|
)
|
||||||
|
self.pid_signal = PersonIdentifier.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
person=self.person,
|
||||||
|
service="signal",
|
||||||
|
identifier="+15551230000",
|
||||||
|
)
|
||||||
|
|
||||||
def test_tasks_hub_requires_group_scope_for_project_create(self):
|
def test_tasks_hub_can_create_project_name_only(self):
|
||||||
create_response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("tasks_hub"),
|
reverse("tasks_hub"),
|
||||||
{
|
{"action": "project_create", "name": "Ops"},
|
||||||
"action": "project_create",
|
|
||||||
"name": "Ops",
|
|
||||||
},
|
|
||||||
follow=True,
|
follow=True,
|
||||||
)
|
)
|
||||||
self.assertEqual(200, create_response.status_code)
|
self.assertEqual(200, response.status_code)
|
||||||
self.assertFalse(TaskProject.objects.filter(user=self.user, name="Ops").exists())
|
|
||||||
|
|
||||||
def test_tasks_hub_can_create_scoped_project_and_delete(self):
|
|
||||||
create_response = self.client.post(
|
|
||||||
reverse("tasks_hub"),
|
|
||||||
{
|
|
||||||
"action": "project_create",
|
|
||||||
"name": "Ops",
|
|
||||||
"service": "whatsapp",
|
|
||||||
"channel_identifier": "120363402761690215@g.us",
|
|
||||||
},
|
|
||||||
follow=True,
|
|
||||||
)
|
|
||||||
self.assertEqual(200, create_response.status_code)
|
|
||||||
project = TaskProject.objects.get(user=self.user, name="Ops")
|
project = TaskProject.objects.get(user=self.user, name="Ops")
|
||||||
self.assertIsNotNone(project)
|
self.assertIsNotNone(project)
|
||||||
|
self.assertFalse(ChatTaskSource.objects.filter(user=self.user, project=project).exists())
|
||||||
|
|
||||||
|
def test_tasks_hub_can_map_identifier_to_selected_project(self):
|
||||||
|
project = TaskProject.objects.create(user=self.user, name="Mapped")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("tasks_hub"),
|
||||||
|
{
|
||||||
|
"action": "project_map_identifier",
|
||||||
|
"project_id": str(project.id),
|
||||||
|
"person_identifier_id": str(self.pid_signal.id),
|
||||||
|
"person": str(self.person.id),
|
||||||
|
"service": "whatsapp",
|
||||||
|
"identifier": "120363402761690215@g.us",
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
ChatTaskSource.objects.filter(
|
ChatTaskSource.objects.filter(
|
||||||
user=self.user,
|
user=self.user,
|
||||||
service="whatsapp",
|
|
||||||
channel_identifier="120363402761690215@g.us",
|
|
||||||
project=project,
|
project=project,
|
||||||
|
service="signal",
|
||||||
|
channel_identifier="+15551230000",
|
||||||
|
enabled=True,
|
||||||
).exists()
|
).exists()
|
||||||
)
|
)
|
||||||
|
|
||||||
delete_response = self.client.post(
|
|
||||||
reverse("tasks_hub"),
|
|
||||||
{
|
|
||||||
"action": "project_delete",
|
|
||||||
"project_id": str(project.id),
|
|
||||||
},
|
|
||||||
follow=True,
|
|
||||||
)
|
|
||||||
self.assertEqual(200, delete_response.status_code)
|
|
||||||
self.assertFalse(TaskProject.objects.filter(user=self.user, name="Ops").exists())
|
|
||||||
|
|
||||||
def test_project_page_can_create_and_delete_epic(self):
|
def test_project_page_can_create_and_delete_epic(self):
|
||||||
project = TaskProject.objects.create(user=self.user, name="Roadmap")
|
project = TaskProject.objects.create(user=self.user, name="Roadmap")
|
||||||
create_response = self.client.post(
|
create_response = self.client.post(
|
||||||
|
|||||||
84
core/tests/test_workspace_graph_sampling.py
Normal file
84
core/tests/test_workspace_graph_sampling.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from core.models import User, WorkspaceConversation, WorkspaceMetricSnapshot
|
||||||
|
from core.views.workspace import _history_points
|
||||||
|
from core.workspace import DENSITY_POINT_CAPS, compact_snapshot_rows, downsample_points
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceGraphSamplingTests(TestCase):
|
||||||
|
def test_downsample_points_respects_density_caps(self):
|
||||||
|
base_ts = 1_700_000_000_000
|
||||||
|
points = []
|
||||||
|
for idx in range(1_000):
|
||||||
|
points.append(
|
||||||
|
{
|
||||||
|
"x": "",
|
||||||
|
"y": float(idx % 100),
|
||||||
|
"ts_ms": base_ts + (idx * 60_000),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
low = downsample_points(points, density="low")
|
||||||
|
high = downsample_points(points, density="high")
|
||||||
|
self.assertLessEqual(len(low), DENSITY_POINT_CAPS["low"])
|
||||||
|
self.assertLessEqual(len(high), DENSITY_POINT_CAPS["high"])
|
||||||
|
self.assertGreaterEqual(len(high), len(low))
|
||||||
|
|
||||||
|
def test_history_points_uses_source_event_ts_for_graph_x(self):
|
||||||
|
user = User.objects.create_user("graph-user", "graph@example.com", "x")
|
||||||
|
conversation = WorkspaceConversation.objects.create(
|
||||||
|
user=user,
|
||||||
|
title="Graph",
|
||||||
|
platform_type="whatsapp",
|
||||||
|
)
|
||||||
|
base_ts = 1_700_000_000_000
|
||||||
|
for idx in range(300):
|
||||||
|
WorkspaceMetricSnapshot.objects.create(
|
||||||
|
conversation=conversation,
|
||||||
|
source_event_ts=base_ts + (idx * 60_000),
|
||||||
|
stability_state=WorkspaceConversation.StabilityState.CALIBRATING,
|
||||||
|
stability_score=None,
|
||||||
|
stability_confidence=0.0,
|
||||||
|
stability_sample_messages=idx + 1,
|
||||||
|
stability_sample_days=1,
|
||||||
|
commitment_inbound_score=None,
|
||||||
|
commitment_outbound_score=None,
|
||||||
|
commitment_confidence=0.0,
|
||||||
|
inbound_messages=0,
|
||||||
|
outbound_messages=idx + 1,
|
||||||
|
reciprocity_score=None,
|
||||||
|
continuity_score=None,
|
||||||
|
response_score=None,
|
||||||
|
volatility_score=None,
|
||||||
|
inbound_response_score=None,
|
||||||
|
outbound_response_score=None,
|
||||||
|
balance_inbound_score=None,
|
||||||
|
balance_outbound_score=None,
|
||||||
|
)
|
||||||
|
points = _history_points(
|
||||||
|
conversation, "stability_sample_messages", density="low"
|
||||||
|
)
|
||||||
|
self.assertTrue(points)
|
||||||
|
self.assertLessEqual(len(points), DENSITY_POINT_CAPS["low"])
|
||||||
|
first_x = str(points[0].get("x") or "")
|
||||||
|
self.assertIn("2023", first_x)
|
||||||
|
|
||||||
|
def test_compact_snapshot_rows_drops_outside_cutoff_and_buckets(self):
|
||||||
|
rows = []
|
||||||
|
now_ts = 1_900_000_000_000
|
||||||
|
old_ts = now_ts - (400 * 24 * 60 * 60 * 1000)
|
||||||
|
for idx in range(30):
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"id": idx + 1,
|
||||||
|
"source_event_ts": old_ts + (idx * 60_000),
|
||||||
|
"computed_at": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
keep = compact_snapshot_rows(
|
||||||
|
snapshot_rows=rows,
|
||||||
|
now_ts_ms=now_ts,
|
||||||
|
cutoff_ts_ms=now_ts - (365 * 24 * 60 * 60 * 1000),
|
||||||
|
)
|
||||||
|
self.assertEqual(set(), keep)
|
||||||
@@ -2753,7 +2753,11 @@ def _panel_context(
|
|||||||
),
|
),
|
||||||
"compose_answer_suggestion_send_url": reverse("compose_answer_suggestion_send"),
|
"compose_answer_suggestion_send_url": reverse("compose_answer_suggestion_send"),
|
||||||
"compose_ws_url": ws_url,
|
"compose_ws_url": ws_url,
|
||||||
"tasks_hub_url": reverse("tasks_hub"),
|
"tasks_hub_url": (
|
||||||
|
f"{reverse('tasks_hub')}?{urlencode({'person': str(base['person'].id), 'service': base['service'], 'identifier': base['identifier'] or ''})}"
|
||||||
|
if base["person"]
|
||||||
|
else reverse("tasks_hub")
|
||||||
|
),
|
||||||
"tasks_group_url": reverse(
|
"tasks_group_url": reverse(
|
||||||
"tasks_group",
|
"tasks_group",
|
||||||
kwargs={
|
kwargs={
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from core.models import (
|
|||||||
WorkspaceConversation,
|
WorkspaceConversation,
|
||||||
WorkspaceMetricSnapshot,
|
WorkspaceMetricSnapshot,
|
||||||
)
|
)
|
||||||
|
from core.workspace import DENSITY_POINT_CAPS, downsample_points
|
||||||
|
|
||||||
SEND_ENABLED_MODES = {"active", "instant"}
|
SEND_ENABLED_MODES = {"active", "instant"}
|
||||||
OPERATION_LABELS = {
|
OPERATION_LABELS = {
|
||||||
@@ -960,21 +961,28 @@ def _metric_psychological_read(metric_slug, conversation):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _history_points(conversation, field_name):
|
def _history_points(conversation, field_name, density="medium"):
|
||||||
rows = (
|
rows = (
|
||||||
conversation.metric_snapshots.exclude(**{f"{field_name}__isnull": True})
|
conversation.metric_snapshots.exclude(**{f"{field_name}__isnull": True})
|
||||||
.order_by("computed_at")
|
.order_by("computed_at")
|
||||||
.values("computed_at", field_name)
|
.values("computed_at", "source_event_ts", field_name)
|
||||||
)
|
)
|
||||||
points = []
|
raw_points = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
points.append(
|
source_ts = int(row.get("source_event_ts") or 0)
|
||||||
|
if source_ts > 0:
|
||||||
|
x_value = datetime.fromtimestamp(source_ts / 1000, tz=timezone.utc).isoformat()
|
||||||
|
else:
|
||||||
|
x_value = row["computed_at"].isoformat()
|
||||||
|
raw_points.append(
|
||||||
{
|
{
|
||||||
"x": row["computed_at"].isoformat(),
|
"x": x_value,
|
||||||
"y": row[field_name],
|
"y": row[field_name],
|
||||||
|
"ts_ms": source_ts,
|
||||||
|
"computed_at": row.get("computed_at"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return points
|
return downsample_points(raw_points, density=density)
|
||||||
|
|
||||||
|
|
||||||
def _metric_supports_history(metric_slug, metric_spec):
|
def _metric_supports_history(metric_slug, metric_spec):
|
||||||
@@ -983,10 +991,15 @@ def _metric_supports_history(metric_slug, metric_spec):
|
|||||||
return any(graph["slug"] == metric_slug for graph in INSIGHT_GRAPH_SPECS)
|
return any(graph["slug"] == metric_slug for graph in INSIGHT_GRAPH_SPECS)
|
||||||
|
|
||||||
|
|
||||||
def _all_graph_payload(conversation):
|
def _all_graph_payload(conversation, density="medium"):
|
||||||
graphs = []
|
graphs = []
|
||||||
for spec in INSIGHT_GRAPH_SPECS:
|
for spec in INSIGHT_GRAPH_SPECS:
|
||||||
points = _history_points(conversation, spec["field"])
|
raw_count = (
|
||||||
|
conversation.metric_snapshots.exclude(
|
||||||
|
**{f"{spec['field']}__isnull": True}
|
||||||
|
).count()
|
||||||
|
)
|
||||||
|
points = _history_points(conversation, spec["field"], density=density)
|
||||||
graphs.append(
|
graphs.append(
|
||||||
{
|
{
|
||||||
"slug": spec["slug"],
|
"slug": spec["slug"],
|
||||||
@@ -995,6 +1008,7 @@ def _all_graph_payload(conversation):
|
|||||||
"group_title": INSIGHT_GROUPS[spec["group"]]["title"],
|
"group_title": INSIGHT_GROUPS[spec["group"]]["title"],
|
||||||
"points": points,
|
"points": points,
|
||||||
"count": len(points),
|
"count": len(points),
|
||||||
|
"raw_count": raw_count,
|
||||||
"y_min": spec["y_min"],
|
"y_min": spec["y_min"],
|
||||||
"y_max": spec["y_max"],
|
"y_max": spec["y_max"],
|
||||||
}
|
}
|
||||||
@@ -1002,6 +1016,13 @@ def _all_graph_payload(conversation):
|
|||||||
return graphs
|
return graphs
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_graph_density(value: str) -> str:
|
||||||
|
density = str(value or "").strip().lower()
|
||||||
|
if density in DENSITY_POINT_CAPS:
|
||||||
|
return density
|
||||||
|
return "medium"
|
||||||
|
|
||||||
|
|
||||||
def _information_overview_rows(conversation):
|
def _information_overview_rows(conversation):
|
||||||
latest_snapshot = conversation.metric_snapshots.first()
|
latest_snapshot = conversation.metric_snapshots.first()
|
||||||
rows = []
|
rows = []
|
||||||
@@ -3668,9 +3689,12 @@ class AIWorkspaceInsightDetail(LoginRequiredMixin, View):
|
|||||||
value = _format_metric_value(conversation, metric, latest_snapshot)
|
value = _format_metric_value(conversation, metric, latest_snapshot)
|
||||||
group = INSIGHT_GROUPS[spec["group"]]
|
group = INSIGHT_GROUPS[spec["group"]]
|
||||||
graph_applicable = _metric_supports_history(metric, spec)
|
graph_applicable = _metric_supports_history(metric, spec)
|
||||||
|
graph_density = _sanitize_graph_density(request.GET.get("density"))
|
||||||
points = []
|
points = []
|
||||||
if graph_applicable:
|
if graph_applicable:
|
||||||
points = _history_points(conversation, spec["history_field"])
|
points = _history_points(
|
||||||
|
conversation, spec["history_field"], density=graph_density
|
||||||
|
)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"person": person,
|
"person": person,
|
||||||
@@ -3682,6 +3706,8 @@ class AIWorkspaceInsightDetail(LoginRequiredMixin, View):
|
|||||||
"metric_group": group,
|
"metric_group": group,
|
||||||
"graph_points": points,
|
"graph_points": points,
|
||||||
"graph_applicable": graph_applicable,
|
"graph_applicable": graph_applicable,
|
||||||
|
"graph_density": graph_density,
|
||||||
|
"graph_density_caps": DENSITY_POINT_CAPS,
|
||||||
**_workspace_nav_urls(person),
|
**_workspace_nav_urls(person),
|
||||||
}
|
}
|
||||||
return render(request, "pages/ai-workspace-insight-detail.html", context)
|
return render(request, "pages/ai-workspace-insight-detail.html", context)
|
||||||
@@ -3696,11 +3722,14 @@ class AIWorkspaceInsightGraphs(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
person = get_object_or_404(Person, pk=person_id, user=request.user)
|
person = get_object_or_404(Person, pk=person_id, user=request.user)
|
||||||
conversation = _conversation_for_person(request.user, person)
|
conversation = _conversation_for_person(request.user, person)
|
||||||
graph_cards = _all_graph_payload(conversation)
|
graph_density = _sanitize_graph_density(request.GET.get("density"))
|
||||||
|
graph_cards = _all_graph_payload(conversation, density=graph_density)
|
||||||
context = {
|
context = {
|
||||||
"person": person,
|
"person": person,
|
||||||
"workspace_conversation": conversation,
|
"workspace_conversation": conversation,
|
||||||
"graph_cards": graph_cards,
|
"graph_cards": graph_cards,
|
||||||
|
"graph_density": graph_density,
|
||||||
|
"graph_density_caps": DENSITY_POINT_CAPS,
|
||||||
**_workspace_nav_urls(person),
|
**_workspace_nav_urls(person),
|
||||||
}
|
}
|
||||||
return render(request, "pages/ai-workspace-insight-graphs.html", context)
|
return render(request, "pages/ai-workspace-insight-graphs.html", context)
|
||||||
@@ -3717,9 +3746,10 @@ class AIWorkspaceInformation(LoginRequiredMixin, View):
|
|||||||
conversation = _conversation_for_person(request.user, person)
|
conversation = _conversation_for_person(request.user, person)
|
||||||
latest_snapshot = conversation.metric_snapshots.first()
|
latest_snapshot = conversation.metric_snapshots.first()
|
||||||
directionality = _commitment_directionality_payload(conversation)
|
directionality = _commitment_directionality_payload(conversation)
|
||||||
|
graph_density = _sanitize_graph_density(request.GET.get("density"))
|
||||||
commitment_graph_cards = [
|
commitment_graph_cards = [
|
||||||
card
|
card
|
||||||
for card in _all_graph_payload(conversation)
|
for card in _all_graph_payload(conversation, density=graph_density)
|
||||||
if card["group"] == "commitment"
|
if card["group"] == "commitment"
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3743,6 +3773,7 @@ class AIWorkspaceInformation(LoginRequiredMixin, View):
|
|||||||
"directionality": directionality,
|
"directionality": directionality,
|
||||||
"overview_rows": _information_overview_rows(conversation),
|
"overview_rows": _information_overview_rows(conversation),
|
||||||
"commitment_graph_cards": commitment_graph_cards,
|
"commitment_graph_cards": commitment_graph_cards,
|
||||||
|
"graph_density": graph_density,
|
||||||
**_workspace_nav_urls(person),
|
**_workspace_nav_urls(person),
|
||||||
}
|
}
|
||||||
return render(request, "pages/ai-workspace-information.html", context)
|
return render(request, "pages/ai-workspace-information.html", context)
|
||||||
|
|||||||
3
core/workspace/__init__.py
Normal file
3
core/workspace/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .sampling import DENSITY_POINT_CAPS, compact_snapshot_rows, downsample_points
|
||||||
|
|
||||||
|
__all__ = ["DENSITY_POINT_CAPS", "compact_snapshot_rows", "downsample_points"]
|
||||||
165
core/workspace/sampling.py
Normal file
165
core/workspace/sampling.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# Reasonable server-side caps per graph to keep payload/UI responsive.
|
||||||
|
DENSITY_POINT_CAPS = {
|
||||||
|
"low": 120,
|
||||||
|
"medium": 280,
|
||||||
|
"high": 560,
|
||||||
|
}
|
||||||
|
|
||||||
|
MS_MINUTE = 60 * 1000
|
||||||
|
MS_HOUR = 60 * MS_MINUTE
|
||||||
|
MS_DAY = 24 * MS_HOUR
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_ts_ms(point: dict) -> int:
|
||||||
|
value = int(point.get("ts_ms") or 0)
|
||||||
|
if value > 0:
|
||||||
|
return value
|
||||||
|
dt_value = point.get("computed_at")
|
||||||
|
if isinstance(dt_value, datetime):
|
||||||
|
if dt_value.tzinfo is None:
|
||||||
|
dt_value = dt_value.replace(tzinfo=timezone.utc)
|
||||||
|
return int(dt_value.timestamp() * 1000)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_ms_for_age(age_ms: int) -> int:
|
||||||
|
if age_ms <= (1 * MS_DAY):
|
||||||
|
return 0
|
||||||
|
if age_ms <= (7 * MS_DAY):
|
||||||
|
return 15 * MS_MINUTE
|
||||||
|
if age_ms <= (30 * MS_DAY):
|
||||||
|
return 2 * MS_HOUR
|
||||||
|
if age_ms <= (180 * MS_DAY):
|
||||||
|
return 12 * MS_HOUR
|
||||||
|
return 1 * MS_DAY
|
||||||
|
|
||||||
|
|
||||||
|
def _compress_to_target(points: list[dict], target: int) -> list[dict]:
|
||||||
|
if target <= 0 or len(points) <= target:
|
||||||
|
return points
|
||||||
|
if target <= 2:
|
||||||
|
return [points[0], points[-1]]
|
||||||
|
stride = max(1, int((len(points) - 2) / (target - 2)))
|
||||||
|
output = [points[0]]
|
||||||
|
idx = 1
|
||||||
|
while idx < (len(points) - 1) and len(output) < (target - 1):
|
||||||
|
output.append(points[idx])
|
||||||
|
idx += stride
|
||||||
|
output.append(points[-1])
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def downsample_points(points: list[dict], density: str = "medium") -> list[dict]:
|
||||||
|
"""
|
||||||
|
Tiered time-range downsampling:
|
||||||
|
keep high-resolution recent data, progressively bucket older ranges.
|
||||||
|
"""
|
||||||
|
rows = [dict(row or {}) for row in list(points or [])]
|
||||||
|
if len(rows) <= 2:
|
||||||
|
return rows
|
||||||
|
rows.sort(key=_effective_ts_ms)
|
||||||
|
latest_ts = _effective_ts_ms(rows[-1])
|
||||||
|
buckets: dict[tuple[int, int], dict] = {}
|
||||||
|
passthrough: list[dict] = []
|
||||||
|
for row in rows:
|
||||||
|
ts_ms = _effective_ts_ms(row)
|
||||||
|
if ts_ms <= 0:
|
||||||
|
continue
|
||||||
|
age_ms = max(0, latest_ts - ts_ms)
|
||||||
|
bucket_ms = _bucket_ms_for_age(age_ms)
|
||||||
|
if bucket_ms <= 0:
|
||||||
|
passthrough.append(
|
||||||
|
{
|
||||||
|
"x": datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).isoformat(),
|
||||||
|
"y": row.get("y"),
|
||||||
|
"ts_ms": ts_ms,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
bucket_key = (bucket_ms, int(ts_ms // bucket_ms))
|
||||||
|
state = buckets.get(bucket_key)
|
||||||
|
y_value = row.get("y")
|
||||||
|
if state is None:
|
||||||
|
buckets[bucket_key] = {
|
||||||
|
"count": 1,
|
||||||
|
"sum": float(y_value) if y_value is not None else 0.0,
|
||||||
|
"last_ts": ts_ms,
|
||||||
|
"last_y": y_value,
|
||||||
|
"has_value": y_value is not None,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
state["count"] += 1
|
||||||
|
if y_value is not None:
|
||||||
|
state["sum"] += float(y_value)
|
||||||
|
state["has_value"] = True
|
||||||
|
if ts_ms >= int(state["last_ts"]):
|
||||||
|
state["last_ts"] = ts_ms
|
||||||
|
state["last_y"] = y_value
|
||||||
|
output = list(passthrough)
|
||||||
|
for state in buckets.values():
|
||||||
|
ts_ms = int(state["last_ts"])
|
||||||
|
y_value = state["last_y"]
|
||||||
|
if bool(state["has_value"]) and int(state["count"]) > 0:
|
||||||
|
y_value = round(float(state["sum"]) / float(state["count"]), 3)
|
||||||
|
output.append(
|
||||||
|
{
|
||||||
|
"x": datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).isoformat(),
|
||||||
|
"y": y_value,
|
||||||
|
"ts_ms": ts_ms,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
output.sort(key=lambda row: int(row.get("ts_ms") or 0))
|
||||||
|
if not output:
|
||||||
|
return []
|
||||||
|
# Preserve first/last and reduce to density cap if still too large.
|
||||||
|
cap = int(DENSITY_POINT_CAPS.get(str(density or "").strip().lower(), DENSITY_POINT_CAPS["medium"]))
|
||||||
|
compact = _compress_to_target(output, cap)
|
||||||
|
return compact
|
||||||
|
|
||||||
|
|
||||||
|
def compact_snapshot_rows(snapshot_rows: list[dict], now_ts_ms: int, cutoff_ts_ms: int) -> set[int]:
|
||||||
|
"""
|
||||||
|
Returns IDs to keep using the same age-bucket policy as graph sampling.
|
||||||
|
Old rows below cutoff are dropped; remaining rows keep one representative
|
||||||
|
per age bucket (latest in bucket), while preserving newest and oldest.
|
||||||
|
"""
|
||||||
|
rows = [dict(row or {}) for row in list(snapshot_rows or [])]
|
||||||
|
if not rows:
|
||||||
|
return set()
|
||||||
|
enriched = []
|
||||||
|
for row in rows:
|
||||||
|
ts_ms = int(row.get("source_event_ts") or 0)
|
||||||
|
if ts_ms <= 0:
|
||||||
|
computed_at = row.get("computed_at")
|
||||||
|
if isinstance(computed_at, datetime):
|
||||||
|
if computed_at.tzinfo is None:
|
||||||
|
computed_at = computed_at.replace(tzinfo=timezone.utc)
|
||||||
|
ts_ms = int(computed_at.timestamp() * 1000)
|
||||||
|
if ts_ms <= 0:
|
||||||
|
continue
|
||||||
|
if cutoff_ts_ms > 0 and ts_ms < int(cutoff_ts_ms):
|
||||||
|
continue
|
||||||
|
enriched.append((int(row.get("id") or 0), ts_ms))
|
||||||
|
if not enriched:
|
||||||
|
return set()
|
||||||
|
enriched.sort(key=lambda item: item[1])
|
||||||
|
keep_ids = {enriched[0][0], enriched[-1][0]}
|
||||||
|
bucket_map: dict[tuple[int, int], tuple[int, int]] = {}
|
||||||
|
latest_ts = int(now_ts_ms or enriched[-1][1])
|
||||||
|
for snapshot_id, ts_ms in enriched:
|
||||||
|
age_ms = max(0, latest_ts - ts_ms)
|
||||||
|
bucket_ms = _bucket_ms_for_age(age_ms)
|
||||||
|
if bucket_ms <= 0:
|
||||||
|
keep_ids.add(snapshot_id)
|
||||||
|
continue
|
||||||
|
key = (bucket_ms, int(ts_ms // bucket_ms))
|
||||||
|
current = bucket_map.get(key)
|
||||||
|
if current is None or ts_ms >= current[1]:
|
||||||
|
bucket_map[key] = (snapshot_id, ts_ms)
|
||||||
|
for snapshot_id, _ in bucket_map.values():
|
||||||
|
keep_ids.add(snapshot_id)
|
||||||
|
return keep_ids
|
||||||
Reference in New Issue
Block a user