mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Improve search logic (#6637)
# Description of Changes Search has got significantly worse since #6581, where I added all the missing tags for tools that should have been there for months. Turns out that the fuzzy matching search logic has always been way too permissive to match words with Levenshtein distances way too far away from the target word, so long searches include way too much stuff. The new tags just exposed that underlying logic issue. This PR makes the Levenshtein logic much stricter, so it is still tolerant to minor typos in tool names, but doesn't match completely inappropriate strings.
This commit is contained in:
@@ -52,14 +52,14 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
|
|||||||
/**
|
/**
|
||||||
* The {@code EntitlementGuard} runs BEFORE the charge interceptor. Spring runs interceptors in
|
* The {@code EntitlementGuard} runs BEFORE the charge interceptor. Spring runs interceptors in
|
||||||
* ascending order on the way in and skips a later interceptor's {@code preHandle} (and its
|
* ascending order on the way in and skips a later interceptor's {@code preHandle} (and its
|
||||||
* {@code afterCompletion}) entirely once an earlier one returns {@code false} — so a request the
|
* {@code afterCompletion}) entirely once an earlier one returns {@code false} — so a request
|
||||||
* guard refuses (over its free allowance / spending cap, or with no subscription to bill)
|
* the guard refuses (over its free allowance / spending cap, or with no subscription to bill)
|
||||||
* short-circuits with its 402 before the charge interceptor ever runs. A blocked request
|
* short-circuits with its 402 before the charge interceptor ever runs. A blocked request
|
||||||
* therefore never opens a process, materialises inputs, or writes a charge: a refused operation
|
* therefore never opens a process, materialises inputs, or writes a charge: a refused operation
|
||||||
* must not bill, and running the guard first guarantees that structurally rather than by
|
* must not bill, and running the guard first guarantees that structurally rather than by
|
||||||
* compensating after the fact. Stays above the legacy {@code UnifiedCreditInterceptor} (default
|
* compensating after the fact. Stays above the legacy {@code UnifiedCreditInterceptor} (default
|
||||||
* order 0, only registered under the {@code legacy-credits} profile) so a legacy rejection still
|
* order 0, only registered under the {@code legacy-credits} profile) so a legacy rejection
|
||||||
* wins.
|
* still wins.
|
||||||
*/
|
*/
|
||||||
public static final int ENTITLEMENT_GUARD_ORDER = 900;
|
public static final int ENTITLEMENT_GUARD_ORDER = 900;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
scoreMatch,
|
||||||
|
minScoreForQuery,
|
||||||
|
isFuzzyMatch,
|
||||||
|
} from "@app/utils/fuzzySearch";
|
||||||
|
|
||||||
|
describe("scoreMatch", () => {
|
||||||
|
it("scores substring matches highest", () => {
|
||||||
|
expect(scoreMatch("rota", "Rotate")).toBeGreaterThanOrEqual(90);
|
||||||
|
expect(scoreMatch("priv", "private")).toBeGreaterThanOrEqual(
|
||||||
|
minScoreForQuery("priv"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates small typos", () => {
|
||||||
|
expect(isFuzzyMatch("rotaet", "rotate")).toBe(true);
|
||||||
|
expect(isFuzzyMatch("comprss", "compress")).toBe(true);
|
||||||
|
// Typo inside one token of a multi-word target
|
||||||
|
expect(isFuzzyMatch("watermrk", "Add Watermark")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unrelated words that share half their letters", () => {
|
||||||
|
// Regression: the old fallback accepted 50% relative Levenshtein
|
||||||
|
// similarity, so longer queries matched more junk (rotate vs update is
|
||||||
|
// edit distance 3 of 6)
|
||||||
|
expect(isFuzzyMatch("rotate", "update")).toBe(false);
|
||||||
|
expect(isFuzzyMatch("rotate", "create")).toBe(false);
|
||||||
|
expect(isFuzzyMatch("rotate", "private")).toBe(false);
|
||||||
|
expect(isFuzzyMatch("rotate", "isolated")).toBe(false);
|
||||||
|
expect(isFuzzyMatch("rotate", "automate")).toBe(false);
|
||||||
|
expect(isFuzzyMatch("rotate", "annotate")).toBe(false);
|
||||||
|
expect(isFuzzyMatch("rotat", "protect")).toBe(false);
|
||||||
|
expect(isFuzzyMatch("rotat", "redact")).toBe(false);
|
||||||
|
expect(isFuzzyMatch("rotat", "contrast")).toBe(false);
|
||||||
|
expect(isFuzzyMatch("rotat", "annotate")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("minScoreForQuery", () => {
|
||||||
|
it("does not loosen the threshold for long queries", () => {
|
||||||
|
expect(minScoreForQuery("rot")).toBe(40);
|
||||||
|
expect(minScoreForQuery("rotate")).toBe(30);
|
||||||
|
expect(minScoreForQuery("orientation")).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,20 +63,27 @@ export function scoreMatch(queryRaw: string, targetRaw: string): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const distance = levenshtein(
|
// Levenshtein fallback is for typos only: allow a small absolute number of
|
||||||
query,
|
// edits against the whole target or any single token. Relative similarity is
|
||||||
target.length > 64 ? target.slice(0, 64) : target,
|
// not enough here; half the letters of an unrelated word can match (e.g.
|
||||||
);
|
// "rotate" vs "update" is edit distance 3 of 6).
|
||||||
const maxLen = Math.max(query.length, target.length, 1);
|
const maxEdits = query.length <= 4 ? 1 : 2;
|
||||||
const similarity = 1 - distance / maxLen; // 0..1
|
let best = 0;
|
||||||
return Math.floor(similarity * 60); // scale below substring scores
|
for (const candidate of [target, ...tokens]) {
|
||||||
|
if (Math.abs(query.length - candidate.length) > maxEdits) continue;
|
||||||
|
const distance = levenshtein(query, candidate);
|
||||||
|
if (distance > maxEdits) continue;
|
||||||
|
const maxLen = Math.max(query.length, candidate.length, 1);
|
||||||
|
const similarity = 1 - distance / maxLen; // 0..1
|
||||||
|
const score = Math.floor(similarity * 60); // scale below substring scores
|
||||||
|
if (score > best) best = score;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function minScoreForQuery(query: string): number {
|
export function minScoreForQuery(query: string): number {
|
||||||
const len = normalizeText(query).length;
|
const len = normalizeText(query).length;
|
||||||
if (len <= 3) return 40;
|
return len <= 3 ? 40 : 30;
|
||||||
if (len <= 6) return 30;
|
|
||||||
return 25;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decide if a target matches a query based on a threshold
|
// Decide if a target matches a query based on a threshold
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { filterToolRegistryByQuery } from "@app/utils/toolSearch";
|
||||||
|
import {
|
||||||
|
ToolCategoryId,
|
||||||
|
SubcategoryId,
|
||||||
|
ToolRegistry,
|
||||||
|
ToolRegistryEntry,
|
||||||
|
} from "@app/data/toolsTaxonomy";
|
||||||
|
|
||||||
|
function makeEntry(name: string, tags: string): ToolRegistryEntry {
|
||||||
|
return {
|
||||||
|
icon: null,
|
||||||
|
name,
|
||||||
|
component: null,
|
||||||
|
description: "",
|
||||||
|
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||||
|
subcategoryId: SubcategoryId.GENERAL,
|
||||||
|
automationSettings: null,
|
||||||
|
synonyms: tags.split(","),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real names and tags from the en-GB translations; these tools previously
|
||||||
|
// polluted results for partial queries like "rotat" and "rotate"
|
||||||
|
const registry: Partial<ToolRegistry> = {
|
||||||
|
rotate: makeEntry(
|
||||||
|
"Rotate",
|
||||||
|
"turn,flip,orient,rotate,orientation,landscape,portrait,90 degrees,180 degrees,clockwise,anticlockwise,counter-clockwise,fix orientation",
|
||||||
|
),
|
||||||
|
addPassword: makeEntry(
|
||||||
|
"Add Password",
|
||||||
|
"encrypt,password,lock,secure,protect,security,encryption,safeguard,confidential,private,restrict access",
|
||||||
|
),
|
||||||
|
changeMetadata: makeEntry(
|
||||||
|
"Change Metadata",
|
||||||
|
"edit,modify,update,metadata,properties,document properties,author,title,subject,keywords,creator,producer,info,document info,file properties",
|
||||||
|
),
|
||||||
|
scannerEffect: makeEntry(
|
||||||
|
"Scanner Effect",
|
||||||
|
"scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan",
|
||||||
|
),
|
||||||
|
adjustContrast: makeEntry(
|
||||||
|
"Adjust Colours/Contrast",
|
||||||
|
"contrast,brightness,saturation,adjust colors,color correction,enhance,lighten,darken,improve quality,color balance,hue,vibrance",
|
||||||
|
),
|
||||||
|
annotate: makeEntry(
|
||||||
|
"Annotate",
|
||||||
|
"annotate,highlight,draw,markup,comment,notes,review,redline,feedback,markup tools,sticky notes,shapes,arrows,text box,freehand",
|
||||||
|
),
|
||||||
|
redact: makeEntry(
|
||||||
|
"Redact",
|
||||||
|
"censor,blackout,hide,redact,redaction,black out,block out,remove sensitive,hide text,privacy,confidential,GDPR,PII,sensitive data,permanently remove,cover up,legal redaction",
|
||||||
|
),
|
||||||
|
compare: makeEntry(
|
||||||
|
"Compare",
|
||||||
|
"difference,compare,diff,compare PDFs,compare documents,find differences,show differences,changes,what changed,track changes,revisions,version compare,side by side,contrast,delta",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
function idsFor(query: string): string[] {
|
||||||
|
return filterToolRegistryByQuery(registry, query).map(({ item: [id] }) => id);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("filterToolRegistryByQuery", () => {
|
||||||
|
it("returns everything for an empty query", () => {
|
||||||
|
expect(idsFor("")).toHaveLength(Object.keys(registry).length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches tools by tag substring", () => {
|
||||||
|
expect(idsFor("protect")).toEqual(["addPassword"]);
|
||||||
|
expect(idsFor("orientation")).toEqual(["rotate"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only returns Rotate for every prefix of 'rotate'", () => {
|
||||||
|
// Regression: "rotat" used to also pull in Redact, Annotate, Compare and
|
||||||
|
// Adjust Colours/Contrast, and "rotate" additionally Scanner Effect,
|
||||||
|
// Change Metadata, Add Password and Air-gapped Setup
|
||||||
|
expect(idsFor("rota")).toEqual(["rotate"]);
|
||||||
|
expect(idsFor("rotat")).toEqual(["rotate"]);
|
||||||
|
expect(idsFor("rotate")).toEqual(["rotate"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user