267 lines
7.9 KiB
TypeScript
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;
|
|
}
|
|
}
|