gadget/gadget-code/src/controllers/api/v1/search.ts
2026-05-19 15:43:24 -04:00

171 lines
5.0 KiB
TypeScript

// src/controllers/api/v1/search.ts
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { Request, Response } from "express";
import { DtpController } from "../../../lib/controller.js";
import VectorStoreService, { ISearchFilters, ISearchResult } from "../../../services/vector-store.js";
import User from "../../../models/user.js";
import Project from "../../../models/project.js";
import ChatSession from "../../../models/chat-session.js";
import ChatTurn from "../../../models/chat-turn.js";
interface IHydratedSearchResult extends ISearchResult {
user?: { _id: string; displayName: string; username: string };
project?: { _id: string; name: string; slug: string };
session?: { _id: string; name: string };
turn?: { _id: string; status: string; mode: string; llm: string };
}
interface ISearchRequestBody {
query: string;
userId?: string;
projectId?: string;
sessionId?: string;
turnId?: string;
topK?: number;
}
class SearchController extends DtpController {
get name(): string {
return "SearchController";
}
get slug(): string {
return "ctrl:api:v1:search";
}
get route(): string {
return "/search";
}
async start(): Promise<void> {
this.router.post("/", this.requireUser(), this.search.bind(this));
}
/**
* POST /api/v1/search
* Semantic search across chat history.
*
* Enforces userId filter: users can only search their own corpus.
*/
private async search(req: Request, res: Response): Promise<void> {
try {
const body = req.body as ISearchRequestBody;
if (!body.query?.trim()) {
res.status(400).json({
success: false,
message: "query is required",
});
return;
}
// Enforce that the user can only search their own corpus
const userId = (req.user as any)?._id;
if (!userId) {
res.status(403).json({
success: false,
message: "Authentication required",
});
return;
}
const filters: ISearchFilters = {
userId,
projectId: body.projectId,
sessionId: body.sessionId,
turnId: body.turnId,
};
const topK = body.topK && body.topK > 0 && body.topK <= 50
? body.topK
: 10;
// Check if vector store is initialized
if (!VectorStoreService.isReady) {
res.status(503).json({
success: false,
message: "Vector store is not configured. Set qdrant.providerId in gadget-code.yaml to enable semantic search.",
});
return;
}
const results = await VectorStoreService.search(
body.query.trim(),
filters,
topK,
);
// Hydrate results with MongoDB documents
const hydratedResults = await this.hydrateResults(results);
res.json({
success: true,
data: hydratedResults,
});
} catch (error) {
this.log.error("search failed", { error });
res.status(500).json({
success: false,
message: (error as Error).message || "Search failed",
});
}
}
/**
* Hydrate search results with user, project, session, and turn data from MongoDB.
* Collects unique IDs to batch-load documents efficiently.
*/
private async hydrateResults(
results: ISearchResult[],
): Promise<IHydratedSearchResult[]> {
if (results.length === 0) return [];
// Collect unique IDs for batch loading
const userIds = [...new Set(results.map((r) => r.userId).filter(Boolean))];
const projectIds = [...new Set(results.map((r) => r.projectId).filter(Boolean))];
const sessionIds = [...new Set(results.map((r) => r.sessionId).filter(Boolean))];
const turnIds = [...new Set(results.map((r) => r.turnId).filter(Boolean))];
// Batch load from MongoDB
const [users, projects, sessions, turns] = await Promise.all([
userIds.length > 0
? User.find({ _id: { $in: userIds } })
.select("_id displayName username")
.lean()
: [],
projectIds.length > 0
? Project.find({ _id: { $in: projectIds } })
.select("_id name slug")
.lean()
: [],
sessionIds.length > 0
? ChatSession.find({ _id: { $in: sessionIds } })
.select("_id name")
.lean()
: [],
turnIds.length > 0
? ChatTurn.find({ _id: { $in: turnIds } })
.select("_id status mode llm")
.lean()
: [],
]);
// Build lookup maps
const userMap = new Map(users.map((u) => [u._id, u]));
const projectMap = new Map(projects.map((p) => [p._id, p]));
const sessionMap = new Map(sessions.map((s) => [s._id, s]));
const turnMap = new Map(turns.map((t) => [t._id, t]));
return results.map((result) => ({
...result,
user: userMap.get(result.userId) as any || undefined,
project: projectMap.get(result.projectId) as any || undefined,
session: sessionMap.get(result.sessionId) as any || undefined,
turn: turnMap.get(result.turnId) as any || undefined,
}));
}
}
export default SearchController;