From 69d617ca38674c9c96f65079f1f284cf69a521ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:12:41 +0000 Subject: [PATCH] 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 --- api/routes/reviewiq_analytics.py | 574 ++++++++++++++++++++++++++----- 1 file changed, 494 insertions(+), 80 deletions(-) diff --git a/api/routes/reviewiq_analytics.py b/api/routes/reviewiq_analytics.py index 58d689f..d65923c 100644 --- a/api/routes/reviewiq_analytics.py +++ b/api/routes/reviewiq_analytics.py @@ -165,6 +165,33 @@ class TimelinePoint(BaseModel): 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 ==================== @@ -271,49 +298,119 @@ class Insights(BaseModel): executive_summary: str = Field("", description="Auto-generated summary") -# ==================== AI Synthesis Models ==================== +# ==================== Report Synthesis Models ==================== -class ActionItemResponse(BaseModel): - """A specific action recommendation from AI synthesis.""" +class ReportActionResponse(BaseModel): + """A prioritized action item for the analyst report.""" - id: str = Field(..., description="Action identifier") - title: str = Field(..., description="Clear action title") - why: str = Field("", description="Root cause from reviews") - what: str = Field("", description="Specific steps to take") - who: str = Field("", description="Department or role responsible") - impact: str = Field("", description="Expected outcome") - evidence: list[str] = Field(default_factory=list, description="Supporting quotes") - estimated_rating_lift: float | None = Field(None, description="Projected rating improvement") - complexity: str = Field("medium", description="quick/medium/complex") - 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") + priority: str = Field(..., description="critical/high/medium") + action: str = Field(..., description="What to do") + owner: str = Field(..., description="Who owns it") + impact: str = Field(..., description="Expected result") + impact_stars: float = Field(0.1, description="Numeric star impact") + effort: str = Field("moderate", description="quick_win/moderate/strategic") + evidence: str = Field("", description="Supporting quote") + complaint_count: int = Field(0, description="Number of complaints addressed") + success_metric: str = Field("", description="Measurable success KPI") -class TimelineAnnotationResponse(BaseModel): - """A key event annotation for timeline visualization.""" +class ReportEvidenceResponse(BaseModel): + """A curated quote that supports the narrative.""" - date: str = Field(..., description="Event date (YYYY-MM-DD)") - label: str = Field(..., description="Short label") - description: str = Field("", description="What happened") - type: str = Field("neutral", description="positive/negative/neutral/event") + quote: str = Field(..., description="Customer words") + context: str = Field(..., description="What this proves") + sentiment: str = Field("damaging", description="damaging/praising") + 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): - """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") - sentiment_insight: str = Field("", description="Why sentiment is distributed this way") - 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)") - priority_issue: str | None = Field(None, description="Subcode to fix first (e.g., V1.03)") - action_plan: list[ActionItemResponse] = Field(default_factory=list, description="Prioritized actions") - timeline_annotations: list[TimelineAnnotationResponse] = Field(default_factory=list, description="Key events") - marketing_angles: list[str] = Field(default_factory=list, description="Ways to promote strengths") - competitor_context: str | None = Field(None, description="Industry comparison") - generated_at: str | None = Field(None, description="When synthesis was generated") + Supports both legacy format (v1) and new 6-section format (v2). + Frontend uses type guards to determine which format to render. + """ + + # Version indicator - "2.0" for new format, absent for legacy + report_version: str | None = Field(None, description="Report format version") + + # ===== LEGACY FORMAT FIELDS (v1) ===== + # The Verdict + headline: str = Field("", description="One punchy insight line") + 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): @@ -367,6 +464,26 @@ DOMAIN_CONFIG = { "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 = {"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"), 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)"), + 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)"), 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)"), @@ -403,10 +521,23 @@ async def get_reviewiq_analytics( start_date = _parse_time_range(time_range) 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 filters_applied = { "time_range": time_range, "start_date": start_date.isoformat(), + "granularity": resolved_granularity, } if job_id: filters_applied["job_id"] = job_id @@ -427,12 +558,14 @@ async def get_reviewiq_analytics( # Query 2: Sentiment Distribution + URT Domain Distribution 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 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 @@ -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-', '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-', '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( conn: asyncpg.Connection, job_id: str | None, @@ -590,6 +880,7 @@ async def _get_distributions( sentiment_filter: list[str] | None, urt_domain: str | None, intensity: str | None, + granularity: str = "week", ) -> tuple[SentimentData, URTData]: """Get sentiment and URT distributions with cross-filtering support.""" @@ -666,17 +957,25 @@ async def _get_distributions( ] # ========== 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""" 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 IN ('V-', 'V±')) as negative, COUNT(*) FILTER (WHERE rs.valence = 'V0') as neutral, COUNT(*) FILTER (WHERE rs.valence = 'V±') as mixed FROM pipeline.review_spans rs WHERE {sentiment_where} - GROUP BY DATE_TRUNC('week', rs.review_time) - ORDER BY DATE_TRUNC('week', rs.review_time) + GROUP BY DATE_TRUNC('{trunc_unit}', rs.review_time) + ORDER BY DATE_TRUNC('{trunc_unit}', rs.review_time) """ trend_rows = await conn.fetch(trend_query, *sentiment_params) @@ -803,6 +1102,7 @@ async def _get_timeline_data( sentiment_filter: list[str] | None, urt_domain: str | None, intensity: str | None, + granularity: str = "week", ) -> list[TimelinePoint]: """Get timeline data for the brush chart.""" @@ -823,9 +1123,17 @@ async def _get_timeline_data( 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""" 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(*) as span_count, AVG(re.rating) as avg_rating, @@ -838,14 +1146,15 @@ async def _get_timeline_data( AND re.review_version = rs.review_version ) WHERE {where_clause} - GROUP BY DATE_TRUNC('week', rs.review_time) - ORDER BY DATE_TRUNC('week', rs.review_time) + GROUP BY DATE_TRUNC('{trunc_unit}', rs.review_time) + ORDER BY DATE_TRUNC('{trunc_unit}', rs.review_time) """ rows = await conn.fetch(query, *params) - return [ - TimelinePoint( + # Convert rows to dict for easy lookup + data_by_date = { + row["date"]: TimelinePoint( date=row["date"], review_count=row["review_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, ) 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( @@ -1461,12 +1819,14 @@ async def _get_synthesis( conn: asyncpg.Connection, job_id: str | 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: return None try: - # Get the latest execution with synthesis for this job row = await conn.fetchrow(""" SELECT synthesis FROM pipeline.executions @@ -1480,52 +1840,106 @@ async def _get_synthesis( return None data = row["synthesis"] - # asyncpg may return JSONB as dict or string if isinstance(data, str): import json data = json.loads(data) - # Convert to response model - action_plan = [ - ActionItemResponse( - id=a.get("id", f"action_{i}"), - title=a.get("title", ""), - why=a.get("why", ""), - what=a.get("what", ""), - who=a.get("who", ""), - impact=a.get("impact", ""), - evidence=a.get("evidence", []), - estimated_rating_lift=a.get("estimated_rating_lift"), - complexity=a.get("complexity", "medium"), - priority=a.get("priority", "medium"), - timeline=a.get("timeline", "This month"), - related_subcode=a.get("related_subcode", ""), + # Check for v2 format (6-section report) + report_version = data.get("report_version") + if report_version == "2.0": + # Parse v2 format + exec_summary = data.get("executive_summary", {}) + + # For V2, return the data structure as the frontend expects it + # The frontend type guard checks for executive_summary to detect V2 + return SynthesisResponse( + # Version + report_version="2.0", + # V2 metadata + report_title=data.get("report_title", ""), + report_date=data.get("report_date", ""), + 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 = [ - TimelineAnnotationResponse( - date=t.get("date", ""), - label=t.get("label", ""), - description=t.get("description", ""), - type=t.get("type", "neutral"), + evidence = [ + ReportEvidenceResponse( + quote=e.get("quote", ""), + context=e.get("context", ""), + sentiment=e.get("sentiment", "damaging"), + 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( - executive_narrative=data.get("executive_narrative", ""), - sentiment_insight=data.get("sentiment_insight", ""), - category_insight=data.get("category_insight", ""), - timeline_insight=data.get("timeline_insight", ""), - priority_domain=data.get("priority_domain"), - priority_issue=data.get("priority_issue"), - action_plan=action_plan, - timeline_annotations=timeline_annotations, - marketing_angles=data.get("marketing_angles", []), - competitor_context=data.get("competitor_context"), + headline=data.get("headline", ""), + verdict=data.get("verdict", ""), + current_rating=data.get("current_rating", 0.0), + potential_rating=data.get("potential_rating", 0.0), + rating_gap=data.get("rating_gap", 0.0), + narrative=data.get("narrative", ""), + sentiment_headline=data.get("sentiment_headline", ""), + category_headline=data.get("category_headline", ""), + timeline_headline=data.get("timeline_headline", ""), + 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"), + review_count=data.get("review_count", 0), + insight_count=data.get("insight_count", 0), ) except Exception as e: