Skip to content

Guest Research

Guest research turns a booking's AI-field answers into a grounded research brief plus categorized interview questions. Answers are captured on the public booking form, snapshotted at enqueue time, and processed by the GuestResearchWorkflow in workers/ai-processor/src/workflows/guest-research.ts.

The five AI field kinds

AI research fields are a CURATED set with fixed prompt-slot semantics (src/lib/booking/ai-research.ts). Hosts toggle kinds on/off per booking link and edit the guest-facing wording; they cannot invent kinds, so every answer's role is known to the pipeline. Generic custom fields stay free-form and are never fed to the AI.

KindInputRole in the pipeline
websiteURL list (max 3)Pages fetched and read as grounding sources
topicTextDrives the brief's focus and the suggested questions
bioTextareaIdentity context, cross-checked against fetched pages
talking_pointsTextareaMust-cover list the suggested questions honour
avoidTextareaHard constraint; nothing in the brief or questions touches these

Validation rules worth knowing:

  • Avoid-only configs are rejected at save time (aiResearchConfigSchema superRefine): avoid constrains output but gives the researcher nothing to work FROM, so an avoid-only link could never produce a run. A legacy stored avoid-only row parses as disabled everywhere (parseAiResearchConfig is defensive).
  • URLs are pinned to http(s) at capture, at normalization, and at display (isHttpAiResearchUrl); z.string().url() alone would accept javascript: and data: schemes.
  • Caps: 3 website URLs, 500 chars per URL, 2,000 chars per text answer. Unknown keys in the response object are a 400 (.strict()).
  • normalizeAiResearchResponses drops empty answers (the bookings row stores NULL, not {}); filterAiResponsesToConfig drops answers for kinds the host removed while the form was open (benign staleness, dropped not rejected).

The tri-state grant on the public path

The stored booking_links.ai_research config survives plan downgrades, so no read site may trust it alone. Two public surfaces resolve the ai_prep_questions feature:

Page load (src/routes/(book)/[podcastSlug]/[bookingSlug]/+page.server.ts): the raw ai_research column is OMITTED from the serialized page data; the only carrier to the client is a redacted aiResearch prop whose enabled is config.enabled AND featureGranted. Any resolution error degrades to a hidden section (cosmetic surface, never a 503). Spreading the raw column would let any visitor recover a hidden question config from the page payload.

Submission (POST /api/bookings, public create in src/api/routes/bookings/index.ts): the validator judges against an EFFECTIVE config built from resolveAiResearchGrant() (src/lib/entitlements/ai-research-feature.ts), a tri-state because the two non-granted cases demand opposite treatment of a submission that carries AI answers:

ResolutionMeaningEffect on the submission
grantedFeature resolves trueStored config applies as-is (including required fields)
deniedReal entitlement decision (downgrade, C8 missing row, unprovisioned account)Effective config is disabled. The live page hides the section, so answers can only come from a stale or replayed form: rejected with an auditable 400 (ai_research_not_enabled); a reload clears the section
unavailableThe entitlement READ itself failedKeep the section's kinds (answers from a form that rendered it are accepted and stored) but waive required-ness so a form that hid it still submits. A cosmetic add-on must never block a booking on an infra blip

This is the opposite failure posture to the advance-booking clamp (a security clamp, which throws): here the guest-safe direction is "the booking succeeds". No ungated job can result either way, because research is separately gated by the fail-closed feature check at confirm time.

Job plan and enqueue sites

buildGuestResearchJobPlan() (src/lib/server/guest-research.ts) is shared by both enqueue sites so they can never drift on eligibility or snapshot shape. It loads the booking (status must be pending or confirmed), parses the link's config, re-normalizes and config-filters the stored answers defensively, refuses avoid-only/empty input (no_inputs), and assembles the GuestResearchJobInput snapshot: bookingId, guestLabel, responses, the link's per-kind fieldLabels, podcast title/description, and additional-guest names (canceled attendees excluded; the prompt must not claim they are still joining).

The snapshot is written into ai_jobs.input at ENQUEUE time. The worker never reads booking DATA (its persist step only re-resolves booking_id against the live table), so a booking deleted mid-flight cannot break a job. The corollary: regeneration requires a live booking; once deleted, the stored brief remains viewable but cannot be re-run.

Confirm side-effect (POST /api/bookings/:id/confirm, step 3c2): when the plan resolves and the LINK enables research (plan.aiResearchEnabled), a fail-closed checkFeature(c, 'ai_prep_questions') gates the enqueue. Credits are deliberately not pre-checked here; the worker's flat hard reserve refuses and the job lands as skipped with an ai.research_skipped notification. Duplicate confirms hit the one-active-job-per-booking unique index (20260704022823) and no-op. Nothing here can fail the confirm.

Manual run / regenerate (POST /api/ai/research/:episodeId, body { bookingId }, member+, requireFeature('ai_prep_questions')): the plan lookup is SCOPED to the authorized episode, because the client is service-role and the failure reasons are distinguishable; an unscoped query would let a member probe foreign booking UUIDs for existence and status. Outside the episode everything is a plain 404. Other failures: 422 booking_inactive (canceled booking), 422 no_research_inputs, 429 quota_exceeded preflight (needs 5 credits), 409 job_active.

