diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 87a298c77aa..d837ef0753a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -304,6 +304,13 @@ export type { ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace, + CompanySearchHighlight, + CompanySearchIssueSummary, + CompanySearchResponse, + CompanySearchResult, + CompanySearchResultType, + CompanySearchScope, + CompanySearchSnippet, ExecutionWorkspace, ExecutionWorkspaceSummary, ExecutionWorkspaceConfig, @@ -531,6 +538,7 @@ export type { QuotaWindow, ProviderQuotaResult, } from "./types/index.js"; +export { COMPANY_SEARCH_SCOPES } from "./types/index.js"; export { ISSUE_REFERENCE_IDENTIFIER_RE, buildIssueReferenceHref, @@ -655,6 +663,13 @@ export { type CreateProjectWorkspace, type UpdateProjectWorkspace, projectExecutionWorkspacePolicySchema, + companySearchQuerySchema, + COMPANY_SEARCH_DEFAULT_LIMIT, + COMPANY_SEARCH_MAX_LIMIT, + COMPANY_SEARCH_MAX_OFFSET, + COMPANY_SEARCH_MAX_QUERY_LENGTH, + COMPANY_SEARCH_MAX_TOKENS, + type CompanySearchQuery, createIssueSchema, createChildIssueSchema, createIssueLabelSchema, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 35eb94c6378..45965e9a9d1 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -90,6 +90,16 @@ export type { } from "./agent.js"; export type { AssetImage } from "./asset.js"; export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js"; +export type { + CompanySearchHighlight, + CompanySearchIssueSummary, + CompanySearchResponse, + CompanySearchResult, + CompanySearchResultType, + CompanySearchScope, + CompanySearchSnippet, +} from "./search.js"; +export { COMPANY_SEARCH_SCOPES } from "./search.js"; export type { ExecutionWorkspace, ExecutionWorkspaceSummary, diff --git a/packages/shared/src/types/search.ts b/packages/shared/src/types/search.ts new file mode 100644 index 00000000000..6b9c408cede --- /dev/null +++ b/packages/shared/src/types/search.ts @@ -0,0 +1,55 @@ +import type { IssuePriority, IssueStatus } from "../constants.js"; + +export const COMPANY_SEARCH_SCOPES = ["all", "issues", "comments", "documents", "agents", "projects"] as const; +export type CompanySearchScope = (typeof COMPANY_SEARCH_SCOPES)[number]; + +export type CompanySearchResultType = "issue" | "agent" | "project"; + +export interface CompanySearchHighlight { + start: number; + end: number; +} + +export interface CompanySearchSnippet { + field: string; + label: string; + text: string; + highlights: CompanySearchHighlight[]; +} + +export interface CompanySearchIssueSummary { + id: string; + identifier: string | null; + title: string; + status: IssueStatus; + priority: IssuePriority; + assigneeAgentId: string | null; + assigneeUserId: string | null; + projectId: string | null; + updatedAt: string; +} + +export interface CompanySearchResult { + id: string; + type: CompanySearchResultType; + score: number; + title: string; + href: string; + matchedFields: string[]; + sourceLabel: string | null; + snippet: string | null; + snippets: CompanySearchSnippet[]; + issue?: CompanySearchIssueSummary; + updatedAt: string | null; +} + +export interface CompanySearchResponse { + query: string; + normalizedQuery: string; + scope: CompanySearchScope; + limit: number; + offset: number; + results: CompanySearchResult[]; + countsByType: Record; + hasMore: boolean; +} diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 7b916e6b7b8..f58848f09ad 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -205,6 +205,16 @@ export { type RestoreIssueDocumentRevision, } from "./issue.js"; +export { + COMPANY_SEARCH_DEFAULT_LIMIT, + COMPANY_SEARCH_MAX_LIMIT, + COMPANY_SEARCH_MAX_OFFSET, + COMPANY_SEARCH_MAX_QUERY_LENGTH, + COMPANY_SEARCH_MAX_TOKENS, + companySearchQuerySchema, + type CompanySearchQuery, +} from "./search.js"; + export { createIssueTreeHoldSchema, issueTreeControlModeSchema, diff --git a/packages/shared/src/validators/search.ts b/packages/shared/src/validators/search.ts new file mode 100644 index 00000000000..08f446339cb --- /dev/null +++ b/packages/shared/src/validators/search.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import { COMPANY_SEARCH_SCOPES } from "../types/search.js"; + +export const COMPANY_SEARCH_MAX_QUERY_LENGTH = 200; +export const COMPANY_SEARCH_MAX_TOKENS = 8; +export const COMPANY_SEARCH_DEFAULT_LIMIT = 20; +export const COMPANY_SEARCH_MAX_LIMIT = 50; +export const COMPANY_SEARCH_MAX_OFFSET = 5_000; + +function firstQueryValue(value: unknown): unknown { + return Array.isArray(value) ? value[0] : value; +} + +function clampInteger(value: unknown, fallback: number, min: number, max: number) { + const raw = firstQueryValue(value); + const numeric = typeof raw === "number" + ? raw + : typeof raw === "string" && raw.trim().length > 0 + ? Number.parseInt(raw, 10) + : Number.NaN; + if (!Number.isFinite(numeric)) return fallback; + return Math.min(max, Math.max(min, Math.floor(numeric))); +} + +export const companySearchQuerySchema = z.object({ + q: z.preprocess(firstQueryValue, z.string().optional().default("")) + .transform((value) => value.slice(0, COMPANY_SEARCH_MAX_QUERY_LENGTH)), + scope: z.preprocess(firstQueryValue, z.enum(COMPANY_SEARCH_SCOPES).catch("all")).optional().default("all"), + limit: z.unknown() + .optional() + .transform((value) => clampInteger(value, COMPANY_SEARCH_DEFAULT_LIMIT, 1, COMPANY_SEARCH_MAX_LIMIT)), + offset: z.unknown() + .optional() + .transform((value) => clampInteger(value, 0, 0, COMPANY_SEARCH_MAX_OFFSET)), +}); + +export type CompanySearchQuery = z.infer; diff --git a/server/src/__tests__/company-search-service.test.ts b/server/src/__tests__/company-search-service.test.ts new file mode 100644 index 00000000000..312375fdd1f --- /dev/null +++ b/server/src/__tests__/company-search-service.test.ts @@ -0,0 +1,201 @@ +import { randomUUID } from "node:crypto"; +import { sql } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + createDb, + documents, + issueComments, + issueDocuments, + issues, + projects, +} from "@paperclipai/db"; +import { companySearchQuerySchema, COMPANY_SEARCH_MAX_QUERY_LENGTH } from "@paperclipai/shared"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { companySearchService } from "../services/company-search.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company search tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describe("company search query validation", () => { + it("clamps query length, limit, and offset without rejecting the request", () => { + const parsed = companySearchQuerySchema.parse({ + q: "x".repeat(COMPANY_SEARCH_MAX_QUERY_LENGTH + 50), + limit: "500", + offset: "9000", + scope: "not-a-scope", + }); + + expect(parsed.q).toHaveLength(COMPANY_SEARCH_MAX_QUERY_LENGTH); + expect(parsed.limit).toBe(50); + expect(parsed.offset).toBe(5000); + expect(parsed.scope).toBe("all"); + }); +}); + +describeEmbeddedPostgres("companySearchService", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-search-"); + db = createDb(tempDb.connectionString); + svc = companySearchService(db); + await db.execute(sql.raw("CREATE EXTENSION IF NOT EXISTS pg_trgm")); + }, 20_000); + + afterEach(async () => { + await db.delete(issueDocuments); + await db.delete(documents); + await db.delete(issueComments); + await db.delete(issues); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function createCompany(name = "Paperclip") { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name, + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + return companyId; + } + + async function createIssue(companyId: string, values: Partial = {}) { + const id = values.id ?? randomUUID(); + await db.insert(issues).values({ + id, + companyId, + title: values.title ?? "Search target", + description: values.description ?? null, + status: values.status ?? "todo", + priority: values.priority ?? "medium", + identifier: values.identifier ?? null, + hiddenAt: values.hiddenAt ?? null, + ...values, + }); + return id; + } + + it("ranks exact issue identifiers before weaker title matches", async () => { + const companyId = await createCompany(); + const exactId = await createIssue(companyId, { + identifier: "TST-42", + title: "Backend endpoint", + }); + await createIssue(companyId, { + identifier: "TST-43", + title: "TST-42 mentioned in title only", + }); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "TST-42" })); + + expect(result.results[0]?.id).toBe(exactId); + expect(result.results[0]?.matchedFields).toContain("identifier"); + }); + + it("matches multiple tokens across the same issue thread and returns comment snippets", async () => { + const companyId = await createCompany(); + const issueId = await createIssue(companyId, { + identifier: "TST-7", + title: "Checkout semantics", + description: "Atomic ownership is enforced here.", + }); + await db.insert(issueComments).values({ + companyId, + issueId, + body: "The ranking snippet should explain why this thread matched.", + }); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "checkout snippet" })); + const match = result.results.find((item) => item.id === issueId); + + expect(match).toBeTruthy(); + expect(match?.matchedFields).toEqual(expect.arrayContaining(["title", "comment"])); + expect(match?.snippets.some((snippet) => /snippet/i.test(snippet.text))).toBe(true); + }); + + it("searches issue documents and returns document metadata for snippets", async () => { + const companyId = await createCompany(); + const issueId = await createIssue(companyId, { + identifier: "TST-8", + title: "Adapter manager", + }); + const documentId = randomUUID(); + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Hermes Parser Plan", + latestBody: "The external adapter parser should be discovered from the plugin package.", + format: "markdown", + }); + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId, + key: "plan", + }); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "Hermes parser", scope: "documents" })); + + expect(result.results).toHaveLength(1); + expect(result.results[0]?.id).toBe(issueId); + expect(result.results[0]?.matchedFields).toContain("document"); + expect(result.results[0]?.href).toContain("#document-plan"); + expect(result.results[0]?.snippet).toMatch(/parser/i); + }); + + it("excludes hidden issues and other companies' data", async () => { + const companyId = await createCompany("Visible Co"); + const otherCompanyId = await createCompany("Other Co"); + const visibleId = await createIssue(companyId, { + identifier: "VIS-1", + title: "Visible needle", + }); + await createIssue(companyId, { + identifier: "HID-1", + title: "Hidden needle", + hiddenAt: new Date(), + }); + await createIssue(otherCompanyId, { + identifier: "OTH-1", + title: "Other company needle", + }); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "needle" })); + + expect(result.results.map((item) => item.id)).toEqual([visibleId]); + }); + + it("uses pg_trgm for conservative fuzzy title matches", async () => { + const companyId = await createCompany(); + const issueId = await createIssue(companyId, { + identifier: "TST-9", + title: "Onboarding wizard polish", + }); + + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "onbordng wizard" })); + + expect(result.results[0]?.id).toBe(issueId); + expect(result.results[0]?.matchedFields).toContain("title"); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 4a6492b531c..4e08eab5231 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -8,6 +8,7 @@ import { addIssueCommentSchema, acceptIssueThreadInteractionSchema, cancelIssueThreadInteractionSchema, + companySearchQuerySchema, createIssueAttachmentMetadataSchema, createIssueThreadInteractionSchema, createIssueWorkProductSchema, @@ -41,6 +42,7 @@ import { accessService, agentService, companyService, + companySearchService, executionWorkspaceService, goalService, heartbeatService, @@ -456,6 +458,7 @@ export function issueRoutes( }); const feedback = feedbackService(db); const companiesSvc = companyService(db); + const searchSvc = companySearchService(db); const instanceSettings = instanceSettingsService(db); const agentsSvc = agentService(db); const projectsSvc = projectService(db); @@ -944,6 +947,14 @@ export function issueRoutes( }); }); + router.get("/companies/:companyId/search", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const query = companySearchQuerySchema.parse(req.query); + const result = await searchSvc.search(companyId, query); + res.json(result); + }); + router.get("/companies/:companyId/issues", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/server/src/services/company-search.ts b/server/src/services/company-search.ts new file mode 100644 index 00000000000..9ba67f0b5f3 --- /dev/null +++ b/server/src/services/company-search.ts @@ -0,0 +1,622 @@ +import { and, desc, eq, isNull, sql } from "drizzle-orm"; +import type { SQL } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agents, companies, issues, projects } from "@paperclipai/db"; +import { + COMPANY_SEARCH_MAX_TOKENS, + type CompanySearchIssueSummary, + type CompanySearchQuery, + type CompanySearchResponse, + type CompanySearchResult, + type CompanySearchResultType, + type CompanySearchScope, + type CompanySearchSnippet, +} from "@paperclipai/shared"; + +const MIN_TOKEN_LENGTH = 2; +const MIN_FUZZY_QUERY_LENGTH = 4; +const SNIPPET_MAX_CHARS = 240; + +type IssueSearchRow = { + id: string; + identifier: string | null; + title: string; + description: string | null; + status: string; + priority: string; + assigneeAgentId: string | null; + assigneeUserId: string | null; + projectId: string | null; + updatedAt: Date; + score: number | string; + matchedFields: string[] | null; + commentSnippet: string | null; + commentId: string | null; + documentSnippet: string | null; + documentTitle: string | null; + documentKey: string | null; +}; + +type SimpleSearchRow = { + id: string; + title: string; + description: string | null; + role?: string | null; + updatedAt: Date; +}; + +function normalizeQuery(query: string) { + return query.trim().replace(/\s+/g, " ").toLowerCase(); +} + +function tokenizeQuery(normalizedQuery: string) { + const matches = normalizedQuery.match(/"[^"]+"|[^\s]+/g) ?? []; + const tokens: string[] = []; + for (const match of matches) { + const token = match.replace(/^"|"$/g, "").replace(/^[^\p{L}\p{N}-]+|[^\p{L}\p{N}-]+$/gu, ""); + if (token.length < MIN_TOKEN_LENGTH) continue; + if (!tokens.includes(token)) tokens.push(token); + if (tokens.length >= COMPANY_SEARCH_MAX_TOKENS) break; + } + return tokens; +} + +function sqlTextArray(values: string[]) { + if (values.length === 0) return sql`ARRAY[]::text[]`; + return sql`ARRAY[${sql.join(values.map((value) => sql`${value}`), sql`, `)}]::text[]`; +} + +function tokenMatchExpression(textExpression: SQL, tokenArray: SQL) { + return sql` + EXISTS ( + SELECT 1 + FROM unnest(${tokenArray}) AS search_token(value) + WHERE lower(coalesce(${textExpression}, '')) LIKE '%' || search_token.value || '%' + ) + `; +} + +function positiveSql() { + return sql`false`; +} + +function plainText(value: string | null | undefined) { + return (value ?? "") + .replace(/```[\s\S]*?```/g, " ") + .replace(/`([^`]+)`/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[#>*_~|]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function findFirstMatchIndex(value: string, terms: string[]) { + const lower = value.toLowerCase(); + let best = -1; + for (const term of terms) { + if (term.length === 0) continue; + const index = lower.indexOf(term.toLowerCase()); + if (index >= 0 && (best < 0 || index < best)) best = index; + } + return best; +} + +function highlightRanges(value: string, terms: string[]) { + const lower = value.toLowerCase(); + const ranges: Array<{ start: number; end: number }> = []; + for (const term of terms) { + const normalized = term.toLowerCase(); + if (normalized.length === 0) continue; + let index = lower.indexOf(normalized); + while (index >= 0) { + const next = { start: index, end: index + normalized.length }; + const overlaps = ranges.some((range) => next.start < range.end && next.end > range.start); + if (!overlaps) ranges.push(next); + index = lower.indexOf(normalized, index + normalized.length); + } + } + return ranges.sort((left, right) => left.start - right.start); +} + +function createSnippet(field: string, label: string, source: string | null | undefined, terms: string[]): CompanySearchSnippet | null { + const text = plainText(source); + if (!text) return null; + const firstMatch = findFirstMatchIndex(text, terms); + const windowStart = firstMatch < 0 ? 0 : Math.max(0, firstMatch - 80); + const windowEnd = Math.min(text.length, windowStart + SNIPPET_MAX_CHARS); + const prefix = windowStart > 0 ? "..." : ""; + const suffix = windowEnd < text.length ? "..." : ""; + const slice = text.slice(windowStart, windowEnd).trim(); + const snippetText = `${prefix}${slice}${suffix}`; + const offset = prefix.length - windowStart; + return { + field, + label, + text: snippetText, + highlights: highlightRanges(text, terms) + .filter((range) => range.end > windowStart && range.start < windowEnd) + .map((range) => ({ + start: Math.max(0, range.start + offset), + end: Math.min(snippetText.length, range.end + offset), + })), + }; +} + +function iso(value: Date | string | null | undefined) { + if (!value) return null; + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + +function routePrefix(issuePrefix: string | null | undefined) { + return issuePrefix?.trim() || "company"; +} + +function issueHref(prefix: string, issue: { id: string; identifier: string | null }, suffix = "") { + return `/${prefix}/issues/${encodeURIComponent(issue.identifier ?? issue.id)}${suffix}`; +} + +function matchTerms(normalizedQuery: string, tokens: string[]) { + return [normalizedQuery, ...tokens].filter((term, index, terms) => term.length > 0 && terms.indexOf(term) === index); +} + +function makeCounts(results: CompanySearchResult[]) { + const counts: Record = { issue: 0, agent: 0, project: 0 }; + for (const result of results) counts[result.type] += 1; + return counts; +} + +function scopeIncludesIssues(scope: CompanySearchScope) { + return scope === "all" || scope === "issues" || scope === "comments" || scope === "documents"; +} + +function scopeIncludesAgents(scope: CompanySearchScope) { + return scope === "all" || scope === "agents"; +} + +function scopeIncludesProjects(scope: CompanySearchScope) { + return scope === "all" || scope === "projects"; +} + +function issueSearchCondition(scope: CompanySearchScope, input: { + issueTextMatch: SQL; + commentMatch: SQL; + documentMatch: SQL; + fuzzyMatch: SQL; +}) { + if (scope === "comments") return input.commentMatch; + if (scope === "documents") return input.documentMatch; + if (scope === "issues") return sql`(${input.issueTextMatch} OR ${input.fuzzyMatch})`; + return sql`(${input.issueTextMatch} OR ${input.commentMatch} OR ${input.documentMatch} OR ${input.fuzzyMatch})`; +} + +function selectPrimarySnippets(row: IssueSearchRow, normalizedQuery: string, tokens: string[]) { + const terms = matchTerms(normalizedQuery, tokens); + const matchedFields = new Set(row.matchedFields ?? []); + const candidates: Array = []; + if (matchedFields.has("identifier")) { + candidates.push(createSnippet("identifier", "Identifier", row.identifier, terms)); + } + if (matchedFields.has("title")) { + candidates.push(createSnippet("title", "Title", row.title, terms)); + } + if (matchedFields.has("comment")) { + candidates.push(createSnippet("comment", "Comment", row.commentSnippet, terms)); + } + if (matchedFields.has("document")) { + candidates.push(createSnippet("document", row.documentTitle || "Document", row.documentSnippet, terms)); + } + if (matchedFields.has("description")) { + candidates.push(createSnippet("description", "Description", row.description, terms)); + } + return candidates.filter((snippet): snippet is CompanySearchSnippet => Boolean(snippet)).slice(0, 2); +} + +function issueResult(row: IssueSearchRow, prefix: string, normalizedQuery: string, tokens: string[]): CompanySearchResult { + const snippets = selectPrimarySnippets(row, normalizedQuery, tokens); + const sourceLabel = snippets[0]?.label ?? null; + const documentSuffix = row.documentKey ? `#document-${encodeURIComponent(row.documentKey)}` : ""; + const commentSuffix = row.commentId ? `#comment-${encodeURIComponent(row.commentId)}` : ""; + const suffix = row.commentId ? commentSuffix : documentSuffix; + const issue: CompanySearchIssueSummary = { + id: row.id, + identifier: row.identifier, + title: row.title, + status: row.status as CompanySearchIssueSummary["status"], + priority: row.priority as CompanySearchIssueSummary["priority"], + assigneeAgentId: row.assigneeAgentId, + assigneeUserId: row.assigneeUserId, + projectId: row.projectId, + updatedAt: iso(row.updatedAt)!, + }; + return { + id: row.id, + type: "issue", + score: Number(row.score), + title: row.identifier ? `${row.identifier} ${row.title}` : row.title, + href: issueHref(prefix, row, suffix), + matchedFields: row.matchedFields ?? [], + sourceLabel, + snippet: snippets[0]?.text ?? null, + snippets, + issue, + updatedAt: issue.updatedAt, + }; +} + +function scoreSimpleRow(row: SimpleSearchRow, normalizedQuery: string, tokens: string[]) { + const haystack = [row.title, row.description, row.role].filter(Boolean).join(" ").toLowerCase(); + let score = haystack.includes(normalizedQuery) ? 90 : 0; + for (const token of tokens) { + if (haystack.includes(token)) score += 20; + } + if (row.title.toLowerCase().startsWith(normalizedQuery)) score += 80; + return score; +} + +function simpleTextCondition(fields: SQL[], containsPattern: string, tokenArray: SQL) { + const phraseConditions = fields.map((field) => sql`lower(coalesce(${field}, '')) LIKE ${containsPattern}`); + const tokenConditions = fields.map((field) => tokenMatchExpression(field, tokenArray)); + return sql`(${sql.join([...phraseConditions, ...tokenConditions], sql` OR `)})`; +} + +export function companySearchService(db: Db) { + return { + search: async (companyId: string, query: CompanySearchQuery): Promise => { + const normalizedQuery = normalizeQuery(query.q); + const tokens = tokenizeQuery(normalizedQuery); + const scope = query.scope; + const limit = query.limit; + const offset = query.offset; + const emptyCounts: Record = { issue: 0, agent: 0, project: 0 }; + if (normalizedQuery.length === 0) { + return { + query: query.q, + normalizedQuery, + scope, + limit, + offset, + results: [], + countsByType: emptyCounts, + hasMore: false, + }; + } + + const company = await db + .select({ issuePrefix: companies.issuePrefix }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + const prefix = routePrefix(company?.issuePrefix); + const fetchLimit = offset + limit + 1; + const tokenArray = sqlTextArray(tokens); + const containsPattern = `%${normalizedQuery}%`; + const startsWithPattern = `${normalizedQuery}%`; + const fuzzyEnabled = normalizedQuery.length >= MIN_FUZZY_QUERY_LENGTH; + + const titlePhraseMatch = sql`lower(${issues.title}) LIKE ${containsPattern}`; + const titleStartsWith = sql`lower(${issues.title}) LIKE ${startsWithPattern}`; + const identifierPhraseMatch = sql`lower(coalesce(${issues.identifier}, '')) LIKE ${containsPattern}`; + const identifierStartsWith = sql`lower(coalesce(${issues.identifier}, '')) LIKE ${startsWithPattern}`; + const descriptionPhraseMatch = sql`lower(coalesce(${issues.description}, '')) LIKE ${containsPattern}`; + const titleTokenMatch = tokenMatchExpression(sql`${issues.title}`, tokenArray); + const identifierTokenMatch = tokenMatchExpression(sql`${issues.identifier}`, tokenArray); + const descriptionTokenMatch = tokenMatchExpression(sql`${issues.description}`, tokenArray); + const issueTextMatch = sql` + ${titlePhraseMatch} + OR ${identifierPhraseMatch} + OR ${descriptionPhraseMatch} + OR ${titleTokenMatch} + OR ${identifierTokenMatch} + OR ${descriptionTokenMatch} + `; + const commentMatch = sql` + EXISTS ( + SELECT 1 + FROM issue_comments search_comments + WHERE search_comments.company_id = ${companyId} + AND search_comments.issue_id = issues.id + AND ( + lower(search_comments.body) LIKE ${containsPattern} + OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)} + ) + ) + `; + const documentMatch = sql` + EXISTS ( + SELECT 1 + FROM issue_documents search_issue_documents + INNER JOIN documents search_documents + ON search_documents.id = search_issue_documents.document_id + WHERE search_issue_documents.company_id = ${companyId} + AND search_documents.company_id = ${companyId} + AND search_issue_documents.issue_id = issues.id + AND ( + lower(coalesce(search_documents.title, '')) LIKE ${containsPattern} + OR lower(search_documents.latest_body) LIKE ${containsPattern} + OR ${tokenMatchExpression(sql`search_documents.title`, tokenArray)} + OR ${tokenMatchExpression(sql`search_documents.latest_body`, tokenArray)} + ) + ) + `; + const fuzzyMatch = fuzzyEnabled + ? sql` + similarity(lower(${issues.title}), ${normalizedQuery}) >= 0.35 + OR similarity(lower(coalesce(${issues.identifier}, '')), ${normalizedQuery}) >= 0.45 + ` + : positiveSql(); + const tokenCoverage = sql` + ( + SELECT count(*)::int + FROM unnest(${tokenArray}) AS search_token(value) + WHERE lower(${issues.title}) LIKE '%' || search_token.value || '%' + OR lower(coalesce(${issues.identifier}, '')) LIKE '%' || search_token.value || '%' + OR lower(coalesce(${issues.description}, '')) LIKE '%' || search_token.value || '%' + OR EXISTS ( + SELECT 1 + FROM issue_comments coverage_comments + WHERE coverage_comments.company_id = ${companyId} + AND coverage_comments.issue_id = issues.id + AND lower(coverage_comments.body) LIKE '%' || search_token.value || '%' + ) + OR EXISTS ( + SELECT 1 + FROM issue_documents coverage_issue_documents + INNER JOIN documents coverage_documents + ON coverage_documents.id = coverage_issue_documents.document_id + WHERE coverage_issue_documents.company_id = ${companyId} + AND coverage_documents.company_id = ${companyId} + AND coverage_issue_documents.issue_id = issues.id + AND ( + lower(coalesce(coverage_documents.title, '')) LIKE '%' || search_token.value || '%' + OR lower(coverage_documents.latest_body) LIKE '%' || search_token.value || '%' + ) + ) + ) + `; + const tokenCount = tokens.length; + const allTokensMatch = tokenCount > 0 + ? sql`${tokenCoverage} = ${tokenCount}` + : positiveSql(); + const score = sql` + ( + CASE WHEN lower(coalesce(${issues.identifier}, '')) = ${normalizedQuery} THEN 1200 ELSE 0 END + + CASE WHEN ${identifierStartsWith} THEN 700 ELSE 0 END + + CASE WHEN lower(${issues.title}) = ${normalizedQuery} THEN 900 ELSE 0 END + + CASE WHEN ${titleStartsWith} THEN 550 ELSE 0 END + + CASE WHEN ${titlePhraseMatch} THEN 350 ELSE 0 END + + CASE WHEN ${identifierPhraseMatch} THEN 320 ELSE 0 END + + CASE WHEN ${commentMatch} THEN 180 ELSE 0 END + + CASE WHEN ${documentMatch} THEN 170 ELSE 0 END + + CASE WHEN ${descriptionPhraseMatch} THEN 120 ELSE 0 END + + CASE WHEN ${allTokensMatch} THEN 260 ELSE 0 END + + (${tokenCoverage} * 70) + + CASE WHEN ${fuzzyMatch} THEN 110 ELSE 0 END + + CASE ${issues.status} WHEN 'done' THEN 0 WHEN 'cancelled' THEN -30 ELSE 20 END + )::double precision + `; + const matchedFields = sql` + array_remove(ARRAY[ + CASE WHEN ${identifierPhraseMatch} OR ${identifierTokenMatch} OR (${fuzzyEnabled} AND similarity(lower(coalesce(${issues.identifier}, '')), ${normalizedQuery}) >= 0.45) THEN 'identifier' END, + CASE WHEN ${titlePhraseMatch} OR ${titleTokenMatch} OR (${fuzzyEnabled} AND similarity(lower(${issues.title}), ${normalizedQuery}) >= 0.35) THEN 'title' END, + CASE WHEN ${descriptionPhraseMatch} OR ${descriptionTokenMatch} THEN 'description' END, + CASE WHEN ${commentMatch} THEN 'comment' END, + CASE WHEN ${documentMatch} THEN 'document' END + ], NULL)::text[] + `; + + const issueRows = scopeIncludesIssues(scope) + ? await db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + description: issues.description, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + projectId: issues.projectId, + updatedAt: issues.updatedAt, + score, + matchedFields, + commentSnippet: sql` + ( + SELECT search_comments.body + FROM issue_comments search_comments + WHERE search_comments.company_id = ${companyId} + AND search_comments.issue_id = issues.id + AND ( + lower(search_comments.body) LIKE ${containsPattern} + OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)} + ) + ORDER BY + CASE WHEN lower(search_comments.body) LIKE ${containsPattern} THEN 0 ELSE 1 END, + search_comments.updated_at DESC, + search_comments.id DESC + LIMIT 1 + ) + `, + commentId: sql` + ( + SELECT search_comments.id + FROM issue_comments search_comments + WHERE search_comments.company_id = ${companyId} + AND search_comments.issue_id = issues.id + AND ( + lower(search_comments.body) LIKE ${containsPattern} + OR ${tokenMatchExpression(sql`search_comments.body`, tokenArray)} + ) + ORDER BY + CASE WHEN lower(search_comments.body) LIKE ${containsPattern} THEN 0 ELSE 1 END, + search_comments.updated_at DESC, + search_comments.id DESC + LIMIT 1 + ) + `, + documentSnippet: sql` + ( + SELECT search_documents.latest_body + FROM issue_documents search_issue_documents + INNER JOIN documents search_documents + ON search_documents.id = search_issue_documents.document_id + WHERE search_issue_documents.company_id = ${companyId} + AND search_documents.company_id = ${companyId} + AND search_issue_documents.issue_id = issues.id + AND ( + lower(coalesce(search_documents.title, '')) LIKE ${containsPattern} + OR lower(search_documents.latest_body) LIKE ${containsPattern} + OR ${tokenMatchExpression(sql`search_documents.title`, tokenArray)} + OR ${tokenMatchExpression(sql`search_documents.latest_body`, tokenArray)} + ) + ORDER BY + CASE + WHEN lower(coalesce(search_documents.title, '')) LIKE ${containsPattern} THEN 0 + WHEN lower(search_documents.latest_body) LIKE ${containsPattern} THEN 1 + ELSE 2 + END, + search_documents.updated_at DESC, + search_documents.id DESC + LIMIT 1 + ) + `, + documentTitle: sql` + ( + SELECT search_documents.title + FROM issue_documents search_issue_documents + INNER JOIN documents search_documents + ON search_documents.id = search_issue_documents.document_id + WHERE search_issue_documents.company_id = ${companyId} + AND search_documents.company_id = ${companyId} + AND search_issue_documents.issue_id = issues.id + AND ( + lower(coalesce(search_documents.title, '')) LIKE ${containsPattern} + OR lower(search_documents.latest_body) LIKE ${containsPattern} + OR ${tokenMatchExpression(sql`search_documents.title`, tokenArray)} + OR ${tokenMatchExpression(sql`search_documents.latest_body`, tokenArray)} + ) + ORDER BY search_documents.updated_at DESC, search_documents.id DESC + LIMIT 1 + ) + `, + documentKey: sql` + ( + SELECT search_issue_documents.key + FROM issue_documents search_issue_documents + INNER JOIN documents search_documents + ON search_documents.id = search_issue_documents.document_id + WHERE search_issue_documents.company_id = ${companyId} + AND search_documents.company_id = ${companyId} + AND search_issue_documents.issue_id = issues.id + AND ( + lower(coalesce(search_documents.title, '')) LIKE ${containsPattern} + OR lower(search_documents.latest_body) LIKE ${containsPattern} + OR ${tokenMatchExpression(sql`search_documents.title`, tokenArray)} + OR ${tokenMatchExpression(sql`search_documents.latest_body`, tokenArray)} + ) + ORDER BY search_documents.updated_at DESC, search_documents.id DESC + LIMIT 1 + ) + `, + }) + .from(issues) + .where(and( + eq(issues.companyId, companyId), + isNull(issues.hiddenAt), + issueSearchCondition(scope, { issueTextMatch, commentMatch, documentMatch, fuzzyMatch }), + )) + .orderBy(desc(score), desc(issues.updatedAt), desc(issues.id)) + .limit(fetchLimit) + : []; + + const simpleCondition = simpleTextCondition([ + sql`${agents.name}`, + sql`${agents.role}`, + sql`${agents.title}`, + sql`${agents.capabilities}`, + ], containsPattern, tokenArray); + const agentRows = scopeIncludesAgents(scope) + ? await db + .select({ + id: agents.id, + title: agents.name, + description: agents.capabilities, + role: agents.role, + updatedAt: agents.updatedAt, + }) + .from(agents) + .where(and(eq(agents.companyId, companyId), simpleCondition)) + .orderBy(desc(agents.updatedAt), desc(agents.id)) + .limit(fetchLimit) + : []; + + const projectCondition = simpleTextCondition([ + sql`${projects.name}`, + sql`${projects.description}`, + ], containsPattern, tokenArray); + const projectRows = scopeIncludesProjects(scope) + ? await db + .select({ + id: projects.id, + title: projects.name, + description: projects.description, + updatedAt: projects.updatedAt, + }) + .from(projects) + .where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition)) + .orderBy(desc(projects.updatedAt), desc(projects.id)) + .limit(fetchLimit) + : []; + + const results: CompanySearchResult[] = [ + ...(issueRows as IssueSearchRow[]).map((row) => issueResult(row, prefix, normalizedQuery, tokens)), + ...(agentRows as SimpleSearchRow[]).map((row) => { + const terms = matchTerms(normalizedQuery, tokens); + const snippet = createSnippet("capabilities", "Agent", row.description ?? row.role ?? row.title, terms); + return { + id: row.id, + type: "agent" as const, + score: scoreSimpleRow(row, normalizedQuery, tokens), + title: row.title, + href: `/${prefix}/agents/${encodeURIComponent(row.id)}`, + matchedFields: ["agent"], + sourceLabel: snippet?.label ?? null, + snippet: snippet?.text ?? null, + snippets: snippet ? [snippet] : [], + updatedAt: iso(row.updatedAt), + }; + }), + ...(projectRows as SimpleSearchRow[]).map((row) => { + const terms = matchTerms(normalizedQuery, tokens); + const snippet = createSnippet("description", "Project", row.description ?? row.title, terms); + return { + id: row.id, + type: "project" as const, + score: scoreSimpleRow(row, normalizedQuery, tokens), + title: row.title, + href: `/${prefix}/projects/${encodeURIComponent(row.id)}`, + matchedFields: ["project"], + sourceLabel: snippet?.label ?? null, + snippet: snippet?.text ?? null, + snippets: snippet ? [snippet] : [], + updatedAt: iso(row.updatedAt), + }; + }), + ].sort((left, right) => { + if (right.score !== left.score) return right.score - left.score; + return (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""); + }); + + const paged = results.slice(offset, offset + limit); + return { + query: query.q, + normalizedQuery, + scope, + limit, + offset, + results: paged, + countsByType: makeCounts(results), + hasMore: results.length > offset + limit, + }; + }, + }; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 6d2d530f565..5cdf0a1d9c1 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -1,4 +1,5 @@ export { companyService } from "./companies.js"; +export { companySearchService } from "./company-search.js"; export { feedbackService } from "./feedback.js"; export { companySkillService } from "./company-skills.js"; export { agentService, deduplicateAgentName } from "./agents.js";