feat(advisor): load real PRD, phases, sessions, apps into COO system prompt

Made-with: Cursor
This commit is contained in:
2026-03-09 22:14:35 -07:00
parent 1af5595e35
commit ff0e1592fa

View File

@@ -3,47 +3,131 @@ import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
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';
const description = (projectData.description as string) ?? '';
const vision = (projectData.vision as string) ?? '';
const giteaRepo = (projectData.giteaRepo as string) ?? '';
// ---------------------------------------------------------------------------
// Context loaders — all best-effort, never block the response
// ---------------------------------------------------------------------------
return `You are the personal AI COO for "${name}" — a product assistant that thinks like a seasoned Chief Operating Officer.
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.
## About this project
${description ? `Description: ${description}` : ''}
${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 SavedPhase {
phase: string;
title: string;
summary: string;
data: Record<string, unknown>;
saved_at: string;
}
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(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
@@ -56,12 +140,12 @@ export async function POST(
}
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) {
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 {
message: string;
history: Array<{ role: 'user' | 'model'; content: string }>;
@@ -71,21 +155,29 @@ export async function POST(
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 phases: SavedPhase[] = [];
let sessions: AgentSession[] = [];
try {
const rows = await query<{ data: Record<string, unknown> }>(
const [projectRows, phasesData, sessionsData] = await Promise.all([
query<{ data: Record<string, unknown> }>(
`SELECT p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id
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 = [
...history.map(h => ({
role: h.role,
@@ -94,7 +186,6 @@ export async function POST(
{ role: 'user' as const, parts: [{ text: message }] },
];
// Call Gemini streaming
const geminiRes = await fetch(STREAM_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -102,34 +193,31 @@ export async function POST(
system_instruction: { parts: [{ text: systemPrompt }] },
contents,
generationConfig: {
temperature: 0.7,
maxOutputTokens: 1024,
temperature: 0.6,
maxOutputTokens: 1500,
},
}),
});
if (!geminiRes.ok) {
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 stream = new ReadableStream({
async start(controller) {
const reader = geminiRes.body!.getReader();
const decoder = new TextDecoder();
let buf = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const json = line.slice(6).trim();
@@ -138,7 +226,7 @@ export async function POST(
const chunk = JSON.parse(json);
const text = chunk.candidates?.[0]?.content?.parts?.[0]?.text;
if (text) controller.enqueue(encoder.encode(text));
} catch { /* skip malformed chunk */ }
} catch { /* skip malformed */ }
}
}
} finally {