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:
parent
ed079ffbe1
commit
9d5acf1683
18 changed files with 1813 additions and 11 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
|
|
@ -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
48
Dockerfile
Normal 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"]
|
||||
|
|
@ -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
47
docker-compose.yml
Normal 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:
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
28
prisma/migrations/20260306_add_pgvector/migration.sql
Normal file
28
prisma/migrations/20260306_add_pgvector/migration.sql
Normal 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;
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
177
scripts/backfill-embeddings.ts
Normal file
177
scripts/backfill-embeddings.ts
Normal 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);
|
||||
});
|
||||
57
src/app/api/search/semantic/route.ts
Normal file
57
src/app/api/search/semantic/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
466
src/components/search/smart-search-panel.tsx
Normal file
466
src/components/search/smart-search-panel.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
src/hooks/use-semantic-search.ts
Normal file
59
src/hooks/use-semantic-search.ts
Normal 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 };
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
227
src/lib/services/embedding-service.ts
Normal file
227
src/lib/services/embedding-service.ts
Normal 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: [] };
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
574
src/lib/services/semantic-search-service.ts
Normal file
574
src/lib/services/semantic-search-service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue