1010import logging
1111import re
1212from 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
1616from django .conf import settings
1717from django .db .models import F , Model
3030from core .llm import build_skill_user_prompt , get_skill_definition , openrouter_chat_json
3131from entities .extraction import run_entity_extraction
3232from entities .models import EntityMention
33+ from newsletter_maker .telemetry import trace_span
3334from 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+
101105def _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 )
126154def 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" )
214250def 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" )
266303def 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" )
315353def 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" )
351390def 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" )
395435def 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" )
433474def 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" )
443485def 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 ()
0 commit comments