Skip to content

Commit 7db2727

Browse files
committed
Finish observability
1 parent 84b3e03 commit 7db2727

29 files changed

Lines changed: 1238 additions & 30 deletions

File tree

‎core/pipeline.py‎

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
import logging
1111
import re
1212
from datetime import timedelta
13-
from functools import lru_cache
14-
from typing import Any, Literal, TypedDict, cast
13+
from functools import lru_cache, wraps
14+
from typing import Any, Callable, Literal, TypedDict, cast
1515

1616
from django.conf import settings
1717
from django.db.models import F, Model
@@ -30,6 +30,7 @@
3030
from core.llm import build_skill_user_prompt, get_skill_definition, openrouter_chat_json
3131
from entities.extraction import run_entity_extraction
3232
from entities.models import EntityMention
33+
from newsletter_maker.telemetry import trace_span
3334
from pipeline.models import (
3435
ReviewQueue,
3536
ReviewReason,
@@ -98,6 +99,9 @@ class PipelineState(TypedDict, total=False):
9899
status: str
99100

100101

102+
PipelineNodeCallable = Callable[[PipelineState], PipelineState]
103+
104+
101105
def _require_pk(instance: Model) -> int:
102106
"""Return a saved model primary key for typed pipeline operations."""
103107

@@ -122,6 +126,30 @@ def _content_id_from_state(state: PipelineState) -> int:
122126
return content_id
123127

124128

129+
def _trace_pipeline_step(
130+
step_name: str,
131+
) -> Callable[[PipelineNodeCallable], PipelineNodeCallable]:
132+
"""Wrap a LangGraph node with a stable telemetry span name."""
133+
134+
def decorator(func: PipelineNodeCallable) -> PipelineNodeCallable:
135+
@wraps(func)
136+
def wrapper(state: PipelineState) -> PipelineState:
137+
with trace_span(
138+
f"pipeline.{step_name}",
139+
attributes={
140+
"pipeline.step": step_name,
141+
"content.id": state.get("content_id"),
142+
"project.id": state.get("project_id"),
143+
"pipeline.status": state.get("status"),
144+
},
145+
):
146+
return func(state)
147+
148+
return cast(PipelineNodeCallable, wrapper)
149+
150+
return decorator
151+
152+
125153
@lru_cache(maxsize=1)
126154
def get_ingestion_graph():
127155
"""Build and cache the LangGraph workflow used for content processing.
@@ -199,18 +227,26 @@ def process_content_pipeline(content_id: int) -> PipelineState:
199227
"""
200228

201229
content = Content.objects.select_related("project").get(pk=content_id)
202-
content.pipeline_state = ContentPipelineState.PROCESSING
203-
content.save(update_fields=["pipeline_state"])
204-
retry_budget = build_retry_budget(PIPELINE_RETRY_SKILLS)
205-
initial_state: PipelineState = {
206-
"content_id": _require_pk(content),
207-
"project_id": _project_pk(content),
208-
"retry_budget_remaining": retry_budget.remaining_retries,
209-
"status": "processing",
210-
}
211-
return cast(PipelineState, get_ingestion_graph().invoke(initial_state))
230+
with trace_span(
231+
"pipeline.process_content",
232+
attributes={
233+
"content.id": _require_pk(content),
234+
"project.id": _project_pk(content),
235+
},
236+
):
237+
content.pipeline_state = ContentPipelineState.PROCESSING
238+
content.save(update_fields=["pipeline_state"])
239+
retry_budget = build_retry_budget(PIPELINE_RETRY_SKILLS)
240+
initial_state: PipelineState = {
241+
"content_id": _require_pk(content),
242+
"project_id": _project_pk(content),
243+
"retry_budget_remaining": retry_budget.remaining_retries,
244+
"status": "processing",
245+
}
246+
return cast(PipelineState, get_ingestion_graph().invoke(initial_state))
212247

213248

249+
@_trace_pipeline_step("deduplicate")
214250
def deduplicate_node(state: PipelineState) -> PipelineState:
215251
"""Detect duplicates before downstream skills consume the content."""
216252

@@ -263,6 +299,7 @@ def deduplicate_node(state: PipelineState) -> PipelineState:
263299
}
264300

