Implement workspace history reconciliation

This commit is contained in:
2026-03-03 17:35:45 +00:00
parent 2898d9e832
commit 18351abb00
14 changed files with 556 additions and 57 deletions

View File

@@ -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(

View File

@@ -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": [],
} }

View File

@@ -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}"
) )

View File

@@ -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 %}">

View File

@@ -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">

View File

@@ -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>

View File

@@ -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)

View 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"))

View File

@@ -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(

View 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)

View File

@@ -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={

View File

@@ -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)

View 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
View 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