feat: add Smart Search Panel with semantic search capabilities

- Implemented Smart Search Panel component for enhanced project and deliverable search functionality.
- Introduced useSemanticSearch and useOllamaHealth hooks for managing search queries and AI availability.
- Developed embedding-service to generate and store vector embeddings for projects and deliverables.
- Created semantic-search-service to handle vector search, structural query detection, and LLM summarization.
- Added support for hybrid search combining structural filters and semantic queries.
- Integrated UI components for displaying search results and user interactions.
This commit is contained in:
Leivur Djurhuus 2026-03-06 16:13:36 -06:00
parent ed079ffbe1
commit 9d5acf1683
18 changed files with 1813 additions and 11 deletions

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
node_modules
.next
.git
.env
.env.local
*.log

View file

@ -11,3 +11,10 @@ AUTH_MICROSOFT_ENTRA_ID_TENANT_ID=""
# App
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# Ollama (AI semantic search — Phase 8.4)
# Local Ollama instance for embeddings and LLM summarization.
# No data leaves the network. Zero ongoing AI costs.
OLLAMA_HOST="http://localhost:11434"
OLLAMA_EMBED_MODEL="nomic-embed-text"
OLLAMA_LLM_MODEL="qwen3.5:9b"

48
Dockerfile Normal file
View file

@ -0,0 +1,48 @@
FROM node:22-alpine AS base
# Install dependencies only when needed
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts
RUN npx prisma generate || true
# Rebuild source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build the Next.js app
RUN npm run build
# Production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Copy Next.js standalone output
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Copy Prisma schema and migrations for runtime
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/src/generated ./src/generated
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View file

