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:
- Haiku classifies the brand using homepage markdown (first 1200 chars).
- Result stored on
AuditInput.industryContext. suggestedQueriesfed tofetchSerpAnalysisin Phase 4.industryContextpassed to all Phase 5 dimension aggregators that need it.inferredIndustry(=industries[0]) stored inseoSection.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:
boundedFireAndForgetusesPromise.racewith 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
-
All new dimension outputs must be appended to
SeoSectionas optional fields. There is no separate table for Phase 11A-17 dimension data. The JSONB approach means zero migrations for new dimensions. -
Dimension aggregators must remain pure functions. No Prisma reads or writes inside
src/lib/audit/data-sources/. Data flows in viaAuditInputslices; output is returned as a typed struct. -
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. -
Any new DB write in Phase 19 (e.g., ComplianceAuditResult) requires both layers:
boundedFireAndForgetat the call site and$transaction({ timeout })inside the write operation. The connection pool has 14 slots shared across all serverless function instances. -
Model names must never appear in user-facing output.
SeoSection.internalis the only field where model/vendor names are permitted. All other fields, dashboard copy, and PDF copy must use generic language. -
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 checkingindustryContext.industry(backward-compat alias) remains correct without changes.