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

@@ -54,6 +54,7 @@ class ReconcileWorkspaceMetricHistoryCommandTests(TestCase):
"36500",
"--step-messages",
"2",
"--skip-compact",
)
conversation = _conversation_for_person(self.user, self.person)
first_count = WorkspaceMetricSnapshot.objects.filter(
@@ -72,6 +73,7 @@ class ReconcileWorkspaceMetricHistoryCommandTests(TestCase):
"--step-messages",
"2",
"--no-reset",
"--skip-compact",
)
second_count = WorkspaceMetricSnapshot.objects.filter(
conversation=conversation
@@ -107,6 +109,7 @@ class ReconcileWorkspaceMetricHistoryCommandTests(TestCase):
"36500",
"--step-messages",
"2",
"--skip-compact",
)
conversation = _conversation_for_person(self.user, self.person)
first_count = WorkspaceMetricSnapshot.objects.filter(
@@ -124,8 +127,28 @@ class ReconcileWorkspaceMetricHistoryCommandTests(TestCase):
"--step-messages",
"2",
"--no-reset",
"--skip-compact",
)
second_count = WorkspaceMetricSnapshot.objects.filter(
conversation=conversation
).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.urls import reverse
from core.models import ChatTaskSource, TaskEpic, TaskProject, User
from core.models import ChatTaskSource, Person, PersonIdentifier, TaskEpic, TaskProject, User
class TasksPagesManagementTests(TestCase):
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.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):
create_response = self.client.post(
def test_tasks_hub_can_create_project_name_only(self):
response = self.client.post(
reverse("tasks_hub"),
{
"action": "project_create",
"name": "Ops",
},
{"action": "project_create", "name": "Ops"},
follow=True,
)
self.assertEqual(200, create_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)
self.assertEqual(200, response.status_code)
project = TaskProject.objects.get(user=self.user, name="Ops")
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(
ChatTaskSource.objects.filter(
user=self.user,
service="whatsapp",
channel_identifier="120363402761690215@g.us",
project=project,
service="signal",
channel_identifier="+15551230000",
enabled=True,
).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):
project = TaskProject.objects.create(user=self.user, name="Roadmap")
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)