feat(ai): optimize tool loops, fix deployments, and integrate new onboarding flow

This commit is contained in:
2026-05-19 12:52:47 -07:00
parent 331312b648
commit 618f7796b2
250 changed files with 2993 additions and 24695 deletions

View File

@@ -1,228 +0,0 @@
/**
* Backend Extraction Module
*
* Runs extraction as a pure backend job, not in chat.
* Called when Collector phase completes.
*/
import { getAdminDb } from '@/lib/firebase/admin';
import { GeminiLlmClient } from '@/lib/ai/gemini-client';
import { BACKEND_EXTRACTOR_SYSTEM_PROMPT } from '@/lib/ai/prompts/extractor';
import { writeKnowledgeChunksForItem } from '@/lib/server/vector-memory';
import type { ExtractionOutput, ExtractedInsight } from '@/lib/types/extraction-output';
import type { PhaseHandoff } from '@/lib/types/phase-handoff';
import { z } from 'zod';
const ExtractionOutputSchema = z.object({
insights: z.array(z.object({
id: z.string(),
type: z.enum(["problem", "user", "feature", "constraint", "opportunity", "other"]),
title: z.string(),
description: z.string(),
sourceText: z.string(),
sourceKnowledgeItemId: z.string(),
importance: z.enum(["primary", "supporting"]),
confidence: z.number().min(0).max(1),
})),
problems: z.array(z.string()),
targetUsers: z.array(z.string()),
features: z.array(z.string()),
constraints: z.array(z.string()),
opportunities: z.array(z.string()),
uncertainties: z.array(z.string()),
missingInformation: z.array(z.string()),
overallConfidence: z.number().min(0).max(1),
});
export async function runBackendExtractionForProject(projectId: string): Promise<void> {
console.log(`[Backend Extractor] Starting extraction for project ${projectId}`);
const adminDb = getAdminDb();
try {
// 1. Load project
const projectDoc = await adminDb.collection('projects').doc(projectId).get();
if (!projectDoc.exists) {
throw new Error(`Project ${projectId} not found`);
}
const projectData = projectDoc.data();
// 2. Load knowledge items
const knowledgeSnapshot = await adminDb
.collection('knowledge_items')
.where('projectId', '==', projectId)
.where('sourceType', '==', 'imported_document')
.get();
if (knowledgeSnapshot.empty) {
console.log(`[Backend Extractor] No documents to extract for project ${projectId} - creating empty handoff`);
// Create a minimal extraction handoff even with no documents
const emptyHandoff: PhaseHandoff = {
phase: 'extraction',
readyForNextPhase: false, // Not ready - no materials to extract from
confidence: 0,
confirmed: {
problems: [],
targetUsers: [],
features: [],
constraints: [],
opportunities: [],
},
uncertain: {},
missing: ['No documents uploaded - need product requirements, specs, or notes'],
questionsForUser: [
'You haven\'t uploaded any documents yet. Do you have any product specs, requirements, or notes to share?',
],
sourceEvidence: [],
version: 'extraction_v1',
timestamp: new Date().toISOString(),
};
await adminDb.collection('projects').doc(projectId).update({
'phaseData.phaseHandoffs.extraction': emptyHandoff,
currentPhase: 'extraction_review',
phaseStatus: 'in_progress',
'phaseData.extractionCompletedAt': new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
console.log(`[Backend Extractor] Set phase to extraction_review with empty handoff`);
return;
}
console.log(`[Backend Extractor] Found ${knowledgeSnapshot.size} documents to process`);
const llm = new GeminiLlmClient();
const allExtractionOutputs: ExtractionOutput[] = [];
const processedKnowledgeItemIds: string[] = [];
// 3. Process each document
for (const knowledgeDoc of knowledgeSnapshot.docs) {
const knowledgeData = knowledgeDoc.data();
const knowledgeItemId = knowledgeDoc.id;
try {
console.log(`[Backend Extractor] Processing document: ${knowledgeData.title || knowledgeItemId}`);
// Call LLM with structured extraction + thinking mode
const extraction = await llm.structuredCall<ExtractionOutput>({
model: 'gemini',
systemPrompt: BACKEND_EXTRACTOR_SYSTEM_PROMPT,
messages: [{
role: 'user',
content: `Document Title: ${knowledgeData.title || 'Untitled'}\nSource Type: ${knowledgeData.sourceType}\n\nContent:\n${knowledgeData.content}`,
}],
schema: ExtractionOutputSchema as any,
temperature: 1.0, // Gemini 3 default (changed from 0.3)
thinking_config: {
thinking_level: 'high', // Enable deep reasoning for document analysis
include_thoughts: false, // Don't include thought tokens in output (saves cost)
},
});
// Add knowledgeItemId to each insight
extraction.insights.forEach(insight => {
insight.sourceKnowledgeItemId = knowledgeItemId;
});
allExtractionOutputs.push(extraction);
processedKnowledgeItemIds.push(knowledgeItemId);
// 4. Persist extraction to chat_extractions
await adminDb.collection('chat_extractions').add({
projectId,
knowledgeItemId,
data: extraction,
overallConfidence: extraction.overallConfidence,
overallCompletion: extraction.overallConfidence > 0.7 ? 0.9 : 0.6,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
console.log(`[Backend Extractor] Extracted ${extraction.insights.length} insights from ${knowledgeData.title || knowledgeItemId}`);
// 5. Write vector chunks for primary insights
const primaryInsights = extraction.insights.filter(i => i.importance === 'primary');
for (const insight of primaryInsights) {
try {
// Create a knowledge chunk for this insight
await writeKnowledgeChunksForItem({
id: knowledgeItemId,
projectId,
content: `${insight.title}\n\n${insight.description}\n\nSource: ${insight.sourceText}`,
sourceMeta: {
sourceType: 'extracted_insight',
importance: 'primary',
},
});
} catch (chunkError) {
console.error(`[Backend Extractor] Failed to write chunk for insight ${insight.id}:`, chunkError);
// Continue processing other insights
}
}
} catch (docError) {
console.error(`[Backend Extractor] Failed to process document ${knowledgeItemId}:`, docError);
// Continue with next document
}
}
// 6. Build extraction PhaseHandoff
// Flatten all extracted items (they're already strings, not objects)
const allProblems = [...new Set(allExtractionOutputs.flatMap(e => e.problems))];
const allUsers = [...new Set(allExtractionOutputs.flatMap(e => e.targetUsers))];
const allFeatures = [...new Set(allExtractionOutputs.flatMap(e => e.features))];
const allConstraints = [...new Set(allExtractionOutputs.flatMap(e => e.constraints))];
const allOpportunities = [...new Set(allExtractionOutputs.flatMap(e => e.opportunities))];
const allUncertainties = [...new Set(allExtractionOutputs.flatMap(e => e.uncertainties))];
const allMissing = [...new Set(allExtractionOutputs.flatMap(e => e.missingInformation))];
const avgConfidence = allExtractionOutputs.length > 0
? allExtractionOutputs.reduce((sum, e) => sum + e.overallConfidence, 0) / allExtractionOutputs.length
: 0;
const readyForNextPhase = allProblems.length > 0 && allFeatures.length > 0 && avgConfidence > 0.5;
const extractionHandoff: PhaseHandoff = {
phase: 'extraction',
readyForNextPhase,
confidence: avgConfidence,
confirmed: {
problems: allProblems,
targetUsers: allUsers,
features: allFeatures,
constraints: allConstraints,
opportunities: allOpportunities,
},
uncertain: {},
missing: allMissing,
questionsForUser: allUncertainties,
sourceEvidence: processedKnowledgeItemIds,
version: 'extraction_v1',
timestamp: new Date().toISOString(),
};
// 7. Persist handoff and update phase
await adminDb.collection('projects').doc(projectId).update({
'phaseData.phaseHandoffs.extraction': extractionHandoff,
currentPhase: 'extraction_review',
phaseStatus: 'in_progress',
'phaseData.extractionCompletedAt': new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
console.log(`[Backend Extractor] ✅ Extraction complete for project ${projectId}`);
console.log(`[Backend Extractor] - Problems: ${allProblems.length}`);
console.log(`[Backend Extractor] - Users: ${allUsers.length}`);
console.log(`[Backend Extractor] - Features: ${allFeatures.length}`);
console.log(`[Backend Extractor] - Confidence: ${(avgConfidence * 100).toFixed(1)}%`);
console.log(`[Backend Extractor] - Ready for next phase: ${readyForNextPhase}`);
} catch (error) {
console.error(`[Backend Extractor] Fatal error during extraction:`, error);
throw error;
}
}

View File

@@ -1,64 +0,0 @@
import { getAdminDb } from '@/lib/firebase/admin';
import { FieldValue } from 'firebase-admin/firestore';
import type { ChatExtractionRecord } from '@/lib/types/chat-extraction';
const COLLECTION = 'chat_extractions';
interface CreateChatExtractionInput<TData> {
projectId: string;
knowledgeItemId: string;
data: TData;
overallCompletion: number;
overallConfidence: number;
}
export async function createChatExtraction<TData>(
input: CreateChatExtractionInput<TData>,
): Promise<ChatExtractionRecord<TData>> {
const adminDb = getAdminDb();
const docRef = adminDb.collection(COLLECTION).doc();
const payload = {
id: docRef.id,
projectId: input.projectId,
knowledgeItemId: input.knowledgeItemId,
data: input.data,
overallCompletion: input.overallCompletion,
overallConfidence: input.overallConfidence,
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
};
await docRef.set(payload);
const snapshot = await docRef.get();
return snapshot.data() as ChatExtractionRecord<TData>;
}
export async function listChatExtractions<TData>(
projectId: string,
): Promise<ChatExtractionRecord<TData>[]> {
const adminDb = getAdminDb();
const querySnapshot = await adminDb
.collection(COLLECTION)
.where('projectId', '==', projectId)
.orderBy('createdAt', 'desc')
.get();
return querySnapshot.docs.map(
(doc) => doc.data() as ChatExtractionRecord<TData>,
);
}
export async function getChatExtraction<TData>(
extractionId: string,
): Promise<ChatExtractionRecord<TData> | null> {
const adminDb = getAdminDb();
const docRef = adminDb.collection(COLLECTION).doc(extractionId);
const snapshot = await docRef.get();
if (!snapshot.exists) {
return null;
}
return snapshot.data() as ChatExtractionRecord<TData>;
}

View File

@@ -1,91 +0,0 @@
/**
* Chat Mode Resolution Logic
*
* Determines which chat mode (collector, extraction_review, vision, mvp, marketing, general)
* should be active based on project state stored in Postgres.
*/
import { query } from '@/lib/db-postgres';
import type { ChatMode } from '@/lib/ai/chat-modes';
/**
* Resolve the appropriate chat mode for a project using Postgres (fs_projects).
*/
export async function resolveChatMode(projectId: string): Promise<ChatMode> {
try {
const rows = await query<{ data: any }>(
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId]
);
if (rows.length === 0) {
console.warn(`[Chat Mode Resolver] Project ${projectId} not found`);
return 'collector_mode';
}
const projectData = rows[0].data ?? {};
const phaseData = (projectData.phaseData ?? {}) as Record<string, any>;
const currentPhase: string = projectData.currentPhase ?? 'collector';
// Explicit phase overrides
if (currentPhase === 'extraction_review' || currentPhase === 'analyzed') return 'extraction_review_mode';
if (currentPhase === 'vision') return 'vision_mode';
if (currentPhase === 'mvp') return 'mvp_mode';
if (currentPhase === 'marketing') return 'marketing_mode';
// Derive from phase artifacts
if (!phaseData.canonicalProductModel) return 'collector_mode';
if (!phaseData.mvpPlan) return 'vision_mode';
if (!phaseData.marketingPlan) return 'mvp_mode';
if (phaseData.marketingPlan) return 'marketing_mode';
return 'general_chat_mode';
} catch (error) {
console.error('[Chat Mode Resolver] Failed to resolve mode:', error);
return 'collector_mode';
}
}
/**
* Summarise knowledge items for context building.
* Uses Postgres fs_knowledge_items if available, otherwise returns empty.
*/
export async function summarizeKnowledgeItems(projectId: string): Promise<{
totalCount: number;
bySourceType: Record<string, number>;
recentTitles: string[];
}> {
try {
const rows = await query<{ data: any }>(
`SELECT data FROM fs_knowledge_items WHERE project_id = $1 ORDER BY created_at DESC LIMIT 20`,
[projectId]
);
const bySourceType: Record<string, number> = {};
const recentTitles: string[] = [];
for (const row of rows) {
const d = row.data ?? {};
const sourceType = d.sourceType ?? 'unknown';
bySourceType[sourceType] = (bySourceType[sourceType] ?? 0) + 1;
if (d.title && recentTitles.length < 5) recentTitles.push(d.title);
}
return { totalCount: rows.length, bySourceType, recentTitles };
} catch {
// Table may not exist for older deployments — return empty
return { totalCount: 0, bySourceType: {}, recentTitles: [] };
}
}
/**
* Summarise extractions for context building.
* Returns empty defaults — extractions not yet migrated to Postgres.
*/
export async function summarizeExtractions(projectId: string): Promise<{
totalCount: number;
avgConfidence: number;
avgCompletion: number;
}> {
return { totalCount: 0, avgConfidence: 0, avgCompletion: 0 };
}

View File

@@ -1,140 +0,0 @@
/**
* Persistent dev-server configuration store.
* Closes BETA_LAUNCH_PLAN P6.B1.
*
* When `dev_server_start` succeeds, the MCP tool should call
* `upsertDevServerConfig` so the project page can auto-resume the
* server on next mount without requiring the user to re-type the
* command (see P6.B2 for the auto-resume hook).
*
* Schema:
* fs_project_dev_servers
* project_id UUID PK → fs_projects.id
* command TEXT NOT NULL e.g. "cd myapp && npm run dev"
* port INT NOT NULL e.g. 3000
* framework TEXT e.g. "nextjs", "vite", "express"
* preview_url TEXT last known *.preview.vibnai.com URL
* last_started_at TIMESTAMPTZ
* status TEXT CHECK IN ('running','stopped','crashed')
* updated_at TIMESTAMPTZ DEFAULT NOW()
*/
import { query } from "@/lib/db-postgres";
import { log } from "@/lib/server/logger";
let tableReady = false;
async function ensureTable() {
if (tableReady) return;
await query(`
CREATE TABLE IF NOT EXISTS fs_project_dev_servers (
project_id TEXT PRIMARY KEY,
command TEXT NOT NULL,
port INT NOT NULL,
framework TEXT,
preview_url TEXT,
last_started_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'stopped'
CHECK (status IN ('running', 'stopped', 'crashed')),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
tableReady = true;
}
export interface DevServerConfig {
projectId: string;
command: string;
port: number;
framework?: string;
previewUrl?: string;
status: "running" | "stopped" | "crashed";
}
/** Called by the MCP dev_server_start handler after a successful start. */
export async function upsertDevServerConfig(
cfg: DevServerConfig,
): Promise<void> {
try {
await ensureTable();
await query(
`INSERT INTO fs_project_dev_servers
(project_id, command, port, framework, preview_url, last_started_at, status, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), $6, NOW())
ON CONFLICT (project_id) DO UPDATE SET
command = EXCLUDED.command,
port = EXCLUDED.port,
framework = COALESCE(EXCLUDED.framework, fs_project_dev_servers.framework),
preview_url = COALESCE(EXCLUDED.preview_url, fs_project_dev_servers.preview_url),
last_started_at = NOW(),
status = EXCLUDED.status,
updated_at = NOW()`,
[
cfg.projectId,
cfg.command,
cfg.port,
cfg.framework ?? null,
cfg.previewUrl ?? null,
cfg.status,
],
);
} catch (err) {
log.warn("dev-server-state: upsert failed (non-fatal)", {
projectId: cfg.projectId,
err: err instanceof Error ? err.message : String(err),
});
}
}
/** Update just the status (e.g. on stop / crash). */
export async function setDevServerStatus(
projectId: string,
status: "running" | "stopped" | "crashed",
): Promise<void> {
try {
await ensureTable();
await query(
`UPDATE fs_project_dev_servers
SET status = $2, updated_at = NOW()
WHERE project_id = $1`,
[projectId, status],
);
} catch (err) {
log.warn("dev-server-state: status update failed (non-fatal)", {
projectId,
err: err instanceof Error ? err.message : String(err),
});
}
}
/** Returns the last-known dev server config for a project, or null. */
export async function getDevServerConfig(
projectId: string,
): Promise<DevServerConfig | null> {
try {
await ensureTable();
const rows = await query<{
project_id: string;
command: string;
port: number;
framework: string | null;
preview_url: string | null;
status: string;
}>(
`SELECT project_id, command, port, framework, preview_url, status
FROM fs_project_dev_servers WHERE project_id = $1`,
[projectId],
);
if (!rows[0]) return null;
const r = rows[0];
return {
projectId: r.project_id,
command: r.command,
port: r.port,
framework: r.framework ?? undefined,
previewUrl: r.preview_url ?? undefined,
status: r.status as "running" | "stopped" | "crashed",
};
} catch {
return null;
}
}

View File

@@ -1,116 +0,0 @@
/**
* Coolify connectivity checks for ops / uptime monitors.
*
* - HTTP API (token) — provisioning via REST
* - SSH → Docker on host — required for shell.exec / dev containers
*/
import { listServers } from '@/lib/coolify';
import { runOnCoolifyHost } from '@/lib/coolify-ssh';
export interface CoolifyInfraHealthReport {
checkedAt: string;
ssh: {
configured: boolean;
missingEnvVars: string[];
reachable?: boolean;
latencyMs?: number;
dockerDaemonOk?: boolean;
/** docker Server.Version when daemon responds */
dockerVersion?: string;
error?: string;
};
api: {
configured: boolean;
reachable?: boolean;
latencyMs?: number;
serverCount?: number;
error?: string;
};
}
export function getCoolifySshConfigGap(): {
configured: boolean;
missingEnvVars: string[];
} {
const missing: string[] = [];
if (!process.env.COOLIFY_SSH_HOST?.trim()) missing.push('COOLIFY_SSH_HOST');
if (!process.env.COOLIFY_SSH_PRIVATE_KEY_B64?.trim()) {
missing.push('COOLIFY_SSH_PRIVATE_KEY_B64');
}
return { configured: missing.length === 0, missingEnvVars: missing };
}
/** True when SSH is wired and `docker` responds on the Coolify host. */
export async function runCoolifyInfraHealthProbe(): Promise<CoolifyInfraHealthReport> {
const checkedAt = new Date().toISOString();
const sshGap = getCoolifySshConfigGap();
const report: CoolifyInfraHealthReport = {
checkedAt,
ssh: {
configured: sshGap.configured,
missingEnvVars: sshGap.missingEnvVars,
},
api: {
configured: !!process.env.COOLIFY_API_TOKEN?.trim(),
},
};
if (report.api.configured) {
const t0 = Date.now();
try {
const servers = await listServers();
report.api.reachable = true;
report.api.latencyMs = Date.now() - t0;
report.api.serverCount = Array.isArray(servers) ? servers.length : 0;
} catch (e) {
report.api.reachable = false;
report.api.latencyMs = Date.now() - t0;
report.api.error = e instanceof Error ? e.message : String(e);
}
} else {
report.api.error = 'COOLIFY_API_TOKEN is not set';
}
if (!sshGap.configured) {
report.ssh.error =
`Missing: ${sshGap.missingEnvVars.join(', ')} — dev containers need SSH to the Docker host (see lib/coolify-ssh.ts).`;
return report;
}
const tSsh = Date.now();
try {
const res = await runOnCoolifyHost(`docker info --format '{{.ServerVersion}}'`, {
timeoutMs: 15_000,
maxBytes: 8192,
});
report.ssh.latencyMs = Date.now() - tSsh;
report.ssh.reachable = true;
const ver = res.stdout.trim().split(/\s+/)[0]?.trim() ?? '';
if (res.code === 0 && ver.length > 0) {
report.ssh.dockerDaemonOk = true;
report.ssh.dockerVersion = ver;
} else {
report.ssh.dockerDaemonOk = false;
report.ssh.error = `docker probe exit ${res.code}: ${(res.stderr || res.stdout || '(empty)').slice(0, 600)}`;
}
} catch (e) {
report.ssh.latencyMs = Date.now() - tSsh;
report.ssh.reachable = false;
report.ssh.dockerDaemonOk = false;
report.ssh.error = e instanceof Error ? e.message : String(e);
}
return report;
}
export function isCoolifyInfraOperational(report: CoolifyInfraHealthReport): boolean {
return (
report.ssh.configured &&
report.ssh.reachable === true &&
report.ssh.dockerDaemonOk === true &&
report.api.configured &&
report.api.reachable === true
);
}

View File

@@ -1,102 +0,0 @@
import { listChatExtractions } from '@/lib/server/chat-extraction';
import { clamp, nowIso, persistPhaseArtifacts, uniqueStrings, toStage } from '@/lib/server/projects';
import type { CanonicalProductModel } from '@/lib/types/product-model';
import type { ChatExtractionRecord } from '@/lib/types/chat-extraction';
const average = (numbers: number[]) =>
numbers.length ? numbers.reduce((sum, value) => sum + value, 0) / numbers.length : 0;
export async function buildCanonicalProductModel(projectId: string): Promise<CanonicalProductModel> {
const extractions = await listChatExtractions(projectId);
if (!extractions.length) {
throw new Error('No chat extractions found for project');
}
const completionAvg = average(
extractions.map(
(record) =>
(record.data as any)?.summary_scores?.overall_completion ?? record.overallCompletion ?? 0,
),
);
const confidenceAvg = average(
extractions.map(
(record) =>
(record.data as any)?.summary_scores?.overall_confidence ?? record.overallConfidence ?? 0,
),
);
const canonical = mapExtractionToCanonical(
projectId,
pickHighestConfidence(extractions as any),
completionAvg,
confidenceAvg,
);
await persistPhaseArtifacts(projectId, (phaseData, phaseScores, phaseHistory) => {
phaseData.canonicalProductModel = canonical;
phaseScores.vision = {
overallCompletion: canonical.overallCompletion,
overallConfidence: canonical.overallConfidence,
updatedAt: nowIso(),
};
phaseHistory.push({ phase: 'vision', status: 'completed', timestamp: nowIso() });
return { phaseData, phaseScores, phaseHistory, nextPhase: 'vision_ready' };
});
return canonical;
}
function pickHighestConfidence(records: ChatExtractionRecord[]) {
return records.reduce((best, record) =>
record.overallConfidence > best.overallConfidence ? record : best,
);
}
function mapExtractionToCanonical(
projectId: string,
record: ChatExtractionRecord,
completionAvg: number,
confidenceAvg: number,
): CanonicalProductModel {
const data = record.data;
const coreFeatures = data.solution_and_features.core_features.map(
(feature) => feature.name || feature.description,
);
const niceToHaveFeatures = data.solution_and_features.nice_to_have_features.map(
(feature) => feature.name || feature.description,
);
return {
projectId,
workingTitle: data.project_summary.working_title ?? null,
oneLiner: data.project_summary.one_liner ?? null,
problem: data.product_vision.problem_statement.description ?? null,
targetUser: data.target_users.primary_segment.description ?? null,
desiredOutcome: data.product_vision.target_outcome.description ?? null,
coreSolution: data.solution_and_features.core_solution.description ?? null,
coreFeatures: uniqueStrings(coreFeatures),
niceToHaveFeatures: uniqueStrings(niceToHaveFeatures),
marketCategory: data.market_and_competition.market_category.description ?? null,
competitors: uniqueStrings(
data.market_and_competition.competitors.map((competitor) => competitor.name),
),
techStack: uniqueStrings(
data.tech_and_constraints.stack_mentions.map((item) => item.description),
),
constraints: uniqueStrings(
data.tech_and_constraints.constraints.map((constraint) => constraint.description),
),
currentStage: toStage(data.project_summary.stage),
shortTermGoals: uniqueStrings(
data.goals_and_success.short_term_goals.map((goal) => goal.description),
),
longTermGoals: uniqueStrings(
data.goals_and_success.long_term_goals.map((goal) => goal.description),
),
overallCompletion: clamp(completionAvg),
overallConfidence: clamp(confidenceAvg),
};
}

View File

@@ -1,453 +0,0 @@
/**
* Server-side helpers for AlloyDB vector memory operations
*
* Handles CRUD operations on knowledge_chunks and semantic search.
*/
import { getAlloyDbClient, executeQuery, getPooledClient } from '@/lib/db/alloydb';
import type {
KnowledgeChunk,
KnowledgeChunkRow,
KnowledgeChunkSearchResult,
VectorSearchOptions,
CreateKnowledgeChunkInput,
BatchCreateKnowledgeChunksInput,
} from '@/lib/types/vector-memory';
/**
* Convert database row (snake_case) to TypeScript object (camelCase)
*/
function rowToKnowledgeChunk(row: KnowledgeChunkRow): KnowledgeChunk {
return {
id: row.id,
projectId: row.project_id,
knowledgeItemId: row.knowledge_item_id,
chunkIndex: row.chunk_index,
content: row.content,
sourceType: row.source_type,
importance: row.importance,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
/**
* Retrieve relevant knowledge chunks using vector similarity search
*
* @param projectId - Firestore project ID to filter by
* @param queryEmbedding - Vector embedding of the query (e.g., user's question)
* @param options - Search options (limit, filters, etc.)
* @returns Array of chunks ordered by similarity (most relevant first)
*
* @example
* ```typescript
* const embedding = await embedText("What's the MVP scope?");
* const chunks = await retrieveRelevantChunks('proj123', embedding, { limit: 10, minSimilarity: 0.7 });
* ```
*/
export async function retrieveRelevantChunks(
projectId: string,
queryEmbedding: number[],
options: VectorSearchOptions = {}
): Promise<KnowledgeChunkSearchResult[]> {
const {
limit = 10,
minSimilarity,
sourceTypes,
importanceLevels,
} = options;
try {
// Build the query with optional filters
let queryText = `
SELECT
id,
project_id,
knowledge_item_id,
chunk_index,
content,
source_type,
importance,
created_at,
updated_at,
1 - (embedding <=> $1::vector) AS similarity
FROM knowledge_chunks
WHERE project_id = $2
`;
const params: any[] = [JSON.stringify(queryEmbedding), projectId];
let paramIndex = 3;
// Filter by source types
if (sourceTypes && sourceTypes.length > 0) {
queryText += ` AND source_type = ANY($${paramIndex})`;
params.push(sourceTypes);
paramIndex++;
}
// Filter by importance levels
if (importanceLevels && importanceLevels.length > 0) {
queryText += ` AND importance = ANY($${paramIndex})`;
params.push(importanceLevels);
paramIndex++;
}
// Filter by minimum similarity
if (minSimilarity !== undefined) {
queryText += ` AND (1 - (embedding <=> $1::vector)) >= $${paramIndex}`;
params.push(minSimilarity);
paramIndex++;
}
// Order by similarity and limit
queryText += ` ORDER BY embedding <=> $1::vector LIMIT $${paramIndex}`;
params.push(limit);
const result = await executeQuery<KnowledgeChunkRow & { similarity: number }>(
queryText,
params
);
return result.rows.map((row) => ({
...rowToKnowledgeChunk(row),
similarity: row.similarity,
}));
} catch (error) {
console.error('[Vector Memory] Failed to retrieve relevant chunks:', error);
throw new Error(
`Failed to retrieve chunks: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Create a single knowledge chunk
*/
export async function createKnowledgeChunk(
input: CreateKnowledgeChunkInput
): Promise<KnowledgeChunk> {
const {
projectId,
knowledgeItemId,
chunkIndex,
content,
embedding,
sourceType = null,
importance = null,
} = input;
try {
const queryText = `
INSERT INTO knowledge_chunks (
project_id,
knowledge_item_id,
chunk_index,
content,
embedding,
source_type,
importance
)
VALUES ($1, $2, $3, $4, $5::vector, $6, $7)
RETURNING
id,
project_id,
knowledge_item_id,
chunk_index,
content,
source_type,
importance,
created_at,
updated_at
`;
const result = await executeQuery<KnowledgeChunkRow>(queryText, [
projectId,
knowledgeItemId,
chunkIndex,
content,
JSON.stringify(embedding),
sourceType,
importance,
]);
if (result.rows.length === 0) {
throw new Error('Failed to insert knowledge chunk');
}
return rowToKnowledgeChunk(result.rows[0]);
} catch (error) {
console.error('[Vector Memory] Failed to create knowledge chunk:', error);
throw new Error(
`Failed to create chunk: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Batch create multiple knowledge chunks efficiently
*
* Uses a transaction to ensure atomicity.
*/
export async function batchCreateKnowledgeChunks(
input: BatchCreateKnowledgeChunksInput
): Promise<KnowledgeChunk[]> {
const { projectId, knowledgeItemId, chunks } = input;
if (chunks.length === 0) {
return [];
}
const client = await getPooledClient();
try {
await client.query('BEGIN');
const createdChunks: KnowledgeChunk[] = [];
for (const chunk of chunks) {
const queryText = `
INSERT INTO knowledge_chunks (
project_id,
knowledge_item_id,
chunk_index,
content,
embedding,
source_type,
importance
)
VALUES ($1, $2, $3, $4, $5::vector, $6, $7)
RETURNING
id,
project_id,
knowledge_item_id,
chunk_index,
content,
source_type,
importance,
created_at,
updated_at
`;
const result = await client.query<KnowledgeChunkRow>(queryText, [
projectId,
knowledgeItemId,
chunk.chunkIndex,
chunk.content,
JSON.stringify(chunk.embedding),
chunk.sourceType ?? null,
chunk.importance ?? null,
]);
if (result.rows.length > 0) {
createdChunks.push(rowToKnowledgeChunk(result.rows[0]));
}
}
await client.query('COMMIT');
console.log(
`[Vector Memory] Batch created ${createdChunks.length} chunks for knowledge_item ${knowledgeItemId}`
);
return createdChunks;
} catch (error) {
await client.query('ROLLBACK');
console.error('[Vector Memory] Failed to batch create chunks:', error);
throw new Error(
`Failed to batch create chunks: ${error instanceof Error ? error.message : String(error)}`
);
} finally {
client.release();
}
}
/**
* Delete all chunks for a specific knowledge_item
*
* Used when regenerating chunks or removing a knowledge_item.
*/
export async function deleteChunksForKnowledgeItem(
knowledgeItemId: string
): Promise<number> {
try {
const queryText = `
DELETE FROM knowledge_chunks
WHERE knowledge_item_id = $1
RETURNING id
`;
const result = await executeQuery(queryText, [knowledgeItemId]);
console.log(
`[Vector Memory] Deleted ${result.rowCount ?? 0} chunks for knowledge_item ${knowledgeItemId}`
);
return result.rowCount ?? 0;
} catch (error) {
console.error('[Vector Memory] Failed to delete chunks:', error);
throw new Error(
`Failed to delete chunks: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Delete all chunks for a specific project
*
* Used when cleaning up or resetting a project.
*/
export async function deleteChunksForProject(projectId: string): Promise<number> {
try {
const queryText = `
DELETE FROM knowledge_chunks
WHERE project_id = $1
RETURNING id
`;
const result = await executeQuery(queryText, [projectId]);
console.log(
`[Vector Memory] Deleted ${result.rowCount ?? 0} chunks for project ${projectId}`
);
return result.rowCount ?? 0;
} catch (error) {
console.error('[Vector Memory] Failed to delete project chunks:', error);
throw new Error(
`Failed to delete project chunks: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Get chunk count for a knowledge_item
*/
export async function getChunkCountForKnowledgeItem(
knowledgeItemId: string
): Promise<number> {
try {
const result = await executeQuery<{ count: string }>(
'SELECT COUNT(*) as count FROM knowledge_chunks WHERE knowledge_item_id = $1',
[knowledgeItemId]
);
return parseInt(result.rows[0]?.count ?? '0', 10);
} catch (error) {
console.error('[Vector Memory] Failed to get chunk count:', error);
return 0;
}
}
/**
* Get chunk count for a project
*/
export async function getChunkCountForProject(projectId: string): Promise<number> {
try {
const result = await executeQuery<{ count: string }>(
'SELECT COUNT(*) as count FROM knowledge_chunks WHERE project_id = $1',
[projectId]
);
return parseInt(result.rows[0]?.count ?? '0', 10);
} catch (error) {
console.error('[Vector Memory] Failed to get project chunk count:', error);
return 0;
}
}
/**
* Regenerate knowledge_chunks for a single knowledge_item
*
* This is the main pipeline that:
* 1. Chunks the knowledge_item.content
* 2. Generates embeddings for each chunk
* 3. Deletes existing chunks for this item
* 4. Inserts new chunks into AlloyDB
*
* @param knowledgeItem - The knowledge item to process
*
* @example
* ```typescript
* const knowledgeItem = await getKnowledgeItem(projectId, itemId);
* await writeKnowledgeChunksForItem(knowledgeItem);
* ```
*/
export async function writeKnowledgeChunksForItem(
knowledgeItem: {
id: string;
projectId: string;
content: string;
sourceMeta?: { sourceType?: string; importance?: 'primary' | 'supporting' | 'irrelevant' };
}
): Promise<void> {
const { chunkText } = await import('@/lib/ai/chunking');
const { embedTextBatch } = await import('@/lib/ai/embeddings');
try {
console.log(
`[Vector Memory] Starting chunking pipeline for knowledge_item ${knowledgeItem.id}`
);
// Step 1: Chunk the content
const textChunks = chunkText(knowledgeItem.content, {
maxTokens: 800,
overlapChars: 200,
preserveParagraphs: true,
});
if (textChunks.length === 0) {
console.warn(
`[Vector Memory] No chunks generated for knowledge_item ${knowledgeItem.id} - content may be empty`
);
return;
}
console.log(
`[Vector Memory] Generated ${textChunks.length} chunks for knowledge_item ${knowledgeItem.id}`
);
// Step 2: Generate embeddings for all chunks
const chunkTexts = textChunks.map((chunk) => chunk.text);
const embeddings = await embedTextBatch(chunkTexts, {
delayMs: 50, // Small delay to avoid rate limiting
skipEmpty: true,
});
if (embeddings.length !== textChunks.length) {
throw new Error(
`Embedding count mismatch: got ${embeddings.length}, expected ${textChunks.length}`
);
}
// Step 3: Delete existing chunks for this knowledge_item
await deleteChunksForKnowledgeItem(knowledgeItem.id);
// Step 4: Insert new chunks
const chunksToInsert = textChunks.map((chunk, index) => ({
chunkIndex: chunk.index,
content: chunk.text,
embedding: embeddings[index],
sourceType: knowledgeItem.sourceMeta?.sourceType ?? null,
importance: knowledgeItem.sourceMeta?.importance ?? null,
}));
await batchCreateKnowledgeChunks({
projectId: knowledgeItem.projectId,
knowledgeItemId: knowledgeItem.id,
chunks: chunksToInsert,
});
console.log(
`[Vector Memory] Successfully processed ${chunksToInsert.length} chunks for knowledge_item ${knowledgeItem.id}`
);
} catch (error) {
console.error(
`[Vector Memory] Failed to write chunks for knowledge_item ${knowledgeItem.id}:`,
error
);
throw new Error(
`Failed to write chunks: ${error instanceof Error ? error.message : String(error)}`
);
}
}