feat(api): Add support for V2 synthesis format in analytics endpoint

- Extended SynthesisResponse model to support both legacy (v1) and
  new 6-section (v2) report formats
- V2 format includes executive_summary, risk_scorecard, critical_issues,
  action_matrix, and tracking_kpis sections
- Frontend type guards use report_version and executive_summary fields
  to detect format and render appropriate components
- Backwards compatible: legacy v1 responses still work unchanged

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-01-30 15:12:41 +00:00
parent 0a53e98bf9
commit 69d617ca38

View File

@@ -165,6 +165,33 @@ class TimelinePoint(BaseModel):
negative_count: int = Field(0, description="Negative sentiment count") negative_count: int = Field(0, description="Negative sentiment count")
# ==================== Trend Models ====================
class TrendDataPoint(BaseModel):
"""Single data point for a trend item."""
date: str = Field(..., description="Date string (YYYY-MM-DD)")
count: int = Field(0, description="Total span count")
positive: int = Field(0, description="Positive sentiment count")
negative: int = Field(0, description="Negative sentiment count")
review_count: int = Field(0, description="Number of distinct reviews")
# Sentiment trend
sentiment_score: float = Field(0, description="Sentiment score: (positive-negative)/total * 100, range -100 to +100")
# Rating impact metrics (the business value)
avg_rating_negative: float | None = Field(None, description="Avg stars when complaints mention this category - THE DAMAGE METRIC")
avg_rating_positive: float | None = Field(None, description="Avg stars when praise mentions this category - THE STRENGTH METRIC")
class TrendItem(BaseModel):
"""A single trend line/series."""
id: str = Field(..., description="Item code (e.g., 'P' or 'P.FRIE')")
label: str = Field(..., description="Human-readable label")
color: str = Field(..., description="Color hex code")
data: list[TrendDataPoint] = Field(default_factory=list, description="Trend data points")
# ==================== Domain Scores & Insights ==================== # ==================== Domain Scores & Insights ====================
@@ -271,49 +298,119 @@ class Insights(BaseModel):
executive_summary: str = Field("", description="Auto-generated summary") executive_summary: str = Field("", description="Auto-generated summary")
# ==================== AI Synthesis Models ==================== # ==================== Report Synthesis Models ====================
class ActionItemResponse(BaseModel): class ReportActionResponse(BaseModel):
"""A specific action recommendation from AI synthesis.""" """A prioritized action item for the analyst report."""
id: str = Field(..., description="Action identifier") priority: str = Field(..., description="critical/high/medium")
title: str = Field(..., description="Clear action title") action: str = Field(..., description="What to do")
why: str = Field("", description="Root cause from reviews") owner: str = Field(..., description="Who owns it")
what: str = Field("", description="Specific steps to take") impact: str = Field(..., description="Expected result")
who: str = Field("", description="Department or role responsible") impact_stars: float = Field(0.1, description="Numeric star impact")
impact: str = Field("", description="Expected outcome") effort: str = Field("moderate", description="quick_win/moderate/strategic")
evidence: list[str] = Field(default_factory=list, description="Supporting quotes") evidence: str = Field("", description="Supporting quote")
estimated_rating_lift: float | None = Field(None, description="Projected rating improvement") complaint_count: int = Field(0, description="Number of complaints addressed")
complexity: str = Field("medium", description="quick/medium/complex") success_metric: str = Field("", description="Measurable success KPI")
priority: str = Field("medium", description="critical/high/medium/low")
timeline: str = Field("This month", description="When to implement")
related_subcode: str = Field("", description="Related URT subcode")
class TimelineAnnotationResponse(BaseModel): class ReportEvidenceResponse(BaseModel):
"""A key event annotation for timeline visualization.""" """A curated quote that supports the narrative."""
date: str = Field(..., description="Event date (YYYY-MM-DD)") quote: str = Field(..., description="Customer words")
label: str = Field(..., description="Short label") context: str = Field(..., description="What this proves")
description: str = Field("", description="What happened") sentiment: str = Field("damaging", description="damaging/praising")
type: str = Field("neutral", description="positive/negative/neutral/event") weight: str = Field("notable", description="critical/notable")
class ReportStrengthResponse(BaseModel):
"""A key strength to protect and leverage."""
title: str = Field(..., description="Strength title")
mention_count: int = Field(0, description="Number of mentions")
quote: str = Field("", description="Supporting quote")
marketing_angle: str = Field("", description="How to leverage in marketing")
class SynthesisResponse(BaseModel): class SynthesisResponse(BaseModel):
"""AI-generated synthesis with narratives and action plans.""" """Analyst report synthesis - consultant-quality business narrative.
executive_narrative: str = Field("", description="2-3 paragraph business story") Supports both legacy format (v1) and new 6-section format (v2).
sentiment_insight: str = Field("", description="Why sentiment is distributed this way") Frontend uses type guards to determine which format to render.
category_insight: str = Field("", description="Pattern in categories") """
timeline_insight: str = Field("", description="Trends over time")
priority_domain: str | None = Field(None, description="Domain needing most attention (P/V/J/O/A/E/R)") # Version indicator - "2.0" for new format, absent for legacy
priority_issue: str | None = Field(None, description="Subcode to fix first (e.g., V1.03)") report_version: str | None = Field(None, description="Report format version")
action_plan: list[ActionItemResponse] = Field(default_factory=list, description="Prioritized actions")
timeline_annotations: list[TimelineAnnotationResponse] = Field(default_factory=list, description="Key events") # ===== LEGACY FORMAT FIELDS (v1) =====
marketing_angles: list[str] = Field(default_factory=list, description="Ways to promote strengths") # The Verdict
competitor_context: str | None = Field(None, description="Industry comparison") headline: str = Field("", description="One punchy insight line")
generated_at: str | None = Field(None, description="When synthesis was generated") verdict: str = Field("", description="One sentence executive summary")
current_rating: float = Field(0.0, description="Current average rating")
potential_rating: float = Field(0.0, description="Achievable rating if issues fixed")
rating_gap: float = Field(0.0, description="Potential improvement")
# The Story
narrative: str = Field("", description="2-3 paragraph consultant-quality prose")
# Section Headlines
sentiment_headline: str = Field("", description="Insight-first title for sentiment chart")
category_headline: str = Field("", description="Insight-first title for category breakdown")
timeline_headline: str = Field("", description="Insight-first title for timeline")
strengths_headline: str = Field("", description="Insight-first title for strengths")
# The Diagnosis
primary_problem: str = Field("", description="The #1 issue in plain English")
primary_problem_code: str = Field("", description="URT code")
root_cause: str = Field("", description="Why this keeps happening")
# The Prescription (v1)
actions: list[ReportActionResponse] = Field(default_factory=list, description="Prioritized actions")
# The Evidence
evidence: list[ReportEvidenceResponse] = Field(default_factory=list, description="Curated quotes")
# The Strengths (can be v1 or v2 format depending on report_version)
# V1: list[ReportStrengthResponse], V2: list[StrengthToProtect dict]
strengths: list[ReportStrengthResponse] | list[dict] = Field(default_factory=list, description="Key strengths to protect")
# Momentum
momentum: str = Field("stable", description="improving/declining/stable")
momentum_detail: str = Field("", description="Trend explanation")
# Metadata
generated_at: str | None = Field(None, description="When report was generated")
review_count: int = Field(0, description="Total reviews analyzed")
insight_count: int = Field(0, description="Total insights extracted")
# ===== NEW FORMAT FIELDS (v2 - 6-section report) =====
report_title: str = Field("", description="Report title for v2")
report_date: str = Field("", description="Report date for v2")
business_name: str = Field("", description="Business name for v2")
analysis_period: str = Field("", description="Analysis period for v2")
# Section 1: Executive Summary (v2)
executive_summary: dict | None = Field(None, description="V2 executive summary section")
# Section 2: Risk Scorecard (v2)
risk_scorecard: dict | None = Field(None, description="V2 risk scorecard section")
# Section 3: Critical Issues (v2)
critical_issues: list[dict] = Field(default_factory=list, description="V2 critical issues")
# Section 4: Strengths to Protect (v2)
# Note: For V2 responses, 'strengths' contains StrengthToProtect objects
# For V1 responses, 'strengths' contains ReportStrengthResponse objects
# Section 5: Action Matrix (v2)
action_matrix: list[dict] = Field(default_factory=list, description="V2 action matrix")
# Section 6: 90-Day Tracking (v2)
tracking_kpis: list[dict] = Field(default_factory=list, description="V2 tracking KPIs")
# Charts for visualization (v2)
charts: dict | None = Field(None, description="V2 chart data")
class ReviewIQAnalyticsResponse(BaseModel): class ReviewIQAnalyticsResponse(BaseModel):
@@ -367,6 +464,26 @@ DOMAIN_CONFIG = {
"R": {"name": "Relationship", "owner": "Leadership / CX", "green": 80, "yellow": 60, "weight": 0.13}, "R": {"name": "Relationship", "owner": "Leadership / CX", "green": 80, "yellow": 60, "weight": 0.13},
} }
# Labels and colors for trends endpoint
DOMAIN_LABELS = {
"P": "Staff & Service",
"J": "Speed & Process",
"O": "Product Quality",
"E": "Facilities",
"A": "Availability",
"V": "Pricing & Value",
"R": "Trust & Ethics",
}
DOMAIN_COLORS = {
"P": "#3b82f6",
"J": "#8b5cf6",
"O": "#f97316",
"E": "#06b6d4",
"A": "#10b981",
"V": "#ec4899",
"R": "#f59e0b",
}
# Intensity weights for scoring # Intensity weights for scoring
INTENSITY_WEIGHTS = {"I1": 1.0, "I2": 2.0, "I3": 4.0} INTENSITY_WEIGHTS = {"I1": 1.0, "I2": 2.0, "I3": 4.0}
@@ -382,6 +499,7 @@ async def get_reviewiq_analytics(
job_id: str | None = Query(None, description="Filter by job ID"), job_id: str | None = Query(None, description="Filter by job ID"),
business_id: str | None = Query(None, description="Filter by business ID"), business_id: str | None = Query(None, description="Filter by business ID"),
time_range: str = Query("30d", description="Time range (7d, 14d, 30d, 90d, 1y, all)"), time_range: str = Query("30d", description="Time range (7d, 14d, 30d, 90d, 1y, all)"),
granularity: str = Query("auto", description="Timeline granularity (day, week, month, year, auto)"),
sentiment: str | None = Query(None, description="Filter by sentiment (comma-separated: positive,negative)"), sentiment: str | None = Query(None, description="Filter by sentiment (comma-separated: positive,negative)"),
urt_domain: str | None = Query(None, description="Filter by URT domain (P, J, O, A)"), urt_domain: str | None = Query(None, description="Filter by URT domain (P, J, O, A)"),
intensity: str | None = Query(None, description="Filter by intensity (I1, I2, I3)"), intensity: str | None = Query(None, description="Filter by intensity (I1, I2, I3)"),
@@ -403,10 +521,23 @@ async def get_reviewiq_analytics(
start_date = _parse_time_range(time_range) start_date = _parse_time_range(time_range)
sentiment_filter = sentiment.split(",") if sentiment else None sentiment_filter = sentiment.split(",") if sentiment else None
# Resolve auto granularity based on time range
resolved_granularity = granularity
if granularity == "auto":
if time_range in ("7d", "14d"):
resolved_granularity = "day"
elif time_range in ("30d", "90d"):
resolved_granularity = "week"
elif time_range == "1y":
resolved_granularity = "month"
else: # "all"
resolved_granularity = "month"
# Build filter conditions # Build filter conditions
filters_applied = { filters_applied = {
"time_range": time_range, "time_range": time_range,
"start_date": start_date.isoformat(), "start_date": start_date.isoformat(),
"granularity": resolved_granularity,
} }
if job_id: if job_id:
filters_applied["job_id"] = job_id filters_applied["job_id"] = job_id
@@ -427,12 +558,14 @@ async def get_reviewiq_analytics(
# Query 2: Sentiment Distribution + URT Domain Distribution # Query 2: Sentiment Distribution + URT Domain Distribution
sentiment_data, urt_data = await _get_distributions( sentiment_data, urt_data = await _get_distributions(
conn, job_id, business_id, start_date, sentiment_filter, urt_domain, intensity conn, job_id, business_id, start_date, sentiment_filter, urt_domain, intensity,
resolved_granularity
) )
# Query 3: Timeline Data # Query 3: Timeline Data
timeline = await _get_timeline_data( timeline = await _get_timeline_data(
conn, job_id, business_id, start_date, sentiment_filter, urt_domain, intensity conn, job_id, business_id, start_date, sentiment_filter, urt_domain, intensity,
resolved_granularity
) )
# Query 4: Issues (paginated) - now with enriched URT data # Query 4: Issues (paginated) - now with enriched URT data
@@ -476,6 +609,163 @@ async def get_reviewiq_analytics(
) )
@router.get("/trends", response_model=list[TrendItem])
async def get_reviewiq_trends(
job_id: str | None = Query(None, description="Filter by job ID"),
business_id: str | None = Query(None, description="Filter by business ID"),
items: str = Query(..., description="Comma-separated item codes (e.g., P,J,O or P.FRIE,J.WAIT)"),
time_range: str = Query("1y", description="Time range"),
granularity: str = Query("auto", description="Granularity (day, week, month, year, auto)"),
) -> list[TrendItem]:
"""
Get trend data for specified URT domains or subcodes.
Items can be:
- Single letter domain codes: P, J, O, E, A, V, R
- Subcode prefixes with dot: P.FRIE, J.WAIT, O.QUAL
Returns time series data for each item showing total count, positive, and negative over time.
"""
if not _pool:
raise HTTPException(status_code=503, detail="Database not initialized")
# Parse time range
start_date = _parse_time_range(time_range)
# Resolve auto granularity based on time range
resolved_granularity = granularity
if granularity == "auto":
if time_range in ("7d", "14d"):
resolved_granularity = "day"
elif time_range in ("30d", "90d"):
resolved_granularity = "week"
elif time_range == "1y":
resolved_granularity = "month"
else: # "all"
resolved_granularity = "month"
# Map granularity to PostgreSQL DATE_TRUNC unit
trunc_unit = {
"day": "day",
"week": "week",
"month": "month",
"year": "year",
}.get(resolved_granularity, "week")
# Parse items
item_codes = [item.strip() for item in items.split(",") if item.strip()]
if not item_codes:
raise HTTPException(status_code=400, detail="At least one item code is required")
result: list[TrendItem] = []
async with _pool.acquire() as conn:
for item_code in item_codes:
# Build WHERE conditions
conditions = ["rs.review_time >= $1"]
params: list[Any] = [start_date]
param_idx = 2
if job_id:
conditions.append(f"rs.job_id = ${param_idx}::uuid")
params.append(job_id)
param_idx += 1
if business_id:
conditions.append(f"rs.business_id = ${param_idx}")
params.append(business_id)
param_idx += 1
# Determine filter type based on item code format
if "." in item_code:
# Subcode prefix (e.g., P.FRIE) - use LIKE
conditions.append(f"rs.urt_primary LIKE ${param_idx}")
params.append(f"{item_code}%")
param_idx += 1
else:
# Single letter domain (e.g., P) - use LEFT()
conditions.append(f"LEFT(rs.urt_primary, 1) = ${param_idx}")
params.append(item_code)
param_idx += 1
where_clause = " AND ".join(conditions)
# Query for trend data with sentiment and rating impact
# Key insight: avg_rating_negative shows the damage caused by complaints in this category
query = f"""
SELECT
TO_CHAR(DATE_TRUNC('{trunc_unit}', rs.review_time), 'YYYY-MM-DD') as date,
COUNT(*) as count,
COUNT(*) FILTER (WHERE rs.valence = 'V+') as positive,
COUNT(*) FILTER (WHERE rs.valence IN ('V-', '')) as negative,
COUNT(DISTINCT rs.review_id) as review_count,
-- Avg rating of reviews with NEGATIVE mentions (the damage metric)
AVG(re.rating) FILTER (WHERE rs.valence IN ('V-', '')) as avg_rating_negative,
-- Avg rating of reviews with POSITIVE mentions (the strength metric)
AVG(re.rating) FILTER (WHERE rs.valence = 'V+') as avg_rating_positive
FROM pipeline.review_spans rs
LEFT JOIN pipeline.reviews_enriched re ON (
re.source = rs.source
AND re.review_id = rs.review_id
AND re.review_version = rs.review_version
)
WHERE {where_clause}
AND rs.urt_primary IS NOT NULL
GROUP BY DATE_TRUNC('{trunc_unit}', rs.review_time)
ORDER BY DATE_TRUNC('{trunc_unit}', rs.review_time)
"""
rows = await conn.fetch(query, *params)
# Build data points with sentiment score and rating impact
data_points = []
for row in rows:
count = row["count"] or 0
positive = row["positive"] or 0
negative = row["negative"] or 0
# Sentiment score: -100 (all negative) to +100 (all positive)
sentiment_score = ((positive - negative) / count * 100) if count > 0 else 0
data_points.append(TrendDataPoint(
date=row["date"],
count=count,
positive=positive,
negative=negative,
review_count=row["review_count"] or 0,
sentiment_score=round(sentiment_score, 1),
# The damage: avg stars when people COMPLAIN about this category
avg_rating_negative=round(float(row["avg_rating_negative"]), 2) if row["avg_rating_negative"] else None,
# The strength: avg stars when people PRAISE this category
avg_rating_positive=round(float(row["avg_rating_positive"]), 2) if row["avg_rating_positive"] else None,
))
# Determine label and color
if "." in item_code:
# For subcodes, try to get name from database
subcode_row = await conn.fetchrow(
"SELECT name FROM pipeline.urt_subcodes WHERE code = $1",
item_code
)
label = subcode_row["name"] if subcode_row else item_code
# Use domain color for subcodes
domain_letter = item_code[0]
color = DOMAIN_COLORS.get(domain_letter, "#6b7280")
else:
# For domains, use the DOMAIN_LABELS dict
label = DOMAIN_LABELS.get(item_code, item_code)
color = DOMAIN_COLORS.get(item_code, "#6b7280")
result.append(TrendItem(
id=item_code,
label=label,
color=color,
data=data_points,
))
return result
async def _get_overview_stats( async def _get_overview_stats(
conn: asyncpg.Connection, conn: asyncpg.Connection,
job_id: str | None, job_id: str | None,
@@ -590,6 +880,7 @@ async def _get_distributions(
sentiment_filter: list[str] | None, sentiment_filter: list[str] | None,
urt_domain: str | None, urt_domain: str | None,
intensity: str | None, intensity: str | None,
granularity: str = "week",
) -> tuple[SentimentData, URTData]: ) -> tuple[SentimentData, URTData]:
"""Get sentiment and URT distributions with cross-filtering support.""" """Get sentiment and URT distributions with cross-filtering support."""
@@ -666,17 +957,25 @@ async def _get_distributions(
] ]
# ========== Sentiment Trend (filtered by domain) ========== # ========== Sentiment Trend (filtered by domain) ==========
# Map granularity to PostgreSQL DATE_TRUNC unit
trunc_unit = {
"day": "day",
"week": "week",
"month": "month",
"year": "year",
}.get(granularity, "week")
trend_query = f""" trend_query = f"""
SELECT SELECT
TO_CHAR(DATE_TRUNC('week', rs.review_time), 'IYYY-"W"IW') as period, TO_CHAR(DATE_TRUNC('{trunc_unit}', rs.review_time), 'YYYY-MM-DD') as period,
COUNT(*) FILTER (WHERE rs.valence = 'V+') as positive, COUNT(*) FILTER (WHERE rs.valence = 'V+') as positive,
COUNT(*) FILTER (WHERE rs.valence IN ('V-', '')) as negative, COUNT(*) FILTER (WHERE rs.valence IN ('V-', '')) as negative,
COUNT(*) FILTER (WHERE rs.valence = 'V0') as neutral, COUNT(*) FILTER (WHERE rs.valence = 'V0') as neutral,
COUNT(*) FILTER (WHERE rs.valence = '') as mixed COUNT(*) FILTER (WHERE rs.valence = '') as mixed
FROM pipeline.review_spans rs FROM pipeline.review_spans rs
WHERE {sentiment_where} WHERE {sentiment_where}
GROUP BY DATE_TRUNC('week', rs.review_time) GROUP BY DATE_TRUNC('{trunc_unit}', rs.review_time)
ORDER BY DATE_TRUNC('week', rs.review_time) ORDER BY DATE_TRUNC('{trunc_unit}', rs.review_time)
""" """
trend_rows = await conn.fetch(trend_query, *sentiment_params) trend_rows = await conn.fetch(trend_query, *sentiment_params)
@@ -803,6 +1102,7 @@ async def _get_timeline_data(
sentiment_filter: list[str] | None, sentiment_filter: list[str] | None,
urt_domain: str | None, urt_domain: str | None,
intensity: str | None, intensity: str | None,
granularity: str = "week",
) -> list[TimelinePoint]: ) -> list[TimelinePoint]:
"""Get timeline data for the brush chart.""" """Get timeline data for the brush chart."""
@@ -823,9 +1123,17 @@ async def _get_timeline_data(
where_clause = " AND ".join(conditions) where_clause = " AND ".join(conditions)
# Map granularity to PostgreSQL DATE_TRUNC unit
trunc_unit = {
"day": "day",
"week": "week",
"month": "month",
"year": "year",
}.get(granularity, "week")
query = f""" query = f"""
SELECT SELECT
TO_CHAR(DATE_TRUNC('week', rs.review_time), 'IYYY-"W"IW') as date, TO_CHAR(DATE_TRUNC('{trunc_unit}', rs.review_time), 'YYYY-MM-DD') as date,
COUNT(DISTINCT CONCAT(rs.source, ':', rs.review_id)) as review_count, COUNT(DISTINCT CONCAT(rs.source, ':', rs.review_id)) as review_count,
COUNT(*) as span_count, COUNT(*) as span_count,
AVG(re.rating) as avg_rating, AVG(re.rating) as avg_rating,
@@ -838,14 +1146,15 @@ async def _get_timeline_data(
AND re.review_version = rs.review_version AND re.review_version = rs.review_version
) )
WHERE {where_clause} WHERE {where_clause}
GROUP BY DATE_TRUNC('week', rs.review_time) GROUP BY DATE_TRUNC('{trunc_unit}', rs.review_time)
ORDER BY DATE_TRUNC('week', rs.review_time) ORDER BY DATE_TRUNC('{trunc_unit}', rs.review_time)
""" """
rows = await conn.fetch(query, *params) rows = await conn.fetch(query, *params)
return [ # Convert rows to dict for easy lookup
TimelinePoint( data_by_date = {
row["date"]: TimelinePoint(
date=row["date"], date=row["date"],
review_count=row["review_count"] or 0, review_count=row["review_count"] or 0,
span_count=row["span_count"] or 0, span_count=row["span_count"] or 0,
@@ -854,7 +1163,56 @@ async def _get_timeline_data(
negative_count=row["negative_count"] or 0, negative_count=row["negative_count"] or 0,
) )
for row in rows for row in rows
] }
if not data_by_date:
return []
# Fill in missing periods with zero values
dates = sorted(data_by_date.keys())
min_date = datetime.strptime(dates[0], "%Y-%m-%d")
max_date = datetime.strptime(dates[-1], "%Y-%m-%d")
def add_period(dt: datetime, gran: str) -> datetime:
"""Add one period to a datetime based on granularity."""
if gran == "day":
return dt + timedelta(days=1)
elif gran == "week":
return dt + timedelta(weeks=1)
elif gran == "month":
# Add one month
month = dt.month + 1
year = dt.year
if month > 12:
month = 1
year += 1
# Handle edge cases like Jan 31 -> Feb 28
day = min(dt.day, 28) # Safe for all months
return dt.replace(year=year, month=month, day=1) # Use 1st of month for consistency
elif gran == "year":
return dt.replace(year=dt.year + 1)
else:
return dt + timedelta(weeks=1)
result = []
current = min_date
while current <= max_date:
date_str = current.strftime("%Y-%m-%d")
if date_str in data_by_date:
result.append(data_by_date[date_str])
else:
# Fill with zero values
result.append(TimelinePoint(
date=date_str,
review_count=0,
span_count=0,
avg_rating=None,
positive_count=0,
negative_count=0,
))
current = add_period(current, granularity)
return result
async def _get_issues( async def _get_issues(
@@ -1461,12 +1819,14 @@ async def _get_synthesis(
conn: asyncpg.Connection, conn: asyncpg.Connection,
job_id: str | None, job_id: str | None,
) -> SynthesisResponse | None: ) -> SynthesisResponse | None:
"""Fetch AI-generated synthesis from pipeline execution.""" """Fetch analyst report synthesis from pipeline execution.
Handles both legacy format (v1) and new 6-section format (v2).
"""
if not job_id: if not job_id:
return None return None
try: try:
# Get the latest execution with synthesis for this job
row = await conn.fetchrow(""" row = await conn.fetchrow("""
SELECT synthesis SELECT synthesis
FROM pipeline.executions FROM pipeline.executions
@@ -1480,52 +1840,106 @@ async def _get_synthesis(
return None return None
data = row["synthesis"] data = row["synthesis"]
# asyncpg may return JSONB as dict or string
if isinstance(data, str): if isinstance(data, str):
import json import json
data = json.loads(data) data = json.loads(data)
# Convert to response model # Check for v2 format (6-section report)
action_plan = [ report_version = data.get("report_version")
ActionItemResponse( if report_version == "2.0":
id=a.get("id", f"action_{i}"), # Parse v2 format
title=a.get("title", ""), exec_summary = data.get("executive_summary", {})
why=a.get("why", ""),
what=a.get("what", ""), # For V2, return the data structure as the frontend expects it
who=a.get("who", ""), # The frontend type guard checks for executive_summary to detect V2
impact=a.get("impact", ""), return SynthesisResponse(
evidence=a.get("evidence", []), # Version
estimated_rating_lift=a.get("estimated_rating_lift"), report_version="2.0",
complexity=a.get("complexity", "medium"), # V2 metadata
priority=a.get("priority", "medium"), report_title=data.get("report_title", ""),
timeline=a.get("timeline", "This month"), report_date=data.get("report_date", ""),
related_subcode=a.get("related_subcode", ""), business_name=data.get("business_name", ""),
analysis_period=data.get("analysis_period", ""),
generated_at=data.get("generated_at"),
review_count=data.get("review_count", 0),
insight_count=data.get("insight_count", 0),
# V2 sections (these are dicts/lists that frontend will parse)
executive_summary=data.get("executive_summary"),
risk_scorecard=data.get("risk_scorecard"),
critical_issues=data.get("critical_issues", []),
action_matrix=data.get("action_matrix", []),
tracking_kpis=data.get("tracking_kpis", []),
charts=data.get("charts"),
# Legacy fields populated from v2 for backwards compat
current_rating=exec_summary.get("current_rating", 0.0),
potential_rating=exec_summary.get("potential_rating", 0.0),
rating_gap=exec_summary.get("rating_gap", 0.0),
headline=exec_summary.get("one_liner", ""),
momentum=exec_summary.get("momentum", "stable"),
momentum_detail=exec_summary.get("momentum_detail", ""),
# V2 strengths are passed as raw dicts (StrengthToProtect format)
# Frontend type guard will handle the different structure
strengths=data.get("strengths", []),
) )
for i, a in enumerate(data.get("action_plan", []))
# Parse legacy v1 format
actions = [
ReportActionResponse(
priority=a.get("priority", "medium"),
action=a.get("action", ""),
owner=a.get("owner", ""),
impact=a.get("impact", ""),
impact_stars=float(a.get("impact_stars", 0.1)),
effort=a.get("effort", "moderate"),
evidence=a.get("evidence", ""),
complaint_count=int(a.get("complaint_count", 0)),
success_metric=a.get("success_metric", ""),
)
for a in data.get("actions", [])
] ]
timeline_annotations = [ evidence = [
TimelineAnnotationResponse( ReportEvidenceResponse(
date=t.get("date", ""), quote=e.get("quote", ""),
label=t.get("label", ""), context=e.get("context", ""),
description=t.get("description", ""), sentiment=e.get("sentiment", "damaging"),
type=t.get("type", "neutral"), weight=e.get("weight", "notable"),
) )
for t in data.get("timeline_annotations", []) for e in data.get("evidence", [])
]
strengths = [
ReportStrengthResponse(
title=s.get("title", ""),
mention_count=int(s.get("mention_count", 0)),
quote=s.get("quote", ""),
marketing_angle=s.get("marketing_angle", ""),
)
for s in data.get("strengths", [])
] ]
return SynthesisResponse( return SynthesisResponse(
executive_narrative=data.get("executive_narrative", ""), headline=data.get("headline", ""),
sentiment_insight=data.get("sentiment_insight", ""), verdict=data.get("verdict", ""),
category_insight=data.get("category_insight", ""), current_rating=data.get("current_rating", 0.0),
timeline_insight=data.get("timeline_insight", ""), potential_rating=data.get("potential_rating", 0.0),
priority_domain=data.get("priority_domain"), rating_gap=data.get("rating_gap", 0.0),
priority_issue=data.get("priority_issue"), narrative=data.get("narrative", ""),
action_plan=action_plan, sentiment_headline=data.get("sentiment_headline", ""),
timeline_annotations=timeline_annotations, category_headline=data.get("category_headline", ""),
marketing_angles=data.get("marketing_angles", []), timeline_headline=data.get("timeline_headline", ""),
competitor_context=data.get("competitor_context"), strengths_headline=data.get("strengths_headline", ""),
primary_problem=data.get("primary_problem", ""),
primary_problem_code=data.get("primary_problem_code", ""),
root_cause=data.get("root_cause", ""),
actions=actions,
evidence=evidence,
strengths=strengths,
momentum=data.get("momentum", "stable"),
momentum_detail=data.get("momentum_detail", ""),
generated_at=data.get("generated_at"), generated_at=data.get("generated_at"),
review_count=data.get("review_count", 0),
insight_count=data.get("insight_count", 0),
) )
except Exception as e: except Exception as e: