fix(search): honor extract match limits

This commit is contained in:
Dotta committed 2026-07-16 00:47:25 +00:00
1 parent 154f126783
commit a8281c6c71
8 files changed
+56 -9

No files matched your search

+2 -2
View File
@@ -810,8 +810,8 @@ The current app also exposes V1-supporting surfaces for:
- issue approvals, issue references/search, labels, read state, inbox/archive state, and work products
- 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, and
explicit issue/match truncation flags
server-owned URL expansion, issue/comment/document scopes, status/date filters, issue-level pagination, a
bounded `matchesPerIssue` override for machine consumers, and explicit issue/match truncation flags
- execution workspaces, project workspaces, workspace runtime services, and workspace operations
- task watchdog configuration and reusable watchdog issue orchestration for explicitly watched issue subtrees
- routines and scheduled/API/webhook triggers
+1
View File
@@ -1269,6 +1269,7 @@ export {
companySearchExtractQuerySchema,
companySearchQuerySchema,
COMPANY_SEARCH_EXTRACT_DEFAULT_LIMIT,
COMPANY_SEARCH_EXTRACT_DEFAULT_MATCHES_PER_ISSUE,
COMPANY_SEARCH_EXTRACT_MAX_LIMIT,
COMPANY_SEARCH_EXTRACT_MAX_MATCHES_PER_ISSUE,
COMPANY_SEARCH_EXTRACT_MAX_OFFSET,
+1
View File
@@ -146,6 +146,7 @@ export interface CompanySearchExtractResponse {
scope: CompanySearchExtractScope;
limit: number;
offset: number;
matchesPerIssue: number;
results: CompanySearchExtractIssueResult[];
hasMore: boolean;
truncated: boolean;
+1
View File
@@ -408,6 +408,7 @@ export {
export {
COMPANY_SEARCH_EXTRACT_DEFAULT_LIMIT,
COMPANY_SEARCH_EXTRACT_DEFAULT_MATCHES_PER_ISSUE,
COMPANY_SEARCH_EXTRACT_MAX_LIMIT,
COMPANY_SEARCH_EXTRACT_MAX_MATCHES_PER_ISSUE,
COMPANY_SEARCH_EXTRACT_MAX_OFFSET,
+12 -1
View File
@@ -16,7 +16,8 @@ export const COMPANY_SEARCH_MAX_OFFSET = 200;
export const COMPANY_SEARCH_EXTRACT_DEFAULT_LIMIT = 100;
export const COMPANY_SEARCH_EXTRACT_MAX_LIMIT = 200;
export const COMPANY_SEARCH_EXTRACT_MAX_OFFSET = 5_000;
export const COMPANY_SEARCH_EXTRACT_MAX_MATCHES_PER_ISSUE = 20;
export const COMPANY_SEARCH_EXTRACT_DEFAULT_MATCHES_PER_ISSUE = 20;
export const COMPANY_SEARCH_EXTRACT_MAX_MATCHES_PER_ISSUE = 200;
const UPDATED_WITHIN_RE = /^[1-9]\d{0,2}(h|d|w|m)$/;
@@ -238,6 +239,16 @@ export const companySearchExtractQuerySchema = z.object({
offset: z.unknown()
.optional()
.transform((value, ctx) => parseIntegerQuery(value, ctx, "offset", 0, 0, COMPANY_SEARCH_EXTRACT_MAX_OFFSET)),
matchesPerIssue: z.unknown()
.optional()
.transform((value, ctx) => parseIntegerQuery(
value,
ctx,
"matchesPerIssue",
COMPANY_SEARCH_EXTRACT_DEFAULT_MATCHES_PER_ISSUE,
1,
COMPANY_SEARCH_EXTRACT_MAX_MATCHES_PER_ISSUE,
)),
status: z.unknown()
.optional()
.transform((value, ctx) => parseEnumList(value, ctx, "status", ISSUE_STATUSES)),
@@ -17,6 +17,7 @@ function extractResponse(query: CompanySearchExtractQuery): CompanySearchExtract
scope: query.scope,
limit: query.limit,
offset: query.offset,
matchesPerIssue: query.matchesPerIssue,
results: [],
hasMore: false,
truncated: false,
@@ -57,7 +58,13 @@ describe("company extract-search route", () => {
const response = await request(app)
.get("/api/companies/company-1/search/extract")
.query({ contains: "github.com/example/repo/pull", kind: "url", scope: "comments", limit: "200" })
.query({
contains: "github.com/example/repo/pull",
kind: "url",
scope: "comments",
limit: "200",
matchesPerIssue: "200",
})
.expect(200);
expect(extract).toHaveBeenCalledWith("company-1", expect.objectContaining({
@@ -65,6 +72,7 @@ describe("company extract-search route", () => {
kind: "url",
scope: "comments",
limit: 200,
matchesPerIssue: 200,
}));
expect(response.body).toMatchObject({ kind: "url", scope: "comments", truncated: false });
});
@@ -9,6 +9,7 @@ import {
issues,
} from "@paperclipai/db";
import {
COMPANY_SEARCH_EXTRACT_DEFAULT_MATCHES_PER_ISSUE,
COMPANY_SEARCH_EXTRACT_MAX_MATCHES_PER_ISSUE,
companySearchExtractQuerySchema,
} from "@paperclipai/shared";
@@ -36,15 +37,18 @@ describe("extract-search query validation", () => {
status: "in_progress,in_review",
limit: "200",
offset: "5000",
matchesPerIssue: "200",
updatedWithin: "30d",
});
expect(parsed.kind).toBe("url");
expect(parsed.scope).toBe("comments");
expect(parsed.status).toEqual(["in_progress", "in_review"]);
expect(parsed.matchesPerIssue).toBe(COMPANY_SEARCH_EXTRACT_MAX_MATCHES_PER_ISSUE);
expect(() => companySearchExtractQuerySchema.parse({ contains: ".*", kind: "regex" })).toThrow();
expect(() => companySearchExtractQuerySchema.parse({ contains: "x" })).toThrow();
expect(() => companySearchExtractQuerySchema.parse({ contains: "needle", limit: "201" })).toThrow();
expect(() => companySearchExtractQuerySchema.parse({ contains: "needle", matchesPerIssue: "201" })).toThrow();
expect(() => companySearchExtractQuerySchema.parse({
contains: "needle",
updatedWithin: "30d",
@@ -214,10 +218,10 @@ describeEmbeddedPostgres("companySearchExtractService", () => {
expect(result.results.map((row) => row.issueId)).toEqual([recentId]);
});
it("caps distinct matches per issue and marks truncation explicitly", async () => {
it("uses the default distinct-match cap and marks truncation explicitly", async () => {
const companyId = await createCompany();
const urls = Array.from(
{ length: COMPANY_SEARCH_EXTRACT_MAX_MATCHES_PER_ISSUE + 1 },
{ length: COMPANY_SEARCH_EXTRACT_DEFAULT_MATCHES_PER_ISSUE + 1 },
(_, index) => `https://github.com/paperclipai/paperclip/pull/${index + 1}`,
);
await createIssue(companyId, { description: urls.join(" ") });
@@ -227,11 +231,32 @@ describeEmbeddedPostgres("companySearchExtractService", () => {
kind: "url",
}));
expect(result.results[0]?.matches).toHaveLength(COMPANY_SEARCH_EXTRACT_MAX_MATCHES_PER_ISSUE);
expect(result.matchesPerIssue).toBe(COMPANY_SEARCH_EXTRACT_DEFAULT_MATCHES_PER_ISSUE);
expect(result.results[0]?.matches).toHaveLength(COMPANY_SEARCH_EXTRACT_DEFAULT_MATCHES_PER_ISSUE);
expect(result.results[0]?.matchesTruncated).toBe(true);
expect(result.truncated).toBe(true);
});
it("supports a bounded per-issue match cap for complete machine extraction", async () => {
const companyId = await createCompany();
const urls = Array.from(
{ length: COMPANY_SEARCH_EXTRACT_DEFAULT_MATCHES_PER_ISSUE + 1 },
(_, index) => `https://github.com/paperclipai/paperclip/pull/${index + 1}`,
);
await createIssue(companyId, { description: urls.join(" ") });
const result = await svc.extract(companyId, companySearchExtractQuerySchema.parse({
contains: "github.com/paperclipai/paperclip/pull",
kind: "url",
matchesPerIssue: COMPANY_SEARCH_EXTRACT_MAX_MATCHES_PER_ISSUE,
}));
expect(result.matchesPerIssue).toBe(COMPANY_SEARCH_EXTRACT_MAX_MATCHES_PER_ISSUE);
expect(result.results[0]?.matches).toHaveLength(urls.length);
expect(result.results[0]?.matchesTruncated).toBe(false);
expect(result.truncated).toBe(false);
});
it("does not return matching issues from another company", async () => {
const companyId = await createCompany();
const otherCompanyId = await createCompany("Other");
@@ -3,7 +3,6 @@ import type { SQL, SQLWrapper } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { documents, issueComments, issueDocuments, issues } from "@paperclipai/db";
import {
COMPANY_SEARCH_EXTRACT_MAX_MATCHES_PER_ISSUE,
type CompanySearchExtractIssueResult,
type CompanySearchExtractMatch,
type CompanySearchExtractQuery,
@@ -131,7 +130,7 @@ function extractMatches(sources: ExtractSource[], query: CompanySearchExtractQue
const dedupeKey = occurrence.value.toLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
if (matches.length >= COMPANY_SEARCH_EXTRACT_MAX_MATCHES_PER_ISSUE) {
if (matches.length >= query.matchesPerIssue) {
matchesTruncated = true;
continue;
}
@@ -340,6 +339,7 @@ export function companySearchExtractService(db: Db) {
scope: query.scope,
limit: query.limit,
offset: query.offset,
matchesPerIssue: query.matchesPerIssue,
results,
hasMore,
truncated: hasMore || results.some((result) => result.matchesTruncated),