fix: improve task search relevance with a PostgreSQL rubric (#13335)

Unify full and quick task search around PostgreSQL term coverage, explicit relevance bands, and conservative typo recovery. Preserve matching evidence and navigation, and add a judged corpus, regression tests, and documented performance measurements.

Validation: local typecheck, build, focused PostgreSQL tests, and task-list tests pass. All final-head CI gates pass and Greptile is 5/5.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-12 16:22:58 -05:00 committed by GitHub
parent 04e364236b
commit 1652a5c7f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 862 additions and 451 deletions

View File

@ -352,6 +352,19 @@ These browser suites are intended for targeted local verification and CI, not th
For normal issue work, start with the smallest targeted check that proves the change. Reserve repo-wide typecheck/build/test runs for PR-ready handoff or changes broad enough that narrow checks do not cover the risk.
### Task search evaluation
The task search relevance rubric and regression corpus are documented in
[SEARCH.md](SEARCH.md). Run the real PostgreSQL relevance suite with:
```sh
pnpm exec vitest run server/src/__tests__/task-search-quality.test.ts
```
Set `SEARCH_EVAL_SCALE=1` to additionally measure a disposable 10,000-task,
30,000-comment dataset. `SEARCH_EVAL_REPORT=/tmp/search-quality.json` saves
per-query results and latency measurements; scale measurements are opt-in.
### Recent task ordering
The streamlined sidebar keeps five recent tasks per company and account in browser

200
doc/SEARCH.md Normal file
View File

@ -0,0 +1,200 @@
# Task search relevance
## Product rubric
Search should help someone reopen work they remember, using whatever fragment
stuck in memory: an ID, a few title words, a technical name, or something in the
conversation. The first screen should contain plausible answers, with enough
context to explain each match.
| Intent | Good result | Failure |
|---|---|---|
| Known task ID | Exact ID first, case-insensitive; accept `PAP-42`, `pap42`, `PAP 42` | A mention or neighboring ID beats the task |
| Remembered title | Exact title, phrase, then all title words in any order | A recent comment mentioning those words beats the title |
| Several concepts | Every meaningful query term contributes, including short terms such as API/UI | A task matches only one common word |
| Exact phrase | Quoted text stays together and literal | Quotes silently behave like OR or fuzzy search |
| Thread memory | Find words across task text, comments and current documents | Relevant content exists but the task cannot be found |
| Technical text | Preserve underscores, percent signs, paths and numbers | SQL wildcard expansion or fuzzy IDs return unrelated work |
| Typo | Conservative title-word correction; all other terms still required | Ignoring a short term changes the query's meaning |
| Result explanation | Show the best evidence and link to its source | A title hit jumps into an unrelated comment |
| Old work | Strong completed-task matches remain ahead of weak recent hits | Recency/activity replaces relevance |
| Boundaries | Company, visibility, deletion and explicit filters always apply | Content leaks through counts, snippets or typo matches |
| Operations | PostgreSQL only, synchronous current-row reads, bounded query/page sizes | A worker, remote index or eventual-consistency repair is required |
Judge results on a 03 scale: **3** directly answers the remembered task intent,
**2** is useful related work, **1** is only an incidental mention, **0** is
irrelevant. Ambiguous short queries may have several grade-3 answers; do not
invent a unique intended task for them.
Acceptance gates:
- Every unambiguous known-task case returns its intended task first.
- Every grade-3 result in the small judged corpus appears in the first five.
- All explicit negative, visibility, filter, freshness and literal-query cases pass.
- Report mean reciprocal rank (first grade-3 result) and nDCG@5 (graded ordering
and recall). Target MRR ≥ 0.95 and nDCG@5 ≥ 0.90 on the authored corpus.
- Measure both the full search page and the command-palette/task-list API.
- Measure database-backed latency separately from relevance. Report dataset
size, warm/cold assumptions and hardware; a small fixture is not scale proof.
Initial target: warm p95 ≤ 250 ms at 10,000 tasks and 30,000 short comments.
A regression greater than 20% from baseline requires investigation and an
explicit explanation of the cost; do not describe a quality improvement as
latency-neutral when it is not.
The initial corpus is synthetic and deliberately adversarial. It includes
plausible distractors and gives older completed tasks strong relevance labels.
It is not evidence that every real user's search is solved. Add real failed
queries and human judgments as they become available. Do not adjust judgments
just to improve a score.
## Previous behavior
The command palette calls the issue-list endpoint. It searched one literal
substring across title, identifier, description and comments, prioritized titles
before identifiers, and did not search documents or recover typos. Reordered
words commonly returned no result.
Company search used a different algorithm: any token admitted a result, bonuses
from titles, comments and documents accumulated, and title-only token coverage
was indistinguishable from words scattered across a long thread. It ran edit
distance for title words, discarded short terms from fuzzy matching, and also
fuzzed identifiers. Quotes were tokenized but did not constrain other matches.
## Matching contract
Both task search paths use `server/src/services/task-search.ts`. Search is lexical:
trim/collapse whitespace, normalize case, keep quoted phrases, remove a small
set of unquoted grammatical filler words, deduplicate terms, and retain up to
8 terms within the existing 200-character query bound. All-filler queries keep
their terms. No synonym service, embedding model or language-specific stemming
is involved.
All retained terms must match. Full search and task lists allow terms to occur
across task text and current, undeleted conversation/document content. The
Tasks scope requires coverage in task text. Comments and Documents require a
participating match in that source, while retaining the task context. Exact/prefix
identifiers and conservative title-word typo matches are additional task matches.
Typo matching runs only when no literal match satisfies the requested filters.
It never guesses task numbers, loosens a quoted phrase or drops a short query
term. Alphabetic terms of one to three characters must begin a word, so `UI`
does not match `build`, while incomplete longer words still support typeahead.
Ranking uses disjoint bands: exact ID, ID prefix, exact title, title phrase,
all title terms, all task-text terms, all thread terms, then title typo recovery.
Whole-word title matches and title prefixes break close ties; status has only a
small effect within a band. Full search uses recency and stable IDs for remaining
ties; task lists retain their existing priority/activity tie-breaking. Explicit
created/updated/priority sort modes retain their documented behavior. Other
entity types retain their existing scoring rules, rescaled to keep exact names
ahead of speculative task typo matches. The UI displays the server's order
without regrouping results by source.
The existing `pg_trgm` indexes support literal substring retrieval. Tagged
comment/document match sets are computed once per search with separate indexed
patterns. Ranking stages carry compact flags; descriptions and matching snippets
are fetched for the result window. The database reads current rows, so creates, edits, deletions and
hidden-task changes take effect without indexing jobs. Bounded edit-distance
checks operate on titles only, run only as a zero-result fallback, and guard
fuzzystrmatch's 255-character argument limit. There is no schema migration or
new extension in this change.
PostgreSQL documents the existing index support in
[pg_trgm](https://www.postgresql.org/docs/17/pgtrgm.html).
## Reproduce the evaluation
```sh
pnpm exec vitest run server/src/__tests__/task-search-quality.test.ts
# Also write per-query rankings and metrics for inspection:
SEARCH_EVAL_REPORT=/tmp/search-quality.json pnpm exec vitest run server/src/__tests__/task-search-quality.test.ts
# Include the larger latency dataset and query plans:
SEARCH_EVAL_SCALE=1 SEARCH_EVAL_REPORT=/tmp/search-scale.json pnpm exec vitest run server/src/__tests__/task-search-quality.test.ts
```
The fixture is `server/src/__tests__/fixtures/task-search-corpus.ts`. Tests run
the real services against a temporary embedded PostgreSQL database with the
normal migrations. `SEARCH_EVAL_BASELINE=1` records judgments without asserting
improved behavior. To compare another revision, copy this test, its fixture and
`task-search.ts` into a separate worktree for that revision, leave its actual
`company-search.ts` and `issues.ts` services unchanged, and run with
`SEARCH_EVAL_BASELINE=1`. The copied helper is not used by the baseline services;
its query-plan branch is disabled in baseline mode.
## Initial evaluation — 2026-09-12
Compared against `2083bf6f9` using the same 31-task corpus and 24 queries (23
queries with intended answers, plus one no-result query).
| Surface | Intended answer first, before → after | MRR, before → after | nDCG@5, before → after |
|---|---|---|---|
| Full search | 17/23 → 23/23 | 0.828 → 1.000 | 0.904 → 0.999 |
| Quick search / task list | 5/23 → 23/23 | 0.268 → 1.000 | 0.339 → 0.999 |
The relevance gates pass. These results measure the authored corpus, not general
search accuracy. The no-result query also returns no tasks in both surfaces.
The scale run adds 10,000 tasks with ~300-character descriptions and 30,000
~345-character comments. Measurements call the real service methods (including
facets/snippets or task-list hydration), excluding HTTP and UI debounce. Each
query has one separately recorded first request and 20 warm repetitions; p95
is the 19th sorted warm sample. This is not a cold-disk test. Both revisions used
PostgreSQL 18.1, default planner/memory settings, and `ANALYZE` after seeding.
The host was an Apple M5 Max with 128 GiB RAM, running an x86_64 PostgreSQL
binary and other development tests concurrently. Treat timing deltas as local
measurements, not production capacity or a controlled concurrency benchmark.
| Query | Full p95 before → after (ms) | Quick p95 before → after (ms) |
|---|---|---|
| `GitHub OAuth` | 139 → 95 | 39 → 128 |
| `OAuth callback GitHub` | 209 → 81 | 26 → 134 |
| `mibile api` | 153 → 131 | 19 → 111 |
| `search` | 172 → 41 | 22 → 67 |
| `quasarxylophone` | 154 → 105 | 18 → 218 |
| `routine` (matches all 10,000 added tasks) | 239 → 253 | 152 → 371 |
Selective full searches improved. Quick search is more expensive: it now
evaluates term coverage, searches documents, and can scan company titles for
typo recovery. The old quick search returned no answers for the reordered and
typo queries, so its lower cost did not deliver equivalent results. The relative
regression threshold is triggered, and broad-query p95 does **not** meet the
initial 250 ms target. This is an explicit performance limitation of this pass.
The implementation adds no operational service, but it is not latency-neutral.
`EXPLAIN (ANALYZE, BUFFERS)` confirmed existing trigram indexes on selective
comment/document retrieval and zero fuzzy-branch executions for successful
literal searches. Removing descriptions from intermediate materialized rows
eliminated 1,699 temporary blocks (~13 MiB) of writes in the broad-query core
plan; its final measured execution was 139 ms with no temporary writes. The
quick-search endpoint also performs its existing activity sorting and task
hydration. Larger companies, long threads and sustained concurrent searches
still need production-shaped measurement before a stronger latency claim.
Browser acceptance used the real built app against an isolated PostgreSQL
database containing this corpus. Starting from the dashboard, the Search link
found an older completed task from reordered title words and opened that task.
Command-K ranked `PAP-42` first for `pap42`; `mibile api` recovered only the
intended mobile API task and carried the query into full search. Quoted
`"connection timeout"` excluded scattered words. `Hermes parser` showed the
document title as evidence and opened the correct plan in the task's side panel.
The desktop result layout was visually inspected. Mobile layout, continuous
transition timing and production data were not part of this walkthrough.
For a future failed search, record the query, what the person remembered, and
the intended task IDs. Grade the old top five and any missed intended tasks
before changing the ranker, add realistic distractors, then run both entry
points. Keep these judgments independent of the ranking constants.
Verification: 78 search/parser tests (including the real PostgreSQL scale run),
14 existing task-list search/filter tests, and 29 Search/CommandPalette UI tests
passed. Workspace typecheck, the final server typecheck, production build,
Storybook build and token gates passed. The full repository test run was stopped
after `chat-channels.integration.test.ts` reported one failure in “publishes a
closed-choice question, settles its Slack card, and delivers its exact
continuation response”; that test passed when rerun alone. The remaining broad
suite was not completed, so this is not a claim of a green repository-wide run.
The API response contracts and company authorization stay unchanged. Artifact,
agent and project ranking are separate from the task relevance rubric. Extraction
search and the specialized blocked-attention queue retain their existing
literal matching. Pure semantic paraphrases and
language-specific word inflections are outside this first lexical rubric.

View File

@ -1142,6 +1142,9 @@ The current app also exposes V1-supporting surfaces for:
- company-scoped summary slots for projects, the workspaces overview, project workspaces, and individual execution workspaces; execution-workspace slots are keyed by execution workspace id so a new workspace never inherits another workspace's summary
- issue thread interactions (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`, `request_item_verdicts`) with the open-default resolver contract in §9.8.1
- issue approvals, issue references/search, labels, read state, inbox/archive state, and work products
- task search uses shared PostgreSQL matching/ranking for company search and task-list quick search;
all query terms contribute, quoted phrases stay literal, exact identifiers and direct title matches
lead relevance ordering, and the UI preserves server result order (see `doc/SEARCH.md`)
- company search through `GET /companies/:companyId/search` plus agent-oriented bulk extraction through
`GET /companies/:companyId/search/extract`; extraction accepts a server-escaped literal `contains`, optional
server-owned URL expansion, issue/comment/document scopes, status/date filters, issue-level pagination, a

View File

@ -586,3 +586,12 @@ allocation retains one selected number and individually enabled groups.
See [iMessage Photon](connections/IMESSAGE-PHOTON.md) for the implementation
contract, setup, recovery, boundaries, and qualification status.
## Task search relevance
Task discovery uses PostgreSQL and the existing search indexes, with no external
search service or background indexing job. The task-list quick search and full
company search share lexical matching and ranking. Known identifiers and direct
title matches lead; current conversation and document content supplies supporting
evidence. See [Task search relevance](SEARCH.md) for the evaluation rubric,
matching contract and reproducible quality tests.

View File

@ -163,6 +163,19 @@ describeEmbeddedPostgres("companySearchService", () => {
return id;
}
it("keeps exact entity names ahead of speculative task typos and rejects empty quotes", async () => {
const companyId = await createCompany();
const agentId = await createAgent(companyId, { name: "Mibile" });
const projectId = await createProject(companyId, { name: "Mibile" });
const taskId = await createIssue(companyId, { title: "Mobile navigation" });
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "mibile" }));
const ids = result.results.map((row) => row.id);
expect(ids).toContain(taskId);
expect(ids.indexOf(agentId)).toBeLessThan(ids.indexOf(taskId));
expect(ids.indexOf(projectId)).toBeLessThan(ids.indexOf(taskId));
expect((await svc.search(companyId, companySearchQuerySchema.parse({ q: '""' }))).results).toEqual([]);
});
it("ranks exact issue identifiers before weaker title matches", async () => {
const companyId = await createCompany();
const exactId = await createIssue(companyId, {
@ -180,7 +193,7 @@ describeEmbeddedPostgres("companySearchService", () => {
expect(result.results[0]?.matchedFields).toContain("identifier");
});
it("ranks phrase and all-token issue matches before partial scattered-token matches", async () => {
it("ranks phrase before reordered title words and rejects partial matches", async () => {
const companyId = await createCompany();
const base = new Date("2026-01-01T00:00:00.000Z").getTime();
const partialTokenId = await createIssue(companyId, {
@ -201,7 +214,8 @@ describeEmbeddedPostgres("companySearchService", () => {
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "alpha beta", scope: "issues" }));
expect(result.results.map((row) => row.id)).toEqual([phraseId, allTokenId, partialTokenId]);
expect(result.results.map((row) => row.id)).toEqual([phraseId, allTokenId]);
expect(result.results.map((row) => row.id)).not.toContain(partialTokenId);
});
it("matches multiple tokens across the same issue thread and returns comment snippets", async () => {
@ -682,6 +696,83 @@ describeEmbeddedPostgres("companySearchService", () => {
}
});
it("does not interpret short UI terms as the middle of unrelated words", async () => {
const companyId = await createCompany();
const target = await createIssue(companyId, { title: "Improve mobile UI" });
await createIssue(companyId, { title: "Build billing reports" });
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "UI" }));
expect(result.results.map((row) => row.id)).toEqual([target]);
});
it("keeps typo fallback inside the requested filters", async () => {
const companyId = await createCompany();
await createIssue(companyId, { title: "Mibile API", status: "done" });
const target = await createIssue(companyId, { title: "Mobile API", status: "todo" });
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "mibile api", status: "todo" }));
expect(result.results.map((row) => row.id)).toEqual([target]);
});
it("keeps exact title hits on the task and chooses the most complete context evidence", async () => {
const companyId = await createCompany();
const task = await createIssue(companyId, { title: "Aurora callback" });
await db.insert(issueComments).values({ companyId, issueId: task, body: "Aurora callback is mentioned here too." });
const exact = await svc.search(companyId, companySearchQuerySchema.parse({ q: "Aurora callback" }));
expect(exact.results[0]?.href).not.toContain("#comment-");
const holder = await createIssue(companyId, { title: "Connection investigation" });
await db.insert(issueComments).values({ companyId, issueId: holder, body: "Aurora was discussed.", updatedAt: new Date("2026-06-01") });
const strongest = randomUUID();
await db.insert(issueComments).values({ id: strongest, companyId, issueId: holder,
body: "Aurora loses the callback state.", updatedAt: new Date("2026-01-01") });
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "aurora state callback" }));
expect(result.results[0]?.href).toContain(`#comment-${strongest}`);
expect(result.results[0]?.snippet).toContain("state");
});
it.each(["comment", "document"] as const)("preserves %s evidence when title and identifier both match", async (source) => {
const companyId = await createCompany();
const task = await createIssue(companyId, {
identifier: "CTX-123", title: "CTX callback investigation", description: "CTX callback details",
});
const sourceId = randomUUID();
if (source === "comment") {
await db.insert(issueComments).values({ id: sourceId, companyId, issueId: task, body: "The missing signal is quasar." });
} else {
await db.insert(documents).values({ id: sourceId, companyId, title: "Investigation plan", latestBody: "The missing signal is quasar.", format: "markdown" });
await db.insert(issueDocuments).values({ companyId, issueId: task, documentId: sourceId, key: "plan" });
}
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "CTX quasar" }));
const match = result.results.find((row) => row.id === task)!;
expect(match.matchedFields).toEqual(expect.arrayContaining(["identifier", "title", source]));
expect(match.score).toBeGreaterThanOrEqual(2000);
expect(match.score).toBeLessThan(3000);
expect(match.snippets).toHaveLength(2);
expect(match.snippets[0]).toMatchObject({ field: source, text: expect.stringContaining("quasar") });
expect(match.snippet).toContain("quasar");
expect(match.href).toContain(source === "comment" ? `#comment-${sourceId}` : "#document-plan");
});
it("reflects edits, deleted comments and document updates immediately", async () => {
const companyId = await createCompany();
const task = await createIssue(companyId, { title: "Old uniquequartz title" });
const find = () => svc.search(companyId, companySearchQuerySchema.parse({ q: '"uniquequartz"' }));
expect((await find()).results.map((row) => row.id)).toEqual([task]);
await db.update(issues).set({ title: "New title" }).where(sql`${issues.id} = ${task}`);
expect((await find()).results).toEqual([]);
const comment = randomUUID();
await db.insert(issueComments).values({ id: comment, companyId, issueId: task, body: "uniquequartz" });
expect((await find()).results.map((row) => row.id)).toEqual([task]);
await db.update(issueComments).set({ deletedAt: new Date() }).where(sql`${issueComments.id} = ${comment}`);
expect((await find()).results).toEqual([]);
const doc = randomUUID();
await db.insert(documents).values({ id: doc, companyId, title: "Findings", latestBody: "uniquequartz", format: "markdown" });
await db.insert(issueDocuments).values({ companyId, issueId: task, documentId: doc, key: "plan" });
expect((await find()).results.map((row) => row.id)).toEqual([task]);
await db.update(documents).set({ latestBody: "Updated findings" }).where(sql`${documents.id} = ${doc}`);
expect((await find()).results).toEqual([]);
});
it("uses pg_trgm for conservative fuzzy title matches", async () => {
const companyId = await createCompany();
const issueId = await createIssue(companyId, {

View File

@ -0,0 +1,77 @@
// Authored relevance judgments, independent of the ranker's scoring constants.
export const taskSearchCorpus = [
{ key: "id", identifier: "PAP-42", title: "Repair callback state", status: "done" },
{ key: "id-mention", title: "PAP-42 follow-up discussion" },
{ key: "id-neighbor", identifier: "PAP-420", title: "Repair callback state later" },
{ key: "oauth", title: "Fix GitHub OAuth callback", status: "done" },
{ key: "oauth-noise", title: "GitHub release checklist", comments: ["OAuth is mentioned in an unrelated weekly update."] },
{ key: "oauth-partial", title: "GitHub repository badges" },
{ key: "oauth-body", title: "Repair the connection flow", description: "GitHub OAuth callback loses state on redirect." },
{ key: "oauth-comment", title: "Investigate sign-in", comments: ["The GitHub OAuth callback loses state on redirect."] },
{ key: "oauth-doc", title: "Connection investigation", document: { title: "GitHub OAuth callback findings", body: "State is lost on redirect." } },
{ key: "search", title: "Improve search performance" },
{ key: "search-noise", title: "Improve exports", description: "A search for performance numbers was included in the meeting." },
{ key: "search-partial", title: "Search typography" },
{ key: "mobile", title: "Polish mobile navigation" },
{ key: "mobile-api", title: "Build mobile API" },
{ key: "mobile-ui", title: "Build mobile UI" },
{ key: "onboarding", title: "Onboarding wizard polish" },
{ key: "quoted", title: "Repair connection timeout handling" },
{ key: "quoted-scattered", title: "Connection retry after timeout" },
{ key: "cross", title: "Checkout ownership", comments: ["A concurrency race needs a regression test."] },
{ key: "cross-partial", title: "Checkout style guide" },
{ key: "document", title: "Adapter investigation", document: { title: "Hermes parser plan", body: "Discover plugins from their package manifest." } },
{ key: "percentage", title: "Release 100% checklist" },
{ key: "percentage-decoy", title: "Release 1000 checklist" },
{ key: "path", title: "Fix foo_bar lookup" },
{ key: "path-decoy", title: "Fix fooXbar lookup" },
{ key: "unicode", title: "Réparer navigation mobile" },
{ key: "identifier-code", title: "Document heartbeat_run_events retention" },
{ key: "word", title: "Fix API authentication" },
{ key: "word-decoy", title: "Capistrano migration", description: "API details appear here.", comments: ["API is an incidental mention."] },
{ key: "freshness", title: "Reconcile billing ledger", status: "done" },
{ key: "freshness-noise", title: "Weekly financial update", comments: ["Reconcile billing ledger was one of many completed projects."] },
] as const;
export type TaskSearchCase = {
name: string;
q: string;
relevant: Record<string, number>; // 3 = intended task; 2 = useful; 1 = incidental; absent = irrelevant
first?: string;
absent?: string[];
scope?: "all" | "issues" | "comments" | "documents";
};
export const taskSearchCases: TaskSearchCase[] = [
{ name: "exact identifier", q: "PAP-42", relevant: { id: 3, "id-mention": 1, "id-neighbor": 1 }, first: "id" },
{ name: "identifier case", q: "pap-42", relevant: { id: 3, "id-mention": 1, "id-neighbor": 1 }, first: "id" },
{ name: "compact identifier", q: "pap42", relevant: { id: 3, "id-neighbor": 1 }, first: "id" },
{ name: "spaced identifier", q: "PAP 42", relevant: { id: 3, "id-mention": 1, "id-neighbor": 1 }, first: "id" },
{ name: "title phrase", q: "GitHub OAuth", relevant: { oauth: 3, "oauth-body": 2, "oauth-comment": 2, "oauth-doc": 2, "oauth-noise": 1 }, first: "oauth", absent: ["oauth-partial"] },
{ name: "reordered title words", q: "OAuth GitHub callback", relevant: { oauth: 3, "oauth-body": 2, "oauth-comment": 2, "oauth-doc": 2 }, first: "oauth", absent: ["oauth-partial", "oauth-noise"] },
{ name: "title beats body chatter", q: "performance search", relevant: { search: 3, "search-noise": 1 }, first: "search", absent: ["search-partial"] },
{ name: "title beats incidental comment", q: "billing ledger", relevant: { freshness: 3, "freshness-noise": 1 }, first: "freshness" },
{ name: "filler words", q: "the GitHub OAuth callback", relevant: { oauth: 3, "oauth-body": 2, "oauth-comment": 2, "oauth-doc": 2 }, first: "oauth", absent: ["oauth-noise"] },
{ name: "quoted phrase", q: '"connection timeout"', relevant: { quoted: 3 }, first: "quoted", absent: ["quoted-scattered"] },
{ name: "quoted phrase plus term", q: 'repair "connection timeout"', relevant: { quoted: 3 }, first: "quoted", absent: ["quoted-scattered"] },
{ name: "transposition", q: "serach", relevant: { search: 3, "search-partial": 3 } },
{ name: "substitution", q: "mibile navigation", relevant: { mobile: 3, unicode: 3 }, absent: ["mobile-api", "mobile-ui"] },
{ name: "two missing letters", q: "onbordng wizard", relevant: { onboarding: 3 }, first: "onboarding" },
{ name: "short token constrains typo", q: "mibile api", relevant: { "mobile-api": 3 }, first: "mobile-api", absent: ["mobile", "mobile-ui"] },
{ name: "cross-field thread", q: "checkout concurrency", relevant: { cross: 3 }, first: "cross", absent: ["cross-partial"] },
{ name: "document title", q: "Hermes parser", relevant: { document: 3 }, first: "document" },
{ name: "document body", q: "plugins manifest", relevant: { document: 3 }, first: "document" },
{ name: "literal percent", q: "100%", relevant: { percentage: 3 }, first: "percentage", absent: ["percentage-decoy"] },
{ name: "literal underscore", q: "foo_bar", relevant: { path: 3 }, first: "path", absent: ["path-decoy"] },
{ name: "code identifier", q: "heartbeat_run_events", relevant: { "identifier-code": 3 }, first: "identifier-code" },
{ name: "unicode", q: "réparer mobile", relevant: { unicode: 3 }, first: "unicode" },
{ name: "whole word title", q: "api", relevant: { word: 3, "mobile-api": 3, "word-decoy": 1 } },
{ name: "no result", q: "quasarxylophone", relevant: {} },
];
export function searchQualityMetrics(keys: string[], relevant: Record<string, number>) {
const gain = (grade: number, index: number) => (2 ** grade - 1) / Math.log2(index + 2);
const dcg = keys.slice(0, 5).reduce((sum, key, index) => sum + gain(relevant[key] ?? 0, index), 0);
const ideal = Object.values(relevant).sort((a, b) => b - a).slice(0, 5).reduce((sum, grade, index) => sum + gain(grade, index), 0);
const rank = keys.findIndex((key) => relevant[key] === 3);
return { ndcg5: ideal === 0 ? Number(keys.length === 0) : dcg / ideal, reciprocalRank: rank < 0 ? 0 : 1 / (rank + 1) };
}

View File

@ -1466,7 +1466,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
expect(result.map((issue) => issue.id)).toEqual([recentMediumIssueId]);
});
it("ranks comment matches ahead of description-only matches", async () => {
it("ranks direct description matches ahead of comment-only matches", async () => {
const companyId = randomUUID();
const commentMatchId = randomUUID();
const descriptionMatchId = randomUUID();
@ -1508,7 +1508,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
includeRoutineExecutions: true,
});
expect(result.map((issue) => issue.id)).toEqual([commentMatchId, descriptionMatchId]);
expect(result.map((issue) => issue.id)).toEqual([descriptionMatchId, commentMatchId]);
});
it("filters issue lists to the full descendant tree for a root issue", async () => {

View File

@ -0,0 +1,147 @@
import { randomUUID } from "node:crypto";
import { performance } from "node:perf_hooks";
import { writeFile } from "node:fs/promises";
import { cpus, platform, release, totalmem } from "node:os";
import { sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { companies, createDb, documents, issueComments, issueDocuments, issues, getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "@paperclipai/db";
import { companySearchQuerySchema } from "@paperclipai/shared";
import { companySearchService } from "../services/company-search.js";
import { parseTaskSearch, taskSearchCtes, taskSearchScore } from "../services/task-search.js";
import { issueService } from "../services/issues.js";
import { searchQualityMetrics, taskSearchCases, taskSearchCorpus } from "./fixtures/task-search-corpus.js";
const support = await getEmbeddedPostgresTestSupport();
const baseline = process.env.SEARCH_EVAL_BASELINE === "1";
describe.skipIf(!support.supported)("task search relevance rubric (real PostgreSQL)", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
const companyId = randomUUID();
const keys = new Map<string, string>();
const plans: Record<string, unknown> = {};
const latency: Array<{ engine: string; q: string; firstMs: number; p95Ms: number; warmMs: number[] }> = [];
let postgresVersion = "";
const report: Array<{ engine: string; name: string; q: string; keys: string[]; ndcg5: number; reciprocalRank: number; ms: number }> = [];
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-search-quality-");
db = createDb(tempDb.connectionString);
postgresVersion = String((await db.execute(sql`SELECT version()`))[0]!.version);
await db.insert(companies).values({ id: companyId, name: "Search benchmark", issuePrefix: "EVAL" });
for (const [index, entry] of taskSearchCorpus.entries()) {
const id = randomUUID();
keys.set(id, entry.key);
const updatedAt = new Date(Date.UTC(2026, 0, 1) + index * 60_000);
await db.insert(issues).values({ id, companyId, title: entry.title,
identifier: "identifier" in entry ? entry.identifier : `EVAL-${index + 1}`,
description: "description" in entry ? entry.description : null,
status: "status" in entry ? entry.status : "todo", updatedAt, createdAt: updatedAt });
if ("comments" in entry) {
for (const body of entry.comments) await db.insert(issueComments).values({ companyId, issueId: id, body, updatedAt });
}
if ("document" in entry) {
const documentId = randomUUID();
await db.insert(documents).values({ id: documentId, companyId, title: entry.document.title, latestBody: entry.document.body, format: "markdown" });
await db.insert(issueDocuments).values({ companyId, issueId: id, documentId, key: "plan" });
}
}
});
afterAll(async () => {
const summary = ["full", "quick"].map((engine) => {
const rows = report.filter((row) => row.engine === engine);
const known = rows.filter((row) => Object.keys(taskSearchCases.find((item) => item.name === row.name)!.relevant).length > 0);
return { engine, queries: rows.length, ndcg5: rows.reduce((sum, row) => sum + row.ndcg5, 0) / rows.length,
mrr: known.reduce((sum, row) => sum + row.reciprocalRank, 0) / known.length };
});
console.log("SEARCH QUALITY", JSON.stringify(summary));
if (process.env.SEARCH_EVAL_REPORT) await writeFile(process.env.SEARCH_EVAL_REPORT, JSON.stringify({
environment: { postgresVersion, platform: platform(), release: release(), cpu: cpus()[0]?.model, memoryBytes: totalmem(), warmSamples: 20 },
summary, queries: report, latency, plans,
}, null, 2));
await tempDb?.cleanup();
});
for (const engine of ["full", "quick"] as const) {
for (const testCase of taskSearchCases) {
it(`${engine}: ${testCase.name}`, async () => {
const start = performance.now();
const rows = engine === "full"
? (await companySearchService(db).search(companyId, companySearchQuerySchema.parse({ q: testCase.q }))).results.filter((row) => row.type === "issue")
: await issueService(db).list(companyId, { q: testCase.q, limit: 50 });
const resultKeys = rows.map((row) => keys.get(row.id)!);
report.push({ engine, name: testCase.name, q: testCase.q, keys: resultKeys, ...searchQualityMetrics(resultKeys, testCase.relevant), ms: performance.now() - start });
if (baseline) return;
if (testCase.first) expect(resultKeys[0], JSON.stringify(resultKeys)).toBe(testCase.first);
for (const absent of testCase.absent ?? []) expect(resultKeys).not.toContain(absent);
for (const [key, grade] of Object.entries(testCase.relevant)) if (grade === 3) expect(resultKeys.slice(0, 5)).toContain(key);
if (Object.keys(testCase.relevant).length === 0) expect(resultKeys).toEqual([]);
});
}
}
it("meets the aggregate relevance gates", () => {
if (baseline) return;
for (const engine of ["full", "quick"]) {
const rows = report.filter((row) => row.engine === engine);
const known = rows.filter((row) => taskSearchCases.find((entry) => entry.name === row.name)!.q !== "quasarxylophone");
expect(known.reduce((sum, row) => sum + row.reciprocalRank, 0) / known.length).toBeGreaterThanOrEqual(0.95);
expect(rows.reduce((sum, row) => sum + row.ndcg5, 0) / rows.length).toBeGreaterThanOrEqual(0.90);
}
});
it("handles empty quotes, literal punctuation, and oversized title words without errors", async () => {
if (baseline) return;
await db.insert(issues).values({ companyId, title: "x".repeat(300) });
for (const q of ['""', "%", "_", "\\", "z".repeat(200)]) {
const full = await companySearchService(db).search(companyId, companySearchQuerySchema.parse({ q }));
const quick = await issueService(db).list(companyId, { q, limit: 50 });
if (q === '""' || q.startsWith("z")) {
expect(full.results).toEqual([]);
expect(quick).toEqual([]);
} else {
for (const row of quick) expect(row.title).toContain(q);
}
}
});
it.runIf(process.env.SEARCH_EVAL_SCALE === "1")("measures 10k tasks / 30k comments", async () => {
await db.execute(sql`
INSERT INTO issues (company_id, title, description, identifier)
SELECT ${companyId}, 'Routine deployment checkpoint ' || n,
repeat('Review the build output and update the deployment checklist. ', 5), 'SCALE-' || n
FROM generate_series(1, 10000) n
`);
await db.execute(sql`
INSERT INTO issue_comments (company_id, issue_id, body)
SELECT ${companyId}, id, repeat('Routine progress report: verified the output and recorded the findings. ', 5)
FROM issues CROSS JOIN generate_series(1, 3) n
WHERE company_id = ${companyId} AND identifier LIKE 'SCALE-%'
`);
await db.execute(sql`ANALYZE issues`);
await db.execute(sql`ANALYZE issue_comments`);
for (const engine of ["full", "quick"] as const) {
for (const q of ["GitHub OAuth", "OAuth callback GitHub", "mibile api", "search", "quasarxylophone", "routine"]) {
const durations: number[] = [];
for (let i = 0; i < 21; i++) {
const start = performance.now();
if (engine === "full") await companySearchService(db).search(companyId, companySearchQuerySchema.parse({ q }));
else await issueService(db).list(companyId, { q, limit: 20 });
durations.push(performance.now() - start);
}
const warmMs = durations.slice(1);
latency.push({ engine, q, firstMs: durations[0]!, p95Ms: [...warmMs].sort((a, b) => a - b)[18]!, warmMs });
}
}
for (const q of ["GitHub OAuth", "mibile api", "routine"]) {
const search = parseTaskSearch(q);
if (!baseline) plans[q] = await db.execute(sql`
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
${taskSearchCtes(companyId, search)}
SELECT m.id, ${taskSearchScore(search)} AS score FROM matched m ORDER BY score DESC LIMIT 20
`);
}
console.log("SEARCH LATENCY", JSON.stringify(latency.map(({ warmMs: _warmMs, ...row }) => row)));
}, 120_000);
});

View File

@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { parseTaskSearch } from "../services/task-search.js";
describe("task search query intent", () => {
it("keeps negation, short domain terms and quoted filler", () => {
expect(parseTaskSearch('the API is not "in the UI"').tokens).toEqual(["api", "is", "not", "in the ui"]);
expect(parseTaskSearch("the and").tokens).toEqual(["the", "and"]);
});
it("retains the strictest intent for repeated terms", () => {
expect(parseTaskSearch('callback "callback"').terms).toEqual([{ text: "callback", quoted: true }]);
});
it("normalizes task identifiers without guessing their numbers", () => {
for (const q of ["PAP-42", "pap42", "PAP 42"]) expect(parseTaskSearch(q).identifierQuery).toBe("pap-42");
expect(parseTaskSearch("T123-42").identifierQuery).toBe("t123-42");
expect(parseTaskSearch("PAP-420").identifierQuery).toBe("pap-420");
});
});

View File

@ -15,7 +15,6 @@ import {
import {
COMPANY_SEARCH_MAX_LIMIT,
COMPANY_SEARCH_MAX_OFFSET,
COMPANY_SEARCH_MAX_TOKENS,
COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS,
COMPANY_ARTIFACTS_MAX_LIMIT,
COMPANY_ARTIFACTS_MAX_QUERY_LENGTH,
@ -39,18 +38,8 @@ import {
import { companyArtifactsService } from "./company-artifacts.js";
import { companySearchExtractService } from "./company-search-extract.js";
import { visibleIssueCondition } from "./issue-visibility.js";
import { parseTaskSearch, taskSearchCtes, taskSearchScore, taskSearchFieldMatch, taskSearchTermMatch } from "./task-search.js";
const MIN_TOKEN_LENGTH = 2;
const MIN_FUZZY_QUERY_LENGTH = 4;
const MIN_FUZZY_TOKEN_LENGTH = 4;
// Cap fuzzy edits using the shorter of (query token, title word) so common
// 45 letter English words don't sweep in noise (e.g. "serach" vs "each").
const FUZZY_PAIR_LONG_LENGTH = 6;
const FUZZY_PAIR_LONG_MAX_EDITS = 2;
const FUZZY_PAIR_MEDIUM_LENGTH = 5;
const FUZZY_PAIR_MEDIUM_MAX_EDITS = 1;
const FUZZY_PAIR_SHORT_MAX_EDITS = 0;
const FUZZY_IDENTIFIER_SIMILARITY_THRESHOLD = 0.45;
const SNIPPET_MAX_CHARS = 240;
export const COMPANY_SEARCH_BRANCH_FETCH_LIMIT = COMPANY_SEARCH_MAX_OFFSET + COMPANY_SEARCH_MAX_LIMIT + 1;
@ -95,30 +84,6 @@ type SearchAggregateRow = {
count: number | string;
};
function normalizeQuery(query: string) {
return query.trim().replace(/\s+/g, " ").toLowerCase();
}
function escapeLikePattern(value: string): string {
return value.replace(/[\\%_]/g, "\\$&");
}
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 fuzzyEligibleTokens(tokens: string[]): string[] {
return tokens.filter((token) => token.length >= MIN_FUZZY_TOKEN_LENGTH);
}
function sqlTextArray(values: string[]) {
if (values.length === 0) return sql`ARRAY[]::text[]`;
return sql`ARRAY[${sql.join(values.map((value) => sql`${value}`), sql`, `)}]::text[]`;
@ -138,7 +103,7 @@ function plainText(value: string | null | undefined) {
.replace(/```[\s\S]*?```/g, " ")
.replace(/`([^`]+)`/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[#>*_~|]+/g, " ")
.replace(/(^|\s)[#>*_~|]+|[#>*_~|]+(?=\s|$)/g, " ")
.replace(/\s+/g, " ")
.trim();
}
@ -431,6 +396,8 @@ function scopeIncludesProjects(scope: CompanySearchScope) {
function selectPrimarySnippets(row: IssueSearchRow, normalizedQuery: string, tokens: string[]) {
const terms = matchTerms(normalizedQuery, tokens);
const identifierQuery = parseTaskSearch(normalizedQuery).identifierQuery;
if (!terms.includes(identifierQuery)) terms.push(identifierQuery);
const matchedFields = new Set(row.matchedFields ?? []);
const candidates: Array<CompanySearchSnippet | null> = [];
if (matchedFields.has("identifier")) {
@ -439,15 +406,22 @@ function selectPrimarySnippets(row: IssueSearchRow, normalizedQuery: string, tok
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));
}
const description = matchedFields.has("description")
? createSnippet("description", "Description", row.description, terms) : null;
const directMatch = Number(row.score) >= 3000 || Number(row.score) < 2000;
if (directMatch) candidates.push(description);
const context = [
{ field: "comment", label: "Comment", text: row.commentSnippet },
{ field: "document", label: row.documentTitle || "Document", text: [row.documentTitle, row.documentSnippet].filter(Boolean).join(" ") },
].filter((source) => matchedFields.has(source.field));
const coverage = (text: string | null) => tokens.filter((term) => (text ?? "").toLowerCase().includes(term)).length;
context.sort((left, right) => coverage(right.text) - coverage(left.text));
const contextSnippets = context.map((source) => createSnippet(source.field, source.label, source.text, terms));
// The title and identifier are already visible in the row. For thread-only
// coverage, preserve the evidence before applying the two-snippet limit.
if (directMatch) candidates.push(...contextSnippets);
else candidates.unshift(...contextSnippets);
if (!directMatch) candidates.push(description);
return candidates.filter((snippet): snippet is CompanySearchSnippet => Boolean(snippet)).slice(0, 2);
}
@ -456,7 +430,11 @@ function issueResult(row: IssueSearchRow, prefix: string, normalizedQuery: strin
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;
// Direct task matches open the task; context matches open the evidence shown.
const directMatch = Number(row.score) >= 3000 || Number(row.score) < 2000;
const evidence = snippets.find((snippet) => snippet.field === "comment" || snippet.field === "document");
const suffix = directMatch ? "" : evidence?.field === "comment" ? commentSuffix
: evidence?.field === "document" ? documentSuffix : "";
const issue: CompanySearchIssueSummary = {
id: row.id,
identifier: row.identifier,
@ -495,7 +473,9 @@ function scoreSimpleRow(row: SimpleSearchRow, normalizedQuery: string, tokens: s
if (haystack.includes(token)) score += 20;
}
if (row.title.toLowerCase().startsWith(normalizedQuery)) score += 80;
return score;
// Keep other entity types on the same scale as the task relevance bands:
// an exact agent/project name must still outrank a speculative task typo.
return score * 10;
}
function artifactResult(artifact: CompanyArtifact, normalizedQuery: string, tokens: string[]): CompanySearchResult {
@ -559,9 +539,10 @@ export function companySearchService(db: Db) {
return {
extract: extractService.extract,
search: async (companyId: string, query: CompanySearchQuery): Promise<CompanySearchResponse> => {
const normalizedQuery = normalizeQuery(query.q);
const taskSearch = parseTaskSearch(query.q);
const normalizedQuery = taskSearch.normalizedQuery;
const hasSearchText = normalizedQuery.length > 0;
const tokens = tokenizeQuery(normalizedQuery);
const tokens = taskSearch.tokens;
const scope = query.scope;
const sort = query.sort;
const limit = query.limit;
@ -583,147 +564,20 @@ export function companySearchService(db: Db) {
}
const fetchLimit = companySearchBranchFetchLimit(limit, offset);
const escapedTokens = tokens.map(escapeLikePattern);
// LIKE/ILIKE both treat backslash as the default escape character, so the
// escaped tokens stay literal inside ILIKE ANY(...) patterns too.
const tokenPatterns = escapedTokens.map((token) => `%${token}%`);
const tokenPatternArray = sqlTextArray(tokenPatterns);
const fuzzyTokens = fuzzyEligibleTokens(tokens);
const fuzzyTokenArray = sqlTextArray(fuzzyTokens);
const escapedQuery = escapeLikePattern(normalizedQuery);
const containsPattern = hasSearchText ? `%${escapedQuery}%` : "__paperclip_no_match__";
const startsWithPattern = hasSearchText ? `${escapedQuery}%` : "__paperclip_no_match__";
const fuzzyEnabled = hasSearchText && normalizedQuery.length >= MIN_FUZZY_QUERY_LENGTH && !/[\\%_]/.test(normalizedQuery);
const fuzzyTokensEnabled = fuzzyEnabled && fuzzyTokens.length > 0;
const tokenPatternArray = sqlTextArray(taskSearch.patterns);
const containsPattern = hasSearchText && tokens.length > 0 ? taskSearch.containsPattern : "__paperclip_no_match__";
const tokenCount = tokens.length;
// --- shared match expressions against the `issues` table -------------
// Raw-column ILIKE keeps the predicates compatible with the existing
// pg_trgm GIN indexes (lower(col) LIKE expressions cannot use them).
const titlePhraseMatch = hasSearchText ? sql<boolean>`issues.title ILIKE ${containsPattern}` : noMatchSql();
const titleStartsWith = hasSearchText ? sql<boolean>`issues.title ILIKE ${startsWithPattern}` : noMatchSql();
const titleExactMatch = hasSearchText ? sql<boolean>`lower(issues.title) = ${normalizedQuery}` : noMatchSql();
const identifierPhraseMatch = hasSearchText ? sql<boolean>`coalesce(issues.identifier, '') ILIKE ${containsPattern}` : noMatchSql();
const identifierStartsWith = hasSearchText ? sql<boolean>`coalesce(issues.identifier, '') ILIKE ${startsWithPattern}` : noMatchSql();
const identifierExactMatch = hasSearchText ? sql<boolean>`lower(coalesce(issues.identifier, '')) = ${normalizedQuery}` : noMatchSql();
const descriptionPhraseMatch = hasSearchText ? sql<boolean>`coalesce(issues.description, '') ILIKE ${containsPattern}` : noMatchSql();
const titleTokenMatch = tokenCount > 0 ? sql<boolean>`issues.title ILIKE ANY(${tokenPatternArray})` : noMatchSql();
const identifierTokenMatch = tokenCount > 0 ? sql<boolean>`coalesce(issues.identifier, '') ILIKE ANY(${tokenPatternArray})` : noMatchSql();
const descriptionTokenMatch = tokenCount > 0 ? sql<boolean>`coalesce(issues.description, '') ILIKE ANY(${tokenPatternArray})` : noMatchSql();
// Comment/document matches are computed once per request into tagged
// CTEs (issue_id, ord) where ord 1 is the phrase pattern and ord k+1 is
// token k. Flags and per-token coverage become cheap hashed IN probes
// against those sets instead of per-issue-row correlated subqueries.
// Single-pattern queries stay a bare `col ILIKE pattern` so the pg_trgm
// GIN indexes can bitmap-scan them; multi-pattern queries use one tagged
// pass over the table (an OR/ANY form would seq-scan anyway).
const matchPatterns = hasSearchText
? [containsPattern, ...tokenPatterns.filter((pattern) => pattern !== containsPattern)]
: [];
const matchPatternOrdinal = (pattern: string) => matchPatterns.indexOf(pattern) + 1;
const matchPatternArray = sqlTextArray(matchPatterns);
const commentMatchesCte = !hasSearchText
? sql`SELECT NULL::uuid AS issue_id, 0 AS ord WHERE false`
: matchPatterns.length === 1
? sql`
SELECT search_comments.issue_id, 1 AS ord
FROM issue_comments search_comments
WHERE search_comments.company_id = ${companyId}
AND search_comments.deleted_at IS NULL
AND search_comments.body ILIKE ${matchPatterns[0]!}
GROUP BY 1, 2
`
: sql`
SELECT search_comments.issue_id, pat.ord::int AS ord
FROM issue_comments search_comments
INNER JOIN unnest(${matchPatternArray}) WITH ORDINALITY AS pat(pattern, ord)
ON search_comments.body ILIKE pat.pattern
WHERE search_comments.company_id = ${companyId}
AND search_comments.deleted_at IS NULL
GROUP BY 1, 2
`;
// Documents get one UNION ALL arm per pattern (each arm a bare
// `col ILIKE pattern`) so the planner can pick a pg_trgm bitmap scan per
// pattern; latest_body is large enough that skipping the seq scan for
// selective patterns dwarfs the duplicate-recheck cost on common ones.
const documentMatchesCte = !hasSearchText
? sql`SELECT NULL::uuid AS issue_id, 0 AS ord WHERE false`
: sql.join(matchPatterns.map((pattern, index) => sql`
SELECT search_issue_documents.issue_id, ${index + 1}::int AS ord
FROM issue_documents search_issue_documents
INNER JOIN documents search_documents
ON search_documents.id = search_issue_documents.document_id
AND search_documents.company_id = search_issue_documents.company_id
WHERE search_issue_documents.company_id = ${companyId}
AND (
search_documents.title ILIKE ${pattern}
OR search_documents.latest_body ILIKE ${pattern}
)
GROUP BY 1, 2
`), sql` UNION ALL `);
const commentMatch = hasSearchText
? sql<boolean>`issues.id IN (SELECT comment_matches.issue_id FROM comment_matches)`
: noMatchSql();
const documentMatch = hasSearchText
? sql<boolean>`issues.id IN (SELECT document_matches.issue_id FROM document_matches)`
: noMatchSql();
// Each query token (length >= MIN_FUZZY_TOKEN_LENGTH) must have at least
// one title word within Levenshtein edit distance. This handles typos
// like "serach" -> "search" (transposition) and "mibile" -> "mobile"
// (substitution) without the trigram noise that drop-character variants
// produced (e.g. "serac" matching "service"). Edit budget is gated on
// the SHORTER of the two strings so 45 letter English words don't get
// swept in by lev=2 collisions.
const fuzzyMaxEditsExpr = sql.raw(
`CASE
WHEN least(length(qt.value), length(title_word.value)) >= ${FUZZY_PAIR_LONG_LENGTH} THEN ${FUZZY_PAIR_LONG_MAX_EDITS}
WHEN least(length(qt.value), length(title_word.value)) >= ${FUZZY_PAIR_MEDIUM_LENGTH} THEN ${FUZZY_PAIR_MEDIUM_MAX_EDITS}
ELSE ${FUZZY_PAIR_SHORT_MAX_EDITS}
END`,
);
const fuzzyMinTitleWordLengthExpr = sql.raw(`${MIN_FUZZY_TOKEN_LENGTH}`);
const fuzzyTokenTitleMatch = fuzzyTokensEnabled
? sql<boolean>`
coalesce((
SELECT bool_and(
EXISTS (
SELECT 1
FROM regexp_split_to_table(lower(issues.title), '[^a-z0-9]+') AS title_word(value)
WHERE length(title_word.value) >= ${fuzzyMinTitleWordLengthExpr}
AND levenshtein_less_equal(qt.value, title_word.value, ${fuzzyMaxEditsExpr}) <= ${fuzzyMaxEditsExpr}
)
)
FROM unnest(${fuzzyTokenArray}) AS qt(value)
), false)
`
: noMatchSql();
const fuzzyIdentifierMatch = fuzzyEnabled
? sql<boolean>`similarity(lower(coalesce(issues.identifier, '')), ${normalizedQuery}) >= ${FUZZY_IDENTIFIER_SIMILARITY_THRESHOLD}`
: noMatchSql();
const issueTextMatch = sql<boolean>`(
${titlePhraseMatch}
OR ${identifierPhraseMatch}
OR ${descriptionPhraseMatch}
OR ${titleTokenMatch}
OR ${identifierTokenMatch}
OR ${descriptionTokenMatch}
)`;
const fuzzyMatch = sql<boolean>`(${fuzzyTokenTitleMatch} OR ${fuzzyIdentifierMatch})`;
const anySearchMatch = sql<boolean>`(${issueTextMatch} OR ${commentMatch} OR ${documentMatch} OR ${fuzzyMatch})`;
const issueFilters = issueFilterConditions(companyId, query);
const hasIssueOnlyFilters = issueOnlyFiltersActive(query);
// Scope conditions over precomputed flag columns (alias-qualified).
function flagTextMatch(alias: string) {
return sql<boolean>`(
${sql.raw(alias)}.title_phrase OR ${sql.raw(alias)}.ident_phrase OR ${sql.raw(alias)}.desc_phrase
OR ${sql.raw(alias)}.title_token OR ${sql.raw(alias)}.ident_token OR ${sql.raw(alias)}.desc_token
)`;
return sql<boolean>`(${sql.raw(alias)}.issue_coverage = ${tokenCount}
OR ${sql.raw(alias)}.ident_exact OR ${sql.raw(alias)}.ident_starts)`;
}
function flagFuzzyMatch(alias: string) {
return sql<boolean>`(${sql.raw(alias)}.fuzzy_title OR ${sql.raw(alias)}.fuzzy_ident)`;
return sql<boolean>`${sql.raw(alias)}.fuzzy_title`;
}
function flagScopeCondition(alias: string, forScope: CompanySearchScope): SQL<boolean> {
if (!hasSearchText) {
@ -732,7 +586,7 @@ export function companySearchService(db: Db) {
if (forScope === "comments") return sql<boolean>`${sql.raw(alias)}.comment_match`;
if (forScope === "documents") return sql<boolean>`${sql.raw(alias)}.document_match`;
if (forScope === "issues") return sql<boolean>`(${flagTextMatch(alias)} OR ${flagFuzzyMatch(alias)})`;
return sql<boolean>`(${flagTextMatch(alias)} OR ${sql.raw(alias)}.comment_match OR ${sql.raw(alias)}.document_match OR ${flagFuzzyMatch(alias)})`;
return sql<boolean>`true`;
}
// --- combined issue results + aggregates statement ---------------------
@ -764,24 +618,7 @@ export function companySearchService(db: Db) {
const wantResultRows = scopeIncludesIssues(scope)
&& !(!hasSearchText && (scope === "comments" || scope === "documents"));
if (wantResultRows) {
const allTokensBonus = tokenCount > 0
? sql`CASE WHEN m.token_coverage = ${tokenCount} THEN 260 ELSE 0 END`
: sql`0`;
const scoreSql = sql`(
CASE WHEN m.ident_exact THEN 1200 ELSE 0 END
+ CASE WHEN m.ident_starts THEN 700 ELSE 0 END
+ CASE WHEN m.title_exact THEN 900 ELSE 0 END
+ CASE WHEN m.title_starts THEN 550 ELSE 0 END
+ CASE WHEN m.title_phrase THEN 350 ELSE 0 END
+ CASE WHEN m.ident_phrase THEN 320 ELSE 0 END
+ CASE WHEN m.comment_match THEN 180 ELSE 0 END
+ CASE WHEN m.document_match THEN 170 ELSE 0 END
+ CASE WHEN m.desc_phrase THEN 120 ELSE 0 END
+ ${allTokensBonus}
+ (m.token_coverage * 70)
+ CASE WHEN (m.fuzzy_title OR m.fuzzy_ident) THEN 110 ELSE 0 END
+ CASE m.status WHEN 'done' THEN 0 WHEN 'cancelled' THEN -30 ELSE 20 END
)::double precision`;
const scoreSql = taskSearchScore(taskSearch);
const priorityOrderSql = sql`CASE m.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
const orderBySql = sort === "updated"
? sql`m.updated_at DESC, score DESC, m.id DESC`
@ -798,7 +635,8 @@ export function companySearchService(db: Db) {
m.id,
m.identifier,
m.title,
m.description,
(SELECT issue_text.description FROM issues issue_text
WHERE issue_text.id = m.id AND issue_text.company_id = ${companyId}) AS description,
m.status,
m.priority,
m.assignee_agent_id AS "assigneeAgentId",
@ -808,9 +646,9 @@ export function companySearchService(db: Db) {
m.updated_at AS "updatedAt",
${scoreSql} AS score,
array_remove(ARRAY[
CASE WHEN m.ident_phrase OR m.ident_token OR m.fuzzy_ident THEN 'identifier' END,
CASE WHEN m.title_phrase OR m.title_token OR m.fuzzy_title THEN 'title' END,
CASE WHEN m.desc_phrase OR m.desc_token THEN 'description' END,
CASE WHEN m.ident_exact OR m.ident_starts OR m.ident_phrase OR m.ident_token THEN 'identifier' END,
CASE WHEN m.title_token OR m.fuzzy_title THEN 'title' END,
CASE WHEN m.desc_token THEN 'description' END,
CASE WHEN m.comment_match THEN 'comment' END,
CASE WHEN m.document_match THEN 'document' END
], NULL)::text[] AS "matchedFields"
@ -825,10 +663,10 @@ export function companySearchService(db: Db) {
branches.push(sql`SELECT 'type:issue' AS kind, NULL::text AS value, count(*)::int AS count ${countTail} FROM matched m ${branchWhere([...facetsAll, titleCond])}`);
}
if (hasSearchText && (scope === "all" || scope === "comments")) {
branches.push(sql`SELECT 'type:comment' AS kind, NULL::text AS value, count(*)::int AS count ${countTail} FROM matched m ${branchWhere([...facetsAll, sql`m.comment_match`])}`);
branches.push(sql`SELECT 'type:comment' AS kind, NULL::text AS value, count(*)::int AS count ${countTail} FROM matched m ${branchWhere([...facetsAll, flagScopeCondition("m", "comments")])}`);
}
if (hasSearchText && (scope === "all" || scope === "documents")) {
branches.push(sql`SELECT 'type:document' AS kind, NULL::text AS value, count(*)::int AS count ${countTail} FROM matched m ${branchWhere([...facetsAll, sql`m.document_match`])}`);
branches.push(sql`SELECT 'type:document' AS kind, NULL::text AS value, count(*)::int AS count ${countTail} FROM matched m ${branchWhere([...facetsAll, flagScopeCondition("m", "documents")])}`);
}
const facetBranch = (kind: string, valueSql: SQL, omit: CompanySearchIssueFilterKey, extra: SQL[] = []) => sql`
@ -878,59 +716,8 @@ export function companySearchService(db: Db) {
}
}
// Per-token coverage counts matches across issue text and the tagged
// comment/document match sets (hashed IN probes, one set per token).
const coverageSql = tokenCount > 0
? sql`(${sql.join(tokens.map((_, index) => {
const pattern = tokenPatterns[index]!;
const ord = matchPatternOrdinal(pattern);
return sql`(CASE WHEN
issues.title ILIKE ${pattern}
OR coalesce(issues.identifier, '') ILIKE ${pattern}
OR coalesce(issues.description, '') ILIKE ${pattern}
OR issues.id IN (SELECT comment_matches.issue_id FROM comment_matches WHERE comment_matches.ord = ${ord})
OR issues.id IN (SELECT document_matches.issue_id FROM document_matches WHERE document_matches.ord = ${ord})
THEN 1 ELSE 0 END)`;
}), sql` + `)})`
: sql`0`;
const matchedWhere = hasSearchText ? sql` AND ${anySearchMatch}` : sql``;
const resultRows = await db.execute(sql`
WITH comment_matches AS MATERIALIZED (${commentMatchesCte}),
document_matches AS MATERIALIZED (${documentMatchesCte}),
matched AS MATERIALIZED (
SELECT
issues.id,
issues.identifier,
issues.title,
issues.description,
issues.status,
issues.priority,
issues.assignee_agent_id,
issues.assignee_user_id,
issues.project_id,
issues.created_at,
issues.updated_at,
${titlePhraseMatch} AS title_phrase,
${titleStartsWith} AS title_starts,
${titleExactMatch} AS title_exact,
${identifierPhraseMatch} AS ident_phrase,
${identifierStartsWith} AS ident_starts,
${identifierExactMatch} AS ident_exact,
${descriptionPhraseMatch} AS desc_phrase,
${titleTokenMatch} AS title_token,
${identifierTokenMatch} AS ident_token,
${descriptionTokenMatch} AS desc_token,
${commentMatch} AS comment_match,
${documentMatch} AS document_match,
${fuzzyTokenTitleMatch} AS fuzzy_title,
${fuzzyIdentifierMatch} AS fuzzy_ident,
${coverageSql} AS token_coverage
FROM issues
WHERE issues.company_id = ${companyId}
AND ${visibleIssueCondition()}
${matchedWhere}
)
${taskSearchCtes(companyId, taskSearch, scope !== "issues", and(...issueFilters))}
${sql.join(branches, sql` UNION ALL `)}
`) as unknown as Array<SearchAggregateRow & Omit<IssueSearchRow, "commentSnippet" | "commentId" | "documentSnippet" | "documentTitle" | "documentKey">>;
@ -1014,11 +801,11 @@ export function companySearchService(db: Db) {
AND search_comments.issue_id = target.id
AND search_comments.deleted_at IS NULL
AND (
search_comments.body ILIKE ${containsPattern}
OR search_comments.body ILIKE ANY(${tokenPatternArray})
${taskSearchFieldMatch(sql`search_comments.body`, taskSearch)}
)
ORDER BY
CASE WHEN search_comments.body ILIKE ${containsPattern} THEN 0 ELSE 1 END,
${sql.join(tokens.map((_, index) => sql`CASE WHEN ${taskSearchTermMatch(sql`search_comments.body`, taskSearch, index)} THEN 1 ELSE 0 END`), sql` + `)} DESC,
search_comments.updated_at DESC,
search_comments.id DESC
LIMIT 1
@ -1032,10 +819,8 @@ export function companySearchService(db: Db) {
WHERE search_issue_documents.company_id = ${companyId}
AND search_issue_documents.issue_id = target.id
AND (
coalesce(search_documents.title, '') ILIKE ${containsPattern}
OR search_documents.latest_body ILIKE ${containsPattern}
OR coalesce(search_documents.title, '') ILIKE ANY(${tokenPatternArray})
OR search_documents.latest_body ILIKE ANY(${tokenPatternArray})
${taskSearchFieldMatch(sql`search_documents.title`, taskSearch)}
OR ${taskSearchFieldMatch(sql`search_documents.latest_body`, taskSearch)}
)
ORDER BY
CASE
@ -1043,6 +828,7 @@ export function companySearchService(db: Db) {
WHEN search_documents.latest_body ILIKE ${containsPattern} THEN 1
ELSE 2
END,
${sql.join(tokens.map((_, index) => sql`CASE WHEN ${taskSearchTermMatch(sql`search_documents.title`, taskSearch, index)} OR ${taskSearchTermMatch(sql`search_documents.latest_body`, taskSearch, index)} THEN 1 ELSE 0 END`), sql` + `)} DESC,
search_documents.updated_at DESC,
search_documents.id DESC
LIMIT 1

View File

@ -1,4 +1,5 @@
import { documentService } from "./documents.js";
import { parseTaskSearch, taskSearchCtes, taskSearchScore } from "./task-search.js";
import { createdFromIssueCondition } from "./issue-creation-origin.js";
import { executionProjectionsForRuns } from "./execution-projection.js";
import type { ExecutionProjection } from "@paperclipai/shared";
@ -7810,24 +7811,7 @@ export function issueService(db: Db) {
filters?.includeLiveDescendantSummary === true;
const rawSearch = filters?.q?.trim() ?? "";
const hasSearch = rawSearch.length > 0;
const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : "";
const startsWithPattern = `${escapedSearch}%`;
const containsPattern = `%${escapedSearch}%`;
const titleStartsWithMatch = sql<boolean>`${issues.title} ILIKE ${startsWithPattern} ESCAPE '\\'`;
const titleContainsMatch = sql<boolean>`${issues.title} ILIKE ${containsPattern} ESCAPE '\\'`;
const identifierStartsWithMatch = sql<boolean>`${issues.identifier} ILIKE ${startsWithPattern} ESCAPE '\\'`;
const identifierContainsMatch = sql<boolean>`${issues.identifier} ILIKE ${containsPattern} ESCAPE '\\'`;
const descriptionContainsMatch = sql<boolean>`${issues.description} ILIKE ${containsPattern} ESCAPE '\\'`;
const commentContainsMatch = sql<boolean>`
EXISTS (
SELECT 1
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND ${issueComments.deletedAt} IS NULL
AND ${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'
)
`;
const taskSearch = parseTaskSearch(rawSearch);
if (filters?.createdFromIssueId) {
conditions.push(createdFromIssueCondition(companyId, filters.createdFromIssueId));
}
@ -7935,16 +7919,6 @@ export function issueService(db: Db) {
),
);
}
if (hasSearch) {
conditions.push(
or(
titleContainsMatch,
identifierContainsMatch,
descriptionContainsMatch,
commentContainsMatch,
)!,
);
}
if (filters?.updatedSince) {
const since = new Date(filters.updatedSince);
if (Number.isFinite(since.getTime())) {
@ -7959,20 +7933,15 @@ export function issueService(db: Db) {
conditions.push(ne(issues.originKind, "routine_execution"));
}
const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
const searchOrder = sql<number>`
CASE
WHEN ${titleStartsWithMatch} THEN 0
WHEN ${titleContainsMatch} THEN 1
WHEN ${identifierStartsWithMatch} THEN 2
WHEN ${identifierContainsMatch} THEN 3
WHEN ${commentContainsMatch} THEN 4
WHEN ${descriptionContainsMatch} THEN 5
ELSE 6
END
`;
const baseQuery = db
.select(issueListSelect)
.from(issues)
const searchOrder = sql<number>`-task_search.score`;
const issueSource = db.select(issueListSelect).from(issues);
const searchedSource = hasSearch
? issueSource.innerJoin(sql`(
${taskSearchCtes(companyId, taskSearch, true, and(...conditions))}
SELECT m.id, ${taskSearchScore(taskSearch)} AS score FROM matched m
) task_search`, sql`task_search.id = ${issues.id}`)
: issueSource;
const baseQuery = searchedSource
.where(and(...conditions))
.orderBy(
...issueListOrderBy(companyId, {

View File

@ -0,0 +1,196 @@
import { sql, type SQL } from "drizzle-orm";
import { COMPANY_SEARCH_MAX_QUERY_LENGTH, COMPANY_SEARCH_MAX_TOKENS } from "@paperclipai/shared";
import { visibleIssueCondition } from "./issue-visibility.js";
// Only grammatical filler is ignored, only in multi-term queries, and never
// inside quotes. Keep negation and domain words (API, UI, PR, etc.) meaningful.
const FILLER = new Set(["a", "an", "the", "and", "of", "to", "for", "in", "on", "with"]);
export function escapeTaskSearchPattern(value: string) {
return value.replace(/[\\%_]/g, "\\$&");
}
export function parseTaskSearch(text: string) {
const normalizedQuery = text.slice(0, COMPANY_SEARCH_MAX_QUERY_LENGTH).trim().replace(/\s+/g, " ").toLowerCase();
const parsed = Array.from(normalizedQuery.matchAll(/"([^"]+)"|([^\s"]+)/g), (match) => ({
text: match[1] ?? match[2]!, quoted: match[1] !== undefined,
}));
const meaningful = parsed.filter((term) => term.quoted || !FILLER.has(term.text));
const uniqueTerms = new Map<string, { text: string; quoted: boolean }>();
for (const term of meaningful.length > 0 ? meaningful : parsed) {
uniqueTerms.set(term.text, { ...term, quoted: term.quoted || uniqueTerms.get(term.text)?.quoted === true });
}
const terms = [...uniqueTerms.values()].slice(0, COMPANY_SEARCH_MAX_TOKENS);
const tokens = terms.map((term) => term.text);
const phrase = tokens.join(" ");
// A copied/typed task identifier is navigation, never a fuzzy number match.
const identifier = /^([a-z][a-z0-9]*)[- ](\d+)$/i.exec(normalizedQuery) ?? /^([a-z]+)(\d+)$/i.exec(normalizedQuery);
const identifierQuery = identifier ? `${identifier[1]}-${identifier[2]}` : normalizedQuery;
const patterns = tokens.map((token) => `%${escapeTaskSearchPattern(token)}%`);
const containsPattern = `%${escapeTaskSearchPattern(phrase)}%`;
const startsWithPattern = `${escapeTaskSearchPattern(phrase)}%`;
return { normalizedQuery, terms, tokens, phrase, identifierQuery, patterns, containsPattern, startsWithPattern };
}
export type TaskSearch = ReturnType<typeof parseTaskSearch>;
function taskSearchAny(field: SQL, search: TaskSearch): SQL<boolean> {
return search.patterns.length === 0 ? sql`false`
: sql`(${sql.join(search.patterns.map((pattern) => sql`${field} ILIKE ${pattern}`), sql` OR `)})`;
}
// Short typeahead terms must start a word: UI must not match "build", and
// API must not match "Capistrano". Keep an indexable literal precondition.
export function taskSearchTermMatch(field: SQL, search: TaskSearch, index: number): SQL<boolean> {
const term = search.tokens[index]!;
const literal = sql<boolean>`${field} ILIKE ${search.patterns[index]!}`;
return /^[\p{L}]{1,3}$/u.test(term)
? sql`(${literal} AND ${field} ~* ${`(^|[^[:alnum:]])${term}`})`
: literal;
}
export function taskSearchFieldMatch(field: SQL, search: TaskSearch): SQL<boolean> {
return search.tokens.length === 0 ? sql`false`
: sql`(${sql.join(search.tokens.map((_, index) => taskSearchTermMatch(field, search, index)), sql` OR `)})`;
}
function coverage(matches: SQL[]): SQL<number> {
return matches.length === 0 ? sql`0`
: sql`(${sql.join(matches.map((match) => sql`CASE WHEN ${match} THEN 1 ELSE 0 END`), sql` + `)})`;
}
// Score bands are deliberately disjoint. Incidental comments, repeated terms,
// status and recency cannot outweigh a stronger kind of match.
export function taskSearchScore(search: TaskSearch): SQL<number> {
const n = search.tokens.length;
if (n === 0) return sql`0`;
return sql`(
CASE
WHEN m.ident_exact THEN 8000
WHEN m.ident_starts THEN 7000
WHEN m.title_exact THEN 6000
WHEN m.title_phrase AND m.title_coverage = ${n} THEN 5000
WHEN m.title_coverage = ${n} THEN 4000
WHEN m.issue_coverage = ${n} THEN 3000
WHEN m.token_coverage = ${n} THEN 2000
WHEN m.fuzzy_title THEN 1000
ELSE 0
END
+ m.title_word_coverage * 10
+ CASE WHEN m.title_starts THEN 30 ELSE 0 END
+ CASE m.status WHEN 'done' THEN 0 WHEN 'cancelled' THEN 0 ELSE 10 END
)::double precision`;
}
/** Shared task retrieval for company search and issue-list/command-palette search.
* Uses existing pg_trgm indexes and current rows: no derived corpus or worker.
* The tagged comment/document sets are evaluated once, not once per task.
*/
export function taskSearchCtes(companyId: string, search: TaskSearch, includeContext = true, fallbackFilters?: SQL): SQL {
const n = search.tokens.length;
const comments = n === 0 || !includeContext ? sql`SELECT NULL::uuid AS issue_id, 0 AS ord WHERE false`
: sql.join(search.patterns.map((_, index) => sql`
SELECT c.issue_id, ${index}::int AS ord FROM issue_comments c
WHERE c.company_id = ${companyId} AND c.deleted_at IS NULL AND ${taskSearchTermMatch(sql`c.body`, search, index)}
GROUP BY c.issue_id
`), sql` UNION ALL `);
const documents = n === 0 || !includeContext ? sql`SELECT NULL::uuid AS issue_id, 0 AS ord WHERE false`
: sql.join(search.patterns.map((_, index) => sql`
SELECT d.issue_id, ${index}::int AS ord FROM issue_documents d
JOIN documents body ON body.id = d.document_id AND body.company_id = d.company_id
WHERE d.company_id = ${companyId} AND (${taskSearchTermMatch(sql`body.title`, search, index)} OR ${taskSearchTermMatch(sql`body.latest_body`, search, index)})
GROUP BY d.issue_id
`), sql` UNION ALL `);
const titleTerms = search.patterns.map((_, index) => taskSearchTermMatch(sql`issues.title`, search, index));
const issueTerms = search.patterns.map((_, index) => sql`(
${titleTerms[index]!} OR ${taskSearchTermMatch(sql`issues.identifier`, search, index)}
OR ${taskSearchTermMatch(sql`issues.description`, search, index)}
)`);
const commentTerms = search.patterns.map((_, index) => sql`issues.id IN (SELECT issue_id FROM comment_matches WHERE ord = ${index})`);
const documentTerms = search.patterns.map((_, index) => sql`issues.id IN (SELECT issue_id FROM document_matches WHERE ord = ${index})`);
const allTerms = issueTerms.map((term, index) => sql`(${term} OR ${commentTerms[index]!} OR ${documentTerms[index]!})`);
const phraseMatch = (field: SQL) => n > 0 ? sql`coalesce(${field} ILIKE ${search.containsPattern}, false)` : sql`false`;
const identExact = n > 0 ? sql`lower(issues.identifier) = ${search.identifierQuery}` : sql`false`;
const identStarts = n > 0 ? sql`issues.identifier ILIKE ${escapeTaskSearchPattern(search.identifierQuery) + "%"}` : sql`false`;
const fuzzyAllowed = !search.terms.some((term) => term.quoted)
&& search.terms.some((term) => /^[\p{L}]{4,255}$/u.test(term.text))
&& !/^[a-z][a-z0-9]*[- ]?\d+$/i.test(search.normalizedQuery);
const fuzzyTerms = search.terms.map((term, index) => {
if (!/^[\p{L}]{4,255}$/u.test(term.text)) return titleTerms[index]!;
// Bound both arguments before calling fuzzystrmatch (255-character limit).
// Cheap length checks prune word pairs before bounded edit-distance work.
const edits = sql`CASE WHEN least(char_length(word), ${Array.from(term.text).length}) >= 6 THEN 2
WHEN least(char_length(word), ${Array.from(term.text).length}) >= 5 THEN 1 ELSE 0 END`;
return sql`(${titleTerms[index]!} OR EXISTS (
SELECT 1 FROM regexp_split_to_table(lower(issues.title), '[^[:alnum:]]+') AS word
WHERE CASE WHEN char_length(word) BETWEEN 4 AND 255
AND abs(char_length(word) - ${Array.from(term.text).length}) <= ${edits}
THEN levenshtein_less_equal(${term.text}, word, ${edits}) <= ${edits}
ELSE false END
))`;
});
const fuzzy = fuzzyAllowed ? sql`CASE WHEN ${coverage(titleTerms)} = ${n} THEN false
ELSE (${sql.join(fuzzyTerms, sql` AND `)}) END` : sql`false`;
const wordTerms = search.tokens.map((token) => {
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return sql`issues.title ~* ${`(^|[^[:alnum:]_])${escaped}($|[^[:alnum:]_])`}`;
});
// Carry flags, not potentially large bodies, through materialized stages.
// The search page fetches descriptions only for its result window.
const flags = (fuzzyMatch: SQL) => sql`
SELECT issues.id, issues.identifier, issues.title,
issues.status, issues.priority, issues.assignee_agent_id, issues.assignee_user_id,
issues.project_id, issues.created_at, issues.updated_at,
${identExact} AS ident_exact, ${identStarts} AS ident_starts,
${phraseMatch(sql`issues.identifier`)} AS ident_phrase,
${taskSearchFieldMatch(sql`issues.identifier`, search)} AS ident_token,
${n > 0 ? sql`lower(issues.title) = ${search.phrase}` : sql`false`} AS title_exact,
${n > 0 ? sql`issues.title ILIKE ${search.startsWithPattern}` : sql`false`} AS title_starts,
${phraseMatch(sql`issues.title`)} AS title_phrase,
${taskSearchFieldMatch(sql`issues.title`, search)} AS title_token,
${phraseMatch(sql`issues.description`)} AS desc_phrase,
${taskSearchFieldMatch(sql`issues.description`, search)} AS desc_token,
${coverage(titleTerms)} AS title_coverage,
${coverage(wordTerms)} AS title_word_coverage,
${coverage(issueTerms)} AS issue_coverage,
${coverage(commentTerms)} AS comment_coverage,
${coverage(documentTerms)} AS document_coverage,
${coverage(allTerms)} AS token_coverage,
${fuzzyMatch} AS fuzzy_title,
issues.id IN (SELECT issue_id FROM comment_matches) AS comment_match,
issues.id IN (SELECT issue_id FROM document_matches) AS document_match
FROM issues
`;
return sql`
WITH comment_matches AS MATERIALIZED (${comments}),
document_matches AS MATERIALIZED (${documents}),
literal_candidates AS MATERIALIZED (
SELECT issues.id FROM issues
WHERE issues.company_id = ${companyId} AND ${visibleIssueCondition()}
AND ${n === 0 ? sql`${search.normalizedQuery.length === 0}` : sql`(
${taskSearchAny(sql`issues.title`, search)}
OR ${taskSearchAny(sql`issues.identifier`, search)}
OR ${taskSearchAny(sql`issues.description`, search)}
OR ${identStarts}
)`}
UNION SELECT issue_id FROM comment_matches
UNION SELECT issue_id FROM document_matches
), search_flags AS MATERIALIZED (
${flags(sql`false`)}
WHERE issues.company_id = ${companyId} AND ${visibleIssueCondition()}
AND issues.id IN (SELECT id FROM literal_candidates)
), literal_matches AS MATERIALIZED (
SELECT * FROM search_flags
WHERE ${n === 0 ? sql`true` : sql`token_coverage = ${n} OR ident_exact OR ident_starts`}
), fuzzy_candidates AS MATERIALIZED (
SELECT issues.id FROM issues
WHERE NOT EXISTS (
SELECT 1 FROM literal_matches literal
JOIN issues ON issues.id = literal.id
${fallbackFilters ? sql`WHERE ${fallbackFilters}` : sql``}
)
AND issues.company_id = ${companyId} AND ${visibleIssueCondition()}
AND ${fuzzy}
), matched AS MATERIALIZED (
SELECT * FROM literal_matches
UNION ALL
${flags(sql`true`)}
WHERE issues.id IN (SELECT id FROM fuzzy_candidates)
)
`;
}

View File

@ -263,6 +263,30 @@ describe("Search page", () => {
});
});
it.each(["relevance", "updated"])("preserves server %s order across result sources", async (sort) => {
const results = ["document", "title", "comment"].map((field, index) => ({
id: `rank-${index}`, type: "issue", score: 300 - index,
title: `Rank ${index}`, href: `/PAP/issues/rank-${index}`,
matchedFields: [field], sourceLabel: field, snippet: field,
snippets: [{ field, label: field, text: field, highlights: [] }],
updatedAt: "2026-01-01T00:00:00.000Z",
issue: { id: `rank-${index}`, identifier: `RANK-${index}`, title: `Rank ${index}`,
status: "todo", priority: "medium", assigneeAgentId: null, assigneeUserId: null,
projectId: null, updatedAt: "2026-01-01T00:00:00.000Z" },
}));
searchApiMock.search.mockResolvedValue({ query: "rank", normalizedQuery: "rank", scope: "all",
sort, limit: 20, offset: 0, hasMore: false, zeroResults: null, results,
countsByType: { issue: 1, comment: 1, document: 1, artifact: 0, agent: 0, project: 0 },
filterOptionCounts: { status: {}, priority: {}, assigneeAgentId: {}, assigneeUserId: {}, projectId: {}, labelId: {}, updatedWithin: {} },
});
const { root } = renderSearch(`/search?q=rank&sort=${sort}`, container);
await waitForAssertion(() => {
const links = Array.from(container.querySelectorAll('[data-testid="search-results"] a[data-result-type]'));
expect(links.map((link) => link.getAttribute("href"))).toEqual(results.map((result) => result.href));
});
flushSync(() => root.unmount());
});
it("renders artifact search results in the company search surface", async () => {
searchApiMock.search.mockResolvedValueOnce({
query: "launch brief",

View File

@ -15,7 +15,6 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useNavigate, useSearchParams } from "@/lib/router";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@ -39,7 +38,6 @@ import {
type ParsedSearchQuery,
type SearchQueryParserContext,
} from "../lib/search-query-parser";
import { IssueGroupHeader } from "../components/IssueGroupHeader";
import { SearchResultRow } from "../components/search/SearchResultRow";
import { SearchFilterBar, type SearchFilterDataProps } from "../components/search/SearchFilterBar";
import { SearchFilterChips } from "../components/search/SearchFilterChips";
@ -69,44 +67,6 @@ const SCOPE_LABELS: Record<CompanySearchScope, string> = {
projects: "Projects",
};
type SubGroupKey = "issues" | "comments" | "documents" | "artifacts" | "agents" | "projects";
const SUBGROUP_ORDER: SubGroupKey[] = ["issues", "comments", "documents", "artifacts", "agents", "projects"];
const SUBGROUP_LABELS: Record<SubGroupKey, string> = {
issues: "Tasks",
comments: "Comments",
documents: "Documents",
artifacts: "Artifacts",
agents: "Agents",
projects: "Projects",
};
function classifyResult(result: CompanySearchResult): SubGroupKey {
if (result.type === "artifact") return "artifacts";
if (result.type === "agent") return "agents";
if (result.type === "project") return "projects";
const matched = new Set(result.matchedFields);
if (matched.has("title") || matched.has("identifier") || matched.has("description")) return "issues";
if (matched.has("comment")) return "comments";
if (matched.has("document")) return "documents";
return "issues";
}
function buildSubgroups(results: CompanySearchResult[]): Array<{ key: SubGroupKey; results: CompanySearchResult[] }> {
const buckets = new Map<SubGroupKey, CompanySearchResult[]>();
for (const result of results) {
const key = classifyResult(result);
const list = buckets.get(key) ?? [];
list.push(result);
buckets.set(key, list);
}
return SUBGROUP_ORDER.filter((key) => (buckets.get(key)?.length ?? 0) > 0).map((key) => ({
key,
results: buckets.get(key) ?? [],
}));
}
function isCompanySearchScope(value: string | null): value is CompanySearchScope {
return Boolean(value) && (COMPANY_SEARCH_SCOPES as readonly string[]).includes(value as string);
}
@ -533,8 +493,6 @@ export function Search() {
});
}, [counts, data, filtersActive]);
const subgroups = useMemo(() => buildSubgroups(data?.results ?? []), [data?.results]);
const operatorPills = useMemo(() => searchFilterPills(draftFilters, parserContext), [draftFilters, parserContext]);
const operatorSuggestions = useMemo(
() => (inputFocused ? searchOperatorSuggestions(draftQuery, 4) : []),
@ -721,7 +679,7 @@ export function Search() {
refetch={() => void refetch()}
recentSearches={recentSearches}
onRecentClick={handleRecentClick}
subgroups={subgroups}
results={data?.results ?? []}
totalResults={totalResults}
allMatchTotal={allMatchTotal}
activeFilterCount={activeFilterCount}
@ -767,7 +725,7 @@ interface SearchTabContentProps {
refetch: () => void;
recentSearches: string[];
onRecentClick: (query: string) => void;
subgroups: Array<{ key: SubGroupKey; results: CompanySearchResult[] }>;
results: CompanySearchResult[];
totalResults: number;
allMatchTotal: number;
activeFilterCount: number;
@ -792,7 +750,7 @@ function SearchTabContent({
refetch,
recentSearches,
onRecentClick,
subgroups,
results,
totalResults,
allMatchTotal,
activeFilterCount,
@ -950,47 +908,14 @@ function SearchTabContent({
</span>
{isFetching ? <span aria-live="polite" className="normal-case tracking-normal">Updating</span> : null}
</div>
<div className="flex flex-col pb-10">
{scope === "all" ? (
subgroups.map((group, groupIndex) => (
<section
key={group.key}
aria-label={SUBGROUP_LABELS[group.key]}
className={cn("flex flex-col", groupIndex > 0 && "mt-6")}
>
<IssueGroupHeader
label={SUBGROUP_LABELS[group.key]}
trailing={
<span className="text-xs font-normal tabular-nums text-muted-foreground">
{group.results.length}
</span>
}
className="pt-2 pb-1 text-(length:--text-micro) tracking-wider text-muted-foreground"
/>
<div className="flex flex-col gap-y-1">
{group.results.map((result) => (
<SearchResultRow
key={`${result.type}:${result.id}:${result.href}`}
result={result}
agentsById={agentsById}
/>
))}
</div>
</section>
))
) : (
<div className="flex flex-col gap-y-1">
{subgroups
.flatMap((group) => group.results)
.map((result) => (
<SearchResultRow
key={`${result.type}:${result.id}:${result.href}`}
result={result}
agentsById={agentsById}
/>
))}
</div>
)}
<div className="flex flex-col gap-y-1 pb-10">
{results.map((result) => (
<SearchResultRow
key={`${result.type}:${result.id}:${result.href}`}
result={result}
agentsById={agentsById}
/>
))}
</div>
</div>
);

View File

@ -6,7 +6,6 @@ import type {
CompanySearchZeroResults,
} from "@paperclipai/shared";
import { Badge } from "@/components/ui/badge";
import { IssueGroupHeader } from "@/components/IssueGroupHeader";
import { Input } from "@/components/ui/input";
import { PageTabBar, type PageTabItem } from "@/components/PageTabBar";
import { MatchSourceChip } from "@/components/search/MatchSourceChip";
@ -296,58 +295,11 @@ function SearchPagePreview({
<div className="flex items-center justify-between py-2 text-[11px] uppercase tracking-wide text-muted-foreground">
<span>{response.results.length} results · sorted by relevance</span>
</div>
<section aria-label="Issues" className="flex flex-col">
<IssueGroupHeader
label="Issues"
trailing={
<span className="text-xs font-normal tabular-nums text-muted-foreground">
{fixtureResults.length}
</span>
}
className="pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground"
/>
<div className="flex flex-col gap-y-1">
{fixtureResults.map((result) => (
<SearchResultRow
key={result.id}
result={result}
agentsById={agentsById}
/>
))}
</div>
</section>
<section aria-label="Agents" className="mt-6 flex flex-col">
<IssueGroupHeader
label="Agents"
trailing={
<span className="text-xs font-normal tabular-nums text-muted-foreground">
{fixtureAgents.length}
</span>
}
className="pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground"
/>
<div className="flex flex-col gap-y-1">
{fixtureAgents.map((result) => (
<SearchResultRow key={result.id} result={result} />
))}
</div>
</section>
<section aria-label="Projects" className="mt-6 flex flex-col">
<IssueGroupHeader
label="Projects"
trailing={
<span className="text-xs font-normal tabular-nums text-muted-foreground">
{fixtureProjects.length}
</span>
}
className="pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground"
/>
<div className="flex flex-col gap-y-1">
{fixtureProjects.map((result) => (
<SearchResultRow key={result.id} result={result} />
))}
</div>
</section>
<div className="flex flex-col gap-y-1">
{response.results.map((result) => (
<SearchResultRow key={result.id} result={result} agentsById={agentsById} />
))}
</div>
</div>
) : null}
@ -770,7 +722,7 @@ const meta = {
docs: {
description: {
component:
"Full search page surfaces and Command K Search-all handoff. Reuses StatusIcon, StatusBadge, Identity, IssueGroupHeader, and PageTabBar; adds MatchSourceChip + SearchResultRow.",
"Full search page surfaces and Command K Search-all handoff. Reuses StatusIcon, StatusBadge, Identity and PageTabBar; adds MatchSourceChip + SearchResultRow.",
},
},
},