Files
meseo-staging/docs/architecture/audit-pipeline-current-state.md
T
Andres Resiri 70daeb214a
CI / Quality gate (push) Has been cancelled
Initial project commit for Coolify staging
2026-08-03 10:45:41 -04:00

13 KiB

Audit Pipeline: Current State (Phase 19A.1 Discovery)

Date: 2026-05-26 Scope: Read-only architectural discovery of the full audit pipeline as it exists post-Phase 17.


1. Three Distinct Audit Runners

The codebase contains three independent audit runners with separate entry points, separate Prisma write targets, and separate triggering surfaces. They share no code.

1.1 Main SEO Audit Runner

File: src/lib/audit/audit-runner.ts (1594 lines) Export: runAudit(auditId: string): Promise<void> Triggered by: POST /api/admin/audits/trigger via Vercel waitUntil Prisma write target: Audit.seoSection (JSONB) Model: central to all Phase 11A-17 dimension output

This is the primary audit pipeline. It orchestrates all dimension data sources, the two LLM synthesis calls, and the final DB write. All output lands in a single JSONB field (Audit.seoSection) on the Audit record.

1.2 Technical Audit Runner

File: src/lib/services/audit-runner.ts Export: runTechnicalAudit(brandId: string, domain: string): Promise<AuditResult> Triggered by: POST /api/admin/audits/[id]/retry and brand-health scheduler Prisma write targets: TechnicalAudit, TechnicalIssue (row-per-issue table) Model: independent crawler with headless fallback via Puppeteer/Chromium

Crawls up to 100 pages, checks each for HTTP errors, broken links, redirect chains, missing meta, etc., then bulk-inserts findings as TechnicalIssue rows.

1.3 Performance Audit Runner

File: src/lib/services/performance-audit-runner.ts Export: runPerformanceAudit(brandId: string, domain: string): Promise<PerfAuditResult> Triggered by: POST /api/performance-audit/run Prisma write targets: PerformanceAudit, PerformancePageScore (row-per-URL) Model: PageSpeed Insights API calls per URL, scored per strategy (mobile/desktop)

Reads from GscPage and TrackedEvent to pick the top pages to test, then calls PageSpeed Insights for each, writing per-URL scores to PerformancePageScore.

1.4 Content Audit

API route: POST /api/content-audit/run Prisma write targets: ContentAuditResult, AuditScoreHistory Note: Not a standalone runner file -- logic lives inline in the route handler.


2. Main Audit Runner: Phase Sequence

The runAudit function orchestrates work in five labeled phases plus a degraded-mode path.

Phase 1  DFS site crawl + first-party data fetch + (3rd-party) backlink fetch  [parallel]
Phase 2  Firecrawl key-page content fetch + site analysis                      [parallel]
Phase 3  Industry classification (Haiku, ~5 s)                                 [sequential]
Phase 4  SERP analysis + bilingual audit (3rd-party)                           [parallel]
Phase 5  All dimension aggregators + two LLM synthesis calls                   [parallel where possible]
Phase 12 Live visual annotations + competitor visual comparison                 [parallel, 180 s budget]

Degraded mode activates when the crawl returns 0 pages. It skips SERP-dependent dimensions (snippet capture) and runs a subset: Phase 12 (homepage-only), 13, and 15 in parallel using fallback data.


3. industryContext: Lifecycle and Constraints

industryContext is computed in Phase 3 by classifyIndustry() and passed through the pipeline as an in-memory value. It is never written to Prisma.

Type (as of Phase 19.0):

interface IndustryContext {
  industry: string;      // backward-compat alias for industries[0]
  industries: string[];  // all applicable industries, primary first (max 3)
  subVertical: string;
  audienceFraming: string;
  relevantFrameworks: string[];
  aiVisibilityBenchmark: string;
  schemaPriorities: string[];
  suggestedQueries: string[];        // 5-7 queries; fed to Phase 4 SERP
  conversionPathExpectation: string[];
}

Flow:

  1. Haiku classifies the brand using homepage markdown (first 1200 chars).
  2. Result stored on AuditInput.industryContext.
  3. suggestedQueries fed to fetchSerpAnalysis in Phase 4.
  4. industryContext passed to all Phase 5 dimension aggregators that need it.
  5. inferredIndustry (= industries[0]) stored in seoSection.inferredIndustry -- the only persisted trace of classification.