265301

302+
@_trace_pipeline_step("classify")
266303
def classify_node(state: PipelineState) -> PipelineState:
267304
"""Classify the content item and persist the resulting skill output."""
268305

@@ -312,6 +349,7 @@ def classify_node(state: PipelineState) -> PipelineState:
312349
}
313350

314351

352+
@_trace_pipeline_step("extract_entities")
315353
def extract_entities_node(state: PipelineState) -> PipelineState:
316354
"""Extract tracked-entity mentions before relevance scoring."""
317355

@@ -348,6 +386,7 @@ def extract_entities_node(state: PipelineState) -> PipelineState:
348386
}
349387

350388

389+
@_trace_pipeline_step("score_relevance")
351390
def relevance_node(state: PipelineState) -> PipelineState:
352391
"""Score content relevance, persist the score, and keep the item active."""
353392

@@ -392,6 +431,7 @@ def relevance_node(state: PipelineState) -> PipelineState:
392431
}
393432

394433

434+
@_trace_pipeline_step("summarize")
395435
def summarize_node(state: PipelineState) -> PipelineState:
396436
"""Generate and store a newsletter-ready summary for relevant content."""
397437

@@ -430,6 +470,7 @@ def summarize_node(state: PipelineState) -> PipelineState:
430470
}
431471

432472

473+
@_trace_pipeline_step("archive")
433474
def archive_node(state: PipelineState) -> PipelineState:
434475
"""Mark a low-value content item inactive so it drops out of active flows."""
435476

@@ -440,6 +481,7 @@ def archive_node(state: PipelineState) -> PipelineState:
440481
return {"status": "archived"}
441482

442483

484+
@_trace_pipeline_step("queue_review")
443485
def queue_review_node(state: PipelineState) -> PipelineState:
444486
"""Create or refresh a manual review item for borderline relevance."""
445487

@@ -1582,6 +1624,9 @@ def retry_review_queue_item(review_item: ReviewQueue) -> dict[str, object]:
15821624
else ContentPipelineState.COMPLETED
15831625
)
15841626
content.save(update_fields=["pipeline_state"])
1627+
else:
1628+
content.pipeline_state = ContentPipelineState.AWAITING_REVIEW
1629+
content.save(update_fields=["pipeline_state"])
15851630
else:
15861631
summary = _run_ad_hoc_summarization(content)
15871632
result = {
@@ -1595,7 +1640,7 @@ def retry_review_queue_item(review_item: ReviewQueue) -> dict[str, object]:
15951640
)
15961641
content.save(update_fields=["pipeline_state"])
15971642

1598-
if result.get("status") != "awaiting_review":
1643+
if result.get("status") in {"completed", "archived"}:
15991644
review_item.resolved = True
16001645
review_item.resolution = ReviewResolution.RETRIED
16011646
review_item.resolved_at = timezone.now()

‎core/tasks.py‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
from pipeline.models import ReviewQueue
3434
from pipeline.resilience import opened_circuit_breakers, probe_circuit_breaker
3535
from projects.models import Project, ProjectConfig
36+
from trends.models import (
37+
SourceDiversitySnapshot,
38+
TopicCentroidSnapshot,
39+
TopicVelocitySnapshot,
40+
TrendTaskRun,
41+
)
3642

3743
logger = logging.getLogger(__name__)
3844

@@ -44,11 +50,13 @@
4450
)
4551