@ -954,7 +954,7 @@ self-contained, and free to operate.
4. **LLM summarization layer (optional enhancement)**
- Pass the top search results + the user's original question to a local Ollama LLM
(`POST http://ollama:11434/api/generate`) using `llama3.1:8b` or `mistral`
(`POST http://ollama:11434/api/generate`) using `qwen3.5:9b`
- Generate a natural language summary: *"There are 3 Envy projects currently behind
schedule. The most critical is Envy 16 Refresh with 4 overdue deliverables..."*
- Return both the AI summary and the structured result list
@ -1001,8 +1001,10 @@ model SearchLog {
**New dependencies:**
- `pgvector` PostgreSQL extension (installed on the database, not an npm package)
- Ollama service (Docker container — `ollama/ollama` image)
- Ollama models: `nomic-embed-text` (embeddings, ~274MB), `llama3.1:8b` (summarization,
~4.7GB) — pulled automatically on first container start
- Ollama models: `nomic-embed-text` (embeddings, ~274MB), `qwen3.5:9b` (summarization)
— pulled automatically on first container start
- **Note:** `qwen3.5:9b` has been downloaded to the local dev machine and is ready for
testing. This is our chosen summarization model going forward.
- No paid API services — everything runs locally
**Practical notes:**
@ -1375,7 +1377,7 @@ the Next.js app, PostgreSQL with pgvector, and Ollama with pre-configured models
│ ┌────────────┐ ┌────────────┐ ┌────────────────┐ │
│ │ app │ │ db │ │ ollama │ │
│ │ Next.js │ │ PostgreSQL │ │ nomic-embed │ │
│ │ Port 3000 │ │ + pgvector│ │ llama3.1:8b │ │
│ │ Port 3000 │ │ + pgvector│ │ qwen3.5:9b │ │
│ │ │──│ Port 5432 │ │ Port 11434 │ │
│ │ │ │ │ │ │ │
│ └────────────┘ └────────────┘ └────────────────┘ │
@ -1414,7 +1416,7 @@ the Next.js app, PostgreSQL with pgvector, and Ollama with pre-configured models
3. **`docker/ollama-entrypoint.sh`** (model bootstrap script)
- Starts the Ollama server
- Checks if required models are already pulled (cached in volume)
- If not, pulls `nomic-embed-text` and `llama3.1:8b` automatically
- If not, pulls `nomic-embed-text` and `qwen3.5:9b` automatically
- Subsequent starts skip the pull — models persist in the Docker volume
4. **`docker/db-init.sql`** (database initialization)
@ -1435,7 +1437,7 @@ the Next.js app, PostgreSQL with pgvector, and Ollama with pre-configured models
# Ollama (internal — no need to change)
OLLAMA_HOST=http://ollama:11434
OLLAMA_EMBED_MODEL=nomic-embed-text
OLLAMA_LLM_MODEL=llama3.1:8b
OLLAMA_LLM_MODEL=qwen3.5:9b
```
**Key files:**

47
docker-compose.yml Normal file
View file

@ -0,0 +1,47 @@
services:
# ─── PostgreSQL with pgvector ───────────────────────────
db:
image: pgvector/pgvector:pg17
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: hp_prod_tracker
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
# ─── Ollama (local AI embeddings) ──────────────────────
ollama:
image: ollama/ollama:latest
restart: unless-stopped
ports:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
# ─── Next.js app (production only) ─────────────────────
# Uncomment for deployment. For local dev, run `npm run dev` directly.
# app:
# build: .
# restart: unless-stopped
# ports:
# - "3000:3000"
# environment:
# DATABASE_URL: postgresql://postgres:postgres@db:5432/hp_prod_tracker?schema=public
# OLLAMA_HOST: http://ollama:11434
# OLLAMA_EMBED_MODEL: nomic-embed-text
# NODE_ENV: production
# depends_on:
# db:
# condition: service_healthy
volumes:
pgdata:
ollama_data:

View file

@ -14,7 +14,8 @@
"db:push": "prisma db push",
"db:seed": "prisma db seed",
"db:studio": "prisma studio",
"db:seed-tracker": "tsx prisma/seed-tracker-data.ts"
"db:seed-tracker": "tsx prisma/seed-tracker-data.ts",
"db:backfill-embeddings": "tsx scripts/backfill-embeddings.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",

View file

@ -0,0 +1,28 @@
-- Enable pgvector extension
CREATE EXTENSION IF NOT EXISTS vector;
-- Add embedding columns to projects and deliverables
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "embedding" vector(768);
ALTER TABLE "deliverables" ADD COLUMN IF NOT EXISTS "embedding" vector(768);
-- Create indexes for fast cosine similarity search
CREATE INDEX IF NOT EXISTS "projects_embedding_idx" ON "projects" USING ivfflat ("embedding" vector_cosine_ops) WITH (lists = 100);
CREATE INDEX IF NOT EXISTS "deliverables_embedding_idx" ON "deliverables" USING ivfflat ("embedding" vector_cosine_ops) WITH (lists = 100);
-- Create search_logs table
CREATE TABLE IF NOT EXISTS "search_logs" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"query" TEXT NOT NULL,
"resultCount" INTEGER NOT NULL DEFAULT 0,
"clickedId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "search_logs_pkey" PRIMARY KEY ("id")
);
-- Index for search_logs by user
CREATE INDEX IF NOT EXISTS "search_logs_userId_idx" ON "search_logs" ("userId");
-- Foreign key for search_logs
ALTER TABLE "search_logs" ADD CONSTRAINT "search_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -117,6 +117,7 @@ model User {
comments Comment[]
notifications Notification[]
skills UserSkill[]
searchLogs SearchLog[]
@@map("users")
}
@ -218,6 +219,9 @@ model Project {
actualCost Float?
agency String?
// pgvector embedding for semantic search (raw SQL — Prisma can't query this directly)
embedding Unsupported("vector(768)")?
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
@ -247,6 +251,9 @@ model Deliverable {
actualDeliveryDate DateTime?
wfInputDate DateTime?
// pgvector embedding for semantic search (raw SQL — Prisma can't query this directly)
embedding Unsupported("vector(768)")?
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@ -407,3 +414,18 @@ model StageSkillRequirement {
@@id([stageTemplateId, skillId])
@@map("stage_skill_requirements")
}
// ─── Semantic Search (Phase 8.4) ────────────────────────
model SearchLog {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
query String
resultCount Int @default(0)
clickedId String?
createdAt DateTime @default(now())
@@index([userId])
@@map("search_logs")
}

View file

@ -0,0 +1,177 @@
/**
* Backfill Embeddings Script
*
* One-time script to generate embeddings for all existing projects and deliverables.
* Run with: npx tsx scripts/backfill-embeddings.ts
*
* Prerequisites:
* - PostgreSQL with pgvector extension enabled
* - Ollama running with nomic-embed-text model pulled
* - DATABASE_URL set in environment
*/
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../src/generated/prisma/client";
import {
buildProjectText,
buildDeliverableText,
generateEmbedding,
checkOllamaHealth,
} from "../src/lib/services/embedding-service";
const connectionString = process.env.DATABASE_URL!;
const adapter = new PrismaPg({ connectionString });
const prisma = new PrismaClient({ adapter });
async function backfillProjects() {
const projects = await prisma.project.findMany({
include: {
deliverables: {
select: { name: true, status: true, priority: true },
},
},
});
console.log(`\n📦 Processing ${projects.length} projects...`);
let success = 0;
let failed = 0;
for (const project of projects) {
try {
const text = buildProjectText(project);
const embedding = await generateEmbedding(text);
if (embedding) {
const vectorStr = `[${embedding.join(",")}]`;
await prisma.$executeRawUnsafe(
`UPDATE "projects" SET "embedding" = $1::vector WHERE "id" = $2`,
vectorStr,
project.id
);
success++;
process.stdout.write(
`\r ✅ Projects: ${success} done, ${failed} failed (${success + failed}/${projects.length})`
);
} else {
failed++;
console.warn(`\n ⚠️ Failed to generate embedding for project: ${project.name}`);
}
} catch (error) {
failed++;
console.error(
`\n ❌ Error processing project "${project.name}":`,
error instanceof Error ? error.message : error
);
}
}
console.log(`\n Projects complete: ${success} succeeded, ${failed} failed`);
}
async function backfillDeliverables() {
const deliverables = await prisma.deliverable.findMany({
include: {
project: { select: { name: true, projectCode: true } },
stages: {
include: { template: { select: { name: true } } },
},
},
});
console.log(`\n📋 Processing ${deliverables.length} deliverables...`);
let success = 0;
let failed = 0;
for (const deliverable of deliverables) {
try {
const text = buildDeliverableText({
...deliverable,
stages: deliverable.stages.map((s) => ({
template: s.template,
status: s.status,
})),
});
const embedding = await generateEmbedding(text);
if (embedding) {
const vectorStr = `[${embedding.join(",")}]`;
await prisma.$executeRawUnsafe(
`UPDATE "deliverables" SET "embedding" = $1::vector WHERE "id" = $2`,
vectorStr,
deliverable.id
);
success++;
process.stdout.write(
`\r ✅ Deliverables: ${success} done, ${failed} failed (${success + failed}/${deliverables.length})`
);
} else {
failed++;
console.warn(
`\n ⚠️ Failed to generate embedding for deliverable: ${deliverable.name}`
);
}
} catch (error) {
failed++;
console.error(
`\n ❌ Error processing deliverable "${deliverable.name}":`,
error instanceof Error ? error.message : error
);
}
}
console.log(
`\n Deliverables complete: ${success} succeeded, ${failed} failed`
);
}
async function main() {
console.log("🔍 Backfill Embeddings — HP CG Production Tracker");
console.log("═".repeat(50));
// Check Ollama health first
console.log("\n🏥 Checking Ollama health...");
const health = await checkOllamaHealth();
if (!health.available) {
console.error(
"❌ Ollama is not available. Make sure it's running at",
process.env.OLLAMA_HOST || "http://localhost:11434"
);
console.error(
" Start it with: ollama serve (or docker run -p 11434:11434 ollama/ollama)"
);
process.exit(1);
}
console.log(`✅ Ollama is running. Available models: ${health.models.join(", ")}`);
const embedModel = process.env.OLLAMA_EMBED_MODEL || "nomic-embed-text";
const hasModel = health.models.some((m) => m.includes(embedModel.split(":")[0]));
if (!hasModel) {
console.error(
`❌ Embedding model "${embedModel}" not found. Pull it with: ollama pull ${embedModel}`
);
process.exit(1);
}
console.log(`✅ Using embedding model: ${embedModel}`);
const startTime = Date.now();
await backfillProjects();
await backfillDeliverables();
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`\n${"═".repeat(50)}`);
console.log(`✨ Backfill complete in ${elapsed}s`);
await prisma.$disconnect();
}
main().catch((error) => {
console.error("\n💥 Fatal error:", error);
prisma.$disconnect();
process.exit(1);
});

View file

@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { getAuthSession, badRequest, serverError } from "@/lib/api-utils";
import {
semanticSearch,
logSearch,
} from "@/lib/services/semantic-search-service";
import { checkOllamaHealth } from "@/lib/services/embedding-service";
// POST /api/search/semantic — perform semantic search
export async function POST(request: Request) {
const { session, error } = await getAuthSession();
if (error) return error;
try {
const body = await request.json();
const { query, limit, includeSummary } = body;
if (!query || typeof query !== "string" || query.trim().length === 0) {
return badRequest("Query string is required");
}
if (query.trim().length > 500) {
return badRequest("Query must be 500 characters or less");
}
const results = await semanticSearch(
query.trim(),
session!.user.organizationId!,
{
limit: typeof limit === "number" ? Math.min(limit, 50) : 10,
includeSummary: includeSummary !== false,
}
);
// Log the search asynchronously (non-blocking)
logSearch(session!.user.id, query.trim(), results.totalResults).catch(
() => {}
);
return NextResponse.json(results);
} catch (e) {
return serverError(e);
}
}
// GET /api/search/semantic/health — check Ollama availability
export async function GET() {
const { error } = await getAuthSession();
if (error) return error;
try {
const health = await checkOllamaHealth();
return NextResponse.json(health);
} catch (e) {
return serverError(e);
}
}

View file

@ -13,6 +13,7 @@ import {
Monitor,
FileSpreadsheet,
Search,
Sparkles,
} from "lucide-react";
import { useTheme } from "next-themes";
import {
@ -60,6 +61,25 @@ export function CommandPalette() {
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{/* Actions */}
<CommandGroup heading="Actions">
<CommandItem
onSelect={() =>
runCommand(() =>
window.dispatchEvent(new CustomEvent("open-smart-search"))
)
}
>
<Sparkles className="mr-2 h-4 w-4" />
Smart Search
<span className="ml-auto text-[10px] text-[var(--muted-foreground)]">
AI-powered
</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
{/* Navigation */}
<CommandGroup heading="Navigation">
<CommandItem onSelect={() => runCommand(() => router.push("/dashboard"))}>

View file

@ -1,5 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
import {
@ -12,6 +13,7 @@ import {
AlertTriangle,
Unlock,
Clock,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/layout/theme-toggle";
@ -23,12 +25,14 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
useUnreadCount,
useNotifications,
useMarkAsRead,
useMarkAllAsRead,
} from "@/hooks/use-notifications";
import { SmartSearchPanel } from "@/components/search/smart-search-panel";
import { cn } from "@/lib/utils";
const NOTIF_ICONS: Record<string, typeof Bell> = {
@ -43,6 +47,7 @@ const NOTIF_ICONS: Record<string, typeof Bell> = {
};
export function Topbar() {
const [searchOpen, setSearchOpen] = useState(false);
const { data: countData } = useUnreadCount();
const { data: notifications } = useNotifications(10);
const markRead = useMarkAsRead();
@ -51,6 +56,13 @@ export function Topbar() {
const unreadCount = countData?.count ?? 0;
const items = (notifications as any[]) ?? [];
// Listen for custom event from command palette
useEffect(() => {
const handleOpenSearch = () => setSearchOpen(true);
window.addEventListener("open-smart-search", handleOpenSearch);
return () => window.removeEventListener("open-smart-search", handleOpenSearch);
}, []);
return (
<header className="flex h-14 items-center justify-between border-b bg-[var(--background)]/80 backdrop-blur-sm px-4 md:px-5 sticky top-0 z-10" role="banner">
<div className="flex items-center gap-2">
@ -59,6 +71,22 @@ export function Topbar() {
</div>
<div className="flex items-center gap-1.5">
{/* Smart Search button */}
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setSearchOpen(true)}
aria-label="Smart Search"
>
<Sparkles className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Smart Search</TooltipContent>
</Tooltip>
<ThemeToggle />
<Popover>
@ -171,6 +199,12 @@ export function Topbar() {
</PopoverContent>
</Popover>
</div>
{/* Smart Search Panel */}
<SmartSearchPanel
open={searchOpen}
onClose={() => setSearchOpen(false)}
/>
</header>
);
}

View file

@ -0,0 +1,466 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
Search,
Sparkles,
X,
FolderKanban,
FileBox,
ArrowRight,
Loader2,
AlertCircle,
Zap,
Filter,
Brain,
Blend,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
useSemanticSearch,
useOllamaHealth,
} from "@/hooks/use-semantic-search";
import type { SearchResult, SemanticSearchResponse } from "@/hooks/use-semantic-search";
// ─── Types ─────────────────────────────────────────────
interface ConversationEntry {
id: string;
type: "user" | "assistant";
content: string;
results?: SearchResult[];
searchType?: SemanticSearchResponse["searchType"];
timestamp: Date;
}
// ─── Status badge color helper ─────────────────────────
function statusColor(status: string): string {
const map: Record<string, string> = {
ACTIVE: "bg-[var(--status-in-progress)] text-white",
ON_HOLD: "bg-[var(--status-in-review)] text-white",
COMPLETED: "bg-[var(--status-approved)] text-white",
ARCHIVED: "bg-[var(--status-skipped)] text-white",
NOT_STARTED: "bg-[var(--status-not-started)] text-white",
IN_PROGRESS: "bg-[var(--status-in-progress)] text-white",
IN_REVIEW: "bg-[var(--status-in-review)] text-white",
APPROVED: "bg-[var(--status-approved)] text-white",
BLOCKED: "bg-[var(--status-blocked)] text-white",
DELIVERED: "bg-[var(--status-delivered)] text-white",
};
return map[status] || "bg-[var(--muted)] text-[var(--muted-foreground)]";
}
function searchTypeIcon(type: SemanticSearchResponse["searchType"]) {
switch (type) {
case "semantic":
return <Brain className="h-3 w-3" />;
case "structural":
return <Filter className="h-3 w-3" />;
case "hybrid":
return <Blend className="h-3 w-3" />;
}
}
function searchTypeLabel(type: SemanticSearchResponse["searchType"]) {
switch (type) {
case "semantic":
return "AI semantic search";
case "structural":
return "Filter-based search";
case "hybrid":
return "Hybrid search";
}
}
// ─── Result Card ───────────────────────────────────────
function ResultCard({
result,
onClick,
}: {
result: SearchResult;
onClick: () => void;
}) {
const isProject = result.type === "project";
const relevancePct = Math.round(result.relevanceScore * 100);
return (
<button
onClick={onClick}
className="group w-full rounded-lg border bg-[var(--card)] p-3 text-left transition-all hover:border-[var(--primary)]/30 hover:shadow-[var(--shadow-sm)]"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
{isProject ? (
<FolderKanban className="h-4 w-4 shrink-0 text-[var(--primary)]" />
) : (
<FileBox className="h-4 w-4 shrink-0 text-[var(--accent)]" />
)}
<div className="min-w-0">
<p className="text-sm font-medium truncate">{result.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
{result.projectCode && (
<span className="font-mono text-[10px] text-[var(--muted-foreground)]">
{result.projectCode}
</span>
)}
{result.projectName && !isProject && (
<span className="text-[10px] text-[var(--muted-foreground)] truncate">
in {result.projectName}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Badge
className={cn(
"text-[9px] px-1.5 py-0 h-4 font-semibold",
statusColor(result.status)
)}
>
{result.status.replace(/_/g, " ")}
</Badge>
<ArrowRight className="h-3 w-3 text-[var(--muted-foreground)] opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
{result.description && (
<p className="mt-1.5 text-[11px] text-[var(--muted-foreground)] line-clamp-2">
{result.description}
</p>
)}
<div className="mt-2 flex items-center gap-2">
<span className="label-upper !text-[8px]">
{isProject ? "Project" : "Deliverable"}
</span>
{relevancePct < 100 && (
<span className="text-[9px] text-[var(--muted-foreground)]">
{relevancePct}% match
</span>
)}
<Badge variant="outline" className="text-[8px] px-1 py-0 h-3.5">
{result.priority}
</Badge>
</div>
</button>
);
}
// ─── Suggested queries ─────────────────────────────────
const SUGGESTED_QUERIES = [
"Which projects are running behind?",
"Show me active high priority projects",
"Deliverables in review this week",
"What needs my attention?",
];
// ─── Main Panel Component ──────────────────────────────
export function SmartSearchPanel({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [inputValue, setInputValue] = useState("");
const [conversation, setConversation] = useState<ConversationEntry[]>([]);
const search = useSemanticSearch();
const { data: ollamaHealth } = useOllamaHealth();
// Focus input when panel opens
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [open]);
// Scroll to bottom on new messages
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [conversation, search.isPending]);
// Close on Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
const handleSearch = useCallback(
async (query: string) => {
if (!query.trim() || search.isPending) return;
const userEntry: ConversationEntry = {
id: crypto.randomUUID(),
type: "user",
content: query.trim(),
timestamp: new Date(),
};
setConversation((prev) => [...prev, userEntry]);
setInputValue("");
search.mutate(
{ query: query.trim() },
{
onSuccess: (data) => {
const assistantEntry: ConversationEntry = {
id: crypto.randomUUID(),
type: "assistant",
content: data.summary || `Found ${data.totalResults} result${data.totalResults !== 1 ? "s" : ""}.`,
results: data.results,
searchType: data.searchType,
timestamp: new Date(),
};
setConversation((prev) => [...prev, assistantEntry]);
},
onError: (error) => {
const errorEntry: ConversationEntry = {
id: crypto.randomUUID(),
type: "assistant",
content: `Search failed: ${error.message}. Please try again.`,
timestamp: new Date(),
};
setConversation((prev) => [...prev, errorEntry]);
},
}
);
},
[search]
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
handleSearch(inputValue);
};
const handleResultClick = (result: SearchResult) => {
router.push(result.link);
onClose();
};
const handleClear = () => {
setConversation([]);
setInputValue("");
inputRef.current?.focus();
};
if (!open) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-[2px]"
onClick={onClose}
aria-hidden="true"
/>
{/* Panel */}
<div
className={cn(
"fixed right-0 top-0 z-50 flex h-full w-full max-w-md flex-col border-l bg-[var(--background)] shadow-[var(--shadow-lg)]",
"animate-in slide-in-from-right duration-200"
)}
role="dialog"
aria-label="Smart Search"
>
{/* Header */}
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-[var(--primary)]" />
<h2 className="text-sm font-semibold">Smart Search</h2>
{ollamaHealth?.available ? (
<Badge
variant="outline"
className="h-4 px-1.5 text-[8px] font-semibold border-emerald-500/30 text-emerald-600 dark:text-emerald-400"
>
<Zap className="mr-0.5 h-2 w-2" />
AI Active
</Badge>
) : (
<Badge
variant="outline"
className="h-4 px-1.5 text-[8px] font-semibold border-amber-500/30 text-amber-600 dark:text-amber-400"
>
Text Only
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{conversation.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 text-[10px]"
onClick={handleClear}
>
Clear
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onClose}
aria-label="Close search"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Conversation Area */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{/* Empty state */}
{conversation.length === 0 && !search.isPending && (
<div className="flex flex-col items-center pt-12 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--primary)]/10 mb-4">
<Search className="h-6 w-6 text-[var(--primary)]" />
</div>
<h3 className="text-sm font-semibold mb-1">
Ask anything about your projects
</h3>
<p className="text-[11px] text-[var(--muted-foreground)] max-w-xs mb-6">
Search using natural language. Ask about project status, find
deliverables, or explore your production data.
</p>
{/* Suggested queries */}
<div className="w-full space-y-1.5">
<p className="label-upper mb-2">Try asking</p>
{SUGGESTED_QUERIES.map((q) => (
<button
key={q}
onClick={() => handleSearch(q)}
className="w-full rounded-lg border bg-[var(--card)] px-3 py-2.5 text-left text-xs text-[var(--foreground)] transition-colors hover:border-[var(--primary)]/30 hover:bg-[var(--primary)]/5"
>
<Search className="mr-2 inline h-3 w-3 text-[var(--muted-foreground)]" />
{q}
</button>
))}
</div>
</div>
)}
{/* Messages */}
{conversation.map((entry) => (
<div key={entry.id}>
{entry.type === "user" ? (
/* User message */
<div className="flex justify-end">
<div className="max-w-[85%] rounded-xl rounded-tr-sm bg-[var(--primary)] px-3.5 py-2 text-sm text-[var(--primary-foreground)]">
{entry.content}
</div>
</div>
) : (
/* Assistant response */
<div className="space-y-3">
{/* Search type indicator */}
{entry.searchType && (
<div className="flex items-center gap-1.5">
{searchTypeIcon(entry.searchType)}
<span className="text-[10px] text-[var(--muted-foreground)]">
{searchTypeLabel(entry.searchType)}
</span>
</div>
)}
{/* Summary */}
<div className="rounded-xl rounded-tl-sm border bg-[var(--card)] px-3.5 py-2.5">
<p className="text-sm leading-relaxed">{entry.content}</p>
</div>
{/* Results */}
{entry.results && entry.results.length > 0 && (
<div className="space-y-2">
{entry.results.map((result) => (
<ResultCard
key={result.id}
result={result}
onClick={() => handleResultClick(result)}
/>
))}
</div>
)}
{/* No results */}
{entry.results && entry.results.length === 0 && (
<div className="flex items-center gap-2 rounded-lg border border-dashed px-3 py-4 text-center">
<AlertCircle className="h-4 w-4 text-[var(--muted-foreground)]" />
<p className="text-xs text-[var(--muted-foreground)]">
No matching projects or deliverables found. Try rephrasing
your question.
</p>
</div>
)}
</div>
)}
</div>
))}
{/* Loading indicator */}
{search.isPending && (
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-xs">Searching...</span>
</div>
)}
</div>
{/* Input Area */}
<div className="border-t bg-[var(--card)] p-3">
<form onSubmit={handleSubmit} className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted-foreground)]" />
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Ask about your projects..."
className="w-full rounded-lg border bg-[var(--background)] py-2.5 pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-[var(--muted-foreground)] focus:border-[var(--primary)]/40 focus:ring-1 focus:ring-[var(--primary)]/20"
disabled={search.isPending}
autoComplete="off"
/>
</div>
<Button
type="submit"
size="sm"
className="h-9 px-3"
disabled={!inputValue.trim() || search.isPending}
>
{search.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
</Button>
</form>
<p className="mt-1.5 text-center text-[9px] text-[var(--muted-foreground)]">
{ollamaHealth?.available
? "Powered by local AI — your data never leaves the network"
: "AI unavailable — using text search fallback"}
</p>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
import type {
SearchResult,
SemanticSearchResponse,
} from "@/lib/services/semantic-search-service";
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}
/**
* Performs a semantic search query.
* Uses mutation instead of query because searches are user-initiated actions
* with a POST body, not cacheable data fetches.
*/
export function useSemanticSearch() {
return useMutation({
mutationFn: async ({
query,
limit,
includeSummary,
}: {
query: string;
limit?: number;
includeSummary?: boolean;
}) =>
fetchJson<SemanticSearchResponse>("/api/search/semantic", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, limit, includeSummary }),
}),
});
}
/**
* Checks Ollama health status (for showing AI availability indicator).
*/
export function useOllamaHealth() {
return useQuery({
queryKey: ["ollama-health"],
queryFn: () =>
fetchJson<{ available: boolean; models: string[] }>(
"/api/search/semantic",
{ method: "GET" }
),
staleTime: 60_000, // Re-check every 60 seconds
refetchOnWindowFocus: false,
});
}
// Re-export types for convenience
export type { SearchResult, SemanticSearchResponse };

View file

@ -4,6 +4,10 @@ import type {
UpdateDeliverableInput,
} from "@/lib/validators/deliverable";
import type { StageStatus } from "@/generated/prisma/client";
import {
updateDeliverableEmbedding,
updateProjectEmbedding,
} from "@/lib/services/embedding-service";
/**
* Creates a deliverable and auto-creates all 10 pipeline stages.
@ -63,7 +67,7 @@ export async function createDeliverable(
await tx.deliverableStage.createMany({ data: stageData });
// Return the deliverable with its stages
return tx.deliverable.findUnique({
const result = await tx.deliverable.findUnique({
where: { id: deliverable.id },
include: {
stages: {
@ -72,6 +76,13 @@ export async function createDeliverable(
},
},
});
// Generate embeddings asynchronously (non-blocking, after transaction)
// Update both the deliverable and its parent project (since project text includes deliverable names)
updateDeliverableEmbedding(deliverable.id).catch(() => {});
updateProjectEmbedding(projectId).catch(() => {});
return result;
});
}
@ -110,10 +121,15 @@ export async function updateDeliverable(
id: string,
data: UpdateDeliverableInput
) {
return prisma.deliverable.update({
const deliverable = await prisma.deliverable.update({
where: { id },
data,
});
// Regenerate embedding asynchronously (non-blocking)
updateDeliverableEmbedding(deliverable.id).catch(() => {});
return deliverable;
}
export async function deleteDeliverable(id: string) {

View file

@ -0,0 +1,227 @@
/**
* Embedding Service generates and stores vector embeddings via Ollama.
*
* Uses the local Ollama API with the `nomic-embed-text` model to convert
* text representations of projects/deliverables into 768-dimensional vectors,
* stored in PostgreSQL via pgvector for semantic similarity search.
*
* Zero cost everything runs on-premises. No data leaves the network.
*/
import { prisma } from "@/lib/prisma";
const OLLAMA_HOST =
process.env.OLLAMA_HOST || "http://localhost:11434";
const EMBED_MODEL =
process.env.OLLAMA_EMBED_MODEL || "nomic-embed-text";
// ─── Text representation builders ──────────────────────
/**
* Builds a rich text representation of a project for embedding.
* Concatenates key fields to capture the full semantic meaning.
*/
export function buildProjectText(project: {
name: string;
projectCode: string;
description?: string | null;
status: string;
priority: string;
businessUnit?: string | null;
formFactor?: string | null;
codeName?: string | null;
npiOrRefresh?: string | null;
quarter?: string | null;
requestor?: string | null;
agency?: string | null;
deliverables?: { name: string; status: string; priority: string }[];
}): string {
const parts = [
`Project: ${project.name}`,
`Code: ${project.projectCode}`,
project.description && `Description: ${project.description}`,
`Status: ${project.status}`,
`Priority: ${project.priority}`,
project.businessUnit && `Business Unit: ${project.businessUnit}`,
project.formFactor && `Form Factor: ${project.formFactor}`,
project.codeName && `Code Name: ${project.codeName}`,
project.npiOrRefresh && `NPI/Refresh: ${project.npiOrRefresh}`,
project.quarter && `Quarter: ${project.quarter}`,
project.requestor && `Requestor: ${project.requestor}`,
project.agency && `Agency: ${project.agency}`,
];
if (project.deliverables?.length) {
const delivNames = project.deliverables
.map((d) => `${d.name} (${d.status}, ${d.priority})`)
.join("; ");
parts.push(`Deliverables: ${delivNames}`);
}
return parts.filter(Boolean).join(". ");
}
/**
* Builds a rich text representation of a deliverable for embedding.
*/
export function buildDeliverableText(deliverable: {
name: string;
status: string;
priority: string;
notes?: string | null;
cmfSku?: string | null;
project?: { name: string; projectCode: string } | null;
stages?: { template: { name: string }; status: string }[];
}): string {
const parts = [
`Deliverable: ${deliverable.name}`,
`Status: ${deliverable.status}`,
`Priority: ${deliverable.priority}`,
deliverable.notes && `Notes: ${deliverable.notes}`,
deliverable.cmfSku && `CMF/SKU: ${deliverable.cmfSku}`,
deliverable.project &&
`Project: ${deliverable.project.name} (${deliverable.project.projectCode})`,
];
if (deliverable.stages?.length) {
const stageInfo = deliverable.stages
.map((s) => `${s.template.name}: ${s.status}`)
.join("; ");
parts.push(`Pipeline stages: ${stageInfo}`);
}
return parts.filter(Boolean).join(". ");
}
// ─── Ollama API interaction ────────────────────────────
/**
* Generates a 768-dimensional embedding vector via Ollama's API.
* Returns null if Ollama is unavailable (graceful degradation).
*/
export async function generateEmbedding(
text: string
): Promise<number[] | null> {
try {
const response = await fetch(`${OLLAMA_HOST}/api/embeddings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: EMBED_MODEL,
prompt: text,
}),
signal: AbortSignal.timeout(30_000), // 30s timeout
});
if (!response.ok) {
console.warn(
`[embedding-service] Ollama returned ${response.status}: ${response.statusText}`
);
return null;
}
const data = await response.json();
return data.embedding as number[];
} catch (error) {
console.warn(
"[embedding-service] Ollama unavailable, skipping embedding generation:",
error instanceof Error ? error.message : error
);
return null;
}
}
// ─── Store embeddings via raw SQL ──────────────────────
/**
* Generates and stores an embedding for a project.
* Fetches full project data including deliverables for richer context.
*/
export async function updateProjectEmbedding(
projectId: string
): Promise<void> {
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
deliverables: {
select: { name: true, status: true, priority: true },
},
},
});
if (!project) return;
const text = buildProjectText(project);
const embedding = await generateEmbedding(text);
if (!embedding) return;
const vectorStr = `[${embedding.join(",")}]`;
await prisma.$executeRawUnsafe(
`UPDATE "projects" SET "embedding" = $1::vector WHERE "id" = $2`,
vectorStr,
projectId
);
}
/**
* Generates and stores an embedding for a deliverable.
* Fetches full deliverable data including project context and pipeline stages.
*/
export async function updateDeliverableEmbedding(
deliverableId: string
): Promise<void> {
const deliverable = await prisma.deliverable.findUnique({
where: { id: deliverableId },
include: {
project: { select: { name: true, projectCode: true } },
stages: {
include: { template: { select: { name: true } } },
select: { status: true, template: true },
},
},
});
if (!deliverable) return;
const text = buildDeliverableText(deliverable);
const embedding = await generateEmbedding(text);
if (!embedding) return;
const vectorStr = `[${embedding.join(",")}]`;
await prisma.$executeRawUnsafe(
`UPDATE "deliverables" SET "embedding" = $1::vector WHERE "id" = $2`,
vectorStr,
deliverableId
);
}
// ─── Ollama health check ───────────────────────────────
/**
* Checks if Ollama is running and the embedding model is available.
*/
export async function checkOllamaHealth(): Promise<{
available: boolean;
models: string[];
}> {
try {
const response = await fetch(`${OLLAMA_HOST}/api/tags`, {
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
return { available: false, models: [] };
}
const data = await response.json();
const models = (data.models || []).map(
(m: { name: string }) => m.name
);
return { available: true, models };
} catch {
return { available: false, models: [] };
}
}

View file

@ -1,5 +1,6 @@
import { prisma } from "@/lib/prisma";
import type { CreateProjectInput, UpdateProjectInput } from "@/lib/validators/project";
import { updateProjectEmbedding } from "@/lib/services/embedding-service";
export async function listProjects(organizationId: string) {
return prisma.project.findMany({
@ -32,12 +33,17 @@ export async function createProject(
data: CreateProjectInput,
organizationId: string
) {
return prisma.project.create({
const project = await prisma.project.create({
data: {
...data,
organizationId,
},
});
// Generate embedding asynchronously (non-blocking)
updateProjectEmbedding(project.id).catch(() => {});
return project;
}
export async function updateProject(
@ -45,10 +51,15 @@ export async function updateProject(
data: UpdateProjectInput,
organizationId: string
) {
return prisma.project.update({
const project = await prisma.project.update({
where: { id, organizationId },
data,
});
// Regenerate embedding asynchronously (non-blocking)
updateProjectEmbedding(project.id).catch(() => {});
return project;
}
export async function deleteProject(id: string, organizationId: string) {

View file

@ -0,0 +1,574 @@
/**
* Semantic Search Service vector search + hybrid routing + LLM summarization.
*
* Handles the full search flow:
* 1. Detect if the query is structural (status, dates, priority) or semantic
* 2. For semantic queries: embed the query, run cosine similarity via pgvector
* 3. For structural queries: route to standard Prisma filters
* 4. Optionally summarize results via a local Ollama LLM
*/
import { prisma } from "@/lib/prisma";
import { generateEmbedding } from "@/lib/services/embedding-service";
const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434";
const LLM_MODEL = process.env.OLLAMA_LLM_MODEL || "qwen3:8b";
// ─── Types ─────────────────────────────────────────────
export interface SearchResult {
id: string;
type: "project" | "deliverable";
name: string;
status: string;
priority: string;
projectCode?: string;
projectName?: string;
projectId?: string;
description?: string | null;
relevanceScore: number;
link: string;
}
export interface SemanticSearchResponse {
results: SearchResult[];
summary: string | null;
query: string;
searchType: "semantic" | "structural" | "hybrid";
totalResults: number;
}
// ─── Structural query detection ────────────────────────
/**
* Patterns that indicate a structural/filterable query rather than a
* meaning-based semantic query.
*/
const STRUCTURAL_PATTERNS = {
status: /\b(active|on.?hold|completed|archived|blocked|in.?progress|in.?review|approved|delivered|not.?started|changes.?requested|skipped|overdue|behind|late)\b/i,
priority: /\b(low|medium|high|urgent)\s+priority\b/i,
priorityReverse: /\bpriority\s+(low|medium|high|urgent)\b/i,
dateRelative: /\b(overdue|past.?due|due\s+(?:this|next)\s+(?:week|month)|due\s+(?:today|tomorrow))\b/i,
};
const STATUS_MAP: Record<string, string> = {
active: "ACTIVE",
"on hold": "ON_HOLD",
onhold: "ON_HOLD",
completed: "COMPLETED",
archived: "ARCHIVED",
blocked: "BLOCKED",
"in progress": "IN_PROGRESS",
inprogress: "IN_PROGRESS",
"in review": "IN_REVIEW",
inreview: "IN_REVIEW",
approved: "APPROVED",
delivered: "DELIVERED",
"not started": "NOT_STARTED",
notstarted: "NOT_STARTED",
"changes requested": "CHANGES_REQUESTED",
skipped: "SKIPPED",
};
const PRIORITY_MAP: Record<string, string> = {
low: "LOW",
medium: "MEDIUM",
high: "HIGH",
urgent: "URGENT",
};
interface StructuralFilters {
status?: string;
priority?: string;
isOverdue?: boolean;
}
function detectStructuralFilters(query: string): StructuralFilters | null {
const filters: StructuralFilters = {};
let hasFilter = false;
// Check for status mentions
const statusMatch = query.match(STRUCTURAL_PATTERNS.status);
if (statusMatch) {
const normalized = statusMatch[1].toLowerCase().replace(/[_\s-]+/g, " ").trim();
// "overdue", "behind", "late" are not status values but indicate overdue
if (["overdue", "behind", "late"].includes(normalized)) {
filters.isOverdue = true;
hasFilter = true;
} else {
const mapped = STATUS_MAP[normalized] || STATUS_MAP[normalized.replace(/\s+/g, "")];
if (mapped) {
filters.status = mapped;
hasFilter = true;
}
}
}
// Check for priority
const priorityMatch =
query.match(STRUCTURAL_PATTERNS.priority) ||
query.match(STRUCTURAL_PATTERNS.priorityReverse);
if (priorityMatch) {
const mapped = PRIORITY_MAP[priorityMatch[1].toLowerCase()];
if (mapped) {
filters.priority = mapped;
hasFilter = true;
}
}
// Check for overdue/date-relative
const dateMatch = query.match(STRUCTURAL_PATTERNS.dateRelative);
if (dateMatch) {
const term = dateMatch[1].toLowerCase();
if (term.includes("overdue") || term.includes("past due")) {
filters.isOverdue = true;
hasFilter = true;
}
}
return hasFilter ? filters : null;
}
// ─── Structural search ─────────────────────────────────
async function structuralSearch(
filters: StructuralFilters,
organizationId: string,
limit: number
): Promise<SearchResult[]> {
const results: SearchResult[] = [];
// Search projects
const projectWhere: Record<string, unknown> = { organizationId };
if (filters.status && ["ACTIVE", "ON_HOLD", "COMPLETED", "ARCHIVED"].includes(filters.status)) {
projectWhere.status = filters.status;
}
if (filters.priority) {
projectWhere.priority = filters.priority;
}
if (filters.isOverdue) {
projectWhere.dueDate = { lt: new Date() };
projectWhere.status = { not: "COMPLETED" };
}
const projects = await prisma.project.findMany({
where: projectWhere,
take: limit,
orderBy: { updatedAt: "desc" },
});
for (const p of projects) {
results.push({
id: p.id,
type: "project",
name: p.name,
status: p.status,
priority: p.priority,
projectCode: p.projectCode,
description: p.description,
relevanceScore: 1.0,
link: `/projects/${p.id}`,
});
}
// Search deliverables
const deliverableWhere: Record<string, unknown> = {
project: { organizationId },
};
if (
filters.status &&
["NOT_STARTED", "IN_PROGRESS", "IN_REVIEW", "APPROVED", "ON_HOLD"].includes(filters.status)
) {
deliverableWhere.status = filters.status;
}
if (filters.priority) {
deliverableWhere.priority = filters.priority;
}
if (filters.isOverdue) {
deliverableWhere.dueDate = { lt: new Date() };
deliverableWhere.status = { not: "APPROVED" };
}
const deliverables = await prisma.deliverable.findMany({
where: deliverableWhere,
include: {
project: { select: { name: true, projectCode: true, id: true } },
},
take: limit,
orderBy: { updatedAt: "desc" },
});
for (const d of deliverables) {
results.push({
id: d.id,
type: "deliverable",
name: d.name,
status: d.status,
priority: d.priority,
projectName: d.project.name,
projectCode: d.project.projectCode,
projectId: d.project.id,
relevanceScore: 1.0,
link: `/projects/${d.project.id}`,
});
}
return results.slice(0, limit);
}
// ─── Vector search ─────────────────────────────────────
interface VectorSearchRow {
id: string;
name: string;
status: string;
priority: string;
distance: number;
}
interface ProjectVectorRow extends VectorSearchRow {
projectCode: string;
description: string | null;
}
interface DeliverableVectorRow extends VectorSearchRow {
projectId: string;
project_name: string;
project_code: string;
}
async function vectorSearch(
query: string,
organizationId: string,
limit: number
): Promise<SearchResult[]> {
const embedding = await generateEmbedding(query);
if (!embedding) {
// Ollama unavailable — fall back to text-based search
return textFallbackSearch(query, organizationId, limit);
}
const vectorStr = `[${embedding.join(",")}]`;
const results: SearchResult[] = [];
// Search projects by cosine similarity
const projectRows = await prisma.$queryRawUnsafe<ProjectVectorRow[]>(
`SELECT p."id", p."name", p."status", p."priority", p."projectCode", p."description",
p."embedding" <=> $1::vector AS distance
FROM "projects" p
WHERE p."organizationId" = $2
AND p."embedding" IS NOT NULL
ORDER BY distance ASC
LIMIT $3`,
vectorStr,
organizationId,
limit
);
for (const row of projectRows) {
results.push({
id: row.id,
type: "project",
name: row.name,
status: row.status,
priority: row.priority,
projectCode: row.projectCode,
description: row.description,
relevanceScore: Math.max(0, 1 - Number(row.distance)),
link: `/projects/${row.id}`,
});
}
// Search deliverables by cosine similarity
const deliverableRows = await prisma.$queryRawUnsafe<DeliverableVectorRow[]>(
`SELECT d."id", d."name", d."status", d."priority", d."projectId",
p."name" AS project_name, p."projectCode" AS project_code,
d."embedding" <=> $1::vector AS distance
FROM "deliverables" d
JOIN "projects" p ON d."projectId" = p."id"
WHERE p."organizationId" = $2
AND d."embedding" IS NOT NULL
ORDER BY distance ASC
LIMIT $3`,
vectorStr,
organizationId,
limit
);
for (const row of deliverableRows) {
results.push({
id: row.id,
type: "deliverable",
name: row.name,
status: row.status,
priority: row.priority,
projectName: row.project_name,
projectCode: row.project_code,
projectId: row.projectId,
relevanceScore: Math.max(0, 1 - Number(row.distance)),
link: `/projects/${row.projectId}`,
});
}
// Sort combined results by relevance and take top N
results.sort((a, b) => b.relevanceScore - a.relevanceScore);
return results.slice(0, limit);
}
// ─── Text fallback search (when Ollama is unavailable) ─
async function textFallbackSearch(
query: string,
organizationId: string,
limit: number
): Promise<SearchResult[]> {
const results: SearchResult[] = [];
const searchTerm = `%${query}%`;
const projects = await prisma.project.findMany({
where: {
organizationId,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ description: { contains: query, mode: "insensitive" } },
{ projectCode: { contains: query, mode: "insensitive" } },
{ codeName: { contains: query, mode: "insensitive" } },
{ businessUnit: { contains: query, mode: "insensitive" } },
],
},
take: limit,
orderBy: { updatedAt: "desc" },
});
for (const p of projects) {
results.push({
id: p.id,
type: "project",
name: p.name,
status: p.status,
priority: p.priority,
projectCode: p.projectCode,
description: p.description,
relevanceScore: 0.5,
link: `/projects/${p.id}`,
});
}
const deliverables = await prisma.deliverable.findMany({
where: {
project: { organizationId },
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ notes: { contains: query, mode: "insensitive" } },
{ cmfSku: { contains: query, mode: "insensitive" } },
],
},
include: {
project: { select: { name: true, projectCode: true, id: true } },
},
take: limit,
orderBy: { updatedAt: "desc" },
});
for (const d of deliverables) {
results.push({
id: d.id,
type: "deliverable",
name: d.name,
status: d.status,
priority: d.priority,
projectName: d.project.name,
projectCode: d.project.projectCode,
projectId: d.project.id,
relevanceScore: 0.5,
link: `/projects/${d.project.id}`,
});
}
return results.slice(0, limit);
}
// ─── LLM summarization ────────────────────────────────
/**
* Generates a natural language summary of search results using a local Ollama LLM.
* Returns null if Ollama/LLM is unavailable.
*/
async function generateSummary(
query: string,
results: SearchResult[]
): Promise<string | null> {
if (results.length === 0) return null;
try {
const resultContext = results
.slice(0, 10)
.map((r, i) => {
const parts = [
`${i + 1}. [${r.type}] ${r.name}`,
`Status: ${r.status}`,
`Priority: ${r.priority}`,
];
if (r.projectCode) parts.push(`Code: ${r.projectCode}`);
if (r.projectName) parts.push(`Project: ${r.projectName}`);
if (r.description) parts.push(`Description: ${r.description}`);
return parts.join(", ");
})
.join("\n");
const prompt = `/no_think
You are a helpful assistant for the HP CG Production Tracker, a project management tool for HP product photography and CG rendering.
A producer searched: "${query}"
Here are the matching results:
${resultContext}
Provide a brief, helpful 1-3 sentence summary of these results. Focus on answering the producer's question directly. Be concise and specific. Reference actual project names and statuses. Do not mention result numbers or relevance scores.`;
const response = await fetch(`${OLLAMA_HOST}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: LLM_MODEL,
prompt,
stream: false,
options: {
temperature: 0.3,
num_predict: 200,
},
}),
signal: AbortSignal.timeout(60_000), // 60s timeout for LLM
});
if (!response.ok) {
console.warn(
`[semantic-search] LLM returned ${response.status}: ${response.statusText}`
);
return null;
}
const data = await response.json();
return (data.response as string)?.trim() || null;
} catch (error) {
console.warn(
"[semantic-search] LLM summarization unavailable:",
error instanceof Error ? error.message : error
);
return null;
}
}
// ─── Main search function ──────────────────────────────
export async function semanticSearch(
query: string,
organizationId: string,
options: {
limit?: number;
includeSummary?: boolean;
} = {}
): Promise<SemanticSearchResponse> {
const { limit = 10, includeSummary = true } = options;
// 1. Check for structural filters
const structuralFilters = detectStructuralFilters(query);
let results: SearchResult[];
let searchType: SemanticSearchResponse["searchType"];
if (structuralFilters) {
// Query has clear structural patterns — check if it also has semantic content
const strippedQuery = query
.replace(STRUCTURAL_PATTERNS.status, "")
.replace(STRUCTURAL_PATTERNS.priority, "")
.replace(STRUCTURAL_PATTERNS.priorityReverse, "")
.replace(STRUCTURAL_PATTERNS.dateRelative, "")
.trim();
if (strippedQuery.length > 3) {
// Hybrid: structural filters + semantic search on the remaining query
const [structuralResults, semanticResults] = await Promise.all([
structuralSearch(structuralFilters, organizationId, limit),
vectorSearch(strippedQuery, organizationId, limit),
]);
// Merge: boost results that appear in both sets
const seenIds = new Set(structuralResults.map((r) => r.id));
results = [...structuralResults];
for (const sr of semanticResults) {
if (seenIds.has(sr.id)) {
// Boost existing result
const existing = results.find((r) => r.id === sr.id);
if (existing) {
existing.relevanceScore = Math.min(
1,
existing.relevanceScore + sr.relevanceScore * 0.5
);
}
} else {
results.push(sr);
}
}
results.sort((a, b) => b.relevanceScore - a.relevanceScore);
results = results.slice(0, limit);
searchType = "hybrid";
} else {
// Pure structural query
results = await structuralSearch(structuralFilters, organizationId, limit);
searchType = "structural";
}
} else {
// Pure semantic query
results = await vectorSearch(query, organizationId, limit);
searchType = "semantic";
}
// 2. Optionally generate LLM summary
let summary: string | null = null;
if (includeSummary && results.length > 0) {
summary = await generateSummary(query, results);
}
return {
results,
summary,
query,
searchType,
totalResults: results.length,
};
}
// ─── Search logging ────────────────────────────────────
export async function logSearch(
userId: string,
query: string,
resultCount: number
): Promise<void> {
try {
await prisma.searchLog.create({
data: { userId, query, resultCount },
});
} catch (error) {
// Non-critical — don't fail the request if logging fails
console.warn("[semantic-search] Failed to log search:", error);
}
}
export async function logSearchClick(
searchLogId: string,
clickedId: string
): Promise<void> {
try {
await prisma.searchLog.update({
where: { id: searchLogId },
data: { clickedId },
});
} catch (error) {
console.warn("[semantic-search] Failed to log click:", error);
}
}