feat(advisor): load real PRD, phases, sessions, apps into COO system prompt
Made-with: Cursor
This commit is contained in:
@@ -3,47 +3,131 @@ import { getServerSession } from 'next-auth';
|
|||||||
import { authOptions } from '@/lib/auth/authOptions';
|
import { authOptions } from '@/lib/auth/authOptions';
|
||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
const MODEL = process.env.GEMINI_MODEL ?? 'gemini-2.0-flash';
|
const MODEL = process.env.GEMINI_MODEL ?? 'gemini-2.5-flash';
|
||||||
|
|
||||||
function buildSystemPrompt(projectData: Record<string, unknown>): string {
|
// ---------------------------------------------------------------------------
|
||||||
const name = (projectData.name as string) ?? 'this project';
|
// Context loaders — all best-effort, never block the response
|
||||||
const description = (projectData.description as string) ?? '';
|
// ---------------------------------------------------------------------------
|
||||||
const vision = (projectData.vision as string) ?? '';
|
|
||||||
const giteaRepo = (projectData.giteaRepo as string) ?? '';
|
|
||||||
|
|
||||||
return `You are the personal AI COO for "${name}" — a product assistant that thinks like a seasoned Chief Operating Officer.
|
interface SavedPhase {
|
||||||
|
phase: string;
|
||||||
Your role is to be the founder's single AI interface. The founder talks to you. You figure out what needs to happen and make it clear before anything is done.
|
title: string;
|
||||||
|
summary: string;
|
||||||
## About this project
|
data: Record<string, unknown>;
|
||||||
${description ? `Description: ${description}` : ''}
|
saved_at: string;
|
||||||
${vision ? `Vision: ${vision}` : ''}
|
|
||||||
${giteaRepo ? `Codebase: ${giteaRepo}` : ''}
|
|
||||||
|
|
||||||
## Your principles
|
|
||||||
- You are NOT a chatbot. You are an executive partner.
|
|
||||||
- You reason across the entire business: product, code, growth, analytics, operations.
|
|
||||||
- You surface insights the founder hasn't asked about when they're relevant.
|
|
||||||
- You speak in plain language. No jargon without explanation.
|
|
||||||
- Before any work is delegated, you surface a clear plan: "Here's what I'd do: [plain language]. Want me to start?"
|
|
||||||
- You are brief and direct. Founders are busy.
|
|
||||||
- When something needs to be built, you describe the scope before acting.
|
|
||||||
- When something needs analysis, you explain what you found and what it means.
|
|
||||||
- You never ask the founder to pick a module, agent, or technical approach — you figure that out.
|
|
||||||
|
|
||||||
## What you have access to (context expands over time)
|
|
||||||
- The project codebase and commit history
|
|
||||||
- Agent session outcomes (what's been built)
|
|
||||||
- Analytics and deployment status
|
|
||||||
- Market and competitor knowledge (via web search)
|
|
||||||
- Persistent memory of decisions, preferences, patterns
|
|
||||||
|
|
||||||
## Tone
|
|
||||||
Confident, brief, thoughtful. Like a trusted advisor who has seen this before. Not overly formal, not casual. Never sycophantic — skip "Great question!" entirely.
|
|
||||||
|
|
||||||
Start each conversation ready to help. If this is the user's first message, greet them briefly and ask what's on their mind.`.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AgentSession {
|
||||||
|
id: string;
|
||||||
|
task: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
result?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPhases(projectId: string): Promise<SavedPhase[]> {
|
||||||
|
try {
|
||||||
|
const rows = await query<SavedPhase>(
|
||||||
|
`SELECT phase, title, summary, data, saved_at
|
||||||
|
FROM atlas_phases WHERE project_id = $1
|
||||||
|
ORDER BY saved_at ASC`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecentSessions(projectId: string): Promise<AgentSession[]> {
|
||||||
|
try {
|
||||||
|
const rows = await query<AgentSession>(
|
||||||
|
`SELECT id, data->>'task' as task, data->>'status' as status,
|
||||||
|
data->>'createdAt' as created_at, data->>'result' as result
|
||||||
|
FROM fs_sessions
|
||||||
|
WHERE data->>'projectId' = $1
|
||||||
|
ORDER BY created_at DESC LIMIT 10`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
return rows.filter(r => r.task);
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// System prompt — only states what it actually has
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildSystemPrompt(
|
||||||
|
projectData: Record<string, unknown>,
|
||||||
|
phases: SavedPhase[],
|
||||||
|
sessions: AgentSession[],
|
||||||
|
): string {
|
||||||
|
const name = (projectData.name as string) ?? 'this project';
|
||||||
|
const vision = (projectData.productVision as string) ?? (projectData.vision as string) ?? '';
|
||||||
|
const repo = (projectData.giteaRepo as string) ?? '';
|
||||||
|
const prd = (projectData.prd as string) ?? '';
|
||||||
|
const apps = (projectData.apps as Array<{ name: string; domain?: string }>) ?? [];
|
||||||
|
const theiaUrl = (projectData.theiaWorkspaceUrl as string) ?? '';
|
||||||
|
|
||||||
|
// PRD / discovery phases
|
||||||
|
let prdSection = '';
|
||||||
|
if (prd) {
|
||||||
|
prdSection = `\n## Product Requirements Document (PRD)\n${prd.slice(0, 3000)}${prd.length > 3000 ? '\n[…truncated]' : ''}`;
|
||||||
|
} else if (phases.length > 0) {
|
||||||
|
const phaseLines = phases.map(p =>
|
||||||
|
`### ${p.title} (${p.phase})\n${p.summary}\n${
|
||||||
|
Object.entries(p.data)
|
||||||
|
.filter(([, v]) => v !== null && v !== '' && !Array.isArray(v))
|
||||||
|
.slice(0, 6)
|
||||||
|
.map(([k, v]) => `- ${k.replace(/_/g, ' ')}: ${v}`)
|
||||||
|
.join('\n')
|
||||||
|
}`
|
||||||
|
).join('\n\n');
|
||||||
|
prdSection = `\n## Discovery phases completed (${phases.length})\n${phaseLines}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apps / deployment
|
||||||
|
let deploySection = '';
|
||||||
|
if (apps.length > 0) {
|
||||||
|
const appLines = apps.map(a => `- ${a.name}${a.domain ? ` → https://${a.domain}` : ''}`).join('\n');
|
||||||
|
deploySection = `\n## Deployed apps\n${appLines}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent agent work
|
||||||
|
let sessionsSection = '';
|
||||||
|
if (sessions.length > 0) {
|
||||||
|
const lines = sessions
|
||||||
|
.slice(0, 6)
|
||||||
|
.map(s => `- [${s.status ?? 'unknown'}] ${s.task}`)
|
||||||
|
.join('\n');
|
||||||
|
sessionsSection = `\n## Recent agent sessions (what's been worked on)\n${lines}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `You are the AI COO for "${name}" — an executive-level product partner, not a chatbot.
|
||||||
|
|
||||||
|
Your role: the founder talks to you first. You reason across product, code, growth, and operations. You surface what matters, propose clear plans, and delegate to specialist agents when needed.
|
||||||
|
|
||||||
|
## What you know about this project
|
||||||
|
${vision ? `Vision: ${vision}` : ''}
|
||||||
|
${repo ? `Codebase: ${repo}` : ''}
|
||||||
|
${theiaUrl ? `IDE workspace: ${theiaUrl}` : ''}
|
||||||
|
${prdSection}
|
||||||
|
${deploySection}
|
||||||
|
${sessionsSection}
|
||||||
|
|
||||||
|
## Your operating principles
|
||||||
|
- You are an executive partner, not an assistant. You have opinions.
|
||||||
|
- You reason across the full business — product, tech, growth, ops — not just the question asked.
|
||||||
|
- You surface what the founder hasn't asked about when it's relevant.
|
||||||
|
- Before any work starts: state the scope in plain language, then ask "Want me to proceed?"
|
||||||
|
- Be brief. Founders are busy. No preamble, no "Great question!".
|
||||||
|
- Never ask the founder to pick a technical approach — you decide and explain your reasoning.
|
||||||
|
- When you don't have data (e.g. analytics, recent commits), say so directly rather than guessing.
|
||||||
|
- Be honest about the limits of your current context.`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ projectId: string }> }
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
@@ -56,12 +140,12 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY ?? '';
|
const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY ?? '';
|
||||||
const STREAM_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:streamGenerateContent?key=${GOOGLE_API_KEY}&alt=sse`;
|
|
||||||
|
|
||||||
if (!GOOGLE_API_KEY) {
|
if (!GOOGLE_API_KEY) {
|
||||||
return new Response('GOOGLE_API_KEY not configured', { status: 500 });
|
return new Response('GOOGLE_API_KEY not configured', { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STREAM_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:streamGenerateContent?key=${GOOGLE_API_KEY}&alt=sse`;
|
||||||
|
|
||||||
const { message, history = [] } = await req.json() as {
|
const { message, history = [] } = await req.json() as {
|
||||||
message: string;
|
message: string;
|
||||||
history: Array<{ role: 'user' | 'model'; content: string }>;
|
history: Array<{ role: 'user' | 'model'; content: string }>;
|
||||||
@@ -71,21 +155,29 @@ export async function POST(
|
|||||||
return new Response('Message required', { status: 400 });
|
return new Response('Message required', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load project data for context
|
// Load all context in parallel — never block on failure
|
||||||
let projectData: Record<string, unknown> = {};
|
let projectData: Record<string, unknown> = {};
|
||||||
|
let phases: SavedPhase[] = [];
|
||||||
|
let sessions: AgentSession[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = await query<{ data: Record<string, unknown> }>(
|
const [projectRows, phasesData, sessionsData] = await Promise.all([
|
||||||
`SELECT p.data FROM fs_projects p
|
query<{ data: Record<string, unknown> }>(
|
||||||
JOIN fs_users u ON u.id = p.user_id
|
`SELECT p.data FROM fs_projects p
|
||||||
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
[projectId, session.user.email]
|
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||||
);
|
[projectId, session.user.email]
|
||||||
if (rows.length > 0) projectData = rows[0].data ?? {};
|
),
|
||||||
} catch { /* best-effort, proceed without context */ }
|
loadPhases(projectId),
|
||||||
|
loadRecentSessions(projectId),
|
||||||
|
]);
|
||||||
|
if (projectRows.length > 0) projectData = projectRows[0].data ?? {};
|
||||||
|
phases = phasesData;
|
||||||
|
sessions = sessionsData;
|
||||||
|
} catch { /* proceed with whatever loaded */ }
|
||||||
|
|
||||||
const systemPrompt = buildSystemPrompt(projectData);
|
const systemPrompt = buildSystemPrompt(projectData, phases, sessions);
|
||||||
|
|
||||||
// Build Gemini conversation history
|
|
||||||
const contents = [
|
const contents = [
|
||||||
...history.map(h => ({
|
...history.map(h => ({
|
||||||
role: h.role,
|
role: h.role,
|
||||||
@@ -94,7 +186,6 @@ export async function POST(
|
|||||||
{ role: 'user' as const, parts: [{ text: message }] },
|
{ role: 'user' as const, parts: [{ text: message }] },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Call Gemini streaming
|
|
||||||
const geminiRes = await fetch(STREAM_URL, {
|
const geminiRes = await fetch(STREAM_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -102,34 +193,31 @@ export async function POST(
|
|||||||
system_instruction: { parts: [{ text: systemPrompt }] },
|
system_instruction: { parts: [{ text: systemPrompt }] },
|
||||||
contents,
|
contents,
|
||||||
generationConfig: {
|
generationConfig: {
|
||||||
temperature: 0.7,
|
temperature: 0.6,
|
||||||
maxOutputTokens: 1024,
|
maxOutputTokens: 1500,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!geminiRes.ok) {
|
if (!geminiRes.ok) {
|
||||||
const err = await geminiRes.text();
|
const err = await geminiRes.text();
|
||||||
return new Response(`Gemini error: ${err}`, { status: 502 });
|
return new Response(`Gemini error: ${err}`, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proxy the SSE stream, extracting just the text deltas
|
// Proxy SSE stream — extract text deltas only
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
async start(controller) {
|
async start(controller) {
|
||||||
const reader = geminiRes.body!.getReader();
|
const reader = geminiRes.body!.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buf = '';
|
let buf = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
|
|
||||||
buf += decoder.decode(value, { stream: true });
|
buf += decoder.decode(value, { stream: true });
|
||||||
const lines = buf.split('\n');
|
const lines = buf.split('\n');
|
||||||
buf = lines.pop() ?? '';
|
buf = lines.pop() ?? '';
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.startsWith('data: ')) continue;
|
if (!line.startsWith('data: ')) continue;
|
||||||
const json = line.slice(6).trim();
|
const json = line.slice(6).trim();
|
||||||
@@ -138,7 +226,7 @@ export async function POST(
|
|||||||
const chunk = JSON.parse(json);
|
const chunk = JSON.parse(json);
|
||||||
const text = chunk.candidates?.[0]?.content?.parts?.[0]?.text;
|
const text = chunk.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||||
if (text) controller.enqueue(encoder.encode(text));
|
if (text) controller.enqueue(encoder.encode(text));
|
||||||
} catch { /* skip malformed chunk */ }
|
} catch { /* skip malformed */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user