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.
| Kind | Input | Role in the pipeline |
|---|---|---|
website | URL list (max 3) | Pages fetched and read as grounding sources |
topic | Text | Drives the brief's focus and the suggested questions |
bio | Textarea | Identity context, cross-checked against fetched pages |
talking_points | Textarea | Must-cover list the suggested questions honour |
avoid | Textarea | Hard constraint; nothing in the brief or questions touches these |
Validation rules worth knowing:
- Avoid-only configs are rejected at save time (
aiResearchConfigSchemasuperRefine):avoidconstrains 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 (parseAiResearchConfigis defensive). - URLs are pinned to http(s) at capture, at normalization, and at display (
isHttpAiResearchUrl);z.string().url()alone would acceptjavascript:anddata: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()). normalizeAiResearchResponsesdrops empty answers (the bookings row stores NULL, not{});filterAiResponsesToConfigdrops 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:
| Resolution | Meaning | Effect on the submission |
|---|---|---|
granted | Feature resolves true | Stored config applies as-is (including required fields) |
denied | Real 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 |
unavailable | The entitlement READ itself failed | Keep 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
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- 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. - reserve-credits. Flat hard pre-pay of
GUEST_RESEARCH_CREDITS(5), keyed by the job id. Refusal marks the jobskippedand notifies; never retried. - discover-links. Browser Run's
/linksquick action lists the FIRST provided URL's links; at mostMAX_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. - 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 Runmarkdownquick action, then a fetch withAccept: text/markdown, then a raw fetch with naive HTML stripping. The two direct-fetch paths are byte-capped atMAX_FETCH_BYTES(1 MiB, guest-supplied URLs are never buffered whole) and each page contributes at mostMAX_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'sunverified_or_gapsstays honest. Zero grounding (no page fetched AND no self-description answered) is a terminal failure. - 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;modeis 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. - persist.
upsertEpisodeResearch()upsertsepisode_researchon the partial uniquebooking_idindex (regenerate = replace).booking_idresolves 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). - 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. - notify-and-fan-out.
notify_ai_job_resultplus oneepisode.research_readyautomation event (withbooking_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.svelterenders 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]/research308-redirects toshow-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 whenai_prep_questionsis 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).