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:807—fact_records.challengeTitle(one per fact)packages/db/src/drizzle/schema.ts:812-830—fact_challenge_content(no title column)packages/db/src/drizzle/queries.ts:1331-1371—insertChallengeContent()upsertpackages/ai/src/challenge-content-rules.ts:39-51—GeneratedChallengeContenttypepackages/ai/src/challenge-content.ts:236-261—challengeOutputSchema(Zod)packages/ai/src/challenge-content.ts:354-383— System prompt instructions sectionpackages/ai/src/challenge-content.ts:471-486— User prompt (passeschallenge_titleas input)apps/worker-facts/src/handlers/generate-challenge-content.ts:83-96— Row mapping
Gap Analysis
| Feature | Required | Current | Gap |
|---|---|---|---|
| Per-challenge title in DB | Yes | Missing | FAIL |
| Per-challenge title in AI output schema | Yes | Missing | FAIL |
| Prompt rule for title generation | Yes | Missing | FAIL |
| Worker handler mapping | Yes | Maps other fields only | FAIL |
| DB insert query includes title | Yes | Missing field | FAIL |
| GeneratedChallengeContent type | Yes | No title field | FAIL |
| fact_records.challenge_title preserved | Yes | Exists | PASS |
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.sqlcreated - Column:
challenge_title TEXT(nullable — backfill will populate) -
fact_records.challenge_titleremains untouched - Migration follows project header format
-
bun run migrations:indexregenerates 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.ts—factChallengeContenthaschallengeTitle: text('challenge_title') - Placed logically near other content fields (after
correctAnswer) -
bun run typecheckpasses
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:
-
challengeOutputSchemainchallenge-content.tsincludeschallenge_title: z.string()per challenge -
GeneratedChallengeContentinchallenge-content-rules.tsincludeschallenge_title: string -
bun run typecheckpasses across@eko/aipackage
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_titlegeneration rules - Rules specify: theatrical, cinematic, curiosity-provoking tone (same as fact-engine)
- Rules specify: MUST NOT reveal the answer to
challenge_textor content ofcorrect_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()inqueries.tsacceptschallengeTitlein row type - Upsert's
onConflictDoUpdateset includeschallengeTitle - Worker handler in
generate-challenge-content.tsmapschallenge.challenge_titleto row -
bun run typecheckpasses
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_titleper challenge - Upload phase maps
challenge_titleto 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.tsandseed-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
| Challenge | Result |
|---|---|
| 1.1 Database Migration | PENDING |
| 1.2 Drizzle Schema Update | PENDING |
| 1.3 AI Output Schema & Type | PENDING |
| 1.4 Challenge Generation Prompt | PENDING |
| 1.5 Insert Query & Worker Handler | PENDING |
| 1.6 Seed Script Integration | PENDING |
Functional Score: 0/6 PASS
Quality Tier
| Challenge | Result |
|---|---|
| 1.7 Quality Standards | PENDING |
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:
insertChallengeContentON CONFLICT set includes new column
Implementation Notes
- The
challenge_titlepassed inExportedFactForChallenge(from fact_records) remains as input context — the Phase 2 model can reference or ignore it - Existing
fact_challenge_contentrows will have NULLchallenge_titleuntil backfilled or regenerated - Frontend (when built) should prefer
fact_challenge_content.challenge_titleoverfact_records.challenge_title, falling back to the latter if NULL