171 lines
5.0 KiB
TypeScript
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;
|