diff --git a/CHAINING.md b/CHAINING.md new file mode 100644 index 000000000..a1ac6602f --- /dev/null +++ b/CHAINING.md @@ -0,0 +1,68 @@ +# Sparkle Pills Chaining Contract + +This repository implements an interview-first chaining flow for sparkle-pill use cases in `POST /api/chat`. + +## Goal + +For sparkle-pill requests, avoid jumping straight to a plan. First collect concrete use-case constraints, then emit an actionable system plan. + +## Runtime behavior + +1. Detect sparkle-pill intent in `query` or `message` (`/\bsparkle\s*pills?\b/i`). +2. If required interview answers are missing, return interview questions and stop. +3. If interview answers are present and complete, return a deterministic system plan. +4. Do not call model providers for this branch (no Gateway or Grok dependency). + +## Request payload + +```json +{ + "query": "Build me a system for using sparkle pills", + "sparkleInterview": { + "useCase": "I need to manage daily focus sessions across work and school.", + "trigger": "Start each weekday at 08:30", + "tools": ["Google Calendar", "Slack"], + "constraints": "No notifications after 21:00", + "successSignal": "5 sessions completed per week" + } +} +``` + +## Interview-first response shape + +```json +{ + "answer": "Before I design the system...", + "requiresInterview": true, + "interviewQuestions": [ + "What exact chore or workflow should sparkle pills automate?", + "What should trigger each run (time, event, or manual action)?", + "Which tools and systems must it update (for example Calendar, Slack, email)?", + "What rules or constraints must never be violated?", + "How will you measure success each week?" + ] +} +``` + +## Plan response shape + +```json +{ + "answer": "Sparkle Pills System Plan\n...", + "requiresInterview": false, + "provider": "sparkle-pills-planner", + "interview": { + "useCase": "...", + "trigger": "...", + "tools": ["..."], + "constraints": "...", + "successSignal": "..." + } +} +``` + +## Files + +- `apps/web/src/lib/sparkle-pills.ts` +- `apps/web/src/app/api/chat/route.ts` +- `apps/web/src/app/api/__tests__/chat-sparkle-pills-route.test.ts` diff --git a/apps/web/src/app/api/__tests__/chat-sparkle-pills-route.test.ts b/apps/web/src/app/api/__tests__/chat-sparkle-pills-route.test.ts new file mode 100644 index 000000000..eb2eb8160 --- /dev/null +++ b/apps/web/src/app/api/__tests__/chat-sparkle-pills-route.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { generateText, aiGateway } = vi.hoisted(() => ({ + generateText: vi.fn(), + aiGateway: vi.fn((model: string) => model), +})); + +const { grokChatCompletion } = vi.hoisted(() => ({ + grokChatCompletion: vi.fn(), +})); + +vi.mock('ai', () => ({ + generateText, +})); + +vi.mock('@/lib/ai-gateway', () => ({ + aiGateway, + GATEWAY_CHAT_MODEL: 'openai/gpt-4o', +})); + +vi.mock('@/lib/billing/grok-client', () => ({ + grokChatCompletion, +})); + +import { POST } from '@/app/api/chat/route'; +import { resetEntitlementStoreForTests } from '@/lib/billing/entitlement-store'; +import { resetChatQuotaForTests } from '@/lib/billing/chat-quota'; + +describe('POST /api/chat sparkle pills interview-first flow', () => { + beforeEach(() => { + process.env.AI_GATEWAY_API_KEY = 'vck_test'; + resetEntitlementStoreForTests(); + resetChatQuotaForTests(); + generateText.mockResolvedValue({ text: 'gateway reply' }); + }); + + afterEach(() => { + delete process.env.AI_GATEWAY_API_KEY; + vi.clearAllMocks(); + }); + + it('asks clarifying interview questions before producing a sparkle-pill system plan', async () => { + const request = new Request('http://localhost/api/chat', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + query: 'Build me a system for using sparkle pills', + }), + }); + + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.requiresInterview).toBe(true); + expect(body.interviewQuestions).toEqual(expect.arrayContaining([expect.any(String)])); + expect(body.answer).toContain('Before I design the system'); + expect(generateText).not.toHaveBeenCalled(); + expect(grokChatCompletion).not.toHaveBeenCalled(); + }); + + it('returns a concrete sparkle-pill workflow after interview answers are provided', async () => { + const request = new Request('http://localhost/api/chat', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + query: 'Build me a system for using sparkle pills', + sparkleInterview: { + useCase: 'I need to manage daily focus sessions across work and school.', + trigger: 'Start each weekday at 08:30', + tools: ['Google Calendar', 'Slack'], + constraints: 'No notifications after 21:00', + successSignal: '5 sessions completed per week', + }, + }), + }); + + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.requiresInterview).toBe(false); + expect(body.provider).toBe('sparkle-pills-planner'); + expect(body.answer).toContain('Sparkle Pills System Plan'); + expect(body.answer).toContain('Start each weekday at 08:30'); + expect(body.answer).toContain('5 sessions completed per week'); + expect(generateText).not.toHaveBeenCalled(); + expect(grokChatCompletion).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/chat/route.ts b/apps/web/src/app/api/chat/route.ts index 8eb126de6..d25e58b49 100644 --- a/apps/web/src/app/api/chat/route.ts +++ b/apps/web/src/app/api/chat/route.ts @@ -14,6 +14,7 @@ import { parseChatPackBinding, resolveChatPackGrounding, } from '@/lib/chat-pack-grounding'; +import { resolveSparklePillsResponse } from '@/lib/sparkle-pills'; type ChatHistoryMessage = { role: 'user' | 'assistant'; content: string }; const rawBackendUrl = process.env.BACKEND_URL || ''; @@ -136,6 +137,14 @@ export async function POST(request: Request) { : []; const query = body.query || body.message || ''; + const sparkleResponse = resolveSparklePillsResponse({ + query: typeof query === 'string' ? query : String(query), + sparkleInterview: body.sparkleInterview, + }); + if (sparkleResponse) { + return NextResponse.json(sparkleResponse); + } + if (isPro) { const leadScore = await scoreLeadWithJev({ query, history }); const scoredPrompt = diff --git a/apps/web/src/lib/sparkle-pills.ts b/apps/web/src/lib/sparkle-pills.ts new file mode 100644 index 000000000..bde98ec00 --- /dev/null +++ b/apps/web/src/lib/sparkle-pills.ts @@ -0,0 +1,95 @@ +export type SparkleInterviewAnswers = { + useCase: string; + trigger: string; + tools: string[]; + constraints: string; + successSignal: string; +}; + +const SPARKLE_RE = /\bsparkle\s*pills?\b/i; + +function compact(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function compactList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((entry) => (typeof entry === 'string' ? entry.trim() : '')) + .filter((entry) => entry.length > 0) + .slice(0, 8); +} + +function parseAnswers(value: unknown): SparkleInterviewAnswers | null { + if (!value || typeof value !== 'object') return null; + const source = value as Record; + const useCase = compact(source.useCase); + const trigger = compact(source.trigger); + const tools = compactList(source.tools); + const constraints = compact(source.constraints); + const successSignal = compact(source.successSignal); + if (!useCase || !trigger || tools.length === 0 || !constraints || !successSignal) { + return null; + } + return { useCase, trigger, tools, constraints, successSignal }; +} + +export function isSparklePillsRequest(query: string): boolean { + return SPARKLE_RE.test(query); +} + +export function sparklePillsInterviewQuestions(): string[] { + return [ + 'What exact chore or workflow should sparkle pills automate?', + 'What should trigger each run (time, event, or manual action)?', + 'Which tools and systems must it update (for example Calendar, Slack, email)?', + 'What rules or constraints must never be violated?', + 'How will you measure success each week?', + ]; +} + +export function resolveSparklePillsResponse(input: { + query: string; + sparkleInterview?: unknown; +}): + | { + answer: string; + requiresInterview: boolean; + interviewQuestions: string[]; + } + | { + answer: string; + requiresInterview: false; + provider: 'sparkle-pills-planner'; + interview: SparkleInterviewAnswers; + } + | null { + if (!isSparklePillsRequest(input.query)) return null; + + const interview = parseAnswers(input.sparkleInterview); + if (!interview) { + return { + answer: + 'Before I design the system, I need a short use-case interview so the workflow stays concrete and executable.', + requiresInterview: true, + interviewQuestions: sparklePillsInterviewQuestions(), + }; + } + + return { + answer: [ + 'Sparkle Pills System Plan', + '', + `1. Objective: ${interview.useCase}`, + `2. Trigger: ${interview.trigger}`, + `3. Integrations: ${interview.tools.join(', ')}`, + `4. Guardrails: ${interview.constraints}`, + `5. Success metric: ${interview.successSignal}`, + '6. Execution loop: intake -> validate context -> run actions -> confirm outputs -> log weekly feedback.', + '7. First rollout: ship one low-risk automation path, then expand after two stable weekly cycles.', + ].join('\n'), + requiresInterview: false, + provider: 'sparkle-pills-planner', + interview, + }; +}