Models used:

  • Industry classifier: claude-haiku-4-5-20251001
  • SEO narrative: claude-sonnet-4-6 (with prompt caching)
  • Structured synthesis: claude-haiku-4-5-20251001

4. AuditInput: The Pipeline Accumulator

AuditInput (defined in src/lib/audit/types.ts) is a mutable object assembled incrementally across Phases 1-5. Each phase appends its output to the relevant optional field. The synthesizer receives the fully-populated input at Phase 5.

All *Context fields are optional. Absent fields signal "not available" -- dimension aggregators and the synthesizer each have fallback behavior when their input is null.

First-party-only fields (absent on 3rd-party audits): brandContext, behavioralContext, aiAttributionContext, webVitalsContext, crawlStatsContext, signalCenterContext

Conditionally-present fields (both audit types, depend on crawl success): All remaining *Context fields.


5. Dimension Data Sources: Pure Functions

Every Phase 5 aggregator in src/lib/audit/data-sources/ is a pure function -- no Prisma reads or writes. They receive AuditInput slices and return typed structs that get merged into enrichedSeoSection before the single final DB write.

Import name File Activates when
aggregateSignalCenter data-sources/signal-center signalCenterContext present
aggregateBilingualAudit data-sources/bilingual-audit bilingualAuditContext present
aggregateCompetitorAnalysis data-sources/competitor-analysis serpAnalysis present
aggregateSchemaOpportunityForecast data-sources/schema-opportunity-forecast industryContext present
aggregateIndustryBenchmarkComparison data-sources/industry-benchmark-comparison industryContext present
aggregatePageStrategicPass data-sources/page-strategic-pass pages available
aggregateAiEnginePersonalityAnalysis data-sources/ai-engine-personality-analysis always
aggregateAiSourceAttributionComparison data-sources/ai-source-attribution-comparison aiAttributionContext present
aggregateBehavioralActionBridge data-sources/behavioral-action-bridge behavioralContext present
aggregateEeatAudit data-sources/eeat-audit pages + industryContext
runVoiceSearchEligibility data-sources/voice-search-eligibility pages + contentSamples
aggregateAiSnippetEligibility data-sources/ai-snippet-eligibility pages present
aggregateMultiTouchAiPath data-sources/multi-touch-ai-path aiAttributionContext present
aggregateRevenueAttribution data-sources/revenue-attribution first-party data
aggregatePredictiveConversionScoring data-sources/predictive-conversion-scoring first-party data
aggregateConversionHeatMap data-sources/conversion-heat-map behavioral data
aggregateAiSearchVerification data-sources/ai-search-verification always (async agent)
aggregateLostOpportunity data-sources/lost-opportunity-calculator findings present
aggregateLiveVisualAnnotations data-sources/live-visual-annotations Phase 12, parallel
aggregateCompetitorVisualComparison data-sources/competitor-visual-comparison Phase 12, parallel
aggregateBrandAuthorityScore data-sources/brand-authority eeat + schema data
aggregateSnippetCaptureStrategy data-sources/snippet-capture-strategy serpAnalysis present
aggregateMobileFirstAudit data-sources/mobile-first-audit pages present
aggregatePageSpeedInsights data-sources/page-speed-deep-dive webVitalsContext

6. SeoSection: The Single JSONB Output

SeoSection (defined in src/lib/audit/types.ts, ~155 lines) is the TypeScript shape of Audit.seoSection in Prisma. It is a wide JSON blob -- every dimension output lives as an optional field on this single object.

Core fields (always present on completed audits):

healthScore, narrative, keyFindings, priorityActions, quickWins, scoringBreakdown

Additive consulting fields:

prioritizedFindings, additionalFindings, summaryThemes, inferredIndustry, displayName

Conditional enrichments (field absent when not applicable):

