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")
# ==================== 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-', '')) 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(
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-', '')) as negative,
COUNT(*) FILTER (WHERE rs.valence = 'V0') as neutral,
COUNT(*) FILTER (WHERE rs.valence = '') 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: