Files
vibn-agent-runner/vibn-frontend/lib/ai/plan-extract.ts

267 lines
7.9 KiB
TypeScript

/**
* Fire-and-forget plan extraction from chat conversations.
*
* After each chat turn, we call a cheap Gemini model (Flash) to scan the
* conversation for plan-worthy content — new tasks, decisions, vision updates —
* and auto-persist them via the same `fs_projects.data->plan` path used by
* the Plan tab MCP tools.
*
* The cheap model is configured via VIBN_CHEAP_MODEL (default: gemini-3.1-pro-preview).
*/
import { query } from "@/lib/db-postgres";
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || "";
const CHEAP_MODEL =
process.env.VIBN_CHEAP_MODEL || "gemini-3.1-pro-preview";
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
interface PlanExtraction {
tasks: Array<{ title: string; description?: string }>;
decisions: Array<{ title: string; choice: string; why?: string }>;
visionUpdate?: string;
}
/**
* Call the cheap Gemini model to extract plan updates from the transcript.
*/
async function extractPlanFromTranscript(
transcript: string,
): Promise<PlanExtraction | null> {
const url = `${GEMINI_BASE_URL}/models/${CHEAP_MODEL}:generateContent?key=${GEMINI_API_KEY}`;
const body = {
contents: [
{
role: "user",
parts: [
{
text:
"Extract any plan-worthy content from this AI coding conversation. " +
"Return ONLY valid JSON with this schema:\n" +
'{\n "tasks": [{"title": "short task name", "description": "optional details"}],\n' +
' "decisions": [{"title": "what was decided", "choice": "the chosen option", "why": "reasoning"}],\n' +
' "visionUpdate": "updated product vision (only if the conversation meaningfully changes or clarifies it)"\n' +
"}\n\n" +
"Rules:\n" +
"- Only extract CLEAR tasks the AI committed to do or the user explicitly requested.\n" +
"- Only extract NON-TRIVIAL decisions (not 'I'll read that file').\n" +
"- visionUpdate: set ONLY when the user articulates or refines their product vision. Omit entirely if not.\n" +
"- Return empty arrays if nothing worthy found.\n" +
"- Do NOT wrap in markdown code fences. Just the raw JSON.\n\n" +
"Conversation:\n" +
transcript.slice(0, 12000),
},
],
},
],
generationConfig: { temperature: 0.1, maxOutputTokens: 1024 },
};
let res: Response;
try {
res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
} catch {
return null;
}
const data = await res.json().catch(() => ({}));
if (!res.ok) return null;
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text || "";
if (!text.trim()) return null;
try {
return JSON.parse(text.trim()) as PlanExtraction;
} catch {
// Strip markdown code fences if present
const cleaned = text.replace(/```(?:json)?\s*/g, "").trim();
try {
return JSON.parse(cleaned) as PlanExtraction;
} catch {
return null;
}
}
}
function planNewId(): string {
return `plan_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
interface PlanProject {
id: string;
data: Record<string, unknown>;
}
async function loadPlanProject(
projectId: string,
): Promise<PlanProject | null> {
const rows = await query<PlanProject>(
`SELECT id, data FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId],
);
return rows[0] ?? null;
}
interface PlanTask {
id: string;
title: string;
description?: string;
status: "open" | "in_progress" | "review" | "done" | "blocked";
text?: string;
createdAt: string;
}
interface PlanDecision {
id: string;
title: string;
choice: string;
why?: string;
createdAt: string;
}
interface PlanShape {
vision?: string;
ideas: Array<{ id: string; text: string; createdAt: string }>;
tasks: PlanTask[];
decisions: PlanDecision[];
}
function readPlanFromData(data: Record<string, unknown>): PlanShape {
const raw = (data?.plan as Record<string, unknown>) ?? {};
const ideas = Array.isArray(raw.ideas) ? raw.ideas : [];
const tasks = Array.isArray(raw.tasks)
? (raw.tasks as PlanTask[]).map((t) => ({
...t,
id: String(t.id ?? planNewId()),
title: String(t.title ?? t.text ?? "").trim(),
status: t.status ?? "open",
createdAt: String(t.createdAt ?? new Date().toISOString()),
}))
: [];
const decisions = Array.isArray(raw.decisions)
? (raw.decisions as PlanDecision[]).map((d) => ({
...d,
id: String(d.id ?? planNewId()),
title: String(d.title ?? "").trim(),
choice: String(d.choice ?? "").trim(),
createdAt: String(d.createdAt ?? new Date().toISOString()),
}))
: [];
return {
vision: typeof raw.vision === "string" ? raw.vision : undefined,
ideas,
tasks,
decisions,
};
}
async function writePlan(
projectId: string,
plan: PlanShape,
alsoVision?: string,
): Promise<void> {
const serialized = {
vision: plan.vision,
ideas: plan.ideas,
tasks: plan.tasks,
decisions: plan.decisions,
};
if (alsoVision !== undefined) {
await query(
`UPDATE fs_projects
SET data = data || jsonb_build_object('plan', $2::jsonb, 'productVision', $3::text),
updated_at = NOW()
WHERE id = $1`,
[projectId, JSON.stringify(serialized), alsoVision],
);
} else {
await query(
`UPDATE fs_projects
SET data = data || jsonb_build_object('plan', $2::jsonb),
updated_at = NOW()
WHERE id = $1`,
[projectId, JSON.stringify(serialized)],
);
}
}
/**
* Main entry point: scan the conversation transcript and auto-update the
* project plan with any extracted tasks/decisions/vision.
*
* Called fire-and-forget after each chat turn. Never throws.
*/
export async function autoExtractPlanUpdates(
projectId: string,
transcript: string,
): Promise<{ tasks: number; decisions: number; vision: boolean } | null> {
if (!projectId || transcript.length < 20) return null;
try {
const extraction = await extractPlanFromTranscript(transcript);
if (!extraction) return null;
const hasTasks = extraction.tasks?.length > 0;
const hasDecisions = extraction.decisions?.length > 0;
const hasVision =
typeof extraction.visionUpdate === "string" &&
extraction.visionUpdate.trim().length > 0;
if (!hasTasks && !hasDecisions && !hasVision) return null;
const project = await loadPlanProject(projectId);
if (!project) return null;
const plan = readPlanFromData(project.data);
const now = new Date().toISOString();
let taskCount = 0;
let decisionCount = 0;
for (const t of extraction.tasks ?? []) {
const exists = plan.tasks.some(
(existing) => existing.title.toLowerCase() === t.title.toLowerCase(),
);
if (exists) continue;
plan.tasks.unshift({
id: planNewId(),
title: t.title.trim(),
description: t.description?.trim(),
status: "open",
createdAt: now,
});
taskCount++;
}
for (const d of extraction.decisions ?? []) {
const exists = plan.decisions.some(
(existing) => existing.title.toLowerCase() === d.title.toLowerCase(),
);
if (exists) continue;
plan.decisions.unshift({
id: planNewId(),
title: d.title.trim(),
choice: d.choice.trim(),
why: d.why?.trim(),
createdAt: now,
});
decisionCount++;
}
if (hasVision && extraction.visionUpdate) {
plan.vision = extraction.visionUpdate.trim();
}
if (taskCount === 0 && decisionCount === 0 && !hasVision) return null;
await writePlan(projectId, plan, hasVision ? extraction.visionUpdate : undefined);
return { tasks: taskCount, decisions: decisionCount, vision: hasVision };
} catch {
return null;
}
}