4652
__all__ = [
53+
"apply_retention_policies",
4754
"circuit_breaker_health_check",
4855
"recompute_source_quality",
4956
"recompute_authority_scores",
5057
"run_all_source_quality_recomputations",
5158
"run_all_authority_recomputations",
59+
"run_all_retention_policies",
5260
"run_relevance_scoring_skill",
5361
"run_summarization_skill",
5462
"queue_content_skill",
@@ -84,6 +92,12 @@ def _require_pk(instance: Model) -> int:
8492
return int(pk)
8593

8694

95+
def _retention_cutoff(days: int):
96+
"""Return the timestamp cutoff for a retention window in days."""
97+
98+
return timezone.now() - timedelta(days=days)
99+
100+
87101
@shared_task(name="core.tasks.run_all_authority_recomputations")
88102
def run_all_authority_recomputations():
89103
"""Queue authority recomputation for every project.
@@ -115,6 +129,19 @@ def run_all_source_quality_recomputations() -> int:
115129
return len(project_ids)
116130

117131

132+
@shared_task(name="core.tasks.run_all_retention_policies")
133+
def run_all_retention_policies() -> int:
134+
"""Queue retention-policy cleanup for every project."""
135+
136+
project_ids = list(Project.objects.values_list("id", flat=True))
137+
for project_id in project_ids:
138+
if settings.CELERY_TASK_ALWAYS_EAGER:
139+
apply_retention_policies(project_id)
140+
else:
141+
_enqueue_task(apply_retention_policies, project_id)
142+
return len(project_ids)
143+
144+
118145
@shared_task(name="core.tasks.process_content")
119146
def process_content(content_id: int):
120147
"""Run the main AI pipeline for a stored content item."""
@@ -152,6 +179,58 @@ def retry_pipeline_review_item(review_item_id: int) -> dict[str, object]:
152179
return retry_review_queue_item_from_pipeline(review_item)
153180

154181

182+
@shared_task(name="core.tasks.apply_retention_policies")
183+
def apply_retention_policies(project_id: int) -> dict[str, object]:
184+
"""Delete old observability records for one project."""
185+
186+
project = Project.objects.get(pk=project_id)
187+
snapshot_cutoff = _retention_cutoff(settings.OBSERVABILITY_SNAPSHOT_RETENTION_DAYS)
188+
trend_run_cutoff = _retention_cutoff(
189+
settings.OBSERVABILITY_TREND_TASK_RUN_RETENTION_DAYS
190+
)
191+
review_cutoff = _retention_cutoff(
192+
settings.OBSERVABILITY_REVIEW_QUEUE_RETENTION_DAYS
193+
)
194+
195+
deleted = {
196+
"topic_centroid_snapshots": TopicCentroidSnapshot.objects.filter(
197+
project=project,
198+
computed_at__lt=snapshot_cutoff,
199+
).delete()[0],
200+
"topic_velocity_snapshots": TopicVelocitySnapshot.objects.filter(
201+
project=project,
202+
computed_at__lt=snapshot_cutoff,
203+
).delete()[0],
204+
"source_diversity_snapshots": SourceDiversitySnapshot.objects.filter(
205+
project=project,
206+
computed_at__lt=snapshot_cutoff,
207+
).delete()[0],
208+
"entity_authority_snapshots": EntityAuthoritySnapshot.objects.filter(
209+
project=project,
210+
computed_at__lt=snapshot_cutoff,
211+
).delete()[0],
212+
"trend_task_runs": TrendTaskRun.objects.filter(project=project)
213+
.filter(
214+
Q(finished_at__lt=trend_run_cutoff)
215+
| Q(finished_at__isnull=True, started_at__lt=trend_run_cutoff)
216+
)
217+
.delete()[0],
218+
"resolved_review_items": ReviewQueue.objects.filter(
219+
project=project,
220+
resolved=True,
221+
resolved_at__lt=review_cutoff,
222+
).delete()[0],
223+
}
224+
225+
return {
226+
"project_id": project_id,
227+
"deleted": deleted,
228+
"snapshot_cutoff": snapshot_cutoff.isoformat(),
229+
"trend_run_cutoff": trend_run_cutoff.isoformat(),
230+
"review_cutoff": review_cutoff.isoformat(),
231+
}
232+
233+
155234
@shared_task(name="core.tasks.recompute_source_quality")
156235
def recompute_source_quality(project_id: int) -> dict[str, object]:
157236
"""Recompute source-quality scores used by entity authority scoring."""

0 commit comments

Comments
 (0)