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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user