Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions CHAINING.md
Original file line number Diff line number Diff line change
@@ -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`
90 changes: 90 additions & 0 deletions apps/web/src/app/api/__tests__/chat-sparkle-pills-route.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
9 changes: 9 additions & 0 deletions apps/web/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '';
Expand Down Expand Up @@ -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,
});
Comment on lines +140 to +143
if (sparkleResponse) {
return NextResponse.json(sparkleResponse);
Comment on lines +144 to +145
}

if (isPro) {
const leadScore = await scoreLeadWithJev({ query, history });
const scoredPrompt =
Expand Down
95 changes: 95 additions & 0 deletions apps/web/src/lib/sparkle-pills.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +19 to +20
}

function parseAnswers(value: unknown): SparkleInterviewAnswers | null {
if (!value || typeof value !== 'object') return null;
const source = value as Record<string, unknown>;
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,
};
}
Loading