Workflow steps

text
validate -> reserve-credits (flat 5; refusal => skipped + notify + return)
  -> discover-links (best-effort) -> fetch-page-{i} (<= 5 pages)
  -> generate -> persist -> finalize -> notify-and-fan-out
catch: mark-failed -> refund-credits -> notify-failed
  1. validate. Re-checks job type/status, episode existence, and (defence in depth, the app refuses these at enqueue) that the snapshot has researchable answers. Transitions to running.
  2. reserve-credits. Flat hard pre-pay of GUEST_RESEARCH_CREDITS (5), keyed by the job id. Refusal marks the job skipped and notifies; never retried.
  3. discover-links. Browser Run's /links quick action lists the FIRST provided URL's links; at most MAX_FOLLOWED_LINKS (2) same-host links whose path looks like an identity/press page (/about|bio|press|media|work|speaking/) are kept, deduped against the provided set (workers/ai-processor/src/lib/link-follow.ts). Discovery failure degrades to the provided URLs, never fatal.
  4. fetch-page-{i}. One durable step per page, capped at MAX_TOTAL_PAGES (5) overall. Acquisition is a graded chain (workers/ai-processor/src/lib/fetch-page.ts): Browser Run markdown quick action, then a fetch with Accept: text/markdown, then a raw fetch with naive HTML stripping. The two direct-fetch paths are byte-capped at MAX_FETCH_BYTES (1 MiB, guest-supplied URLs are never buffered whole) and each page contributes at most MAX_PAGE_CHARS (18,000) to the prompt. A failed page is RECORDED, not fatal: the prompt tells the model which links died so the brief's unverified_or_gaps stays honest. Zero grounding (no page fetched AND no self-description answered) is a terminal failure.
  5. generate. One GLM-4.7-flash call (workers/ai-processor/src/lib/guest-research.ts). Primary path is native JSON mode (response_format: json_schema, spike-proven); fallback is a tolerant prompt-parse; mode is recorded in the job result. Source URLs are rebuilt from the actually-fetched pages; the model only echoes source ids, it can never mint URLs. The avoid list is a hard constraint and the current date is injected.
  6. persist. upsertEpisodeResearch() upserts episode_research on the partial unique booking_id index (regenerate = replace). booking_id resolves through a subselect against the live bookings table, so a booking hard-deleted mid-flight inserts with NULL (the row is keyed to the EPISODE and survives booking deletion, 20260704022825).
  7. finalize. Marks complete with credits_spent = 5, cost telemetry, and a result of { mode, pages } where pages are per-page fetch metadata without the markdown.
  8. notify-and-fan-out. notify_ai_job_result plus one episode.research_ready automation event (with booking_id).

The catch path mirrors transcription: mark-failed, refund-credits (returns the flat 5 on terminal failure; see Credits & Metering), notify-failed.

The brief artifact

ResearchBrief (src/lib/research/types.ts) is stored verbatim in episode_research.brief (roughly 10KB of JSONB, no R2 artifact): grounded sections with [S#] citations (guest_summary, topic_overview, background_context, expertise, angles, misconceptions_or_tensions, glossary), 12 to 15 categorized questions (icebreaker | core | probing | closing, each with a why), an honest unverified_or_gaps list, and sources. parseResearchBrief() is the defensive rendering floor: lists are filtered to well-shaped entries (each item flows into citation parsing) and source URLs are re-pinned to http(s) because they render as clickable hrefs.

Surfaces

  • Research briefs panel: src/lib/components/research/ResearchBriefsPanel.svelte renders the briefs as a card on the episode's Show notes surface (e/[episodeSlug]/show-notes), because a brief's output is appended into the host-only notes and a tab of its own was always a detour. e/[episodeSlug]/research 308-redirects to show-notes#research; every old link, including the ones in guest emails, still lands. See SvelteKit Routing. Zero briefs is a real state: the panel renders an in-progress pane (active job), an empty pane, or a locked upsell pane when ai_prep_questions is not granted (resolved fail-closed by the shell layout load).
  • Append to host notes: the panel calls back into the Show notes page rather than stashing blocks and navigating. The collaborative doc is local-first, so a direct DB write would be invisible to, and later overwritten by, the mounted editor's cached state; the host feeds the blocks into the same pending-append effect the cross-page stash used to.
  • Reads: GET /api/ai/research/:episodeId (member+) returns all briefs for the episode, ordered by creation.
  • Deletes: DELETE /api/ai/research/:episodeId/:researchId (admin+) is a user-client delete so the admin RLS policy is the enforcement; scoped to the episode so a cross-episode id can only 404.
  • Host answers view: the bookings hub detail sheet shows the guest's AI answers via buildAiResearchAnswers (see Bookings Hub).

Internal documentation - Not for public distribution