fix(search): S1/W1/W3/W4/S3/S5/S2 — required-term details, CAP note, restored guards (#529)

S1: fetchDetails now builds rank/highlight/matchedFields from the same
[...positive, ...required] set matchedTerms uses, so a required-only query
(`+кофейня`) no longer reports matchedFields:[] / rank:null.

W1: documented that CANDIDATE_CAP bounds pagination reachability (JS-side), not
query compute — a SQL LIMIT would corrupt the exact permission-filtered total.

S2: one-line note that literal-`%`/`_` search was intentionally dropped (total:0).

W3: restored the trgm-index EXPLAIN guard (search-lookup-explain.int-spec) that
asserts the coalesce-free substring predicates use idx_pages_*_trgm, not Seq Scan.

W4: added an integration ordering test proving title-exact > title-substring >
text-only tier dominance under RRF.

S3: added a test that an exact-title hit survives a tiny CANDIDATE_CAP window.

S5: server pretest now also builds @docmost/mcp so the ToolWriteClass import
resolves from clean CI (mirrors editor-ext/prosemirror-markdown).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 02:52:05 +03:00
parent 1413b26287
commit 01637fa86b
4 changed files with 229 additions and 7 deletions
+29 -6
View File
@@ -55,6 +55,11 @@ export function buildTsQuery(raw: string): string {
// Escape the LIKE metacharacters (`%`, `_`, `\`) so every character is matched
// LITERALLY by a `col LIKE '%' || q || '%'` predicate. Without this a query of
// `%` or `_` would match every row.
//
// S2 (acceptance #10): escaping is intentionally SYMMETRIC on `%` and `_` — the
// ability to literal-search a bare `%` (or `_`) as a real character was
// deliberately dropped. Such a query parses to no positive recall and returns
// total:0 rather than matching everything. That trade-off is accepted.
export function escapeLikePattern(raw: string): string {
return (raw ?? '')
.replace(/\\/g, '\\\\')
@@ -361,6 +366,16 @@ export class SearchService {
orderedIds = orderedIds.filter((id) => accessibleSet.has(id));
}
// W1 — CANDIDATE_CAP bounds pagination REACHABILITY (JS-side), NOT query
// compute. The `ranked` CTE deliberately has no SQL LIMIT: an exact `total`
// requires counting EVERY match, and the permission anti-join must run over
// the full match set, so a SQL LIMIT would corrupt `total`. The consequence
// is that a broad common term in a tenant with restricted pages sorts +
// anti-joins the whole match set on every request; the cap only truncates the
// reachable window afterwards (rows past it are unreachable → truncatedAtCap).
// Accepted for the small-tenant fork target — do NOT convert `cap` into a SQL
// LIMIT thinking it bounds compute; it does not, and doing so breaks the exact
// total contract (acceptance #8/#11).
const total = orderedIds.length;
const truncatedAtCap = total > cap;
const window = orderedIds.slice(0, cap);
@@ -438,7 +453,15 @@ export class SearchService {
parsed: ParsedQuery,
titleOnly: boolean,
): Promise<SearchResultDto[]> {
const tsq = this.combineTsq(parsed.positive, parsed.mode);
// S1: rank/highlight/matchedFields must reflect required terms too, not just
// bare positives — a required-only query like `+кофейня` (no bare positive)
// really matches, so it must not report matchedFields:[] / rank:null. Build
// every detail expression from the SAME [...positive, ...required] set the
// matchedTerms path uses (`detailTerms` below). Excluded terms stay out (they
// are anti-matches). The candidate WHERE already enforced required-AND, so
// OR-combining them here is purely for display scoring/highlighting.
const detailTerms = [...parsed.positive, ...parsed.required];
const tsq = this.combineTsq(detailTerms, parsed.mode);
// rank/highlight are FTS-only (null for substring-only hits — the web already
// falls back). ts_headline runs over the ORIGINAL text.
const rankExpr = tsq
@@ -475,7 +498,7 @@ export class SearchService {
);
}
}
for (const t of parsed.positive.filter((x) => x.branch === 'substring')) {
for (const t of detailTerms.filter((x) => x.branch === 'substring')) {
const pat = this.likePattern(t);
titleMatchParts.push(
sql`LOWER(f_unaccent(pages.title)) LIKE ${pat} ESCAPE '\\'`,
@@ -494,10 +517,10 @@ export class SearchService {
: sql<boolean>`false`;
// Per-term matched flags (positive + required), assembled into matchedTerms.
// NB: aliases must be UNDERSCORE-FREE — the app's Kysely runs the
// CamelCasePlugin, which would rewrite a `t_0` result key to `t0`. `tmatch0`
// survives the round-trip unchanged.
const allTerms = [...parsed.positive, ...parsed.required];
// Same set as `detailTerms` above. NB: aliases must be UNDERSCORE-FREE — the
// app's Kysely runs the CamelCasePlugin, which would rewrite a `t_0` result
// key to `t0`. `tmatch0` survives the round-trip unchanged.
const allTerms = detailTerms;
const termCols = allTerms.map(
(t, i) =>
sql`(${this.termMatchPred(t, titleOnly)}) AS "tmatch${sql.raw(String(i))}"`,