aiSearchPerformance, brandEcho, dimensionScores, siteTagContext, methodology,
industryFramework, competitiveLandscape, backlinkProfile, publicAiVisibility,
organicSearchSummary, aiConversionAttribution, signalCenter, bilingualAudit,
competitorAnalysis, schemaOpportunityForecast, strategicRoadmap,
industryBenchmarkComparison, pageStrategicPass, aiEnginePersonalityAnalysis,
aiSourceAttributionComparison, behavioralActionBridge, eeatAudit,
voiceSearchEligibility, aiSnippetEligibility, multiTouchAiPath, revenueAttribution,
predictiveConversionScoring, conversionHeatMap, aiSearchVerification,
lostOpportunityCalculation, liveVisualAnnotations, competitorVisualComparison,
brandAuthorityScore, snippetCaptureStrategy, mobileFirstAudit, pageSpeedInsights,
executiveBrief, lockedFirstPartyTeaser

Engineering-only internal field:

internal.dataSourcesUsed, internal.costLog, internal.auditType

Must not be rendered by dashboard components or PDF templates.

Single Prisma write pattern:

await prisma.audit.update({
  where: { id: auditId },
  data: { seoSection: enrichedSeoSection as object },
});

All 30+ dimension outputs are assembled in memory then written in one round trip.


7. Persistence: Write Targets by Runner

Runner Table(s) written Pattern
Main SEO (audit-runner.ts) Audit.seoSection (JSONB) Single update at end of run
Main SEO (progress) Audit (status, progress fields) Multiple incremental updates during run
Technical (services/audit-runner.ts) TechnicalAudit, TechnicalIssue Create + bulk createMany
Performance (performance-audit-runner.ts) PerformanceAudit, PerformancePageScore Create + createMany per URL
Content audit (/api/content-audit/run) ContentAuditResult, AuditScoreHistory Create per run

Connection-leak mitigation (two-layer):

  • Client-side: boundedFireAndForget uses Promise.race with a hard timeout so the serverless function is never blocked waiting on a Prisma query past its useful lifetime.
  • Server-side: prisma.$transaction({ timeout }) enforces a server-enforced maximum on any transaction, releasing the connection-pool slot even if the JS promise chain is abandoned.

8. UI Rendering: Audit Report Surfaces

The seoSection JSONB is read (never mutated) by three display surfaces:

Surface Route Notes
Public audit page src/app/audit/[id]/page.tsx Gated by publicSlug; shows subset of sections
Admin audit page src/app/admin/audits/[id]/page.tsx Full section visibility
Client portal src/app/client/[brandId]/audit/page.tsx Brand-linked first-party view
PDF report src/lib/audit/pdf/AuditPdf.tsx Generated via @react-pdf/renderer; served from /api/admin/audits/[id]/pdf

Section rendering convention: Each dashboard section component checks hasData (or a null check on the parent object) and returns null when the dimension output is absent. The TOC is constructed by the page from the same boolean flags, so suppressed sections disappear from the table of contents automatically.

Separate performance/technical UIs: /technical-audit, /site-performance-audit, and /content-audit are independent pages that read from their respective tables (TechnicalAudit, PerformanceAudit, ContentAuditResult) -- they do not read Audit.seoSection.


9. Key Architectural Constraints for Phase 19 Work

  1. All new dimension outputs must be appended to SeoSection as optional fields. There is no separate table for Phase 11A-17 dimension data. The JSONB approach means zero migrations for new dimensions.

  2. Dimension aggregators must remain pure functions. No Prisma reads or writes inside src/lib/audit/data-sources/. Data flows in via AuditInput slices; output is returned as a typed struct.

  3. industryContext is ephemeral. If a dimension needs to activate conditionally on industry (e.g., HIPAA compliance when industries.some(i => i.startsWith('healthcare-'))), the check must happen inside the aggregator or inside the runner at Phase 5 gate -- not via a DB lookup.

  4. Any new DB write in Phase 19 (e.g., ComplianceAuditResult) requires both layers: boundedFireAndForget at the call site and $transaction({ timeout }) inside the write operation. The connection pool has 14 slots shared across all serverless function instances.

  5. Model names must never appear in user-facing output. SeoSection.internal is the only field where model/vendor names are permitted. All other fields, dashboard copy, and PDF copy must use generic language.

  6. Primary industry stability. The industries[0] value must equal what would have been returned in single-industry mode. Secondary industries are additive-only. Downstream gate logic checking industryContext.industry (backward-compat alias) remains correct without changes.