Per-Challenge Title Generation

Context

Challenge titles are currently generated in Phase 1 (fact-engine.ts) as a single challenge_title per fact, before any challenge questions exist. This causes answer-leak bugs: 3 of 8 sampled titles in the 2026-02-24 seed test reveal the challenge answer (e.g., "Twenty-Three Slams" for a question asking "how many Grand Slam titles?").

The fix: generate a unique challenge_title per challenge in Phase 2 (challenge-content.ts), where the model can see the question and answer and craft a title that teases without spoiling.

Current State

Key Files:

  • packages/db/src/drizzle/schema.ts:807fact_records.challengeTitle (one per fact)
  • packages/db/src/drizzle/schema.ts:812-830fact_challenge_content (no title column)
  • packages/db/src/drizzle/queries.ts:1331-1371insertChallengeContent() upsert
  • packages/ai/src/challenge-content-rules.ts:39-51GeneratedChallengeContent type
  • packages/ai/src/challenge-content.ts:236-261challengeOutputSchema (Zod)
  • packages/ai/src/challenge-content.ts:354-383 — System prompt instructions section
  • packages/ai/src/challenge-content.ts:471-486 — User prompt (passes challenge_title as input)
  • apps/worker-facts/src/handlers/generate-challenge-content.ts:83-96 — Row mapping

Gap Analysis

FeatureRequiredCurrentGap
Per-challenge title in DBYesMissingFAIL
Per-challenge title in AI output schemaYesMissingFAIL
Prompt rule for title generationYesMissingFAIL
Worker handler mappingYesMaps other fields onlyFAIL
DB insert query includes titleYesMissing fieldFAIL
GeneratedChallengeContent typeYesNo title fieldFAIL
fact_records.challenge_title preservedYesExistsPASS

Challenges

Challenge 1.1: Database Migration

Requirement: Add challenge_title column to fact_challenge_content table.

Acceptance Criteria:

  • New migration 0132_add_challenge_title_to_challenge_content.sql created
  • Column: challenge_title TEXT (nullable — backfill will populate)
  • fact_records.challenge_title remains untouched
  • Migration follows project header format
  • bun run migrations:index regenerates index cleanly

Evaluation: [ ] PENDING | [ ] PASS | [ ] FAIL

Notes: Nullable because existing rows won't have titles until backfilled.


Challenge 1.2: Drizzle Schema Update

Requirement: Add challengeTitle field to factChallengeContent in Drizzle schema.

Acceptance Criteria:

  • packages/db/src/drizzle/schema.tsfactChallengeContent has challengeTitle: text('challenge_title')
  • Placed logically near other content fields (after correctAnswer)
  • bun run typecheck passes

Evaluation: [ ] PENDING | [ ] PASS | [ ] FAIL


Challenge 1.3: AI Output Schema & Type

Requirement: Add challenge_title to the Zod output schema and TypeScript type.

Acceptance Criteria:

  • challengeOutputSchema in challenge-content.ts includes challenge_title: z.string() per challenge
  • GeneratedChallengeContent in challenge-content-rules.ts includes challenge_title: string
  • bun run typecheck passes across @eko/ai package

Evaluation: [ ] PENDING | [ ] PASS | [ ] FAIL


Challenge 1.4: Challenge Generation Prompt

Requirement: Add title generation instruction to the system prompt in buildSystemPrompt().

Acceptance Criteria:

  • Instructions section includes challenge_title generation rules
  • Rules specify: theatrical, cinematic, curiosity-provoking tone (same as fact-engine)
  • Rules specify: MUST NOT reveal the answer to challenge_text or content of correct_answer
  • Rules specify: each challenge gets its own unique title reflecting its specific question angle
  • Added to the STABLE BLOCK (section [5] instructions) for caching efficiency

Evaluation: [ ] PENDING | [ ] PASS | [ ] FAIL

Notes: The model now has full context (it just generated challenge_text and correct_answer) so the anti-leak rule is enforceable, not aspirational.


Challenge 1.5: Insert Query & Worker Handler

Requirement: Thread challenge_title through the DB insert and worker handler.

Acceptance Criteria:

  • insertChallengeContent() in queries.ts accepts challengeTitle in row type
  • Upsert's onConflictDoUpdate set includes challengeTitle
  • Worker handler in generate-challenge-content.ts maps challenge.challenge_title to row
  • bun run typecheck passes

Evaluation: [ ] PENDING | [ ] PASS | [ ] FAIL


Challenge 1.6: Seed Script Integration

Requirement: The seed script generate-challenge-content.ts handles the new field.

Acceptance Criteria:

  • Generated JSONL output includes challenge_title per challenge
  • Upload phase maps challenge_title to the DB insert
  • Validation phase checks for presence of challenge_title

Evaluation: [ ] PENDING | [ ] PASS | [ ] FAIL


Quality Tier Challenges (A+ Grade)

Challenge 1.7: Quality Standards

Requirement: Meet testing and type safety quality criteria.

Acceptance Criteria:

  • bun run typecheck — zero errors across all packages
  • bun run test -- --filter=@eko/ai — all tests pass (1027+)
  • bun run lint — no new lint errors
  • Revert the earlier anti-leak prompt additions to fact-engine.ts and seed-explosion.ts (now superseded by Phase 2 generation)
  • Accessibility: N/A (no UI changes)
  • Motion: N/A (no UI changes)
  • Design Tokens: N/A (no UI changes)

Evaluation: [ ] PENDING | [ ] PASS | [ ] FAIL


Evaluation Summary

Functional Challenges

ChallengeResult
1.1 Database MigrationPENDING
1.2 Drizzle Schema UpdatePENDING
1.3 AI Output Schema & TypePENDING
1.4 Challenge Generation PromptPENDING
1.5 Insert Query & Worker HandlerPENDING
1.6 Seed Script IntegrationPENDING

Functional Score: 0/6 PASS

Quality Tier

ChallengeResult
1.7 Quality StandardsPENDING

Quality Score: 0/1 PASS

Total: 0/7 PASS | Grade: PENDING


Schema Impact

  • New column: fact_challenge_content.challenge_title TEXT (nullable)
  • Preserved column: fact_records.challenge_title (unchanged, serves as skeleton-mode fallback)
  • Upsert update: insertChallengeContent ON CONFLICT set includes new column

Implementation Notes

  • The challenge_title passed in ExportedFactForChallenge (from fact_records) remains as input context — the Phase 2 model can reference or ignore it
  • Existing fact_challenge_content rows will have NULL challenge_title until backfilled or regenerated
  • Frontend (when built) should prefer fact_challenge_content.challenge_title over fact_records.challenge_title, falling back to the latter if NULL