Skip to content

Commit bd413e0

Browse files
committed
Add trend velocity detection
1 parent 7e790ac commit bd413e0

13 files changed

Lines changed: 1477 additions & 15 deletions

File tree

‎core/models.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,25 @@
2626
from pipeline.models import SkillResult as _SkillResult
2727
from pipeline.models import SkillStatus as _SkillStatus
2828
from projects.models import Project as _Project
29+
from trends.models import ContentClusterMembership as _ContentClusterMembership
2930
from trends.models import TopicCentroidSnapshot as _TopicCentroidSnapshot
31+
from trends.models import TopicCluster as _TopicCluster
32+
from trends.models import TopicVelocitySnapshot as _TopicVelocitySnapshot
3033

3134
Project = _Project
3235
ReviewQueue = _ReviewQueue
3336
ReviewReason = _ReviewReason
3437
ReviewResolution = _ReviewResolution
3538
SkillResult = _SkillResult
3639
SkillStatus = _SkillStatus
40+
ContentClusterMembership = _ContentClusterMembership
41+
TopicCluster = _TopicCluster
3742
TopicCentroidSnapshot = _TopicCentroidSnapshot
43+
TopicVelocitySnapshot = _TopicVelocitySnapshot
3844

3945
__all__ = [
4046
"Content",
47+
"ContentClusterMembership",
4148
"Entity",
4249
"EntityAuthoritySnapshot",
4350
"EntityCandidate",
@@ -53,6 +60,9 @@
5360
"NewsletterIntakeStatus",
5461
"Project",
5562
"RunStatus",
63+
"TopicCluster",
64+
"TopicCentroidSnapshot",
65+
"TopicVelocitySnapshot",
5666
"UserFeedback",
5767
]
5868

‎core/tasks.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,13 @@
4949
from newsletters.tasks import process_newsletter_intake
5050
from trends.tasks import (
5151
TOPIC_CENTROID_MIN_UPVOTES,
52+
assign_content_to_topic_cluster,
5253
queue_topic_centroid_recompute,
5354
recompute_topic_centroid,
55+
recompute_topic_clusters,
56+
recompute_topic_velocity,
5457
run_all_topic_centroid_recomputations,
58+
run_all_topic_cluster_recomputations,
5559
)
5660

