diff --git a/CHAINING.md b/CHAINING.md new file mode 100644 index 000000000..38e19712f --- /dev/null +++ b/CHAINING.md @@ -0,0 +1,70 @@ +# 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`/`message` (`/\bsparkle\s*pills?\b/i`) or explicit `sparkleIntent: true` for interview follow-up turns. +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). +5. If the request is pack-bound (`video_id`/`pack_id`), stay on the existing pack-grounded chat path instead of deterministic sparkle planning. + +## Request payload + +```json +{ + "query": "Build me a system for using sparkle pills", + "sparkleIntent": true, + "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..eff8df095 --- /dev/null +++ b/apps/web/src/app/api/__tests__/chat-sparkle-pills-route.test.ts @@ -0,0 +1,176 @@ +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'; +import { buildIdentityPack } from '@/lib/video-pack'; +import { QJ_TRANSCRIPT, QJ_VIDEO_ID } from '@/lib/__fixtures__/qj-z5ohr7sga-emit'; +import * as store from '@/lib/video-pack-store'; + +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(); + }); + + it('keeps pack-bound sparkle requests on grounded chat instead of deterministic planner', async () => { + const pack = buildIdentityPack(QJ_VIDEO_ID); + pack.transcript = QJ_TRANSCRIPT; + pack.id = `vp:v0:${QJ_VIDEO_ID}`; + vi.spyOn(store, 'getPackRecordWithMeta').mockResolvedValue({ + outcome: 'hit', + record: { state: 'ready', pack }, + store: { backend: 'memory', ok: true }, + }); + + 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', + video_id: QJ_VIDEO_ID, + pack_id: `vp:v0:${QJ_VIDEO_ID}`, + sparkleInterview: { + useCase: 'Weekday focus sessions', + trigger: 'Weekdays at 08:30', + tools: ['Calendar', 'Slack'], + constraints: 'No messages after 21:00', + successSignal: '5 sessions weekly', + }, + }), + }); + + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.provider).toBe('vercel-ai-gateway'); + expect(body.requiresInterview).toBeUndefined(); + expect(body.answer).toBe('gateway reply'); + expect(generateText).toHaveBeenCalledTimes(1); + }); + + it('keeps pack-bound sparkle requests fail-closed when no ready pack exists', async () => { + vi.spyOn(store, 'getPackRecordWithMeta').mockResolvedValue({ + outcome: 'miss', + store: { backend: 'memory', ok: true }, + }); + + 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', + video_id: QJ_VIDEO_ID, + pack_id: `vp:v0:${QJ_VIDEO_ID}`, + }), + }); + + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(404); + expect(body.code).toBe('pack_not_found'); + expect(generateText).not.toHaveBeenCalled(); + }); + + it('continues sparkle interview without repeating sparkle phrase by using sparkleIntent', async () => { + const request = new Request('http://localhost/api/chat', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + query: 'Use case: automate weekday focus sessions.', + sparkleIntent: true, + sparkleInterview: { + useCase: 'weekday focus sessions', + }, + }), + }); + + 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(generateText).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/chat/route.ts b/apps/web/src/app/api/chat/route.ts index 8eb126de6..4df1c2afc 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,17 @@ export async function POST(request: Request) { : []; const query = body.query || body.message || ''; + if (!packBinding) { + const sparkleResponse = resolveSparklePillsResponse({ + query: typeof query === 'string' ? query : String(query), + sparkleIntent: body.sparkleIntent, + 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/components/__tests__/StudioIdeChat.sparkle.test.tsx b/apps/web/src/components/__tests__/StudioIdeChat.sparkle.test.tsx new file mode 100644 index 000000000..adccbcbf0 --- /dev/null +++ b/apps/web/src/components/__tests__/StudioIdeChat.sparkle.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment jsdom +import React from 'react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import StudioIdeChat from '@/components/studio/StudioIdeChat'; + +describe('StudioIdeChat sparkle interview chaining', () => { + beforeEach(() => { + vi.stubGlobal('React', React); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('renders interview questions and keeps sparkle intent/interview payload on follow-up turn', async () => { + const network = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + answer: + 'Before I design the system, I need a short use-case interview so the workflow stays concrete and executable.', + 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?', + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + answer: 'Sparkle Pills System Plan', + requiresInterview: false, + provider: 'sparkle-pills-planner', + }), + }); + vi.stubGlobal('fetch', network); + + render(); + + fireEvent.change(screen.getByTestId('studio-ide-chat-input'), { + target: { value: 'Build me a system for using sparkle pills' }, + }); + fireEvent.submit(screen.getByTestId('studio-ide-chat-composer')); + + await waitFor(() => expect(network).toHaveBeenCalledTimes(1)); + await screen.findByText(/What exact chore or workflow should sparkle pills automate\?/i); + + fireEvent.change(screen.getByTestId('studio-ide-chat-input'), { + target: { + value: [ + 'useCase: Manage weekday focus sessions', + 'trigger: Start each weekday at 08:30', + 'tools: Google Calendar, Slack, Notion', + 'constraints: No notifications after 21:00', + 'successSignal: 5 sessions completed per week', + ].join('\n'), + }, + }); + fireEvent.submit(screen.getByTestId('studio-ide-chat-composer')); + + await waitFor(() => expect(network).toHaveBeenCalledTimes(2)); + const secondBody = JSON.parse((network.mock.calls[1]?.[1] as RequestInit).body as string) as { + sparkleIntent?: boolean; + sparkleInterview?: { + useCase?: string; + trigger?: string; + tools?: string[]; + constraints?: string; + successSignal?: string; + }; + }; + + expect(secondBody.sparkleIntent).toBe(true); + expect(secondBody.sparkleInterview).toEqual({ + useCase: 'Manage weekday focus sessions', + trigger: 'Start each weekday at 08:30', + tools: ['Google Calendar', 'Slack', 'Notion'], + constraints: 'No notifications after 21:00', + successSignal: '5 sessions completed per week', + }); + await screen.findByText('Sparkle Pills System Plan'); + }); + + it('does not treat the request that starts the interview as its first answer', async () => { + const interview = { + answer: 'Before I design the system, I need a short use-case interview.', + requiresInterview: true, + interviewQuestions: ['What exact chore or workflow should sparkle pills automate?'], + }; + const network = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => interview, + }); + vi.stubGlobal('fetch', network); + + render(); + fireEvent.change(screen.getByTestId('studio-ide-chat-input'), { + target: { value: 'Build me a system for using sparkle pills' }, + }); + fireEvent.submit(screen.getByTestId('studio-ide-chat-composer')); + await screen.findByText(/What exact chore or workflow should sparkle pills automate\?/i); + + fireEvent.change(screen.getByTestId('studio-ide-chat-input'), { + target: { value: 'Manage weekday focus sessions' }, + }); + fireEvent.submit(screen.getByTestId('studio-ide-chat-composer')); + await waitFor(() => expect(network).toHaveBeenCalledTimes(2)); + + const secondBody = JSON.parse((network.mock.calls[1]?.[1] as RequestInit).body as string); + expect(secondBody.sparkleIntent).toBe(true); + expect(secondBody.sparkleInterview).toEqual({ useCase: 'Manage weekday focus sessions' }); + }); +}); diff --git a/apps/web/src/components/studio/StudioIdeChat.tsx b/apps/web/src/components/studio/StudioIdeChat.tsx index dd15bdf45..b50beaf24 100644 --- a/apps/web/src/components/studio/StudioIdeChat.tsx +++ b/apps/web/src/components/studio/StudioIdeChat.tsx @@ -2,6 +2,7 @@ import { FormEvent, useCallback, useEffect, useRef, useState } from 'react'; import { ArrowUp, Sparkles } from 'lucide-react'; +import type { SparkleInterviewAnswers } from '@/lib/sparkle-pills'; type ChatMessage = { id: string; @@ -16,6 +17,143 @@ type StudioIdeChatProps = { }; const FREE_QUOTA_MESSAGE = 'Free plan includes 5 AI chat messages per day.'; +const SPARKLE_FIELDS = ['useCase', 'trigger', 'tools', 'constraints', 'successSignal'] as const; +type SparkleField = (typeof SPARKLE_FIELDS)[number]; +type SparkleInterviewDraft = Partial; +type SparkleChatResponse = { + answer?: string; + requiresInterview?: boolean; + interviewQuestions?: string[]; + provider?: string; + sparkleInterview?: unknown; + [key: string]: unknown; +}; + +function parseTools(value: string): string[] { + return value + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function normalizeInterviewKey(value: string): SparkleField | null { + const key = value.replace(/[^a-z]/gi, '').toLowerCase(); + if (key === 'usecase' || key === 'workflow' || key === 'objective') return 'useCase'; + if (key === 'trigger' || key === 'schedule') return 'trigger'; + if (key === 'tools' || key === 'integrations' || key === 'systems') return 'tools'; + if (key === 'constraints' || key === 'guardrails' || key === 'rules') return 'constraints'; + if (key === 'successsignal' || key === 'successmetric' || key === 'success') return 'successSignal'; + return null; +} + +function parseStructuredInterviewAnswer(text: string): SparkleInterviewDraft | null { + const labelPattern = + /(useCase|workflow|objective|trigger|schedule|tools|integrations|systems|constraints|guardrails|rules|successSignal|successMetric|success)\s*:/gi; + const labelMatches = Array.from(text.matchAll(labelPattern)); + if (labelMatches.length > 0) { + const fromLabels: SparkleInterviewDraft = {}; + for (let index = 0; index < labelMatches.length; index += 1) { + const current = labelMatches[index]; + const next = labelMatches[index + 1]; + if (!current || current.index == null) continue; + const rawKey = current[1]; + const key = rawKey ? normalizeInterviewKey(rawKey) : null; + if (!key) continue; + const valueStart = current.index + current[0].length; + const valueEnd = next?.index ?? text.length; + const rawValue = text.slice(valueStart, valueEnd).trim().replace(/[|,;]+$/, '').trim(); + if (!rawValue) continue; + if (key === 'tools') { + fromLabels.tools = parseTools(rawValue); + } else { + fromLabels[key] = rawValue; + } + } + if (Object.keys(fromLabels).length > 0) return fromLabels; + } + + const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const fromLabels: SparkleInterviewDraft = {}; + let matchedLabel = false; + for (const line of lines) { + const idx = line.indexOf(':'); + if (idx < 1) continue; + const key = normalizeInterviewKey(line.slice(0, idx).trim()); + if (!key) continue; + const rawValue = line.slice(idx + 1).trim(); + if (!rawValue) continue; + matchedLabel = true; + if (key === 'tools') { + fromLabels.tools = parseTools(rawValue); + } else { + fromLabels[key] = rawValue; + } + } + if (matchedLabel) return fromLabels; + + const parts = text + .split('|') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + if (parts.length !== 5) return null; + return { + useCase: parts[0], + trigger: parts[1], + tools: parseTools(parts[2]), + constraints: parts[3], + successSignal: parts[4], + }; +} + +function nextMissingInterviewField(interview: SparkleInterviewDraft): SparkleField | null { + for (const field of SPARKLE_FIELDS) { + if (field === 'tools') { + if (!Array.isArray(interview.tools) || interview.tools.length === 0) return field; + continue; + } + if (typeof interview[field] !== 'string' || interview[field]?.trim().length === 0) { + return field; + } + } + return null; +} + +function mergeInterviewDraft(base: SparkleInterviewDraft, patch: SparkleInterviewDraft): SparkleInterviewDraft { + return { + ...base, + ...patch, + ...(patch.tools ? { tools: patch.tools } : {}), + }; +} + +function withSequentialAnswer(base: SparkleInterviewDraft, answer: string): SparkleInterviewDraft { + const field = nextMissingInterviewField(base); + if (!field) return base; + const normalized = answer.trim(); + if (!normalized) return base; + if (field === 'tools') { + return { ...base, tools: parseTools(normalized) }; + } + return { ...base, [field]: normalized }; +} + +function composeInterviewAssistantMessage(answer: string, questions: string[]): string { + return [ + answer.trim(), + '', + ...questions.map((question, index) => `${index + 1}. ${question}`), + '', + 'Reply in one message with:', + 'useCase: ...', + 'trigger: ...', + 'tools: tool1, tool2', + 'constraints: ...', + 'successSignal: ...', + ].join('\n'); +} /** * Center-pane chat for the Studio IDE. Wired to the real POST /api/chat @@ -30,6 +168,8 @@ export default function StudioIdeChat({ videoId, packId, disabled }: StudioIdeCh const [sending, setSending] = useState(false); const [quotaHit, setQuotaHit] = useState(false); const [error, setError] = useState(null); + const [sparkleInterviewQuestions, setSparkleInterviewQuestions] = useState(null); + const [sparkleInterviewDraft, setSparkleInterviewDraft] = useState({}); const messagesRef = useRef(null); const idCounter = useRef(0); @@ -49,6 +189,8 @@ export default function StudioIdeChat({ videoId, packId, disabled }: StudioIdeCh setMessages([]); setQuotaHit(false); setError(null); + setSparkleInterviewQuestions(null); + setSparkleInterviewDraft({}); }, [videoId, packId]); const send = async (event?: FormEvent) => { @@ -70,13 +212,21 @@ export default function StudioIdeChat({ videoId, packId, disabled }: StudioIdeCh const body: Record = { query, history }; if (videoId) body.video_id = videoId; if (packId) body.pack_id = packId; + if (sparkleInterviewQuestions) { + const structured = parseStructuredInterviewAnswer(query); + const draftPayload = structured + ? mergeInterviewDraft(sparkleInterviewDraft, structured) + : withSequentialAnswer(sparkleInterviewDraft, query); + body.sparkleIntent = true; + body.sparkleInterview = draftPayload; + } const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); - const data = await res.json(); + const data = (await res.json()) as SparkleChatResponse; if (res.status === 402 && data.upgradeRequired) { setQuotaHit(true); @@ -92,13 +242,43 @@ export default function StudioIdeChat({ videoId, packId, disabled }: StudioIdeCh } if (!res.ok) { - throw new Error(data.answer || data.error || `Chat failed (${res.status}).`); + const failure = + typeof data.answer === 'string' + ? data.answer + : typeof data.error === 'string' + ? data.error + : `Chat failed (${res.status}).`; + throw new Error(failure); } + const interviewQuestions = Array.isArray(data.interviewQuestions) + ? data.interviewQuestions.filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + : []; + const assistantContent = + data.requiresInterview && interviewQuestions.length > 0 + ? composeInterviewAssistantMessage(data.answer || '(empty response)', interviewQuestions) + : data.answer || '(empty response)'; + setMessages((prev) => [ ...prev, - { id: `a-${idCounter.current++}`, role: 'assistant', content: data.answer || '(empty response)' }, + { id: `a-${idCounter.current++}`, role: 'assistant', content: assistantContent }, ]); + + if (data.requiresInterview) { + const structured = parseStructuredInterviewAnswer(query); + const nextDraft = structured + ? mergeInterviewDraft(sparkleInterviewDraft, structured) + : sparkleInterviewQuestions + ? withSequentialAnswer(sparkleInterviewDraft, query) + : sparkleInterviewDraft; + setSparkleInterviewQuestions(interviewQuestions.length > 0 ? interviewQuestions : []); + setSparkleInterviewDraft(nextDraft); + } else if (data.provider === 'sparkle-pills-planner') { + setSparkleInterviewQuestions(null); + setSparkleInterviewDraft({}); + } else if (!data.requiresInterview) { + setSparkleInterviewQuestions(null); + } } catch (err) { const text = err instanceof Error ? err.message : 'Chat failed.'; setError(text); diff --git a/apps/web/src/lib/__tests__/emit-app-builder-sandbox.test.ts b/apps/web/src/lib/__tests__/emit-app-builder-sandbox.test.ts index ba0472083..9e6588a30 100644 --- a/apps/web/src/lib/__tests__/emit-app-builder-sandbox.test.ts +++ b/apps/web/src/lib/__tests__/emit-app-builder-sandbox.test.ts @@ -310,6 +310,8 @@ describe('emitAppBuilderSandbox (ingest→App Builder sandbox emit)', () => { expect(main).toContain('bindOutlineNav'); expect(main).toContain("fetch('/api/chat'"); expect(main).toContain('postLiveChat'); + expect(main).toContain('sparkleIntent'); + expect(main).toContain('sparkleInterview'); expect(html).toContain('data-testid="shell-chat-send">Send'); expect(html).not.toContain('Send (preview)'); expect(html).not.toContain('Sign in required'); diff --git a/apps/web/src/lib/__tests__/sparkle-pills.test.ts b/apps/web/src/lib/__tests__/sparkle-pills.test.ts new file mode 100644 index 000000000..2ef8c701e --- /dev/null +++ b/apps/web/src/lib/__tests__/sparkle-pills.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { resolveSparklePillsResponse } from '@/lib/sparkle-pills'; + +describe('sparkle-pills', () => { + it('retains every supplied tool instead of truncating after eight entries', () => { + const response = resolveSparklePillsResponse({ + query: 'Build me a system for using sparkle pills', + sparkleInterview: { + useCase: 'focus sessions', + trigger: 'weekdays at 08:30', + tools: ['Tool-1', 'Tool-2', 'Tool-3', 'Tool-4', 'Tool-5', 'Tool-6', 'Tool-7', 'Tool-8', 'Tool-9'], + constraints: 'No notifications after 21:00', + successSignal: '5 sessions each week', + }, + }); + + expect(response).not.toBeNull(); + expect(response?.requiresInterview).toBe(false); + if (response && response.requiresInterview === false && 'interview' in response) { + expect(response.interview.tools).toHaveLength(9); + expect(response.interview.tools[8]).toBe('Tool-9'); + } + }); + + it('supports explicit sparkle intent without sparkle phrase in query', () => { + const response = resolveSparklePillsResponse({ + query: 'Use case details only', + sparkleIntent: true, + }); + + expect(response).not.toBeNull(); + expect(response?.requiresInterview).toBe(true); + }); +}); diff --git a/apps/web/src/lib/emit-app-builder-sandbox.ts b/apps/web/src/lib/emit-app-builder-sandbox.ts index d31f3b12a..2202873eb 100644 --- a/apps/web/src/lib/emit-app-builder-sandbox.ts +++ b/apps/web/src/lib/emit-app-builder-sandbox.ts @@ -1573,8 +1573,150 @@ function bindChatRail(): void { const prompts = Array.from(document.querySelectorAll('button[data-chat-prompt]')); const chatHistory: Array<{ role: 'user' | 'assistant'; content: string }> = []; + const sparkleFields = ['useCase', 'trigger', 'tools', 'constraints', 'successSignal'] as const; + type SparkleField = (typeof sparkleFields)[number]; + type SparkleInterviewDraft = Partial<{ + useCase: string; + trigger: string; + tools: string[]; + constraints: string; + successSignal: string; + }>; + type ChatResponse = { + answer?: string; + requiresInterview?: boolean; + interviewQuestions?: string[]; + provider?: string; + }; + + let sparkleInterviewQuestions: string[] | null = null; + let sparkleInterviewDraft: SparkleInterviewDraft = {}; let inFlight = false; + function parseTools(value: string): string[] { + return value + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + } + + function normalizeInterviewKey(value: string): SparkleField | null { + const key = value.replace(/[^a-z]/gi, '').toLowerCase(); + if (key === 'usecase' || key === 'workflow' || key === 'objective') return 'useCase'; + if (key === 'trigger' || key === 'schedule') return 'trigger'; + if (key === 'tools' || key === 'integrations' || key === 'systems') return 'tools'; + if (key === 'constraints' || key === 'guardrails' || key === 'rules') return 'constraints'; + if (key === 'successsignal' || key === 'successmetric' || key === 'success') return 'successSignal'; + return null; + } + + function parseStructuredInterviewAnswer(text: string): SparkleInterviewDraft | null { + const labelPattern = + /(useCase|workflow|objective|trigger|schedule|tools|integrations|systems|constraints|guardrails|rules|successSignal|successMetric|success)\s*:/gi; + const labelMatches = Array.from(text.matchAll(labelPattern)); + if (labelMatches.length > 0) { + const fromLabels: SparkleInterviewDraft = {}; + for (let index = 0; index < labelMatches.length; index += 1) { + const current = labelMatches[index]; + const next = labelMatches[index + 1]; + if (!current || current.index == null) continue; + const rawKey = current[1]; + const key = rawKey ? normalizeInterviewKey(rawKey) : null; + if (!key) continue; + const valueStart = current.index + current[0].length; + const valueEnd = next?.index ?? text.length; + const rawValue = text.slice(valueStart, valueEnd).trim().replace(/[|,;]+$/, '').trim(); + if (!rawValue) continue; + if (key === 'tools') { + fromLabels.tools = parseTools(rawValue); + } else { + fromLabels[key] = rawValue; + } + } + if (Object.keys(fromLabels).length > 0) return fromLabels; + } + + const lines = text + .split(/\\r?\\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const fromLabels: SparkleInterviewDraft = {}; + let matchedLabel = false; + for (const line of lines) { + const idx = line.indexOf(':'); + if (idx < 1) continue; + const key = normalizeInterviewKey(line.slice(0, idx).trim()); + if (!key) continue; + const rawValue = line.slice(idx + 1).trim(); + if (!rawValue) continue; + matchedLabel = true; + if (key === 'tools') { + fromLabels.tools = parseTools(rawValue); + } else { + fromLabels[key] = rawValue; + } + } + if (matchedLabel) return fromLabels; + + const parts = text + .split('|') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + if (parts.length !== 5) return null; + return { + useCase: parts[0], + trigger: parts[1], + tools: parseTools(parts[2]), + constraints: parts[3], + successSignal: parts[4], + }; + } + + function mergeInterviewDraft(base: SparkleInterviewDraft, patch: SparkleInterviewDraft): SparkleInterviewDraft { + return { + ...base, + ...patch, + ...(patch.tools ? { tools: patch.tools } : {}), + }; + } + + function nextMissingInterviewField(interview: SparkleInterviewDraft): SparkleField | null { + for (const field of sparkleFields) { + if (field === 'tools') { + if (!Array.isArray(interview.tools) || interview.tools.length === 0) return field; + continue; + } + if (typeof interview[field] !== 'string' || interview[field]?.trim().length === 0) { + return field; + } + } + return null; + } + + function withSequentialAnswer(base: SparkleInterviewDraft, answer: string): SparkleInterviewDraft { + const field = nextMissingInterviewField(base); + if (!field) return base; + const normalized = answer.trim(); + if (!normalized) return base; + if (field === 'tools') return { ...base, tools: parseTools(normalized) }; + return { ...base, [field]: normalized }; + } + + function composeInterviewAssistantMessage(answer: string, questions: string[]): string { + return [ + answer.trim(), + '', + ...questions.map((question, index) => \`\${index + 1}. \${question}\`), + '', + 'Reply in one message with:', + 'useCase: ...', + 'trigger: ...', + 'tools: tool1, tool2', + 'constraints: ...', + 'successSignal: ...', + ].join('\\n'); + } + function appendBubble(text: string, kind: 'user' | 'assistant' | 'system'): void { if (!messages) return; const bubble = document.createElement('div'); @@ -1609,6 +1751,13 @@ function bindChatRail(): void { async function postLiveChat(text: string): Promise { setComposerBusy(true); try { + let sparklePayload: SparkleInterviewDraft | null = null; + if (sparkleInterviewQuestions) { + const structured = parseStructuredInterviewAnswer(text); + sparklePayload = structured + ? mergeInterviewDraft(sparkleInterviewDraft, structured) + : withSequentialAnswer(sparkleInterviewDraft, text); + } const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1619,18 +1768,35 @@ function bindChatRail(): void { pack_id: pack.packId, video_url: pack.sourceUrl, history: chatHistory, + ...(sparklePayload + ? { sparkleIntent: true, sparkleInterview: sparklePayload } + : {}), }), signal: AbortSignal.timeout(60_000), }); - const data = (await response.json()) as { answer?: string }; + const data = (await response.json()) as ChatResponse; + const interviewQuestions = Array.isArray(data.interviewQuestions) + ? data.interviewQuestions.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) + : []; const answer = - typeof data.answer === 'string' && data.answer.trim() - ? data.answer.trim() + typeof data.answer === 'string' && data.answer.trim().length > 0 + ? ( + data.requiresInterview && interviewQuestions.length > 0 + ? composeInterviewAssistantMessage(data.answer, interviewQuestions) + : data.answer.trim() + ) : 'No assistant reply returned.'; if (!response.ok) { appendBubble(answer, 'system'); return; } + if (data.requiresInterview) { + sparkleInterviewQuestions = interviewQuestions; + sparkleInterviewDraft = sparklePayload ?? sparkleInterviewDraft; + } else if (data.provider === 'sparkle-pills-planner') { + sparkleInterviewQuestions = null; + sparkleInterviewDraft = {}; + } chatHistory.push({ role: 'user', content: text }); chatHistory.push({ role: 'assistant', content: answer }); appendBubble(answer, 'assistant'); diff --git a/apps/web/src/lib/sparkle-pills.ts b/apps/web/src/lib/sparkle-pills.ts new file mode 100644 index 000000000..1776e565e --- /dev/null +++ b/apps/web/src/lib/sparkle-pills.ts @@ -0,0 +1,98 @@ +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); +} + +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; + sparkleIntent?: unknown; + sparkleInterview?: unknown; +}): + | { + answer: string; + requiresInterview: boolean; + interviewQuestions: string[]; + } + | { + answer: string; + requiresInterview: false; + provider: 'sparkle-pills-planner'; + interview: SparkleInterviewAnswers; + } + | null { + const intentActive = + isSparklePillsRequest(input.query) + || input.sparkleIntent === true; + if (!intentActive) 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, + }; +}