# ReviewIQ Pipeline Contracts v1.0 **Version**: 1.0.0 **Status**: Implementation Blueprint **Date**: 2026-01-24 **Purpose**: Enable parallel development with independent stage validation --- ## Executive Summary This document defines the **contract** for each pipeline stage: inputs, outputs, validation rules, and test fixtures. Each stage can be developed and tested independently. Integration tests verify stage-to-stage compatibility. ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ REVIEWIQ PIPELINE STAGES │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ STAGE 0 STAGE 1 STAGE 2 STAGE 3 STAGE 4 │ │ ════════ ════════ ════════ ════════ ════════ │ │ │ │ [Scrape] ──▶ [Normalize] ──▶ [Classify] ──▶ [Route] ──▶ [Aggregate] │ │ │ │ Google Raw → Clean LLM → Spans Spans → Issues Facts │ │ Maps API Text + URT codes Dedup + Link Timeseries │ │ │ │ ✅ DONE ❌ TODO ❌ TODO ❌ TODO ❌ TODO │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` --- ## Table of Contents 1. [Stage 0: Raw Ingestion](#stage-0-raw-ingestion) (Complete) 2. [Stage 1: Normalization](#stage-1-normalization) 3. [Stage 2: LLM Classification](#stage-2-llm-classification) 4. [Stage 3: Issue Routing](#stage-3-issue-routing) 5. [Stage 4: Fact Aggregation](#stage-4-fact-aggregation) 6. [Integration Tests](#integration-tests) 7. [Test Fixtures](#test-fixtures) --- ## Stage 0: Raw Ingestion **Status**: ✅ COMPLETE **Owner**: Scraper Team **Code**: `scrapers/google_reviews/v1_0_0.py` ### Input Contract ```typescript interface Stage0Input { job_id: string; // UUID url: string; // Google Maps URL max_reviews?: number; // Default: 100 business_id: string; // Tenant identifier place_id?: string; // Google Place ID (extracted from URL if missing) } ``` ### Output Contract ```typescript interface Stage0Output { job_id: string; status: 'completed' | 'failed' | 'partial'; business_id: string; place_id: string; // Business metadata business_info: { name: string; address: string; category: string; total_reviews: number; average_rating: number; }; // Raw reviews array reviews: RawReview[]; // Execution metadata scrape_time_ms: number; reviews_scraped: number; scraper_version: string; } interface RawReview { review_id: string; // Google's review identifier author_name: string; author_id?: string; rating: number; // 1-5 text: string | null; // May be null for rating-only reviews review_time: string; // ISO 8601 response_text?: string; // Owner response response_time?: string; photos?: string[]; // Raw payload for audit raw_payload: object; } ``` ### Validation Rules | Rule | Check | Error Code | |------|-------|------------| | V0.1 | `reviews` is array | `STAGE0_INVALID_OUTPUT` | | V0.2 | Each review has `review_id` | `STAGE0_MISSING_REVIEW_ID` | | V0.3 | Each review has `rating` 1-5 | `STAGE0_INVALID_RATING` | | V0.4 | `review_time` is valid ISO 8601 | `STAGE0_INVALID_TIMESTAMP` | | V0.5 | `business_info.name` is non-empty | `STAGE0_MISSING_BUSINESS` | ### Storage Location ```sql -- Currently stored as JSONB blob jobs.reviews_data -> Stage0Output ``` ### Handoff to Stage 1 Stage 1 reads from `jobs.reviews_data` where `status = 'completed'`. --- ## Stage 1: Normalization **Status**: ❌ NOT IMPLEMENTED **Owner**: TBD **Depends On**: Stage 0 ### Purpose Transform raw scraped reviews into clean, versioned records ready for LLM classification. ### Input Contract ```typescript // Reads Stage0Output from jobs.reviews_data interface Stage1Input { job_id: string; business_id: string; place_id: string; reviews: RawReview[]; // From Stage 0 } ``` ### Output Contract ```typescript interface Stage1Output { job_id: string; business_id: string; place_id: string; // Normalized reviews ready for classification reviews_normalized: NormalizedReview[]; // Processing stats stats: { input_count: number; output_count: number; skipped_empty: number; skipped_duplicate: number; }; } interface NormalizedReview { // Identity (composite key) source: 'google'; review_id: string; review_version: number; // Increments if review edited // Tenant context business_id: string; place_id: string; // Content text: string; // Original text (NOT NULL - empty reviews filtered) text_normalized: string; // Cleaned: lowercase, whitespace normalized, emoji standardized text_language: string; // ISO 639-1 code text_length: number; // Character count word_count: number; // Metadata rating: number; review_time: string; // ISO 8601 author_name: string; author_id?: string; // Dedup content_hash: string; // SHA256 of normalized text dedup_group_id?: string; // If duplicate detected // Reference raw_id: number; // FK to reviews_raw.id } ``` ### Database Schema ```sql -- Stage 1 writes to reviews_raw (immutable) and reviews_enriched (initial) INSERT INTO reviews_raw ( source, review_id, place_id, raw_payload, review_text, rating, review_time, reviewer_name, reviewer_id, review_version, pulled_at ) VALUES (...); -- Then creates reviews_enriched stub (no classification yet) INSERT INTO reviews_enriched ( source, review_id, review_version, is_latest, raw_id, business_id, place_id, text, text_normalized, rating, review_time, language, taxonomy_version, -- Classification fields NULL until Stage 2 urt_primary, valence, intensity, embedding, trust_score ) VALUES ( ..., NULL, NULL, NULL, NULL, NULL -- Filled by Stage 2 ); ``` ### Validation Rules | Rule | Check | Error Code | |------|-------|------------| | V1.1 | `text` is non-empty string | `STAGE1_EMPTY_TEXT` | | V1.2 | `text_normalized` contains no control chars | `STAGE1_INVALID_NORMALIZATION` | | V1.3 | `content_hash` is 64-char hex | `STAGE1_INVALID_HASH` | | V1.4 | `review_version` >= 1 | `STAGE1_INVALID_VERSION` | | V1.5 | `text_language` is valid ISO 639-1 | `STAGE1_INVALID_LANGUAGE` | | V1.6 | `raw_id` references valid reviews_raw row | `STAGE1_ORPHAN_ENRICHED` | ### Validation Script ```python def validate_stage1_output(output: Stage1Output) -> ValidationResult: """ Run after Stage 1 completes. Returns: ValidationResult with passed/failed rules and error details. """ errors = [] for review in output.reviews_normalized: # V1.1: Non-empty text if not review.text or not review.text.strip(): errors.append(ValidationError('V1.1', review.review_id, 'Empty text')) # V1.2: No control characters if has_control_chars(review.text_normalized): errors.append(ValidationError('V1.2', review.review_id, 'Control chars in normalized')) # V1.3: Valid hash if not is_valid_sha256(review.content_hash): errors.append(ValidationError('V1.3', review.review_id, 'Invalid content hash')) # V1.4: Version >= 1 if review.review_version < 1: errors.append(ValidationError('V1.4', review.review_id, 'Invalid version')) # V1.5: Valid language code if not is_valid_iso639(review.text_language): errors.append(ValidationError('V1.5', review.review_id, 'Invalid language')) return ValidationResult( stage='stage1', passed=len(errors) == 0, error_count=len(errors), errors=errors ) ``` ### Handoff to Stage 2 Stage 2 queries: ```sql SELECT * FROM reviews_enriched WHERE urt_primary IS NULL -- Not yet classified AND is_latest = TRUE ORDER BY review_time DESC LIMIT 100; -- Batch size ``` --- ## Stage 2: LLM Classification **Status**: ❌ NOT IMPLEMENTED **Owner**: TBD **Depends On**: Stage 1 **Contract Reference**: `LLM-Classification-Contract-v1.md` ### Purpose Classify normalized reviews into URT codes with span-level extraction. ### Input Contract ```typescript interface Stage2Input { // Batch of reviews to classify reviews: ReviewToClassify[]; // Configuration config: { model: string; // 'gpt-4o-mini', 'claude-3-haiku', etc. taxonomy_version: string; // 'v5.1' profile: 'lite' | 'core' | 'standard' | 'full'; max_spans_per_review: number; // Default: 10 }; } interface ReviewToClassify { source: string; review_id: string; review_version: number; business_id: string; place_id: string; text: string; // Original text (offsets reference this) text_normalized: string; // For LLM input rating: number; review_time: string; } ``` ### Output Contract ```typescript interface Stage2Output { // Batch metadata batch_id: string; taxonomy_version: string; model_version: string; prompt_version: string; // Classified reviews reviews_classified: ClassifiedReview[]; // Stats stats: { input_count: number; success_count: number; error_count: number; total_spans: number; avg_spans_per_review: number; llm_tokens_used: number; llm_cost_usd: number; }; } interface ClassifiedReview { // Identity source: string; review_id: string; review_version: number; // Review-level classification (derived from primary span) urt_primary: string; // Tier-3 code: 'J1.01' urt_secondary: string[]; // Max 2 valence: 'V+' | 'V-' | 'V0' | 'V±'; intensity: 'I1' | 'I2' | 'I3'; comparative: 'CR-N' | 'CR-B' | 'CR-W' | 'CR-S'; // Extracted entities (from spans) staff_mentions: string[]; quotes: Record; // code -> quote // Trust score trust_score: number; // 0.2 to 1.0 // Embedding embedding: number[]; // 384-dim vector // Spans spans: ExtractedSpan[]; // Processing metadata classification_confidence: Record; processing_time_ms: number; } interface ExtractedSpan { // Identity span_id: string; // Deterministic: 'SPN-{hash}' span_index: number; // 0-based within review // Position (offsets into original text, NOT normalized) span_text: string; // Exact substring span_start: number; // Character offset span_end: number; // Character offset (exclusive) // Classification profile: 'lite' | 'core' | 'standard' | 'full'; urt_primary: string; urt_secondary: string[]; valence: 'V+' | 'V-' | 'V0' | 'V±'; intensity: 'I1' | 'I2' | 'I3'; comparative: 'CR-N' | 'CR-B' | 'CR-W' | 'CR-S'; // Extended (standard/full profile) specificity: 'S1' | 'S2' | 'S3'; actionability: 'A1' | 'A2' | 'A3'; temporal: 'TC' | 'TR' | 'TH' | 'TF'; evidence: 'ES' | 'EI' | 'EC'; // Entity entity?: string; entity_type?: 'location' | 'staff' | 'product' | 'process' | 'time' | 'other'; entity_normalized?: string; // Causal (full profile) relation_type?: 'cause_of' | 'effect_of' | 'contrast' | 'resolution'; related_span_index?: number; causal_chain?: CausalLink[]; // Metadata confidence: 'high' | 'medium' | 'low'; usn: string; // URT Semantic Notation // Flags is_primary: boolean; // Primary span for this review } interface CausalLink { code: string; role: 'cause' | 'effect' | 'context' | 'outcome'; order: number; } ``` ### Database Writes ```sql -- Update reviews_enriched with classification UPDATE reviews_enriched SET urt_primary = $urt_primary, urt_secondary = $urt_secondary, valence = $valence, intensity = $intensity, comparative = $comparative, staff_mentions = $staff_mentions, quotes = $quotes, embedding = $embedding, trust_score = $trust_score, classification_model = $model_version, classification_confidence = $confidence, taxonomy_version = $taxonomy_version, processed_at = NOW() WHERE source = $source AND review_id = $review_id AND review_version = $review_version; -- Insert spans INSERT INTO review_spans ( span_id, business_id, place_id, source, review_id, review_version, span_index, span_text, span_start, span_end, profile, urt_primary, urt_secondary, valence, intensity, comparative, specificity, actionability, temporal, evidence, entity, entity_type, entity_normalized, relation_type, related_span_id, causal_chain, is_primary, is_active, review_time, confidence, usn, taxonomy_version, model_version, ingest_batch_id ) VALUES (...); ``` ### Validation Rules | Rule | Check | Error Code | |------|-------|------------| | V2.1 | `urt_primary` matches `^[OPJEAVR][1-4]\.[0-9]{2}$` | `STAGE2_INVALID_URT_CODE` | | V2.2 | `urt_secondary` has max 2 elements | `STAGE2_TOO_MANY_SECONDARY` | | V2.3 | `valence` is valid enum value | `STAGE2_INVALID_VALENCE` | | V2.4 | `intensity` is valid enum value | `STAGE2_INVALID_INTENSITY` | | V2.5 | `span_end > span_start` | `STAGE2_INVALID_SPAN_BOUNDS` | | V2.6 | `span_text == text[span_start:span_end]` | `STAGE2_SPAN_TEXT_MISMATCH` | | V2.7 | Spans do not overlap | `STAGE2_OVERLAPPING_SPANS` | | V2.8 | Exactly one `is_primary = true` per review | `STAGE2_PRIMARY_SPAN_COUNT` | | V2.9 | `trust_score` >= 0.2 and <= 1.0 | `STAGE2_INVALID_TRUST` | | V2.10 | `embedding` is 384-dim array | `STAGE2_INVALID_EMBEDDING` | | V2.11 | `usn` matches profile-specific regex | `STAGE2_INVALID_USN` | | V2.12 | `related_span_index` references valid span | `STAGE2_INVALID_RELATION` | ### Validation Script ```python def validate_stage2_output(output: Stage2Output, input_reviews: dict) -> ValidationResult: """ Validate Stage 2 classification output. input_reviews: dict mapping (source, review_id, version) -> original text """ errors = [] for review in output.reviews_classified: # V2.1: Valid URT code if not re.match(r'^[OPJEAVR][1-4]\.[0-9]{2}$', review.urt_primary): errors.append(ValidationError('V2.1', review.review_id, f'Invalid code: {review.urt_primary}')) # V2.2: Max 2 secondary codes if len(review.urt_secondary) > 2: errors.append(ValidationError('V2.2', review.review_id, 'Too many secondary codes')) # Get original text for span validation key = (review.source, review.review_id, review.review_version) original_text = input_reviews.get(key, {}).get('text', '') primary_count = 0 span_ranges = [] for span in review.spans: # V2.5: Valid bounds if span.span_end <= span.span_start: errors.append(ValidationError('V2.5', span.span_id, 'Invalid bounds')) # V2.6: Text matches expected_text = original_text[span.span_start:span.span_end] if span.span_text != expected_text: errors.append(ValidationError('V2.6', span.span_id, f'Text mismatch: expected "{expected_text[:30]}..." got "{span.span_text[:30]}..."')) # V2.7: No overlap (check against previous spans) for prev_start, prev_end in span_ranges: if not (span.span_end <= prev_start or span.span_start >= prev_end): errors.append(ValidationError('V2.7', span.span_id, 'Overlapping span')) span_ranges.append((span.span_start, span.span_end)) # V2.8: Count primaries if span.is_primary: primary_count += 1 if primary_count != 1: errors.append(ValidationError('V2.8', review.review_id, f'Primary count: {primary_count}')) # V2.9: Trust score bounds if not (0.2 <= review.trust_score <= 1.0): errors.append(ValidationError('V2.9', review.review_id, f'Trust: {review.trust_score}')) # V2.10: Embedding dimension if len(review.embedding) != 384: errors.append(ValidationError('V2.10', review.review_id, f'Embedding dim: {len(review.embedding)}')) return ValidationResult( stage='stage2', passed=len(errors) == 0, error_count=len(errors), errors=errors ) ``` ### Handoff to Stage 3 Stage 3 queries: ```sql SELECT rs.* FROM review_spans rs WHERE rs.is_active = TRUE AND rs.valence IN ('V-', 'V±') -- Only negative/mixed create issues AND NOT EXISTS ( SELECT 1 FROM issue_spans iss WHERE iss.span_id = rs.span_id ) ORDER BY rs.review_time DESC; ``` --- ## Stage 3: Issue Routing **Status**: ❌ NOT IMPLEMENTED **Owner**: TBD **Depends On**: Stage 2 ### Purpose Route classified spans to issues (create new or aggregate to existing). ### Input Contract ```typescript interface Stage3Input { // Spans to route (from Stage 2) spans: SpanToRoute[]; } interface SpanToRoute { span_id: string; business_id: string; place_id: string; urt_primary: string; valence: string; intensity: string; entity_normalized?: string; review_time: string; confidence: string; // For trust weighting trust_score: number; } ``` ### Output Contract ```typescript interface Stage3Output { // Routing results routed_spans: RoutedSpan[]; // Issues created/updated issues_created: string[]; // issue_ids issues_updated: string[]; // issue_ids // Stats stats: { spans_processed: number; spans_routed: number; spans_skipped: number; // Positive valence, etc. issues_created: number; issues_updated: number; }; } interface RoutedSpan { span_id: string; issue_id: string; routing_key: string; // business|place|code|entity is_new_issue: boolean; } ``` ### Issue ID Generation ```typescript // Deterministic issue ID from routing key function generateIssueId( business_id: string, place_id: string, urt_primary: string, entity_normalized: string | null ): string { const key = `${business_id}|${place_id}|${urt_primary}|${entity_normalized || ''}`; const hash = sha256(key); return `ISS-${hash.substring(0, 16)}`; } ``` ### Database Writes ```sql -- Create or update issue INSERT INTO issues ( issue_id, business_id, place_id, primary_subcode, domain, state, priority_score, confidence_score, span_count, max_intensity, entity, entity_normalized, taxonomy_version ) VALUES (...) ON CONFLICT (issue_id) DO UPDATE SET span_count = issues.span_count + 1, max_intensity = GREATEST(issues.max_intensity, EXCLUDED.max_intensity), updated_at = NOW(); -- Link span to issue INSERT INTO issue_spans ( issue_id, span_id, source, review_id, review_version, is_primary_match, intensity, review_time ) VALUES (...) ON CONFLICT (span_id) DO NOTHING; -- 1:1 mapping -- Log event INSERT INTO issue_events (issue_id, event_type, span_id, ...) VALUES (...); ``` ### Validation Rules | Rule | Check | Error Code | |------|-------|------------| | V3.1 | `issue_id` matches `^ISS-[a-f0-9]{16}$` | `STAGE3_INVALID_ISSUE_ID` | | V3.2 | `routing_key` is non-empty | `STAGE3_EMPTY_ROUTING_KEY` | | V3.3 | Span not already linked to different issue | `STAGE3_DUPLICATE_ROUTING` | | V3.4 | Issue exists in `issues` table | `STAGE3_ORPHAN_SPAN_LINK` | | V3.5 | Only V-/V± spans create issues | `STAGE3_POSITIVE_ROUTED` | ### Validation Script ```python def validate_stage3_output(output: Stage3Output, db: Database) -> ValidationResult: errors = [] for routed in output.routed_spans: # V3.1: Valid issue ID format if not re.match(r'^ISS-[a-f0-9]{16}$', routed.issue_id): errors.append(ValidationError('V3.1', routed.span_id, 'Invalid issue_id')) # V3.2: Non-empty routing key if not routed.routing_key: errors.append(ValidationError('V3.2', routed.span_id, 'Empty routing key')) # V3.3: Check no duplicate routing existing = db.query( "SELECT issue_id FROM issue_spans WHERE span_id = %s", [routed.span_id] ) if existing and existing['issue_id'] != routed.issue_id: errors.append(ValidationError('V3.3', routed.span_id, 'Already routed elsewhere')) # V3.4: Issue exists issue_exists = db.query( "SELECT 1 FROM issues WHERE issue_id = %s", [routed.issue_id] ) if not issue_exists: errors.append(ValidationError('V3.4', routed.span_id, 'Orphan issue_id')) return ValidationResult( stage='stage3', passed=len(errors) == 0, error_count=len(errors), errors=errors ) ``` ### Handoff to Stage 4 Stage 4 runs as scheduled job, reads from all tables. --- ## Stage 4: Fact Aggregation **Status**: ❌ NOT IMPLEMENTED **Owner**: TBD **Depends On**: Stages 1, 2, 3 **Schedule**: Daily batch job ### Purpose Pre-aggregate span/review data into `fact_timeseries` for fast dashboard queries. ### Input Contract ```typescript interface Stage4Input { business_id: string; date: string; // YYYY-MM-DD bucket_types: ('day' | 'week' | 'month')[]; taxonomy_version: string; } ``` ### Output Contract ```typescript interface Stage4Output { facts_written: FactRecord[]; stats: { business_id: string; date: string; locations_processed: number; codes_aggregated: number; facts_upserted: number; }; } interface FactRecord { // Keys business_id: string; place_id: string; // Or 'ALL' for rollup period_date: string; bucket_type: string; subject_type: 'overall' | 'urt_code' | 'domain' | 'issue'; subject_id: string; taxonomy_version: string; // Metrics review_count: number; span_count: number; negative_count: number; positive_count: number; neutral_count: number; mixed_count: number; strength_score: number; negative_strength: number; positive_strength: number; avg_rating: number; i1_count: number; i2_count: number; i3_count: number; cr_better: number; cr_worse: number; cr_same: number; trust_weighted_strength: number; trust_weighted_negative: number; } ``` ### Database Writes ```sql INSERT INTO fact_timeseries ( business_id, place_id, period_date, bucket_type, subject_type, subject_id, taxonomy_version, review_count, span_count, negative_count, positive_count, ... ) VALUES (...) ON CONFLICT (business_id, place_id, period_date, bucket_type, subject_type, subject_id, taxonomy_version) DO UPDATE SET review_count = EXCLUDED.review_count, span_count = EXCLUDED.span_count, ... computed_at = NOW(); ``` ### Validation Rules | Rule | Check | Error Code | |------|-------|------------| | V4.1 | `place_id` is valid or 'ALL' | `STAGE4_INVALID_PLACE` | | V4.2 | `period_date` matches bucket | `STAGE4_DATE_BUCKET_MISMATCH` | | V4.3 | `span_count >= review_count` (reviews have >=1 span) | `STAGE4_COUNT_MISMATCH` | | V4.4 | `negative_count + positive_count + neutral_count + mixed_count == span_count` | `STAGE4_VALENCE_SUM` | | V4.5 | `i1_count + i2_count + i3_count == span_count` | `STAGE4_INTENSITY_SUM` | | V4.6 | `strength_score >= 0` | `STAGE4_NEGATIVE_STRENGTH` | | V4.7 | `avg_rating` between 1.0 and 5.0 (or NULL) | `STAGE4_INVALID_RATING` | ### Validation Script ```python def validate_stage4_output(output: Stage4Output) -> ValidationResult: errors = [] for fact in output.facts_written: # V4.3: Span count >= review count if fact.span_count < fact.review_count: errors.append(ValidationError('V4.3', f'{fact.subject_id}', 'span < review count')) # V4.4: Valence sum valence_sum = fact.negative_count + fact.positive_count + fact.neutral_count + fact.mixed_count if valence_sum != fact.span_count: errors.append(ValidationError('V4.4', f'{fact.subject_id}', f'Valence sum {valence_sum} != {fact.span_count}')) # V4.5: Intensity sum intensity_sum = fact.i1_count + fact.i2_count + fact.i3_count if intensity_sum != fact.span_count: errors.append(ValidationError('V4.5', f'{fact.subject_id}', f'Intensity sum {intensity_sum} != {fact.span_count}')) # V4.7: Rating bounds if fact.avg_rating is not None and not (1.0 <= fact.avg_rating <= 5.0): errors.append(ValidationError('V4.7', f'{fact.subject_id}', f'Invalid rating: {fact.avg_rating}')) return ValidationResult( stage='stage4', passed=len(errors) == 0, error_count=len(errors), errors=errors ) ``` --- ## Integration Tests ### Test 1: Stage 0 → Stage 1 Handoff ```python def test_stage0_to_stage1(): """Verify Stage 0 output is valid Stage 1 input.""" # Get Stage 0 output stage0_output = db.query("SELECT reviews_data FROM jobs WHERE job_id = %s", [JOB_ID]) # Validate as Stage 1 input stage1_input = Stage1Input.parse(stage0_output) # Check all required fields present assert stage1_input.job_id is not None assert stage1_input.business_id is not None assert len(stage1_input.reviews) > 0 for review in stage1_input.reviews: assert review.review_id is not None assert review.rating in [1, 2, 3, 4, 5] assert review.review_time is not None ``` ### Test 2: Stage 1 → Stage 2 Handoff ```python def test_stage1_to_stage2(): """Verify Stage 1 output can be classified.""" # Get Stage 1 output from DB reviews = db.query(""" SELECT * FROM reviews_enriched WHERE urt_primary IS NULL AND is_latest = TRUE LIMIT 10 """) # Build Stage 2 input stage2_input = Stage2Input( reviews=[ReviewToClassify.from_db(r) for r in reviews], config=ClassificationConfig( model='gpt-4o-mini', taxonomy_version='v5.1', profile='standard' ) ) # Validate input for review in stage2_input.reviews: assert review.text is not None assert len(review.text) > 0 assert review.text_normalized is not None ``` ### Test 3: Stage 2 → Stage 3 Handoff ```python def test_stage2_to_stage3(): """Verify classified spans can be routed.""" # Get unrouted negative spans spans = db.query(""" SELECT * FROM review_spans WHERE is_active = TRUE AND valence IN ('V-', 'V±') AND NOT EXISTS (SELECT 1 FROM issue_spans WHERE span_id = review_spans.span_id) LIMIT 10 """) # Build Stage 3 input stage3_input = Stage3Input( spans=[SpanToRoute.from_db(s) for s in spans] ) # Validate for span in stage3_input.spans: assert span.span_id is not None assert span.business_id is not None assert span.urt_primary is not None assert span.valence in ['V-', 'V±'] ``` ### Test 4: End-to-End Pipeline ```python def test_e2e_pipeline(): """Full pipeline integration test.""" # Stage 0: Scrape job_id = submit_scrape_job(TEST_URL) wait_for_job(job_id) stage0_result = validate_stage0_output(get_job(job_id)) assert stage0_result.passed # Stage 1: Normalize stage1_output = run_stage1(job_id) stage1_result = validate_stage1_output(stage1_output) assert stage1_result.passed # Stage 2: Classify stage2_output = run_stage2(stage1_output.reviews_normalized) stage2_result = validate_stage2_output(stage2_output, stage1_output) assert stage2_result.passed # Stage 3: Route negative_spans = [s for r in stage2_output.reviews_classified for s in r.spans if s.valence in ['V-', 'V±']] stage3_output = run_stage3(negative_spans) stage3_result = validate_stage3_output(stage3_output, db) assert stage3_result.passed # Stage 4: Aggregate stage4_output = run_stage4(TEST_BUSINESS_ID, today(), ['day']) stage4_result = validate_stage4_output(stage4_output) assert stage4_result.passed # Final assertion: Facts exist facts = db.query(""" SELECT COUNT(*) FROM fact_timeseries WHERE business_id = %s AND period_date = %s """, [TEST_BUSINESS_ID, today()]) assert facts['count'] > 0 ``` --- ## Test Fixtures ### Fixture 1: Sample Raw Review (Stage 0 Output) ```json { "job_id": "test-job-001", "status": "completed", "business_id": "acme-corp", "place_id": "ChIJN1t_tDeuEmsRUsoyG83frY4", "business_info": { "name": "Acme Restaurant", "address": "123 Main St, Anytown, USA", "category": "Restaurant", "total_reviews": 1247, "average_rating": 4.2 }, "reviews": [ { "review_id": "ChdDSUhNMG9nS0VJQ0FnSURBdWJQX3h3RRAB", "author_name": "John Smith", "author_id": "103456789012345678901", "rating": 2, "text": "The food was great but the wait was absolutely terrible. We waited 45 minutes just to be seated, and another 30 minutes for our appetizers. The server Mike was rude and dismissive when we complained. However, the steak was cooked perfectly and the dessert was amazing.", "review_time": "2026-01-20T14:30:00Z", "response_text": null, "photos": [], "raw_payload": {} } ], "scrape_time_ms": 12500, "reviews_scraped": 1, "scraper_version": "v1.0.0" } ``` ### Fixture 2: Normalized Review (Stage 1 Output) ```json { "source": "google", "review_id": "ChdDSUhNMG9nS0VJQ0FnSURBdWJQX3h3RRAB", "review_version": 1, "business_id": "acme-corp", "place_id": "ChIJN1t_tDeuEmsRUsoyG83frY4", "text": "The food was great but the wait was absolutely terrible. We waited 45 minutes just to be seated, and another 30 minutes for our appetizers. The server Mike was rude and dismissive when we complained. However, the steak was cooked perfectly and the dessert was amazing.", "text_normalized": "the food was great but the wait was absolutely terrible we waited 45 minutes just to be seated and another 30 minutes for our appetizers the server mike was rude and dismissive when we complained however the steak was cooked perfectly and the dessert was amazing", "text_language": "en", "text_length": 267, "word_count": 52, "rating": 2, "review_time": "2026-01-20T14:30:00Z", "author_name": "John Smith", "content_hash": "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd", "raw_id": 12345 } ``` ### Fixture 3: Classified Review with Spans (Stage 2 Output) ```json { "source": "google", "review_id": "ChdDSUhNMG9nS0VJQ0FnSURBdWJQX3h3RRAB", "review_version": 1, "urt_primary": "J1.01", "urt_secondary": ["P1.02"], "valence": "V±", "intensity": "I3", "comparative": "CR-N", "staff_mentions": ["Mike"], "quotes": { "J1.01": "waited 45 minutes just to be seated", "P1.02": "rude and dismissive" }, "trust_score": 0.85, "embedding": [0.123, -0.456, 0.789, "... 384 values ..."], "spans": [ { "span_id": "SPN-a1b2c3d4e5f67890", "span_index": 0, "span_text": "The food was great", "span_start": 0, "span_end": 18, "profile": "standard", "urt_primary": "O1.01", "urt_secondary": [], "valence": "V+", "intensity": "I2", "comparative": "CR-N", "specificity": "S1", "actionability": "A1", "temporal": "TC", "evidence": "ES", "confidence": "high", "usn": "URT:S:O1.01:+2:11TC.ES.N", "is_primary": false }, { "span_id": "SPN-b2c3d4e5f6789012", "span_index": 1, "span_text": "the wait was absolutely terrible. We waited 45 minutes just to be seated, and another 30 minutes for our appetizers", "span_start": 23, "span_end": 138, "profile": "standard", "urt_primary": "J1.01", "urt_secondary": [], "valence": "V-", "intensity": "I3", "comparative": "CR-N", "specificity": "S3", "actionability": "A2", "temporal": "TC", "evidence": "EC", "confidence": "high", "usn": "URT:S:J1.01:-3:32TC.EC.N", "is_primary": true }, { "span_id": "SPN-c3d4e5f678901234", "span_index": 2, "span_text": "The server Mike was rude and dismissive when we complained", "span_start": 140, "span_end": 198, "profile": "standard", "urt_primary": "P1.02", "urt_secondary": [], "valence": "V-", "intensity": "I2", "comparative": "CR-N", "specificity": "S2", "actionability": "A2", "temporal": "TC", "evidence": "ES", "entity": "Mike", "entity_type": "staff", "entity_normalized": "mike", "confidence": "high", "usn": "URT:S:P1.02:-2:22TC.ES.N", "is_primary": false }, { "span_id": "SPN-d4e5f67890123456", "span_index": 3, "span_text": "the steak was cooked perfectly and the dessert was amazing", "span_start": 209, "span_end": 267, "profile": "standard", "urt_primary": "O1.01", "urt_secondary": [], "valence": "V+", "intensity": "I2", "comparative": "CR-N", "specificity": "S2", "actionability": "A1", "temporal": "TC", "evidence": "ES", "confidence": "high", "usn": "URT:S:O1.01:+2:21TC.ES.N", "is_primary": false } ] } ``` ### Fixture 4: Routed Spans (Stage 3 Output) ```json { "routed_spans": [ { "span_id": "SPN-b2c3d4e5f6789012", "issue_id": "ISS-7a8b9c0d1e2f3a4b", "routing_key": "acme-corp|ChIJN1t_tDeuEmsRUsoyG83frY4|J1.01|", "is_new_issue": true }, { "span_id": "SPN-c3d4e5f678901234", "issue_id": "ISS-2b3c4d5e6f7a8b9c", "routing_key": "acme-corp|ChIJN1t_tDeuEmsRUsoyG83frY4|P1.02|mike", "is_new_issue": true } ], "issues_created": ["ISS-7a8b9c0d1e2f3a4b", "ISS-2b3c4d5e6f7a8b9c"], "issues_updated": [], "stats": { "spans_processed": 4, "spans_routed": 2, "spans_skipped": 2, "issues_created": 2, "issues_updated": 0 } } ``` ### Fixture 5: Aggregated Facts (Stage 4 Output) ```json { "facts_written": [ { "business_id": "acme-corp", "place_id": "ChIJN1t_tDeuEmsRUsoyG83frY4", "period_date": "2026-01-20", "bucket_type": "day", "subject_type": "urt_code", "subject_id": "J1.01", "taxonomy_version": "v5.1", "review_count": 1, "span_count": 1, "negative_count": 1, "positive_count": 0, "neutral_count": 0, "mixed_count": 0, "strength_score": 4.0, "negative_strength": 4.0, "positive_strength": 0.0, "avg_rating": 2.0, "i1_count": 0, "i2_count": 0, "i3_count": 1, "cr_better": 0, "cr_worse": 0, "cr_same": 0, "trust_weighted_strength": 3.4, "trust_weighted_negative": 3.4 } ], "stats": { "business_id": "acme-corp", "date": "2026-01-20", "locations_processed": 1, "codes_aggregated": 3, "facts_upserted": 5 } } ``` --- ## Validation Summary Table | Stage | Input From | Output To | Validation Rules | Test Coverage | |-------|------------|-----------|------------------|---------------| | 0 | Google Maps | jobs.reviews_data | V0.1-V0.5 | Unit + E2E | | 1 | Stage 0 | reviews_raw, reviews_enriched | V1.1-V1.6 | Unit + Integration | | 2 | Stage 1 | reviews_enriched, review_spans | V2.1-V2.12 | Unit + Integration | | 3 | Stage 2 | issues, issue_spans, issue_events | V3.1-V3.5 | Unit + Integration | | 4 | All stages | fact_timeseries | V4.1-V4.7 | Unit + E2E | --- ## Document Control | Field | Value | |-------|-------| | **Document** | ReviewIQ Pipeline Contracts v1.0 | | **Status** | Implementation Blueprint | | **Date** | 2026-01-24 | | **Purpose** | Enable parallel development with stage validation | --- *End of Pipeline Contracts Document*