5761
_COMPAT_TASK_EXPORTS = {
@@ -61,15 +65,25 @@
6165
),
6266
"run_all_ingestions": ("ingestion.tasks", "run_all_ingestions"),
6367
"run_ingestion": ("ingestion.tasks", "run_ingestion"),
68+
"assign_content_to_topic_cluster": (
69+
"trends.tasks",
70+
"assign_content_to_topic_cluster",
71+
),
6472
"TOPIC_CENTROID_MIN_UPVOTES": (
6573
"trends.tasks",
6674
"TOPIC_CENTROID_MIN_UPVOTES",
6775
),
76+
"recompute_topic_clusters": ("trends.tasks", "recompute_topic_clusters"),
6877
"queue_topic_centroid_recompute": (
6978
"trends.tasks",
7079
"queue_topic_centroid_recompute",
7180
),
7281
"recompute_topic_centroid": ("trends.tasks", "recompute_topic_centroid"),
82+
"recompute_topic_velocity": ("trends.tasks", "recompute_topic_velocity"),
83+
"run_all_topic_cluster_recomputations": (
84+
"trends.tasks",
85+
"run_all_topic_cluster_recomputations",
86+
),
7387
"run_all_topic_centroid_recomputations": (
7488
"trends.tasks",
7589
"run_all_topic_centroid_recomputations",
@@ -79,12 +93,16 @@
7993
__all__ = [
8094
"process_newsletter_intake",
8195
"run_all_ingestions",
96+
"assign_content_to_topic_cluster",
8297
"run_ingestion",
8398
"TOPIC_CENTROID_MIN_UPVOTES",
8499
"queue_topic_centroid_recompute",
85100
"recompute_authority_scores",
101+
"recompute_topic_clusters",
86102
"recompute_topic_centroid",
103+
"recompute_topic_velocity",
87104
"run_all_authority_recomputations",
105+
"run_all_topic_cluster_recomputations",
88106
"run_all_topic_centroid_recomputations",
89107
"run_relevance_scoring_skill",
90108
"run_summarization_skill",

‎core/tests/test_api.py‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from core.models import (
1010
Content,
11+
ContentClusterMembership,
1112
Entity,
1213
EntityAuthoritySnapshot,
1314
EntityCandidate,
@@ -24,6 +25,8 @@
2425
SkillResult,
2526
SkillStatus,
2627
TopicCentroidSnapshot,
28+
TopicCluster,
29+
TopicVelocitySnapshot,
2730
UserFeedback,
2831
)
2932
from projects.model_support import SourcePluginName
@@ -540,6 +543,115 @@ def test_topic_centroid_summary_action_returns_latest_snapshot_and_averages(self
540543
self.assertAlmostEqual(response.json()["avg_drift_from_previous"], 0.2)
541544
self.assertAlmostEqual(response.json()["avg_drift_from_week_ago"], 0.3)
542545

546+
def test_topic_cluster_list_returns_current_velocity_annotation(self):
547+
cluster = TopicCluster.objects.create(
548+
project=self.owner_project,
549+
first_seen_at="2026-04-22T00:00:00Z",
550+
last_seen_at="2026-04-24T00:00:00Z",
551+
is_active=True,
552+
member_count=3,
553+
dominant_entity=self.owner_entity,
554+
)
555+
TopicVelocitySnapshot.objects.create(
556+
cluster=cluster,
557+
project=self.owner_project,
558+
window_count=4,
559+
trailing_mean=1.5,
560+
trailing_stddev=0.5,
561+
z_score=3.0,
562+
velocity_score=1.0,
563+
)
564+
565+
response = self.client.get(
566+
reverse(
567+
"v1:project-topic-cluster-list",
568+
kwargs={"project_id": self.owner_project.id},
569+
),
570+
{"ordering": "-velocity_score"},
571+
)
572+
573+
self.assertEqual(response.status_code, status.HTTP_200_OK)
574+
self.assertEqual(len(response.json()), 1)
575+
self.assertEqual(response.json()[0]["id"], cluster.id)
576+
self.assertEqual(response.json()[0]["member_count"], 3)
577+
self.assertEqual(
578+
response.json()[0]["dominant_entity"]["id"], self.owner_entity.id
579+
)
580+
self.assertAlmostEqual(response.json()[0]["velocity_score"], 1.0)
581+
self.assertAlmostEqual(response.json()[0]["z_score"], 3.0)
582+
self.assertEqual(response.json()[0]["window_count"], 4)
583+
584+
def test_topic_cluster_detail_and_velocity_history_action_return_memberships(self):
585+
cluster = TopicCluster.objects.create(
586+
project=self.owner_project,
587+
first_seen_at="2026-04-22T00:00:00Z",
588+
last_seen_at="2026-04-24T00:00:00Z",
589+
is_active=True,
590+
member_count=1,
591+
dominant_entity=self.owner_entity,
592+
)
593+
ContentClusterMembership.objects.create(
594+
content=self.owner_content,
595+
cluster=cluster,
596+
project=self.owner_project,
597+
similarity=0.92,
598+
)
599+
first_snapshot = TopicVelocitySnapshot.objects.create(
600+
cluster=cluster,
601+
project=self.owner_project,
602+
window_count=2,
603+
trailing_mean=1.0,
604+
trailing_stddev=0.2,
605+
z_score=1.5,
606+
velocity_score=0.75,
607+
)
608+
second_snapshot = TopicVelocitySnapshot.objects.create(
609+
cluster=cluster,
610+
project=self.owner_project,
611+
window_count=3,
612+
trailing_mean=1.0,
613+
trailing_stddev=0.3,
614+
z_score=3.0,
615+
velocity_score=1.0,
616+
)
617+
TopicVelocitySnapshot.objects.filter(pk=first_snapshot.pk).update(
618+
computed_at="2026-04-23T00:00:00Z"
619+
)
620+
TopicVelocitySnapshot.objects.filter(pk=second_snapshot.pk).update(
621+
computed_at="2026-04-24T00:00:00Z"
622+
)
623+
624+
detail_response = self.client.get(
625+
reverse(
626+
"v1:project-topic-cluster-detail",
627+
kwargs={"project_id": self.owner_project.id, "pk": cluster.id},
628+
)
629+
)
630+
history_response = self.client.get(
631+
reverse(
632+
"v1:project-topic-cluster-velocity-history",
633+
kwargs={"project_id": self.owner_project.id, "pk": cluster.id},
634+
),
635+
{"limit": 1},
636+
)
637+
638+
self.assertEqual(detail_response.status_code, status.HTTP_200_OK)
639+
self.assertEqual(detail_response.json()["id"], cluster.id)
640+
self.assertEqual(len(detail_response.json()["memberships"]), 1)
641+
self.assertEqual(
642+
detail_response.json()["memberships"][0]["content"]["id"],
643+
self.owner_content.id,
644+
)
645+
self.assertEqual(len(detail_response.json()["velocity_history"]), 2)
646+
self.assertEqual(
647+
detail_response.json()["velocity_history"][0]["id"],
648+
second_snapshot.id,
649+
)
650+
651+
self.assertEqual(history_response.status_code, status.HTTP_200_OK)
652+
self.assertEqual(len(history_response.json()), 1)
653+
self.assertEqual(history_response.json()[0]["id"], second_snapshot.id)
654+
543655
def test_content_detail_includes_duplicate_state(self):
544656
canonical = self.owner_content
545657
canonical.canonical_url = "https://example.com/owner"

0 commit comments

Comments
 (0)