diff --git a/CHAINING.md b/CHAINING.md new file mode 100644 index 000000000..c7d821dae --- /dev/null +++ b/CHAINING.md @@ -0,0 +1,37 @@ +# Video Pack planning contract + +This repository supports an interview-first planning flow in `POST /api/chat` for a build derived from a hosted Video Pack. The plan is a source-bound starting point for codebase work, not a claim that the target repository has been changed. + +## Runtime flow + +1. The request must name a canonical `video_id` and matching `pack_id`. The existing chat grounding path loads a ready pack from its hashed identity and rejects missing, processing, error, identity-only, or mismatched packs. +2. A planning request is detected from the query (including existing sparkle-pill requests) or `planIntent: true` on a follow-up turn. Without a ready pack, it returns `pack_required` or the existing pack error. +3. The first turn returns five questions about the deliverable, trigger, available tools, constraints, and acceptance signal. The Studio and hosted App Builder chat clients display the questions and collect labeled or sequential answers. +4. Once the answers are complete, the planner returns a deterministic plan. It separates user-supplied interview answers from excerpts extracted from the pack. Frontend, backend, middleware, database, LSP, MCP, tools, and environment sections show matching cited excerpts or state that the pack has no explicit evidence for that area. +5. The plan proposes repository inspection, a bounded implementation slice, and build/test verification. It never claims the codebase was changed, tests passed, or anything deployed. The planning branch does not call a model provider. + +## Request shape + +```json +{ + "query": "Plan a build from this Video Pack", + "video_id": "auJzb1D-fag", + "pack_id": "vp:v0:auJzb1D-fag", + "planIntent": true, + "planInterview": { + "useCase": "The exact deliverable to build", + "trigger": "The event or action that starts it", + "tools": ["Tools available in the target codebase"], + "constraints": "Implementation boundaries", + "successSignal": "An observable acceptance result" + } +} +``` + +The first turn omits `planIntent` and `planInterview`. A follow-up turn sends the accumulated interview answers with `planIntent: true`, so the user's intent survives even if the follow-up query does not repeat “plan.” A normal pack chat request remains on the grounded model path. + +## Implementation + +- `apps/web/src/lib/video-pack-plan.ts` holds intent detection, interview validation, evidence selection, and plan rendering. +- `apps/web/src/app/api/chat/route.ts` invokes it only after ready pack verification and quota checks. +- `apps/web/src/components/studio/StudioIdeChat.tsx` and the hosted chat in `apps/web/src/lib/emit-app-builder-sandbox.ts` handle the two-turn UI flow. diff --git a/apps/web/src/app/api/__tests__/chat-video-pack-plan-route.test.ts b/apps/web/src/app/api/__tests__/chat-video-pack-plan-route.test.ts new file mode 100644 index 000000000..8e156102b --- /dev/null +++ b/apps/web/src/app/api/__tests__/chat-video-pack-plan-route.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { generateText } = vi.hoisted(() => ({ generateText: vi.fn() })); +const { grokChatCompletion } = vi.hoisted(() => ({ grokChatCompletion: vi.fn() })); +vi.mock('ai', () => ({ generateText })); +vi.mock('@/lib/ai-gateway', () => ({ + aiGateway: vi.fn((model: string) => model), + 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'; + +const packId = `vp:v0:${QJ_VIDEO_ID}`; +const interview = { + useCase: 'A tire-change safety checklist from the video', + trigger: 'User detects a flat tire', + tools: ['Target repository', 'Test runner'], + constraints: 'Do not invent safety instructions', + successSignal: 'The checklist follows source-backed steps', +}; + +function request(body: Record): Request { + return new Request('http://localhost/api/chat', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('POST /api/chat Video Pack plan', () => { + beforeEach(() => { + vi.restoreAllMocks(); + process.env.AI_GATEWAY_API_KEY = 'vck_test'; + resetEntitlementStoreForTests(); + resetChatQuotaForTests(); + generateText.mockResolvedValue({ text: 'gateway reply' }); + }); + + afterEach(() => { + delete process.env.AI_GATEWAY_API_KEY; + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + function readyPack() { + const pack = buildIdentityPack(QJ_VIDEO_ID); + pack.transcript = QJ_TRANSCRIPT; + pack.requirements = [{ id: 'REQ-01', title: 'Park on level ground', detail: 'Set the parking brake' }]; + vi.spyOn(store, 'getPackRecordWithMeta').mockResolvedValue({ + outcome: 'hit', + record: { state: 'ready', pack }, + store: { backend: 'memory', ok: true }, + }); + } + + it('asks for a use case on the ready, matching hosted pack before planning', async () => { + readyPack(); + const response = await POST(request({ + query: 'Plan a build from this Video Pack', + video_id: QJ_VIDEO_ID, + pack_id: packId, + })); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.requiresInterview).toBe(true); + expect(body.interviewQuestions).toHaveLength(5); + expect(body.interviewQuestions[0]).toContain('deliverable'); + expect(generateText).not.toHaveBeenCalled(); + expect(grokChatCompletion).not.toHaveBeenCalled(); + }); + + it('uses interview answers and pack excerpts in the second turn', async () => { + readyPack(); + const response = await POST(request({ + query: 'Here are my answers', + planIntent: true, + planInterview: interview, + video_id: QJ_VIDEO_ID, + pack_id: packId, + })); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.requiresInterview).toBe(false); + expect(body.provider).toBe('video-pack-planner'); + expect(body.answer).toContain(packId); + expect(body.answer).toContain('Park on level ground'); + expect(body.answer).toContain('Frontend: No explicit evidence'); + expect(body.answer).toContain(interview.successSignal); + expect(body.answer).toContain('No codebase change, test result, or live deployment is claimed'); + expect(generateText).not.toHaveBeenCalled(); + }); + + it('requires a Video Pack rather than returning an ungrounded plan', async () => { + const response = await POST(request({ + query: 'Build me a system for using sparkle pills', + planInterview: interview, + })); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.code).toBe('pack_required'); + expect(generateText).not.toHaveBeenCalled(); + }); + + it('fails closed when the pack is missing', async () => { + vi.spyOn(store, 'getPackRecordWithMeta').mockResolvedValue({ + outcome: 'miss', + store: { backend: 'memory', ok: true }, + }); + const response = await POST(request({ + query: 'Plan a build from this Video Pack', + video_id: QJ_VIDEO_ID, + pack_id: packId, + })); + const body = await response.json(); + + expect(response.status).toBe(404); + expect(body.code).toBe('pack_not_found'); + 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..63e742a28 100644 --- a/apps/web/src/app/api/chat/route.ts +++ b/apps/web/src/app/api/chat/route.ts @@ -14,6 +14,8 @@ import { parseChatPackBinding, resolveChatPackGrounding, } from '@/lib/chat-pack-grounding'; +import { isVideoPackPlanRequest, resolveVideoPackPlanResponse } from '@/lib/video-pack-plan'; +import type { VideoPackV0Json } from '@/lib/video-pack'; type ChatHistoryMessage = { role: 'user' | 'assistant'; content: string }; const rawBackendUrl = process.env.BACKEND_URL || ''; @@ -83,6 +85,7 @@ export async function POST(request: Request) { routing = resolvePaidTierRouting(isPro); let packSystemPrompt: string | undefined; + let groundedPack: VideoPackV0Json | undefined; const packBinding = parseChatPackBinding(body); if ( (typeof body.video_id === 'string' && body.video_id.trim()) @@ -106,6 +109,7 @@ export async function POST(request: Request) { ); } packSystemPrompt = grounded.systemPrompt; + groundedPack = grounded.pack; } if (!isPro) { @@ -136,6 +140,23 @@ export async function POST(request: Request) { : []; const query = body.query || body.message || ''; + const planQuery = typeof query === 'string' ? query : String(query); + if (isVideoPackPlanRequest(planQuery) || body.planIntent === true) { + if (!groundedPack) { + return NextResponse.json( + { answer: 'A ready, identity-matched Video Pack is required before planning from a video.', code: 'pack_required' }, + { status: 400 }, + ); + } + const planResponse = resolveVideoPackPlanResponse({ + query: planQuery, + planIntent: body.planIntent, + planInterview: body.planInterview, + pack: groundedPack, + }); + if (planResponse) return NextResponse.json(planResponse); + } + if (isPro) { const leadScore = await scoreLeadWithJev({ query, history }); const scoredPrompt = diff --git a/apps/web/src/components/__tests__/StudioIdeChat.plan.test.tsx b/apps/web/src/components/__tests__/StudioIdeChat.plan.test.tsx new file mode 100644 index 000000000..d0d310bb8 --- /dev/null +++ b/apps/web/src/components/__tests__/StudioIdeChat.plan.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 plan interview chaining', () => { + beforeEach(() => { + vi.stubGlobal('React', React); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('renders interview questions and keeps plan 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 deliverable should the agent build from this video?', + 'What event, schedule, or manual action should start the workflow?', + 'Which tools or systems are available in the target codebase?', + 'What constraints must the implementation respect?', + 'What observable test or result will prove the deliverable works?', + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + answer: 'Video Pack Build Plan', + requiresInterview: false, + provider: 'video-pack-planner', + }), + }); + vi.stubGlobal('fetch', network); + + render(); + + fireEvent.click(screen.getByTestId('studio-ide-chat-plan-prompt')); + expect(screen.getByTestId('studio-ide-chat-input').tagName).toBe('TEXTAREA'); + expect(screen.getByTestId('studio-ide-chat-input')).toHaveProperty('value', 'Plan a build from this Video Pack'); + fireEvent.submit(screen.getByTestId('studio-ide-chat-composer')); + + await waitFor(() => expect(network).toHaveBeenCalledTimes(1)); + await screen.findByText(/What exact deliverable should the agent build from this video\?/i); + + fireEvent.change(screen.getByTestId('studio-ide-chat-input'), { + target: { + value: [ + 'useCase: Build a safety checklist from the video', + 'trigger: User selects the source video', + 'tools: Git, npm, Playwright', + 'constraints: No invented video claims', + 'successSignal: Checklist steps match the cited source', + ].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 { + planIntent?: boolean; + planInterview?: { + useCase?: string; + trigger?: string; + tools?: string[]; + constraints?: string; + successSignal?: string; + }; + }; + + expect(secondBody.planIntent).toBe(true); + expect(secondBody.planInterview).toEqual({ + useCase: 'Build a safety checklist from the video', + trigger: 'User selects the source video', + tools: ['Git', 'npm', 'Playwright'], + constraints: 'No invented video claims', + successSignal: 'Checklist steps match the cited source', + }); + await screen.findByText('Video Pack Build 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 deliverable should the agent build from this video?'], + }; + 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: 'Plan a build from this Video Pack' }, + }); + fireEvent.submit(screen.getByTestId('studio-ide-chat-composer')); + await screen.findByText(/What exact deliverable should the agent build from this video\?/i); + + fireEvent.change(screen.getByTestId('studio-ide-chat-input'), { + target: { value: 'Build a safety checklist from the video' }, + }); + 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.planIntent).toBe(true); + expect(secondBody.planInterview).toEqual({ useCase: 'Build a safety checklist from the video' }); + }); +}); diff --git a/apps/web/src/components/studio/StudioIdeChat.tsx b/apps/web/src/components/studio/StudioIdeChat.tsx index dd15bdf45..b4e2bfd77 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 { VideoPlanInterviewAnswers } from '@/lib/video-pack-plan'; type ChatMessage = { id: string; @@ -16,6 +17,143 @@ type StudioIdeChatProps = { }; const FREE_QUOTA_MESSAGE = 'Free plan includes 5 AI chat messages per day.'; +const PLAN_FIELDS = ['useCase', 'trigger', 'tools', 'constraints', 'successSignal'] as const; +type PlanField = (typeof PLAN_FIELDS)[number]; +type PlanInterviewDraft = Partial; +type PlanChatResponse = { + answer?: string; + requiresInterview?: boolean; + interviewQuestions?: string[]; + provider?: string; + planInterview?: 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): PlanField | 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): PlanInterviewDraft | 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: PlanInterviewDraft = {}; + 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: PlanInterviewDraft = {}; + 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: PlanInterviewDraft): PlanField | null { + for (const field of PLAN_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: PlanInterviewDraft, patch: PlanInterviewDraft): PlanInterviewDraft { + return { + ...base, + ...patch, + ...(patch.tools ? { tools: patch.tools } : {}), + }; +} + +function withSequentialAnswer(base: PlanInterviewDraft, answer: string): PlanInterviewDraft { + 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 [planInterviewQuestions, setPlanInterviewQuestions] = useState(null); + const [planInterviewDraft, setPlanInterviewDraft] = 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); + setPlanInterviewQuestions(null); + setPlanInterviewDraft({}); }, [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 (planInterviewQuestions) { + const structured = parseStructuredInterviewAnswer(query); + const draftPayload = structured + ? mergeInterviewDraft(planInterviewDraft, structured) + : withSequentialAnswer(planInterviewDraft, query); + body.planIntent = true; + body.planInterview = 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 PlanChatResponse; 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(planInterviewDraft, structured) + : planInterviewQuestions + ? withSequentialAnswer(planInterviewDraft, query) + : planInterviewDraft; + setPlanInterviewQuestions(interviewQuestions.length > 0 ? interviewQuestions : []); + setPlanInterviewDraft(nextDraft); + } else if (data.provider === 'video-pack-planner') { + setPlanInterviewQuestions(null); + setPlanInterviewDraft({}); + } else if (!data.requiresInterview) { + setPlanInterviewQuestions(null); + } } catch (err) { const text = err instanceof Error ? err.message : 'Chat failed.'; setError(text); @@ -128,13 +308,23 @@ export default function StudioIdeChat({ videoId, packId, disabled }: StudioIdeCh

