Implement workspace history reconciliation
This commit is contained in:
@@ -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)
|
||||
|
||||
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.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(
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user