#!/usr/bin/env python3 """ Reputation Report Generator v1.0 Generates business-facing, time-windowed, language-aware reputation reports from detected_spans_v2 data. Usage: python reputation_report.py --business "Pura Vida Hostel" --days 30 python reputation_report.py --business "gokarts" --start 2026-01-01 --end 2026-01-31 python reputation_report.py --business "gokarts" --run-id Backfill run_id for legacy rows (idempotent, only fills NULL run_ids): WITH job_groups AS ( SELECT business_id, job_id, gen_random_uuid() AS new_run_id FROM pipeline.detected_spans_v2 WHERE job_id IS NOT NULL AND run_id IS NULL GROUP BY business_id, job_id ) UPDATE pipeline.detected_spans_v2 s SET run_id = g.new_run_id FROM job_groups g WHERE s.business_id = g.business_id AND s.job_id = g.job_id AND s.run_id IS NULL; """ import argparse import asyncio import json import os import uuid from datetime import datetime, timedelta, timezone from typing import Any import asyncpg # Optional: OpenAI for executive summary try: from openai import AsyncOpenAI OPENAI_AVAILABLE = True except ImportError: OPENAI_AVAILABLE = False # Database URL DB_URL = os.environ.get( "DATABASE_URL", "postgresql://scraper:scraper123@localhost:5437/scraper" ) # Schema version SCHEMA_VERSION = "1.0" # ============================================================================= # DOMAIN MAPPING (static) # ============================================================================= DOMAIN_MAP = { # O - Output/Product Quality "TASTE": "O", "CRAFT": "O", "FRESHNESS": "O", "TEMPERATURE": "O", "EFFECTIVENESS": "O", "ACCURACY": "O", "CONDITION": "O", "CONSISTENCY": "O", # P - People/Service "MANNER": "P", "COMPETENCE": "P", "ATTENTIVENESS": "P", "COMMUNICATION": "P", # J - Journey/Process "SPEED": "J", "FRICTION": "J", "RELIABILITY": "J", "AVAILABILITY": "J", # E - Environment "CLEANLINESS": "E", "COMFORT": "E", "SAFETY": "E", "AMBIANCE": "E", "ACCESSIBILITY": "E", "DIGITAL_UX": "E", # V - Value "PRICE_LEVEL": "V", "PRICE_FAIRNESS": "V", "PRICE_TRANSPARENCY": "V", "VALUE_FOR_MONEY": "V", # Meta primitives "HONESTY": "meta", "ETHICS": "meta", "PROMISES": "meta", "ACKNOWLEDGMENT": "meta", "RESPONSE_QUALITY": "meta", "RECOVERY": "meta", "RETURN_INTENT": "meta", "RECOMMEND": "meta", "RECOGNITION": "meta", "UNMAPPED": "meta", "NON_INFORMATIVE": "meta", } DOMAIN_NAMES = { "O": "Output/Product", "P": "People/Service", "J": "Journey/Process", "E": "Environment", "V": "Value", "meta": "Meta", } # Valence mapping for scoring VALENCE_NUM = { "+": 1.0, "-": -1.0, "0": 0.0, "±": 0.0, # Mixed = neutral for scoring } # Alert thresholds (v1 defaults) ALERT_THRESHOLDS = { "unmapped_rate_content": {"warn": 0.10, "critical": 0.15}, "safety_negative_share": {"warn": 0.05, "critical": 0.10}, "avg_confidence": {"warn": 0.70}, # Below this triggers warn "low_confidence_share": {"warn": 0.10}, } # Executive summary prompt EXEC_SUMMARY_SYSTEM = """You are a business analyst writing short performance summaries for business owners.""" EXEC_SUMMARY_USER = """Here is the performance data for a business: {json_data} Write a 3-5 sentence executive summary in clear business language. Rules: - Mention current performance score and what it means (0-100 scale; 80+ strong, 60-80 moderate, below 60 needs attention) - Mention trend vs previous period if available (use comparisons.previous_window) - Mention strongest positive driver from drivers.positives (highest impact) - what customers love - For weaknesses: - If drivers.negatives is non-empty: cite the top negative driver as the main weakness - If drivers.negatives is empty but timeline shows a recent dip: cite the dip as the "main watch item" with month and metric (avg_rating or strength_score) - Only cite a "recent dip" if that month is within 90 days of window.end AND review_count >= 3 for that bucket - If multiple qualifying dips exist, cite the most recent one (closest to window.end) - If the dip month is older or has low volume (<3 reviews), either skip or phrase as "a dip was observed in [month] (limited data)" - If you mention a "dip", do NOT also say "no major issues identified" - instead say "no dominant negative driver identified overall" - If no qualifying dip and no negative drivers: say "no persistent weaknesses surfaced" - Suggested action must tie directly to (a) the cited weakness/dip (investigate or fix), or (b) the top positive driver (amplify it) - For a dip with unknown cause, suggest investigating what changed that month (staffing, operations, etc.) Do not mention: - spans, primitives, confidence, models, algorithms - technical implementation details - "increase in negative feedback" unless drivers.negatives is non-empty Tone: neutral, professional, actionable.""" # ============================================================================= # HELPER FUNCTIONS # ============================================================================= def to_utc_naive(dt: datetime | None) -> datetime | None: """Convert a datetime to UTC-naive for DB compatibility.""" if not dt: return None if dt.tzinfo is None: return dt # assume already UTC-naive return dt.astimezone(timezone.utc).replace(tzinfo=None) def compute_span_weight(confidence: float, detail: int, intensity: int) -> float: """ Compute span weight for scoring. w = confidence * (0.75 + 0.25*(detail-1)) * (0.8 + 0.2*(intensity-1)) """ detail = detail or 1 intensity = intensity or 1 confidence = float(confidence) if confidence else 0.5 return confidence * (0.75 + 0.25 * (detail - 1)) * (0.8 + 0.2 * (intensity - 1)) def compute_primitive_score(spans: list[dict]) -> float: """ Compute weighted score for a primitive. score = 100 * (sum(w * valence_num) / sum(w)), clamped to [-100, +100] """ if not spans: return 0.0 total_weight = 0.0 weighted_valence = 0.0 for span in spans: w = compute_span_weight( span.get("confidence", 0.5), span.get("detail", 1), span.get("intensity", 1) ) valence_num = VALENCE_NUM.get(span.get("valence", "0"), 0.0) total_weight += w weighted_valence += w * valence_num if total_weight == 0: return 0.0 score = 100 * (weighted_valence / total_weight) return max(-100, min(100, score)) def percentile(values: list[float], p: float) -> float: """Compute percentile of a list of values.""" if not values: return 0.0 sorted_values = sorted(values) k = (len(sorted_values) - 1) * (p / 100) f = int(k) c = f + 1 if f + 1 < len(sorted_values) else f return sorted_values[f] + (k - f) * (sorted_values[c] - sorted_values[f]) def compute_trend_label(delta: float, threshold: float = 5.0) -> str: """Compute trend label based on delta. Caller handles insufficient data.""" if delta > threshold: return "improving" elif delta < -threshold: return "declining" else: return "stable" def iso_z(dt: datetime | None) -> str | None: """Convert datetime to ISO-8601 UTC with Z suffix.""" if not dt: return None if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") # Minimum thresholds for trend comparisons MIN_REVIEWS_FOR_COMPARISON = 10 # At least 10 reviews per period MIN_COVERAGE_FOR_COMPARISON = 0.80 # At least 80% review_time coverage def build_summary_input(report: dict) -> dict: """Extract the minimal slice needed for LLM executive summary.""" summary_input = { "business": { "name": report["business"]["business_id"], "sector": report["business"]["sector_code"], }, "window": report["window"], "scores": { "overall": report["scores"]["overall"], }, "comparisons": report["comparisons"], "drivers": { # Include top driver for each (LLM only needs the highest-impact one) "positives": [report["drivers"]["positives"][0]] if report["drivers"]["positives"] else [], "negatives": [report["drivers"]["negatives"][0]] if report["drivers"]["negatives"] else [], }, "population": { "reviews_processed": report["population"]["reviews_processed"], }, } # Add timeline (last 3 points only) if report.get("timeline") and report["timeline"].get("points"): summary_input["timeline"] = { "granularity": report["timeline"]["granularity"], "points": report["timeline"]["points"][-3:], } return summary_input def generate_fallback_summary(report: dict) -> str: """Generate deterministic fallback summary from computed values.""" score = report["scores"]["overall"]["score"] # Sentence 1: Score interpretation if score >= 80: band = "strong" elif score >= 60: band = "moderate" elif score >= 40: band = "mixed" else: band = "challenging" sentence1 = f"This business has a {band} performance score of {score}." # Sentence 2: Trend if available sentence2 = "" prev = report.get("comparisons", {}).get("previous_window") if prev and prev.get("scores", {}).get("overall", {}).get("trend"): trend = prev["scores"]["overall"]["trend"] if trend == "improving": sentence2 = " Performance is trending upward compared to the previous period." elif trend == "declining": sentence2 = " Performance has declined compared to the previous period." elif trend == "stable": sentence2 = " Performance has remained stable compared to the previous period." # Sentence 3: Top drivers + action positives = report.get("drivers", {}).get("positives", []) negatives = report.get("drivers", {}).get("negatives", []) sentence3 = "" if positives: top_pos = positives[0]["primitive"].replace("_", " ").title() sentence3 += f" Customers respond positively to {top_pos}." if negatives: top_neg = negatives[0]["primitive"].replace("_", " ").title() sentence3 += f" {top_neg} is an area for improvement." return (sentence1 + sentence2 + sentence3).strip() async def generate_executive_summary( summary_input: dict, model: str = "gpt-4o-mini", enabled: bool = True, ) -> tuple[str | None, dict]: """ Generate executive summary using OpenAI. Returns: Tuple of (summary_text, meta_dict) """ meta = { "enabled": enabled, "generated": False, "model": model, "error": None, "generated_at": None, } if not enabled: meta["error"] = "summary generation disabled via --no-summary" return None, meta if not OPENAI_AVAILABLE: meta["error"] = "openai package not installed" return None, meta api_key = os.environ.get("OPENAI_API_KEY") if not api_key: meta["error"] = "OPENAI_API_KEY not set" print("Warning: OPENAI_API_KEY not set, skipping executive summary generation.") return None, meta try: client = AsyncOpenAI(api_key=api_key) response = await client.chat.completions.create( model=model, messages=[ {"role": "system", "content": EXEC_SUMMARY_SYSTEM}, {"role": "user", "content": EXEC_SUMMARY_USER.format(json_data=json.dumps(summary_input, indent=2))}, ], temperature=0.3, max_tokens=300, ) summary = response.choices[0].message.content.strip() meta["generated"] = True meta["generated_at"] = iso_z(datetime.now(timezone.utc)) return summary, meta except Exception as e: meta["error"] = str(e) print(f"Warning: Executive summary generation failed: {e}") return None, meta def select_granularity(days: int) -> str: """Select appropriate time bucket granularity based on window size.""" if days <= 14: return "day" elif days <= 90: return "week" else: return "month" async def resolve_business_id(pool: asyncpg.Pool, search_term: str) -> str | None: """Resolve partial business name to canonical business_id.""" import re # Try exact match first row = await pool.fetchrow(""" SELECT business_id FROM pipeline.business_taxonomy_map WHERE business_id = $1 """, search_term) if row: return row["business_id"] # Build search patterns search_patterns = [ f"%{search_term}%", f"%{search_term.replace(' ', '%')}%", ] # CamelCase pattern camel_pattern = re.sub(r'([a-z])([A-Z])', r'\1%\2', search_term) if camel_pattern != search_term: search_patterns.append(f"%{camel_pattern}%") # Lowercase concatenated words if search_term.islower() and len(search_term) >= 4: for i in range(2, len(search_term) - 1): search_patterns.append(f"%{search_term[:i]}%{search_term[i:]}%") for pattern in search_patterns: row = await pool.fetchrow(""" SELECT business_id FROM pipeline.business_taxonomy_map WHERE LOWER(business_id) LIKE LOWER($1) """, pattern) if row: return row["business_id"] return None # ============================================================================= # SQL QUERIES # ============================================================================= # For latest_run mode: filter by run_id and/or created_at SPANS_QUERY_LATEST_RUN = """ SELECT s.id, s.review_id, s.run_id, s.job_id, s.primitive, s.valence, s.intensity, s.detail, s.confidence, s.span_text, s.language, s.entity, s.entity_type, s.created_at, NULL::timestamptz AS review_time_utc, NULL::smallint AS rating FROM pipeline.detected_spans_v2 s WHERE s.business_id = $1 AND ($2::uuid IS NULL OR s.run_id = $2) ORDER BY s.created_at DESC """ # For time_window mode: filter by review_time_utc via join with review_facts_v1 # CRITICAL: Join on BOTH review_id AND business_id to prevent cross-business contamination SPANS_QUERY_TIME_WINDOW = """ SELECT s.id, s.review_id, s.run_id, s.job_id, s.primitive, s.valence, s.intensity, s.detail, s.confidence, s.span_text, s.language, s.entity, s.entity_type, s.created_at, f.review_time_utc, f.rating FROM pipeline.detected_spans_v2 s JOIN pipeline.review_facts_v1 f ON f.review_id = s.review_id AND f.business_id = s.business_id WHERE s.business_id = $1 AND f.review_time_utc >= $2 AND f.review_time_utc < $3 ORDER BY f.review_time_utc DESC """ BUSINESS_CONFIG_QUERY = """ SELECT business_id, sector_code, gbp_path::text as gbp_path FROM pipeline.business_taxonomy_map WHERE business_id = $1 """ TIMESERIES_QUERY = """ SELECT date_trunc($4, f.review_time_utc) AS bucket, COUNT(DISTINCT s.review_id) AS review_count, COUNT(*) AS span_count, COUNT(*) FILTER (WHERE s.valence = '+') AS positive_count, COUNT(*) FILTER (WHERE s.valence = '-') AS negative_count, COUNT(*) FILTER (WHERE s.valence = '0') AS neutral_count, COUNT(*) FILTER (WHERE s.valence = '±') AS mixed_count, ROUND(AVG(f.rating)::numeric, 2) AS avg_rating, -- Strength score scaled to [-100, 100] CASE WHEN SUM(s.confidence * s.intensity) > 0 THEN ROUND((100 * SUM( CASE s.valence WHEN '+' THEN 1 WHEN '-' THEN -1 ELSE 0 END * s.confidence * s.intensity ) / SUM(s.confidence * s.intensity))::numeric, 1) ELSE 0 END AS strength_score FROM pipeline.detected_spans_v2 s JOIN pipeline.review_facts_v1 f ON f.review_id = s.review_id AND f.business_id = s.business_id WHERE s.business_id = $1 AND f.review_time_utc >= $2 AND f.review_time_utc < $3 AND s.primitive <> 'NON_INFORMATIVE' GROUP BY 1 ORDER BY 1 """ PERIOD_SCORES_QUERY = """ SELECT COUNT(DISTINCT s.review_id) AS review_count, COUNT(*) AS span_count, COUNT(*) FILTER (WHERE s.valence = '+') AS positive_count, COUNT(*) FILTER (WHERE s.valence = '-') AS negative_count, ROUND(AVG(f.rating)::numeric, 2) AS avg_rating, CASE WHEN SUM(s.confidence * s.intensity) > 0 THEN ROUND((100 * SUM( CASE s.valence WHEN '+' THEN 1 WHEN '-' THEN -1 ELSE 0 END * s.confidence * s.intensity ) / SUM(s.confidence * s.intensity))::numeric, 1) ELSE 0 END AS overall_score FROM pipeline.detected_spans_v2 s JOIN pipeline.review_facts_v1 f ON f.review_id = s.review_id AND f.business_id = s.business_id WHERE s.business_id = $1 AND f.review_time_utc >= $2 AND f.review_time_utc < $3 AND s.primitive <> 'NON_INFORMATIVE' """ REVIEW_TIME_COVERAGE_QUERY = """ SELECT COUNT(DISTINCT s.review_id) AS spans_reviews, COUNT(DISTINCT f.review_id) AS facts_reviews, MIN(f.review_time_utc) AS min_review_time, MAX(f.review_time_utc) AS max_review_time FROM pipeline.detected_spans_v2 s LEFT JOIN pipeline.review_facts_v1 f ON f.review_id = s.review_id AND f.business_id = s.business_id WHERE s.business_id = $1 AND s.primitive <> 'NON_INFORMATIVE' AND ($2::timestamptz IS NULL OR f.review_time_utc >= $2) AND ($3::timestamptz IS NULL OR f.review_time_utc < $3) """ SECTOR_BENCHMARK_QUERY = """ SELECT ROUND((100 * SUM( CASE s.valence WHEN '+' THEN 1 WHEN '-' THEN -1 ELSE 0 END * s.confidence * s.intensity ) / NULLIF(SUM(s.confidence * s.intensity), 0))::numeric, 1) AS sector_overall_score, COUNT(*) AS sector_span_count, COUNT(DISTINCT s.business_id) AS sector_business_count FROM pipeline.detected_spans_v2 s JOIN pipeline.review_facts_v1 f ON f.review_id = s.review_id AND f.business_id = s.business_id WHERE s.sector_code = $1 AND f.review_time_utc >= $2 AND f.review_time_utc < $3 AND s.primitive <> 'NON_INFORMATIVE' """ LATEST_RUN_QUERY = """ SELECT run_id FROM pipeline.detected_spans_v2 WHERE business_id = $1 AND run_id IS NOT NULL GROUP BY run_id ORDER BY MAX(created_at) DESC, run_id DESC LIMIT 1 """ # ============================================================================= # REPORT GENERATOR # ============================================================================= async def generate_report( business_id: str, start: datetime | None = None, end: datetime | None = None, run_id: str | None = None, tz_name: str = "UTC", pool: asyncpg.Pool | None = None, summary_enabled: bool = True, summary_model: str = "gpt-4o-mini", ) -> dict[str, Any]: """ Generate a reputation report for a business. Args: business_id: Business ID or search pattern start: Window start (optional if run_id provided) end: Window end (optional if run_id provided) run_id: Specific run ID to use (overrides time window) tz_name: IANA timezone for window pool: Database pool (created if not provided) Returns: ReputationReport JSON object """ close_pool = False if pool is None: pool = await asyncpg.create_pool(DB_URL) close_pool = True try: # Resolve business_id canonical_id = await resolve_business_id(pool, business_id) if not canonical_id: return {"error": f"Business not found: {business_id}"} # Get business config config_row = await pool.fetchrow(BUSINESS_CONFIG_QUERY, canonical_id) if not config_row: return {"error": f"Business not mapped: {canonical_id}"} # Determine window mode: time_window if start/end provided, otherwise latest_run window_mode = "time_window" if start and end else "latest_run" # Convert to UTC-naive for DB compatibility start_naive = to_utc_naive(start) end_naive = to_utc_naive(end) # Fetch spans based on mode - SINGLE SOURCE OF TRUTH run_uuid = None if window_mode == "time_window": # Time window mode: filter by review_time_utc spans = await pool.fetch( SPANS_QUERY_TIME_WINDOW, canonical_id, start_naive, end_naive ) else: # Latest run mode: filter by run_id if run_id: run_uuid = uuid.UUID(run_id) else: latest = await pool.fetchrow(LATEST_RUN_QUERY, canonical_id) if latest and latest["run_id"]: run_uuid = latest["run_id"] spans = await pool.fetch( SPANS_QUERY_LATEST_RUN, canonical_id, run_uuid ) if not spans: return {"error": "No spans found for this business/window"} spans = [dict(s) for s in spans] # Determine actual window from data if window_mode == "time_window": # Use review_time_utc for time window mode review_times = [s["review_time_utc"] for s in spans if s.get("review_time_utc")] actual_start = min(review_times) if review_times else start actual_end = max(review_times) if review_times else end else: # Use created_at for latest run mode created_times = [s["created_at"] for s in spans if s["created_at"]] actual_start = min(created_times) if created_times else datetime.now(timezone.utc) actual_end = max(created_times) if created_times else datetime.now(timezone.utc) # Get unique run_ids (handle legacy data without run_id), sorted for determinism run_ids_set = set(str(s["run_id"]) for s in spans if s["run_id"]) runs_included = sorted(run_ids_set) if run_ids_set else ["legacy:run_id_missing"] # Get unique job_ids for traceability (ingestion vs classification debugging) job_ids_set = set(str(s["job_id"]) for s in spans if s.get("job_id")) jobs_included = sorted(job_ids_set) if job_ids_set else [] # Primary run_id: # - In latest_run mode: the specific run we queried # - In time_window mode: the newest run by max(created_at) if window_mode == "latest_run": primary_run_id = str(run_uuid) if run_uuid else None else: # time_window mode: find run with max created_at run_max_created: dict[str, datetime] = {} for s in spans: rid = s.get("run_id") if not rid: continue t = s.get("created_at") rid_str = str(rid) if t and (rid_str not in run_max_created or t > run_max_created[rid_str]): run_max_created[rid_str] = t primary_run_id = max(run_max_created, key=run_max_created.get) if run_max_created else None # Latest span timestamp for traceability (UTC) latest_span_created_at_utc = actual_end # Get unique review_ids review_ids = set(s["review_id"] for s in spans) # ================================================================= # POPULATION STATS # ================================================================= total_spans = len(spans) non_informative = [s for s in spans if s["primitive"] == "NON_INFORMATIVE"] content_spans = [s for s in spans if s["primitive"] != "NON_INFORMATIVE"] unmapped_spans = [s for s in spans if s["primitive"] == "UNMAPPED"] # Normalize language codes: map internal markers to standard codes def normalize_language(lang: str | None) -> str: if not lang: return "unknown" lang = lang.lower().strip() # Map internal markers to "unknown" if lang in ("auto", "auto-detect", ""): return "unknown" return lang # Language distribution (by reviews - use majority span language per review) review_lang_counts: dict[str, dict[str, int]] = {} for s in spans: rid = s["review_id"] lang = normalize_language(s.get("language")) if rid not in review_lang_counts: review_lang_counts[rid] = {} review_lang_counts[rid][lang] = review_lang_counts[rid].get(lang, 0) + 1 # Pick majority language for each review review_langs = {rid: max(counts, key=counts.get) for rid, counts in review_lang_counts.items()} lang_review_counts = {} for lang in review_langs.values(): lang_review_counts[lang] = lang_review_counts.get(lang, 0) + 1 total_reviews = len(review_langs) lang_dist_reviews = {k: v / total_reviews for k, v in lang_review_counts.items()} if total_reviews > 0 else {} # Language distribution (by spans) lang_span_counts = {} for s in content_spans: lang = normalize_language(s.get("language")) lang_span_counts[lang] = lang_span_counts.get(lang, 0) + 1 total_content = len(content_spans) lang_dist_spans = {k: v / total_content for k, v in lang_span_counts.items()} if total_content > 0 else {} # Confidence stats confidences = [float(s["confidence"]) for s in content_spans if s["confidence"]] avg_conf = sum(confidences) / len(confidences) if confidences else 0.0 low_conf_count = len([c for c in confidences if c < 0.5]) population = { "runs_included": runs_included, "jobs_included": jobs_included, "latest_span_created_at_utc": iso_z(latest_span_created_at_utc), "reviews_processed": total_reviews, "spans_total": total_spans, "spans_non_informative": len(non_informative), "spans_content": total_content, "unmapped_spans": len(unmapped_spans), "unmapped_rate_raw": len(unmapped_spans) / total_spans if total_spans > 0 else 0, "unmapped_rate_content": len(unmapped_spans) / total_content if total_content > 0 else 0, "language_distribution_reviews": lang_dist_reviews, "language_distribution_spans": lang_dist_spans, "confidence": { "avg": round(avg_conf, 3), "p10": round(percentile(confidences, 10), 3) if confidences else 0, "p50": round(percentile(confidences, 50), 3) if confidences else 0, "p90": round(percentile(confidences, 90), 3) if confidences else 0, "low_confidence_count": low_conf_count, } } # ================================================================= # SCORES # ================================================================= # Group spans by primitive primitive_spans = {} for s in content_spans: prim = s["primitive"] if prim not in primitive_spans: primitive_spans[prim] = [] primitive_spans[prim].append(s) # Compute primitive scores primitive_scores = {} for prim, prim_spans in primitive_spans.items(): domain = DOMAIN_MAP.get(prim, "meta") valence_counts = {"+": 0, "-": 0, "0": 0, "±": 0} intensities = [] details = [] confidences_prim = [] entities = {} for s in prim_spans: v = s.get("valence", "0") if v in valence_counts: valence_counts[v] += 1 if s.get("intensity"): intensities.append(s["intensity"]) if s.get("detail"): details.append(s["detail"]) if s.get("confidence"): confidences_prim.append(float(s["confidence"])) if s.get("entity"): key = (s["entity"], s.get("entity_type", "unknown")) entities[key] = entities.get(key, 0) + 1 # Top entities top_entities = [ {"entity": e[0], "entity_type": e[1], "count": c} for e, c in sorted(entities.items(), key=lambda x: -x[1])[:5] ] primitive_scores[prim] = { "domain": domain, "enabled": True, "score": round(compute_primitive_score(prim_spans), 1), "volume": len(prim_spans), "valence_counts": valence_counts, "intensity_avg": round(sum(intensities) / len(intensities), 2) if intensities else 1.0, "detail_avg": round(sum(details) / len(details), 2) if details else 1.0, "confidence_avg": round(sum(confidences_prim) / len(confidences_prim), 3) if confidences_prim else 0.5, "top_entities": top_entities, } # Compute domain scores domain_scores = {} for domain_code in ["O", "P", "J", "E", "V"]: domain_prims = [p for p, d in DOMAIN_MAP.items() if d == domain_code and p in primitive_scores] if domain_prims: total_volume = sum(primitive_scores[p]["volume"] for p in domain_prims) weighted_score = sum( primitive_scores[p]["score"] * primitive_scores[p]["volume"] for p in domain_prims ) domain_scores[domain_code] = { "score": round(weighted_score / total_volume, 1) if total_volume > 0 else 0, "volume": total_volume, } else: domain_scores[domain_code] = {"score": 0, "volume": 0} # Overall score - use same formula as PERIOD_SCORES_QUERY for consistency: # 100 * SUM(valence * confidence * intensity) / SUM(confidence * intensity) total_weight = 0.0 weighted_valence_sum = 0.0 for s in content_spans: conf = float(s.get("confidence", 0.5)) intensity = float(s.get("intensity", 1)) weight = conf * intensity total_weight += weight valence = s.get("valence", "0") if valence == "+": weighted_valence_sum += weight elif valence == "-": weighted_valence_sum -= weight # "0" and "±" contribute 0 overall_score = (100 * weighted_valence_sum / total_weight) if total_weight > 0 else 0 # Domain-weighted score (kept for domain breakdown, but not used as primary) total_domain_volume = sum(d["volume"] for d in domain_scores.values()) domain_weighted_score = sum( d["score"] * d["volume"] for d in domain_scores.values() ) / total_domain_volume if total_domain_volume > 0 else 0 # Valence shares all_valences = [s.get("valence", "0") for s in content_spans] valence_total = len(all_valences) scores = { "overall": { "score": round(overall_score, 1), "score_domain_weighted": round(domain_weighted_score, 1), # Alternative metric "positive_share": round(all_valences.count("+") / valence_total, 3) if valence_total > 0 else 0, "negative_share": round(all_valences.count("-") / valence_total, 3) if valence_total > 0 else 0, "mixed_share": round(all_valences.count("±") / valence_total, 3) if valence_total > 0 else 0, "neutral_share": round(all_valences.count("0") / valence_total, 3) if valence_total > 0 else 0, }, "domains": domain_scores, "primitives": primitive_scores, } # ================================================================= # DRIVERS # ================================================================= def compute_impact(prim_spans: list[dict], valence_filter: str) -> float: """Compute impact as share of total weight for a valence.""" filtered = [s for s in prim_spans if s.get("valence") == valence_filter] if not filtered: return 0.0 filtered_weight = sum( compute_span_weight(s.get("confidence", 0.5), s.get("detail", 1), s.get("intensity", 1)) for s in filtered ) total_weight = sum( compute_span_weight(s.get("confidence", 0.5), s.get("detail", 1), s.get("intensity", 1)) for s in content_spans ) return filtered_weight / total_weight if total_weight > 0 else 0 def select_evidence(prim_spans: list[dict], valence: str, max_count: int = 3) -> list[dict]: """Select top evidence spans by weight, one per review_id.""" filtered = [s for s in prim_spans if s.get("valence") == valence] weighted = [ (s, compute_span_weight(s.get("confidence", 0.5), s.get("detail", 1), s.get("intensity", 1))) for s in filtered ] weighted.sort(key=lambda x: -x[1]) selected = [] seen_reviews = set() for s, w in weighted: if s["review_id"] not in seen_reviews and len(selected) < max_count: text = s.get("span_text", "")[:240] # Cap at 240 chars selected.append({ "review_id": s["review_id"], "language": s.get("language", "unknown"), "span_text": text, "valence": s.get("valence", "0"), "intensity": s.get("intensity", 1), "detail": s.get("detail", 1), "confidence": round(float(s.get("confidence", 0.5)), 3), }) seen_reviews.add(s["review_id"]) return selected # Compute drivers positive_drivers = [] negative_drivers = [] for prim, prim_spans in primitive_spans.items(): if prim in ("UNMAPPED", "NON_INFORMATIVE"): continue pos_impact = compute_impact(prim_spans, "+") neg_impact = compute_impact(prim_spans, "-") if pos_impact > 0.02: # Threshold for significance positive_drivers.append({ "primitive": prim, "impact": round(pos_impact, 3), "summary": f"Positive {DOMAIN_MAP.get(prim, 'meta')}/{prim} mentions.", "evidence": select_evidence(prim_spans, "+"), }) if neg_impact > 0.02: negative_drivers.append({ "primitive": prim, "impact": round(neg_impact, 3), "summary": f"Negative {DOMAIN_MAP.get(prim, 'meta')}/{prim} mentions.", "evidence": select_evidence(prim_spans, "-"), }) # Sort by impact and take top 5 positive_drivers.sort(key=lambda x: -x["impact"]) negative_drivers.sort(key=lambda x: -x["impact"]) drivers = { "positives": positive_drivers[:5], "negatives": negative_drivers[:5], } # ================================================================= # ALERTS # ================================================================= alerts = [] # UNMAPPED rate alert unmapped_rate = population["unmapped_rate_content"] if unmapped_rate > ALERT_THRESHOLDS["unmapped_rate_content"]["critical"]: alerts.append({ "severity": "critical", "type": "unmapped_high", "message": f"Content-adjusted UNMAPPED rate ({unmapped_rate:.1%}) exceeds critical threshold.", "metric": {"name": "unmapped_rate_content", "value": unmapped_rate, "threshold": 0.15}, "scope": {"primitive": None, "language": None}, }) elif unmapped_rate > ALERT_THRESHOLDS["unmapped_rate_content"]["warn"]: alerts.append({ "severity": "warn", "type": "unmapped_high", "message": f"Content-adjusted UNMAPPED rate ({unmapped_rate:.1%}) above target.", "metric": {"name": "unmapped_rate_content", "value": unmapped_rate, "threshold": 0.10}, "scope": {"primitive": None, "language": None}, }) # SAFETY alert if "SAFETY" in primitive_scores: safety_neg = primitive_scores["SAFETY"]["valence_counts"].get("-", 0) safety_total = primitive_scores["SAFETY"]["volume"] safety_neg_share = safety_neg / safety_total if safety_total > 0 else 0 if safety_neg_share > ALERT_THRESHOLDS["safety_negative_share"]["critical"]: alerts.append({ "severity": "critical", "type": "safety_spike", "message": f"SAFETY negative share ({safety_neg_share:.1%}) exceeds critical threshold.", "metric": {"name": "safety_negative_share", "value": safety_neg_share, "threshold": 0.10}, "scope": {"primitive": "SAFETY", "language": None}, }) elif safety_neg_share > ALERT_THRESHOLDS["safety_negative_share"]["warn"]: alerts.append({ "severity": "warn", "type": "safety_spike", "message": f"SAFETY negative share ({safety_neg_share:.1%}) above warning threshold.", "metric": {"name": "safety_negative_share", "value": safety_neg_share, "threshold": 0.05}, "scope": {"primitive": "SAFETY", "language": None}, }) # Low confidence alert if avg_conf < ALERT_THRESHOLDS["avg_confidence"]["warn"]: alerts.append({ "severity": "warn", "type": "low_confidence_high", "message": f"Average confidence ({avg_conf:.2f}) below threshold.", "metric": {"name": "avg_confidence", "value": avg_conf, "threshold": 0.70}, "scope": {"primitive": None, "language": None}, }) # ================================================================= # RECOMMENDATIONS (templated based on negative drivers) # ================================================================= recommendations = [] # Generate recommendations from top negative drivers playbook_templates = { "CLEANLINESS": { "title": "Improve cleanliness protocols", "actions": [ "Increase cleaning frequency during peak hours", "Implement visible cleaning checklists", "Train staff on cleanliness standards" ], "owner": "ops", "time_to_impact": 14, }, "SPEED": { "title": "Reduce wait times", "actions": [ "Analyze peak hour staffing", "Optimize queue management", "Set wait time targets and monitor" ], "owner": "ops", "time_to_impact": 21, }, "MANNER": { "title": "Enhance staff friendliness", "actions": [ "Conduct hospitality training", "Implement customer feedback program", "Recognize staff for positive mentions" ], "owner": "service", "time_to_impact": 30, }, "PRICE_FAIRNESS": { "title": "Review pricing perception", "actions": [ "Audit pricing vs. competitor benchmarks", "Improve value communication", "Consider value-add bundles" ], "owner": "pricing", "time_to_impact": 45, }, "SAFETY": { "title": "Address safety concerns immediately", "actions": [ "Conduct safety audit", "Review and update safety protocols", "Increase visible safety measures" ], "owner": "safety", "time_to_impact": 7, }, } priority_map = {"critical": "P0", "high": "P1", "medium": "P2"} for i, driver in enumerate(negative_drivers[:3]): prim = driver["primitive"] template = playbook_templates.get(prim, { "title": f"Improve {prim.replace('_', ' ').title()}", "actions": [ f"Investigate root causes of negative {prim} feedback", "Develop improvement action plan", "Monitor {prim} score weekly" ], "owner": "ops", "time_to_impact": 30, }) priority = "P0" if i == 0 and driver["impact"] > 0.10 else ("P1" if i < 2 else "P2") recommendations.append({ "priority": priority, "primitive": prim, "title": template["title"], "rationale": driver["summary"], "playbook": { "actions": template["actions"], "owner": template["owner"], "expected_time_to_impact_days": template["time_to_impact"], "measurement": [f"{prim} score improvement", f"Negative {prim} share reduction"], }, }) # If SAFETY alert triggered, ensure SAFETY is P0 recommendation safety_alert_triggered = any(a["type"] == "safety_spike" for a in alerts) if safety_alert_triggered: # Check if SAFETY already in recommendations safety_rec_idx = next( (i for i, r in enumerate(recommendations) if r["primitive"] == "SAFETY"), None ) if safety_rec_idx is not None: # Upgrade existing SAFETY recommendation to P0 and move to front safety_rec = recommendations.pop(safety_rec_idx) safety_rec["priority"] = "P0" safety_rec["rationale"] = "SAFETY alert triggered - immediate action required." recommendations.insert(0, safety_rec) else: # Add new SAFETY P0 recommendation at front safety_template = playbook_templates["SAFETY"] recommendations.insert(0, { "priority": "P0", "primitive": "SAFETY", "title": safety_template["title"], "rationale": "SAFETY alert triggered - immediate action required.", "playbook": { "actions": safety_template["actions"], "owner": safety_template["owner"], "expected_time_to_impact_days": safety_template["time_to_impact"], "measurement": ["SAFETY score improvement", "Negative SAFETY share reduction"], }, }) # ================================================================= # TIME COMPARISON (previous period) # ================================================================= comparisons = { "previous_window": None, "sector_benchmark": None, } timeline_points = [] timeline_granularity = "week" # Default granularity # Use CLI-provided window for time comparison current_start = start current_end = end # Check review_time coverage within the requested window coverage_row = await pool.fetchrow( REVIEW_TIME_COVERAGE_QUERY, canonical_id, current_start, current_end ) review_time_coverage = 0.0 review_time_window_utc = None if coverage_row and coverage_row["spans_reviews"] > 0: review_time_coverage = (coverage_row["facts_reviews"] or 0) / coverage_row["spans_reviews"] if coverage_row["min_review_time"] and coverage_row["max_review_time"]: review_time_window_utc = { "start": iso_z(coverage_row["min_review_time"]), "end": iso_z(coverage_row["max_review_time"]), } # Add to population population["review_time_coverage_pct"] = round(review_time_coverage * 100, 1) population["review_time_window_utc"] = review_time_window_utc # Only compute time comparison if we have sufficient coverage if current_start and current_end and review_time_coverage >= MIN_COVERAGE_FOR_COMPARISON: # Compute window duration window_duration = current_end - current_start days_in_window = max(window_duration.days, 1) # Previous period: same duration, immediately preceding current prev_end = current_start prev_start = prev_end - window_duration # Get current period scores current_row = await pool.fetchrow( PERIOD_SCORES_QUERY, canonical_id, current_start, current_end ) # Get previous period scores prev_row = await pool.fetchrow( PERIOD_SCORES_QUERY, canonical_id, prev_start, prev_end ) current_reviews = current_row["review_count"] if current_row else 0 prev_reviews = prev_row["review_count"] if prev_row else 0 # Check minimum sample size thresholds if current_reviews >= MIN_REVIEWS_FOR_COMPARISON: current_score = float(current_row["overall_score"] or 0) current_rating = float(current_row["avg_rating"] or 0) current_pos_share = (current_row["positive_count"] or 0) / max(current_row["span_count"], 1) current_neg_share = (current_row["negative_count"] or 0) / max(current_row["span_count"], 1) prev_score = 0.0 prev_rating = 0.0 prev_pos_share = 0.0 prev_neg_share = 0.0 if prev_reviews >= MIN_REVIEWS_FOR_COMPARISON: prev_score = float(prev_row["overall_score"] or 0) prev_rating = float(prev_row["avg_rating"] or 0) prev_pos_share = (prev_row["positive_count"] or 0) / max(prev_row["span_count"], 1) prev_neg_share = (prev_row["negative_count"] or 0) / max(prev_row["span_count"], 1) # Compute delta and trend delta = current_score - prev_score if prev_reviews < MIN_REVIEWS_FOR_COMPARISON: trend = "insufficient_data" else: trend = compute_trend_label(delta) comparisons["previous_window"] = { "window": { "start": iso_z(prev_start), "end": iso_z(prev_end), }, "scores": { "overall": { "current": round(current_score, 1), "previous": round(prev_score, 1) if prev_reviews >= MIN_REVIEWS_FOR_COMPARISON else None, "delta": round(delta, 1) if prev_reviews >= MIN_REVIEWS_FOR_COMPARISON else None, "trend": trend, }, "avg_rating": { "current": round(current_rating, 2), "previous": round(prev_rating, 2) if prev_reviews >= MIN_REVIEWS_FOR_COMPARISON else None, "delta": round(current_rating - prev_rating, 2) if prev_reviews >= MIN_REVIEWS_FOR_COMPARISON else None, }, "positive_share": { "current": round(current_pos_share, 3), "previous": round(prev_pos_share, 3) if prev_reviews >= MIN_REVIEWS_FOR_COMPARISON else None, "delta": round(current_pos_share - prev_pos_share, 3) if prev_reviews >= MIN_REVIEWS_FOR_COMPARISON else None, }, "negative_share": { "current": round(current_neg_share, 3), "previous": round(prev_neg_share, 3) if prev_reviews >= MIN_REVIEWS_FOR_COMPARISON else None, "delta": round(current_neg_share - prev_neg_share, 3) if prev_reviews >= MIN_REVIEWS_FOR_COMPARISON else None, }, }, "reviews": { "current": current_reviews, "previous": prev_reviews, }, } # Generate timeline for charts (over current window only) timeline_granularity = select_granularity(days_in_window) if timeline_granularity not in ("day", "week", "month"): timeline_granularity = "week" timeline_rows = await pool.fetch( TIMESERIES_QUERY, canonical_id, current_start, current_end, timeline_granularity ) for row in timeline_rows: if row["bucket"]: timeline_points.append({ "bucket_start_utc": iso_z(row["bucket"]), "review_count": row["review_count"], "span_count": row["span_count"], "positive_count": row["positive_count"], "negative_count": row["negative_count"], "avg_rating": float(row["avg_rating"]) if row["avg_rating"] else None, "strength_score": float(row["strength_score"]) if row["strength_score"] else 0, }) # ================================================================= # SECTOR BENCHMARK (always a dict with status field) # ================================================================= sector_code = config_row["sector_code"] if not sector_code: comparisons["sector_benchmark"] = { "status": "missing_sector_code", "sector_code": None, "window": None, "overall": None, "sample_size": None, } else: sector_row = await pool.fetchrow( SECTOR_BENCHMARK_QUERY, sector_code, current_start, current_end ) if sector_row: sector_spans = sector_row["sector_span_count"] or 0 sector_businesses = sector_row["sector_business_count"] or 0 sector_score = float(sector_row["sector_overall_score"] or 0) # Only include benchmark if sufficient sector data if sector_spans >= 500 and sector_businesses >= 3: delta_vs_sector = current_score - sector_score comparisons["sector_benchmark"] = { "status": "ok", "sector_code": sector_code, "window": { "start": iso_z(current_start), "end": iso_z(current_end), }, "overall": { "business": round(current_score, 1), "sector_avg": round(sector_score, 1), "delta_vs_sector": round(delta_vs_sector, 1), }, "sample_size": { "sector_spans": sector_spans, "sector_businesses": sector_businesses, }, } else: comparisons["sector_benchmark"] = { "status": "insufficient_data", "sector_code": sector_code, "window": { "start": iso_z(current_start), "end": iso_z(current_end), }, "overall": None, "sample_size": { "sector_spans": sector_spans, "sector_businesses": sector_businesses, }, } else: comparisons["sector_benchmark"] = { "status": "no_sector_data", "sector_code": sector_code, "window": { "start": iso_z(current_start), "end": iso_z(current_end), }, "overall": None, "sample_size": None, } # Add alert if coverage is too low for comparisons if review_time_coverage < MIN_COVERAGE_FOR_COMPARISON and current_start and current_end: alerts.append({ "severity": "warn", "type": "insufficient_trend_data", "message": f"Review time coverage ({review_time_coverage:.0%}) below threshold for trend analysis.", "metric": {"name": "review_time_coverage_pct", "value": review_time_coverage, "threshold": MIN_COVERAGE_FOR_COMPARISON}, "scope": {"primitive": None, "language": None}, }) # ================================================================= # INVARIANT CHECK: scores.overall must match comparisons current score # ================================================================= if window_mode == "time_window" and comparisons.get("previous_window"): comparison_current = comparisons["previous_window"]["scores"]["overall"].get("current") if comparison_current is not None: delta = abs(overall_score - comparison_current) if delta > 1.0: # Allow small rounding differences alerts.append({ "severity": "error", "type": "internal_inconsistency", "message": f"INTERNAL ERROR: scores.overall ({overall_score:.1f}) != comparisons.current ({comparison_current:.1f}). Report data is inconsistent.", "metric": {"name": "score_delta", "value": delta, "threshold": 1.0}, "scope": {"primitive": None, "language": None}, }) # ================================================================= # BUILD REPORT # ================================================================= report = { "schema_version": SCHEMA_VERSION, "report_id": str(uuid.uuid4()), "primary_run_id": primary_run_id, "generated_at": iso_z(datetime.now(timezone.utc)), "window": { "start": iso_z(start), "end": iso_z(end), "timezone": tz_name, "mode": window_mode, }, "business": { "business_id": canonical_id, "sector_code": config_row["sector_code"], "gbp_path": config_row["gbp_path"], "config_version": "1.0", # TODO: Get from spans "l2_applied": False, # TODO: Get from config }, "population": population, "scores": scores, "drivers": drivers, "alerts": alerts, "recommendations": recommendations, "comparisons": comparisons, # Timeline: always a dict with points: [] (never null in time_window mode) "timeline": { "granularity": timeline_granularity, "points": timeline_points, } if window_mode == "time_window" else None, "executive_summary": None, # Placeholder, filled below "executive_summary_meta": None, # Placeholder, filled below } # ================================================================= # EXECUTIVE SUMMARY (LLM-generated with fallback) # ================================================================= summary_input = build_summary_input(report) summary_text, summary_meta = await generate_executive_summary( summary_input, model=summary_model, enabled=summary_enabled, ) # If LLM failed but summary was enabled, use deterministic fallback if summary_enabled and not summary_text: summary_text = generate_fallback_summary(report) summary_meta["fallback_used"] = True else: summary_meta["fallback_used"] = False report["executive_summary"] = summary_text report["executive_summary_meta"] = summary_meta return report finally: if close_pool: await pool.close() # ============================================================================= # CLI # ============================================================================= async def main_async(args): """Main async entry point.""" # Determine time window now_utc = datetime.now(timezone.utc) end = now_utc start = None days = args.days if args.start and args.end: start = datetime.fromisoformat(args.start) end = datetime.fromisoformat(args.end) # Make timezone-aware if not if start.tzinfo is None: start = start.replace(tzinfo=timezone.utc) if end.tzinfo is None: end = end.replace(tzinfo=timezone.utc) elif args.days: start = end - timedelta(days=args.days) else: # Default: last 30 days days = 30 start = end - timedelta(days=30) # Determine summary settings summary_enabled = not args.no_summary # Generate report report = await generate_report( business_id=args.business, start=start, end=end, run_id=args.run_id, tz_name=args.timezone, summary_enabled=summary_enabled, summary_model=args.summary_model, ) if "error" in report: print(f"Error: {report['error']}") raise SystemExit(1) # Check --require-summary: if LLM summary required but not generated, fail before writing if args.require_summary: meta = report.get("executive_summary_meta", {}) if not meta.get("generated", False): error_msg = meta.get("error", "unknown error") print(f"Error: --require-summary specified but LLM summary failed: {error_msg}") raise SystemExit(2) # Output if args.output: with open(args.output, "w") as f: json.dump(report, f, indent=2, default=str) print(f"Report written to {args.output}") else: print(json.dumps(report, indent=2, default=str)) # Print summary if not args.quiet: print("\n" + "=" * 60) print(f"REPUTATION REPORT: {report['business']['business_id']}") print("=" * 60) print(f"Window: {report['window']['start']} - {report['window']['end']}") print(f"Reviews: {report['population']['reviews_processed']}") print(f"Content spans: {report['population']['spans_content']}") print(f"Overall score: {report['scores']['overall']['score']}") print(f"Positive share: {report['scores']['overall']['positive_share']:.1%}") print(f"Negative share: {report['scores']['overall']['negative_share']:.1%}") print(f"\nTop positive drivers:") for d in report['drivers']['positives'][:3]: print(f" {d['primitive']}: {d['impact']:.1%} impact") print(f"\nTop negative drivers:") for d in report['drivers']['negatives'][:3]: print(f" {d['primitive']}: {d['impact']:.1%} impact") if report['alerts']: print(f"\nAlerts ({len(report['alerts'])}):") for a in report['alerts']: print(f" [{a['severity']}] {a['message']}") print("=" * 60) def main(): parser = argparse.ArgumentParser(description="Generate reputation report") parser.add_argument("--business", required=True, help="Business ID or search pattern") parser.add_argument("--start", help="Window start (ISO-8601)") parser.add_argument("--end", help="Window end (ISO-8601)") parser.add_argument("--days", type=int, help="Last N days (alternative to start/end)") parser.add_argument("--run-id", help="Specific run ID to use") parser.add_argument("--timezone", default="UTC", help="IANA timezone (default: UTC)") parser.add_argument("--output", "-o", help="Output file (default: stdout)") parser.add_argument("--quiet", "-q", action="store_true", help="Suppress summary output") # Executive summary options parser.add_argument("--no-summary", action="store_true", help="Disable executive summary generation") parser.add_argument("--require-summary", action="store_true", help="Fail with exit code 2 if LLM summary fails") parser.add_argument("--summary-model", default="gpt-4o-mini", help="Model for executive summary (default: gpt-4o-mini)") args = parser.parse_args() if not args.start and not args.end and not args.days and not args.run_id: print("Note: No time window specified, using latest run.") asyncio.run(main_async(args)) if __name__ == "__main__": main()