- {canChat ? 'Ask about this pack' : 'Run a video to start chatting'} + {canChat ? 'Ask about this pack or plan a build' : 'Run a video to start chatting'}

{canChat ? 'Grounded in the pack — events, lingo, tools, intent. No invented answers.' : 'Paste a YouTube URL above and hit Run.'}

+ {canChat ? ( + + ) : null}
) : (
@@ -171,15 +361,15 @@ export default function StudioIdeChat({ videoId, packId, disabled }: StudioIdeCh className="template-chat-composer p-4" >
- setDraft(e.target.value)} - placeholder={canChat ? 'Ask about events, tools, intent…' : 'Run a video first'} + placeholder={canChat ? 'Ask about this pack or plan a build…' : 'Run a video first'} disabled={!canChat || sending} aria-label="Chat message" data-testid="studio-ide-chat-input" - className="uvai-input min-w-0 flex-1 disabled:opacity-40" + rows={3} + className="uvai-input min-w-0 flex-1 resize-y disabled:opacity-40" /> + `; + vi.stubGlobal('scrollTo', vi.fn()); + Element.prototype.scrollIntoView = vi.fn(); + const network = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + answer: 'Before planning, answer these questions.', + requiresInterview: true, + interviewQuestions: [ + 'What exact deliverable should the agent build from this video?', + 'What event starts it?', + 'Which tools are available?', + 'What constraints apply?', + 'What proves it works?', + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ answer: 'Video Pack Build Plan', requiresInterview: false, provider: 'video-pack-planner' }), + }); + runInNewContext(`${compiled}\nbindChatRail();`, { + document, + fetch: network, + pack: { videoId: QJ_VIDEO_ID, packId: QJ_PACK_ID, sourceUrl: QJ_SOURCE_URL }, + storagePrefix: 'test-pack', + localStorage, + AbortSignal, + console, + }); + + const form = document.querySelector('[data-testid="shell-chat-composer"]')!; + const input = document.querySelector('[data-testid="shell-chat-input"]')!; + const messages = document.querySelector('[data-testid="shell-chat-messages"]')!; + input.value = 'Plan a build from this Video Pack'; + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await waitFor(() => expect(messages.textContent).toContain('What exact deliverable should the agent build')); + + input.value = [ + 'useCase: Build a safety checklist from the video', + 'trigger: User selects the source video', + 'tools: Git, npm, Playwright', + 'constraints: No invented video claims', + 'successSignal: Checklist steps match the cited source', + ].join('\n'); + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await waitFor(() => expect(messages.textContent).toContain('Video Pack Build Plan')); + + expect(network).toHaveBeenCalledTimes(2); + const second = JSON.parse((network.mock.calls[1]?.[1] as RequestInit).body as string); + expect(second).toMatchObject({ + video_id: QJ_VIDEO_ID, + pack_id: QJ_PACK_ID, + planIntent: true, + planInterview: { + useCase: 'Build a safety checklist from the video', + trigger: 'User selects the source video', + tools: ['Git', 'npm', 'Playwright'], + constraints: 'No invented video claims', + successSignal: 'Checklist steps match the cited source', + }, + }); + }); +}); 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..90c4a4832 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('planIntent'); + expect(main).toContain('planInterview'); 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__/video-pack-plan.test.ts b/apps/web/src/lib/__tests__/video-pack-plan.test.ts new file mode 100644 index 000000000..232990773 --- /dev/null +++ b/apps/web/src/lib/__tests__/video-pack-plan.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { buildIdentityPack } from '@/lib/video-pack'; +import { resolveVideoPackPlanResponse } from '@/lib/video-pack-plan'; + +describe('Video Pack planner', () => { + const pack = buildIdentityPack('auJzb1D-fag'); + pack.transcript.full_text = 'A video about a workflow.'; + + it('keeps every user-supplied tool and separates them from pack evidence', () => { + const tools = Array.from({ length: 9 }, (_, index) => `Tool-${index + 1}`); + const response = resolveVideoPackPlanResponse({ + query: 'Plan a build from this Video Pack', + pack, + planInterview: { + useCase: 'Focus workflow', + trigger: 'Weekdays', + tools, + constraints: 'No notifications at night', + successSignal: 'Five sessions each week', + }, + }); + + expect(response?.requiresInterview).toBe(false); + if (response?.requiresInterview === false) { + expect(response.interview.tools).toEqual(tools); + expect(response.answer).toContain('Tool-9'); + expect(response.answer).toContain('LSP: No explicit evidence'); + expect(response.answer).toContain('MCP: No explicit evidence'); + expect(response.answer).toContain('Pack-extracted cues'); + } + }); + + it('continues interview without repeating the planning phrase', () => { + const response = resolveVideoPackPlanResponse({ + query: 'useCase: Focus workflow', + planIntent: true, + planInterview: { useCase: 'Focus workflow' }, + pack, + }); + expect(response?.requiresInterview).toBe(true); + }); + + it('uses full transcript when there are no segments and retains all extracted tools', () => { + const sourcePack = buildIdentityPack('auJzb1D-fag'); + sourcePack.transcript.full_text = 'The video calls for a React frontend and a test runner.'; + sourcePack.stack.tools = Array.from({ length: 9 }, (_, index) => ({ name: `PackTool-${index + 1}` })); + const response = resolveVideoPackPlanResponse({ + query: 'Plan a build from this Video Pack', + pack: sourcePack, + planInterview: { + useCase: 'A source-bound app', + trigger: 'Manual action', + tools: ['Git'], + constraints: 'Cite the source', + successSignal: 'Passing test', + }, + }); + expect(response?.requiresInterview).toBe(false); + if (response?.requiresInterview === false) { + expect(response.answer).toContain('[transcript] The video calls for a React frontend'); + expect(response.answer).toContain('Frontend: [transcript]'); + expect(response.answer).toContain('[pack tool PackTool-9] PackTool-9'); + } + }); +}); diff --git a/apps/web/src/lib/emit-app-builder-sandbox.ts b/apps/web/src/lib/emit-app-builder-sandbox.ts index d31f3b12a..7f3f5077b 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 planFields = ['useCase', 'trigger', 'tools', 'constraints', 'successSignal'] as const; + type PlanField = (typeof planFields)[number]; + type PlanInterviewDraft = Partial<{ + useCase: string; + trigger: string; + tools: string[]; + constraints: string; + successSignal: string; + }>; + type ChatResponse = { + answer?: string; + requiresInterview?: boolean; + interviewQuestions?: string[]; + provider?: string; + }; + + let planInterviewQuestions: string[] | null = null; + let planInterviewDraft: PlanInterviewDraft = {}; let inFlight = false; + function parseTools(value: string): string[] { + return value + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + } + + function normalizeInterviewKey(value: string): PlanField | 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): PlanInterviewDraft | 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: PlanInterviewDraft = {}; + 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: PlanInterviewDraft = {}; + 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: PlanInterviewDraft, patch: PlanInterviewDraft): PlanInterviewDraft { + return { + ...base, + ...patch, + ...(patch.tools ? { tools: patch.tools } : {}), + }; + } + + function nextMissingInterviewField(interview: PlanInterviewDraft): PlanField | null { + for (const field of planFields) { + 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: PlanInterviewDraft, answer: string): PlanInterviewDraft { + 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 planPayload: PlanInterviewDraft | null = null; + if (planInterviewQuestions) { + const structured = parseStructuredInterviewAnswer(text); + planPayload = structured + ? mergeInterviewDraft(planInterviewDraft, structured) + : withSequentialAnswer(planInterviewDraft, 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, + ...(planPayload + ? { planIntent: true, planInterview: planPayload } + : {}), }), 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) { + planInterviewQuestions = interviewQuestions; + planInterviewDraft = planPayload ?? planInterviewDraft; + } else if (data.provider === 'video-pack-planner') { + planInterviewQuestions = null; + planInterviewDraft = {}; + } chatHistory.push({ role: 'user', content: text }); chatHistory.push({ role: 'assistant', content: answer }); appendBubble(answer, 'assistant'); diff --git a/apps/web/src/lib/video-pack-plan.ts b/apps/web/src/lib/video-pack-plan.ts new file mode 100644 index 000000000..dfb99b022 --- /dev/null +++ b/apps/web/src/lib/video-pack-plan.ts @@ -0,0 +1,165 @@ +import type { VideoPackV0Json } from '@/lib/video-pack'; + +export type VideoPlanInterviewAnswers = { + useCase: string; + trigger: string; + tools: string[]; + constraints: string; + successSignal: string; +}; + +type Evidence = { source: string; text: string }; + +const PLAN_RE = /\bsparkle\s*pills?\b|\b(?:build|plan|implement|design|create)\b.{0,80}\b(?:video|pack)\b|\b(?:video|pack)\b.{0,80}\b(?:build|plan|implement|architecture)\b/i; + +const ARCHITECTURE_AREAS = [ + ['Frontend', /\b(?:front[ -]?end|ui|interface|react|browser)\b/i], + ['Backend', /\b(?:back[ -]?end|server|api|endpoint)\b/i], + ['Middleware', /\b(?:middleware|auth(?:entication|orization)?|rate limit)\b/i], + ['Database', /\b(?:database|postgres|sql|redis|storage)\b/i], + ['LSP', /\b(?:lsp|language server)\b/i], + ['MCP', /\b(?:mcp|model context protocol)\b/i], + ['Tools', /\b(?:tool|cli|sdk|integration)\b/i], + ['Environment', /\b(?:environment|env var|secret|runtime|deployment)\b/i], +] as const; + +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): VideoPlanInterviewAnswers | 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 isVideoPackPlanRequest(query: string): boolean { + return PLAN_RE.test(query); +} + +export function videoPlanInterviewQuestions(): string[] { + return [ + 'What exact deliverable should the agent build from this video?', + 'What event, schedule, or manual action should start the workflow?', + 'Which tools or systems are available in the target codebase?', + 'What constraints must the implementation respect?', + 'What observable test or result will prove the deliverable works?', + ]; +} + +function excerpt(value: string, pattern?: RegExp): string { + const normalized = value.replace(/\s+/g, ' ').trim(); + if (normalized.length <= 240) return normalized; + const matchAt = pattern?.exec(normalized)?.index ?? 0; + const start = Math.max(0, matchAt - 80); + const slice = normalized.slice(start, start + 240); + return `${start > 0 ? '…' : ''}${slice}${start + 240 < normalized.length ? '…' : ''}`; +} + +function packEvidence(pack: VideoPackV0Json): Evidence[] { + return [ + ...pack.requirements.map((item) => ({ + source: `requirement ${item.id}`, + text: [item.title, item.detail].filter(Boolean).join(' — '), + })), + ...pack.action_items.map((item) => ({ + source: `action ${item.id}`, + text: [item.title, item.description].filter(Boolean).join(' — '), + })), + ...(pack.transcript.segments.length > 0 + ? pack.transcript.segments.map((item) => ({ + source: `transcript ${item.start_s}s`, + text: item.text, + })) + : [{ source: 'transcript', text: pack.transcript.full_text }]), + ...(pack.visual_context?.visual_elements ?? []).map((item) => ({ + source: `visual ${item.timestamp}s`, + text: item.content, + })), + ...pack.stack.tools.map((item) => ({ + source: `pack tool ${item.name}`, + text: [item.name, item.evidence].filter(Boolean).join(' — '), + })), + ].filter((item) => item.text.trim().length > 0); +} + +function buildPlan(pack: VideoPackV0Json, interview: VideoPlanInterviewAnswers): string { + const evidence = packEvidence(pack); + const architecture = ARCHITECTURE_AREAS.map(([area, pattern]) => { + const matches = evidence.filter((item) => + (area === 'Tools' && item.source.startsWith('pack tool ')) || pattern.test(item.text), + ); + const displayed = area === 'Tools' ? matches : matches.slice(0, 3); + return matches.length > 0 + ? `- ${area}: ${displayed.map((item) => `[${item.source}] ${excerpt(item.text, pattern)}`).join(' | ')}${matches.length > displayed.length ? ` | ${matches.length - displayed.length} more cited cues in the pack` : ''}` + : `- ${area}: No explicit evidence in this pack; inspect the target repository before selecting an implementation.`; + }); + const sourceExamples = evidence.slice(0, 8).map((item) => + `- [${item.source}] ${excerpt(item.text)}`, + ); + + return [ + 'Video Pack Build Plan', + `Source: ${pack.id} (${pack.source_url}); source hash: ${pack.provenance.source_hash}`, + 'Pack-extracted cues below are planning inputs, not verified implementation or deployment receipts.', + '', + 'Interview (user supplied)', + `- Deliverable: ${interview.useCase}`, + `- Trigger: ${interview.trigger}`, + `- Available tools: ${interview.tools.join(', ')}`, + `- Constraints: ${interview.constraints}`, + `- Acceptance signal: ${interview.successSignal}`, + '', + 'Video Pack evidence to verify', + ...(sourceExamples.length ? sourceExamples : ['- No source excerpts are available in this pack.']), + '', + 'Architecture evidence by area', + ...architecture, + '', + 'Build sequence', + '1. Verify the cited pack excerpts against the source video and inspect the target repository, build scripts, tests, and environment.', + '2. Map only supported video requirements to frontend, backend, middleware, database, LSP, MCP, tools, and environment decisions; record unknowns before coding.', + '3. Implement one end-to-end slice in the target repository, then run its build and relevant tests.', + `4. Check the result against the user acceptance signal: ${interview.successSignal}`, + 'No codebase change, test result, or live deployment is claimed by this plan.', + ].join('\n'); +} + +export function resolveVideoPackPlanResponse(input: { + query: string; + planIntent?: unknown; + planInterview?: unknown; + pack: VideoPackV0Json; +}): + | { answer: string; requiresInterview: true; interviewQuestions: string[] } + | { answer: string; requiresInterview: false; provider: 'video-pack-planner'; interview: VideoPlanInterviewAnswers } + | null { + if (!isVideoPackPlanRequest(input.query) && input.planIntent !== true) return null; + const interview = parseAnswers(input.planInterview); + if (!interview) { + return { + answer: 'Before I plan from this Video Pack, I need a short use-case interview so the build targets your actual codebase and acceptance test.', + requiresInterview: true, + interviewQuestions: videoPlanInterviewQuestions(), + }; + } + return { + answer: buildPlan(input.pack, interview), + requiresInterview: false, + provider: 'video-pack-planner', + interview, + }; +}