@pithy-sh/support 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/audit/actions.d.ts +9 -0
- package/dist/audit/actions.d.ts.map +1 -1
- package/dist/audit/actions.js +9 -0
- package/dist/capability.d.ts +2 -1
- package/dist/capability.d.ts.map +1 -1
- package/dist/capability.js +30 -6
- package/dist/config/config.d.ts +63 -2
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +63 -5
- package/dist/data/assignment.d.ts +43 -0
- package/dist/data/assignment.d.ts.map +1 -0
- package/dist/data/assignment.js +38 -0
- package/dist/data/embedding.d.ts +21 -0
- package/dist/data/embedding.d.ts.map +1 -0
- package/dist/data/embedding.js +23 -0
- package/dist/data/tables.d.ts +28 -0
- package/dist/data/tables.d.ts.map +1 -1
- package/dist/data/tables.js +10 -2
- package/dist/data/thread.d.ts +9 -4
- package/dist/data/thread.d.ts.map +1 -1
- package/dist/data/thread.js +9 -4
- package/dist/http/guards.d.ts +32 -14
- package/dist/http/guards.d.ts.map +1 -1
- package/dist/http/guards.js +44 -16
- package/dist/http/handlers.d.ts +35 -2
- package/dist/http/handlers.d.ts.map +1 -1
- package/dist/http/handlers.js +81 -8
- package/dist/http/resolve.d.ts +8 -0
- package/dist/http/resolve.d.ts.map +1 -1
- package/dist/http/resolve.js +20 -1
- package/dist/http/responses.d.ts +355 -0
- package/dist/http/responses.d.ts.map +1 -1
- package/dist/http/responses.js +64 -2
- package/dist/http/routes.d.ts +25 -11
- package/dist/http/routes.d.ts.map +1 -1
- package/dist/http/routes.js +34 -5
- package/dist/http/schemas.d.ts +67 -3
- package/dist/http/schemas.d.ts.map +1 -1
- package/dist/http/schemas.js +44 -5
- package/dist/http/scopes.d.ts +10 -5
- package/dist/http/scopes.d.ts.map +1 -1
- package/dist/http/scopes.js +42 -6
- package/dist/http/views.d.ts +9 -1
- package/dist/http/views.d.ts.map +1 -1
- package/dist/http/views.js +40 -1
- package/dist/inbound/ingest.d.ts +1 -1
- package/dist/inbound/ingest.d.ts.map +1 -1
- package/dist/inbound/ingest.js +3 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/migrations/0002_assignment.d.ts +20 -0
- package/dist/migrations/0002_assignment.d.ts.map +1 -0
- package/dist/migrations/0002_assignment.js +45 -0
- package/dist/migrations/0003_embeddings.d.ts +15 -0
- package/dist/migrations/0003_embeddings.d.ts.map +1 -0
- package/dist/migrations/0003_embeddings.js +23 -0
- package/dist/migrations/set.d.ts +13 -0
- package/dist/migrations/set.d.ts.map +1 -0
- package/dist/migrations/set.js +21 -0
- package/dist/presence/room.d.ts +35 -0
- package/dist/presence/room.d.ts.map +1 -0
- package/dist/presence/room.js +66 -0
- package/dist/provision/provisionSupport.d.ts +32 -0
- package/dist/provision/provisionSupport.d.ts.map +1 -1
- package/dist/provision/provisionSupport.js +10 -2
- package/dist/provision/resolveSupportConfig.d.ts.map +1 -1
- package/dist/provision/resolveSupportConfig.js +13 -2
- package/dist/purge/contribution.d.ts +49 -0
- package/dist/purge/contribution.d.ts.map +1 -0
- package/dist/purge/contribution.js +164 -0
- package/dist/secret/registry.d.ts +26 -2
- package/dist/secret/registry.d.ts.map +1 -1
- package/dist/secret/registry.js +15 -3
- package/dist/semantic/documents.d.ts +21 -0
- package/dist/semantic/documents.d.ts.map +1 -0
- package/dist/semantic/documents.js +128 -0
- package/dist/semantic/embed.d.ts +59 -0
- package/dist/semantic/embed.d.ts.map +1 -0
- package/dist/semantic/embed.js +59 -0
- package/dist/semantic/messageIndex.d.ts +46 -0
- package/dist/semantic/messageIndex.d.ts.map +1 -0
- package/dist/semantic/messageIndex.js +67 -0
- package/dist/semantic/query.d.ts +37 -0
- package/dist/semantic/query.d.ts.map +1 -0
- package/dist/semantic/query.js +46 -0
- package/dist/staff/staff.d.ts +22 -0
- package/dist/staff/staff.d.ts.map +1 -0
- package/dist/staff/staff.js +64 -0
- package/dist/store/assignment.d.ts +63 -0
- package/dist/store/assignment.d.ts.map +1 -0
- package/dist/store/assignment.js +86 -0
- package/dist/store/hybrid.d.ts +134 -0
- package/dist/store/hybrid.d.ts.map +1 -0
- package/dist/store/hybrid.js +185 -0
- package/dist/store/paging.d.ts +10 -0
- package/dist/store/paging.d.ts.map +1 -1
- package/dist/store/paging.js +11 -1
- package/dist/store/provisionIndex.d.ts +21 -0
- package/dist/store/provisionIndex.d.ts.map +1 -0
- package/dist/store/provisionIndex.js +36 -0
- package/dist/store/search.d.ts +18 -9
- package/dist/store/search.d.ts.map +1 -1
- package/dist/store/search.js +36 -34
- package/dist/store/searchIndex.d.ts +4 -4
- package/dist/store/searchIndex.js +4 -4
- package/dist/store/threads.d.ts +174 -8
- package/dist/store/threads.d.ts.map +1 -1
- package/dist/store/threads.js +33 -40
- package/dist/submission/submit.d.ts +1 -1
- package/dist/submission/submit.d.ts.map +1 -1
- package/dist/submission/submit.js +2 -1
- package/dist/test-utils/seed.d.ts +33 -0
- package/dist/test-utils/seed.d.ts.map +1 -0
- package/dist/test-utils/seed.js +102 -0
- package/dist/test-utils/semantic.d.ts +21 -0
- package/dist/test-utils/semantic.d.ts.map +1 -0
- package/dist/test-utils/semantic.js +107 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/workflows/specs.d.ts +23 -0
- package/dist/workflows/specs.d.ts.map +1 -1
- package/dist/workflows/specs.js +27 -7
- package/dist/workflows/worker.d.ts +15 -2
- package/dist/workflows/worker.d.ts.map +1 -1
- package/dist/workflows/worker.js +48 -4
- package/package.json +9 -7
- package/pithy.manifest.json +21 -3
- package/src/audit/actions.ts +9 -0
- package/src/capability.ts +43 -4
- package/src/config/config.ts +104 -5
- package/src/data/assignment.ts +55 -0
- package/src/data/embedding.ts +27 -0
- package/src/data/tables.ts +8 -0
- package/src/data/thread.ts +26 -4
- package/src/http/guards.ts +52 -15
- package/src/http/handlers.ts +125 -4
- package/src/http/resolve.ts +19 -0
- package/src/http/responses.ts +154 -0
- package/src/http/routes.ts +98 -12
- package/src/http/schemas.ts +117 -28
- package/src/http/scopes.ts +43 -6
- package/src/http/views.ts +44 -0
- package/src/inbound/ingest.ts +5 -2
- package/src/index.ts +5 -0
- package/src/migrations/0002_assignment.ts +65 -0
- package/src/migrations/0003_embeddings.ts +29 -0
- package/src/migrations/set.ts +21 -0
- package/src/presence/room.ts +94 -0
- package/src/provision/provisionSupport.ts +33 -1
- package/src/provision/resolveSupportConfig.ts +24 -13
- package/src/purge/contribution.ts +294 -0
- package/src/secret/registry.ts +17 -2
- package/src/semantic/documents.ts +178 -0
- package/src/semantic/embed.ts +99 -0
- package/src/semantic/messageIndex.ts +74 -0
- package/src/semantic/query.ts +66 -0
- package/src/staff/staff.ts +128 -0
- package/src/store/assignment.ts +179 -0
- package/src/store/hybrid.ts +314 -0
- package/src/store/paging.ts +14 -0
- package/src/store/provisionIndex.ts +46 -0
- package/src/store/search.ts +58 -37
- package/src/store/searchIndex.ts +4 -4
- package/src/store/threads.ts +91 -91
- package/src/submission/submit.ts +3 -2
- package/src/test-utils/seed.ts +141 -0
- package/src/test-utils/semantic.ts +110 -0
- package/src/version.generated.ts +1 -1
- package/src/workflows/specs.ts +34 -0
- package/src/workflows/worker.ts +84 -8
- package/src/workflows/wrangler.jsonc +8 -3
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { pithyCommandOf } from "@pithy-sh/core/src/cli/command";
|
|
5
|
+
import { chunkByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
|
|
6
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
8
|
+
import type { VectorIndexConfig } from "@pithy-sh/vector/src/config/config";
|
|
9
|
+
import type { VectorAi } from "@pithy-sh/vector/src/embed/embed";
|
|
10
|
+
import type { VectorStore } from "@pithy-sh/vector/src/index/index";
|
|
11
|
+
import { SUPPORT_SEARCH_TABLE, SUPPORT_THREADS_TABLE, type SupportDatabase } from "../data/tables";
|
|
12
|
+
import { semanticHits } from "../semantic/query";
|
|
13
|
+
import { DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT } from "./paging";
|
|
14
|
+
import { type KeywordBackend, keywordHits, type MessageHit } from "./search";
|
|
15
|
+
import { applyThreadFilters, type ListedThread, listedThreadOf, type ThreadFilters, viewerThreads } from "./threads";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hybrid search — keyword and semantic, run together and ranked as one.
|
|
19
|
+
*
|
|
20
|
+
* The two fail in opposite directions. Keyword finds the exact string and misses the paraphrase; semantic
|
|
21
|
+
* finds the paraphrase and misses the identifier, because it cannot retrieve an order number it has never
|
|
22
|
+
* seen. A support inbox needs both.
|
|
23
|
+
*
|
|
24
|
+
* **Search is not listing.** A query goes in, ranked threads come out, up to a limit, and there is no cursor:
|
|
25
|
+
* a ranked list has a top, not pages.
|
|
26
|
+
*
|
|
27
|
+
* ## How a result is ranked
|
|
28
|
+
*
|
|
29
|
+
* 1. Each backend returns **messages**, best first — FTS5 by `bm25`, the `LIKE` scan by recency, Vectorize by
|
|
30
|
+
* similarity.
|
|
31
|
+
* 2. Each list rolls up to **threads**: a thread takes the rank of its best message, and appears once.
|
|
32
|
+
* 3. Hybrid fuses the two thread lists by **reciprocal rank fusion** over rank position — `1 / (60 + rank)`,
|
|
33
|
+
* summed. No weights to tune, and a thread both backends found rises above one only one found. The raw
|
|
34
|
+
* scores are never normalized into one number: each result carries its message's `bm25` and similarity as
|
|
35
|
+
* they were, absent where that backend did not run or did not match.
|
|
36
|
+
* 4. A tie goes to the keyword hit, the better keyword rank first, and then to `lastMessageAt`, newest first.
|
|
37
|
+
* Semantic search cannot retrieve an identifier, so for a query like `ORD-40912` the exact match is keyword's
|
|
38
|
+
* rank 1 while semantic's rank 1 is some other thread: equal fused scores, and the exact match wins.
|
|
39
|
+
*
|
|
40
|
+
* **Filters apply after retrieval, in D1**, never as Vectorize metadata, which would go stale the moment a
|
|
41
|
+
* thread is archived, reassigned or reclassified. A narrow filter can therefore return fewer than the limit,
|
|
42
|
+
* and the result's count says how many it did.
|
|
43
|
+
*
|
|
44
|
+
* **Degrade, never fail.** A backend that is on and cannot answer — FTS5 configured and its table not
|
|
45
|
+
* provisioned, the semantic index or its binding absent, a model that does not answer — is skipped and named,
|
|
46
|
+
* and the rest of the search still runs.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** The search modes a caller may ask for. */
|
|
50
|
+
export type SearchMode = "keyword" | "semantic" | "hybrid";
|
|
51
|
+
|
|
52
|
+
/** A backend that can answer part of a search. */
|
|
53
|
+
export type SearchBackend = KeywordBackend | "semantic";
|
|
54
|
+
|
|
55
|
+
/** Why a backend that is on did not answer. */
|
|
56
|
+
export type SkipReason = "unprovisioned" | "failed";
|
|
57
|
+
|
|
58
|
+
/** The reciprocal-rank-fusion constant — the standard 60, which damps the head without flattening it. */
|
|
59
|
+
export const RRF_K = 60;
|
|
60
|
+
|
|
61
|
+
/** How many messages each backend retrieves before the roll-up. Vectorize's payload-free ceiling. */
|
|
62
|
+
export const SEARCH_HITS = 100;
|
|
63
|
+
|
|
64
|
+
/** One thread a backend ranked, with the message that ranked it. */
|
|
65
|
+
export interface RankedThread {
|
|
66
|
+
/** The thread. */
|
|
67
|
+
threadId: string;
|
|
68
|
+
/** Its best-matching message. */
|
|
69
|
+
messageId: string;
|
|
70
|
+
/** Its rank in this backend's list, 1 first. */
|
|
71
|
+
rank: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Roll a backend's message hits up to threads: a thread takes the rank of its best message, and appears once.
|
|
76
|
+
* The input is best first, so the first message seen for a thread is its best.
|
|
77
|
+
*/
|
|
78
|
+
export function rollUp(hits: readonly MessageHit[]): RankedThread[] {
|
|
79
|
+
const seen = new Set<string>();
|
|
80
|
+
const ranked: RankedThread[] = [];
|
|
81
|
+
for (const hit of hits) {
|
|
82
|
+
if (seen.has(hit.threadId)) continue;
|
|
83
|
+
seen.add(hit.threadId);
|
|
84
|
+
ranked.push({ threadId: hit.threadId, messageId: hit.messageId, rank: ranked.length + 1 });
|
|
85
|
+
}
|
|
86
|
+
return ranked;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One thread after fusion: its fused score and the message the result names. */
|
|
90
|
+
export interface FusedThread {
|
|
91
|
+
/** The thread. */
|
|
92
|
+
threadId: string;
|
|
93
|
+
/** The message the result names — the one from the backend that ranked this thread higher. */
|
|
94
|
+
messageId: string;
|
|
95
|
+
/** The reciprocal-rank-fusion score. Internal: it orders results and is never reported. */
|
|
96
|
+
fused: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Fuse ranked thread lists by reciprocal rank fusion. A thread found by several lists sums its terms.
|
|
101
|
+
*
|
|
102
|
+
* The message a result names comes from the list that ranked the thread highest, the first list winning a
|
|
103
|
+
* tie — the keyword list is passed first, so an exact match names the message that holds it.
|
|
104
|
+
*/
|
|
105
|
+
export function fuse(lists: readonly (readonly RankedThread[])[]): FusedThread[] {
|
|
106
|
+
const fused = new Map<string, FusedThread & { best: number }>();
|
|
107
|
+
for (const list of lists) {
|
|
108
|
+
for (const entry of list) {
|
|
109
|
+
const term = 1 / (RRF_K + entry.rank);
|
|
110
|
+
const known = fused.get(entry.threadId);
|
|
111
|
+
if (!known) {
|
|
112
|
+
fused.set(entry.threadId, {
|
|
113
|
+
threadId: entry.threadId,
|
|
114
|
+
messageId: entry.messageId,
|
|
115
|
+
fused: term,
|
|
116
|
+
best: entry.rank,
|
|
117
|
+
});
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
known.fused += term;
|
|
121
|
+
if (entry.rank < known.best) {
|
|
122
|
+
known.best = entry.rank;
|
|
123
|
+
known.messageId = entry.messageId;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return [...fused.values()].map(({ threadId, messageId, fused: score }) => ({ threadId, messageId, fused: score }));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** What a search asks. */
|
|
131
|
+
export interface SearchThreadsQuery extends ThreadFilters {
|
|
132
|
+
/** The words. */
|
|
133
|
+
q: string;
|
|
134
|
+
/** Which backends to run. Absent runs every backend that is on. */
|
|
135
|
+
mode?: SearchMode;
|
|
136
|
+
/** How many threads to return. Defaults to {@link DEFAULT_SEARCH_LIMIT}, capped at {@link MAX_SEARCH_LIMIT}. */
|
|
137
|
+
limit?: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What a search needs. The semantic seams are absent exactly when that backend cannot run here. */
|
|
141
|
+
export interface SearchDeps {
|
|
142
|
+
/** The support tables. */
|
|
143
|
+
db: SupportDatabase;
|
|
144
|
+
/** Whether `search.fts` is on. */
|
|
145
|
+
fts: boolean;
|
|
146
|
+
/** The message index, when `search.semantic` is on. */
|
|
147
|
+
index?: VectorIndexConfig;
|
|
148
|
+
/** The Workers AI binding, when bound. */
|
|
149
|
+
ai?: VectorAi;
|
|
150
|
+
/** The message index's Vectorize binding, when bound. */
|
|
151
|
+
store?: VectorStore;
|
|
152
|
+
/** The calling viewer — whose read and snooze state each result carries. */
|
|
153
|
+
viewer: string;
|
|
154
|
+
/** Where a skipped backend is reported to the operator. */
|
|
155
|
+
log?: Logger;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** One search result: a thread, and the message that matched best with its raw scores. */
|
|
159
|
+
export interface SearchResult {
|
|
160
|
+
/** The thread, as this viewer sees it. */
|
|
161
|
+
thread: ListedThread;
|
|
162
|
+
/** The best-matching message. */
|
|
163
|
+
messageId: string;
|
|
164
|
+
/** That message's FTS5 `bm25`, raw — lower is better. Absent when FTS5 did not run or did not match it. */
|
|
165
|
+
bm25?: number;
|
|
166
|
+
/** That message's similarity to the query, raw. Absent when semantic search did not run or did not match it. */
|
|
167
|
+
similarity?: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** What a search did, and what it found. */
|
|
171
|
+
export interface SearchOutcome {
|
|
172
|
+
/** The mode that ran — the one asked for, or `hybrid` when none was. */
|
|
173
|
+
mode: SearchMode;
|
|
174
|
+
/** The backends that answered. */
|
|
175
|
+
ran: SearchBackend[];
|
|
176
|
+
/** The backends that were on and could not answer, and why. */
|
|
177
|
+
skipped: { backend: SearchBackend; reason: SkipReason }[];
|
|
178
|
+
/** The threads, best first. */
|
|
179
|
+
results: SearchResult[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Whether an error is SQLite saying the full-text index does not exist. */
|
|
183
|
+
function isMissingSearchTable(error: unknown): boolean {
|
|
184
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
185
|
+
return message.includes("no such table") && message.includes(SUPPORT_SEARCH_TABLE);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A refusal for a mode this configuration cannot run. */
|
|
189
|
+
function semanticOff(mode: SearchMode): ValidationError {
|
|
190
|
+
return new ValidationError({
|
|
191
|
+
message: `Search mode \`${mode}\` needs semantic search, and \`search.semantic\` is off.`,
|
|
192
|
+
action: `Turn on \`search.semantic\` in support()'s config and run \`${pithyCommandOf("support provision", {}, { runtime: true })}\`, or search with \`mode=keyword\`.`,
|
|
193
|
+
detail: `mode ${mode} requested with search.semantic off`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Run a search. */
|
|
198
|
+
export async function searchThreads(deps: SearchDeps, query: SearchThreadsQuery): Promise<SearchOutcome> {
|
|
199
|
+
const semanticOn = deps.index !== undefined;
|
|
200
|
+
if ((query.mode === "semantic" || query.mode === "hybrid") && !semanticOn) throw semanticOff(query.mode);
|
|
201
|
+
const mode: SearchMode = query.mode ?? "hybrid";
|
|
202
|
+
const limit = Math.min(Math.max(query.limit ?? DEFAULT_SEARCH_LIMIT, 1), MAX_SEARCH_LIMIT);
|
|
203
|
+
|
|
204
|
+
const ran: SearchBackend[] = [];
|
|
205
|
+
const skipped: SearchOutcome["skipped"] = [];
|
|
206
|
+
const lists: RankedThread[][] = [];
|
|
207
|
+
const keywordRank = new Map<string, number>();
|
|
208
|
+
const bm25 = new Map<string, number>();
|
|
209
|
+
const similarity = new Map<string, number>();
|
|
210
|
+
|
|
211
|
+
if (mode !== "semantic") {
|
|
212
|
+
let backend: KeywordBackend = deps.fts ? "fts" : "like";
|
|
213
|
+
let hits: MessageHit[];
|
|
214
|
+
try {
|
|
215
|
+
hits = await keywordHits(deps.db, query.q, { backend, limit: SEARCH_HITS });
|
|
216
|
+
} catch (error) {
|
|
217
|
+
// **The index being configured on is not the same as the index existing.** A project that turned
|
|
218
|
+
// `search.fts` on and has not re-provisioned gets the `LIKE` scan — slower, and still an answer —
|
|
219
|
+
// and the result says FTS5 was skipped. Narrow on purpose: a genuine query bug still surfaces.
|
|
220
|
+
if (backend !== "fts" || !isMissingSearchTable(error)) throw error;
|
|
221
|
+
deps.log?.warn("support FTS index is configured but absent — searching with a LIKE scan", {
|
|
222
|
+
action: `Run \`${pithyCommandOf("support provision", {}, { runtime: true })}\` to create and backfill it, or set \`search.fts: false\`.`,
|
|
223
|
+
});
|
|
224
|
+
skipped.push({ backend: "fts", reason: "unprovisioned" });
|
|
225
|
+
backend = "like";
|
|
226
|
+
hits = await keywordHits(deps.db, query.q, { backend, limit: SEARCH_HITS });
|
|
227
|
+
}
|
|
228
|
+
ran.push(backend);
|
|
229
|
+
if (backend === "fts") for (const hit of hits) if (hit.score !== undefined) bm25.set(hit.messageId, hit.score);
|
|
230
|
+
const keywordList = rollUp(hits);
|
|
231
|
+
for (const entry of keywordList) keywordRank.set(entry.threadId, entry.rank);
|
|
232
|
+
lists.push(keywordList);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (mode !== "keyword" && deps.index) {
|
|
236
|
+
if (!deps.ai || !deps.store) {
|
|
237
|
+
// The index has not been provisioned for this Worker yet: its binding, or the model's, is absent.
|
|
238
|
+
deps.log?.warn("support semantic search is on but its index is not bound — skipping it", {
|
|
239
|
+
action: `Run \`${pithyCommandOf("support provision", {}, { runtime: true })}\` to create the index and bind it.`,
|
|
240
|
+
});
|
|
241
|
+
skipped.push({ backend: "semantic", reason: "unprovisioned" });
|
|
242
|
+
} else {
|
|
243
|
+
try {
|
|
244
|
+
const hits = await semanticHits({ db: deps.db, ai: deps.ai, store: deps.store, index: deps.index }, query.q);
|
|
245
|
+
for (const hit of hits) if (hit.score !== undefined) similarity.set(hit.messageId, hit.score);
|
|
246
|
+
lists.push(rollUp(hits));
|
|
247
|
+
ran.push("semantic");
|
|
248
|
+
} catch (error) {
|
|
249
|
+
deps.log?.warn("support semantic search could not answer — skipping it", {
|
|
250
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
251
|
+
});
|
|
252
|
+
skipped.push({ backend: "semantic", reason: "failed" });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const fused = fuse(lists);
|
|
258
|
+
const threads = await hydrate(
|
|
259
|
+
deps.db,
|
|
260
|
+
deps.viewer,
|
|
261
|
+
fused.map((entry) => entry.threadId),
|
|
262
|
+
query,
|
|
263
|
+
);
|
|
264
|
+
const results = fused
|
|
265
|
+
.flatMap((entry) => {
|
|
266
|
+
const thread = threads.get(entry.threadId);
|
|
267
|
+
return thread ? [{ entry, thread }] : [];
|
|
268
|
+
})
|
|
269
|
+
.sort(
|
|
270
|
+
(a, b) =>
|
|
271
|
+
b.entry.fused - a.entry.fused ||
|
|
272
|
+
(keywordRank.get(a.entry.threadId) ?? Number.POSITIVE_INFINITY) -
|
|
273
|
+
(keywordRank.get(b.entry.threadId) ?? Number.POSITIVE_INFINITY) ||
|
|
274
|
+
b.thread.lastMessageAt.getTime() - a.thread.lastMessageAt.getTime() ||
|
|
275
|
+
(a.thread.id < b.thread.id ? 1 : a.thread.id > b.thread.id ? -1 : 0),
|
|
276
|
+
)
|
|
277
|
+
.slice(0, limit)
|
|
278
|
+
.map(({ entry, thread }): SearchResult => {
|
|
279
|
+
const keyword = bm25.get(entry.messageId);
|
|
280
|
+
const near = similarity.get(entry.messageId);
|
|
281
|
+
return {
|
|
282
|
+
thread,
|
|
283
|
+
messageId: entry.messageId,
|
|
284
|
+
...(keyword !== undefined ? { bm25: keyword } : {}),
|
|
285
|
+
...(near !== undefined ? { similarity: near } : {}),
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
return { mode, ran, skipped, results };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The filter values bind before the id list; this is the most a filtered read can bind besides it. */
|
|
293
|
+
const HYDRATE_FIXED_PARAMETERS = 12;
|
|
294
|
+
|
|
295
|
+
/** Read the candidate threads that pass the caller's filters, as this viewer sees them. */
|
|
296
|
+
async function hydrate(
|
|
297
|
+
db: SupportDatabase,
|
|
298
|
+
viewer: string,
|
|
299
|
+
threadIds: readonly string[],
|
|
300
|
+
filters: ThreadFilters,
|
|
301
|
+
): Promise<Map<string, ListedThread>> {
|
|
302
|
+
const threads = new Map<string, ListedThread>();
|
|
303
|
+
if (threadIds.length === 0) return threads;
|
|
304
|
+
for (const group of chunkByBoundParameters(threadIds, HYDRATE_FIXED_PARAMETERS)) {
|
|
305
|
+
const rows = await applyThreadFilters(viewerThreads(db, viewer), filters)
|
|
306
|
+
.where(`${SUPPORT_THREADS_TABLE}.id`, "in", group)
|
|
307
|
+
.execute();
|
|
308
|
+
for (const row of rows) {
|
|
309
|
+
const thread = listedThreadOf(row);
|
|
310
|
+
threads.set(thread.id, thread);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return threads;
|
|
314
|
+
}
|
package/src/store/paging.ts
CHANGED
|
@@ -20,3 +20,17 @@ export const DEFAULT_PAGE_SIZE = 25;
|
|
|
20
20
|
|
|
21
21
|
/** The most a caller may ask for in one page. */
|
|
22
22
|
export const MAX_PAGE_SIZE = 100;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The search route's bounds. Here rather than in `store/hybrid.ts` for the reason the page bounds are:
|
|
26
|
+
* `http/schemas.ts` is a client's business and reaches no Kysely.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** How many threads a search returns by default. */
|
|
30
|
+
export const DEFAULT_SEARCH_LIMIT = 20;
|
|
31
|
+
|
|
32
|
+
/** The most a search returns. A ranked list's tail is noise; fifty is past where anyone reads. */
|
|
33
|
+
export const MAX_SEARCH_LIMIT = 50;
|
|
34
|
+
|
|
35
|
+
/** The longest search term accepted — it becomes a scan predicate and a model input. */
|
|
36
|
+
export const MAX_SEARCH_TERM = 200;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { supportDatabase } from "../data/tables";
|
|
6
|
+
import { reindexAll } from "./search";
|
|
7
|
+
import {
|
|
8
|
+
createSearchIndex,
|
|
9
|
+
dropSearchIndex,
|
|
10
|
+
SEARCH_OBJECTS,
|
|
11
|
+
type SearchIndexAction,
|
|
12
|
+
searchIndexAction,
|
|
13
|
+
searchIndexState,
|
|
14
|
+
} from "./searchIndex";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* **Bring one database's full-text index into line with `search.fts`, and say what that took.**
|
|
18
|
+
*
|
|
19
|
+
* The body of `pithy support provision`'s search step, and of `pithy restore`'s rebuild of the index a
|
|
20
|
+
* backup leaves out (#848). One function for both, because the two have to agree: a restore that rebuilt
|
|
21
|
+
* the index some other way would be a second definition of a provisioned index, and the first time the
|
|
22
|
+
* triggers changed it would restore the old ones.
|
|
23
|
+
*
|
|
24
|
+
* It reads `sqlite_master` for the table and its triggers, decides with {@link searchIndexAction}, and
|
|
25
|
+
* applies the decision. A create and a repair backfill from `pithy_support_messages` straight away: an
|
|
26
|
+
* index that exists and is empty answers "no matches" for a word plainly in a message, because the `LIKE`
|
|
27
|
+
* fallback only fires while the table is absent. A drop takes the triggers first, then the table.
|
|
28
|
+
*
|
|
29
|
+
* Every statement is idempotent, so a re-run is a no-op that answers `none`.
|
|
30
|
+
*/
|
|
31
|
+
export async function provisionSearchIndex(database: D1Database, wanted: boolean): Promise<SearchIndexAction> {
|
|
32
|
+
const listed = await database
|
|
33
|
+
.prepare(`SELECT name FROM sqlite_master WHERE name IN (${SEARCH_OBJECTS.map(() => "?").join(", ")})`)
|
|
34
|
+
.bind(...SEARCH_OBJECTS)
|
|
35
|
+
.all<{ name: string }>();
|
|
36
|
+
const action = searchIndexAction(searchIndexState((listed.results ?? []).map((row) => row.name)), wanted);
|
|
37
|
+
if (action === "none") return action;
|
|
38
|
+
const db = supportDatabase(database);
|
|
39
|
+
if (action === "drop") {
|
|
40
|
+
await dropSearchIndex(db);
|
|
41
|
+
return action;
|
|
42
|
+
}
|
|
43
|
+
await createSearchIndex(db);
|
|
44
|
+
await reindexAll(db);
|
|
45
|
+
return action;
|
|
46
|
+
}
|
package/src/store/search.ts
CHANGED
|
@@ -1,28 +1,23 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
-
import { type
|
|
4
|
+
import { type SqlBool, sql } from "kysely";
|
|
5
5
|
import { SUPPORT_MESSAGES_TABLE, type SupportDatabase } from "../data/tables";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* Keyword retrieval over the inbox — two backends behind one call.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* with support. `LIKE` is an unindexed scan and is completely adequate for an inbox measured in
|
|
15
|
-
* thousands of messages, which is what this capability is for.
|
|
10
|
+
* **FTS5 is the default and `LIKE` is the fallback.** The index lives in a virtual table that
|
|
11
|
+
* `pithy support provision` creates beside its triggers, so a project that has not provisioned it — or
|
|
12
|
+
* that set `search.fts: false` — searches with an unindexed `LIKE` scan, which is adequate for an inbox
|
|
13
|
+
* measured in thousands of messages. `pithy backup` leaves the index out and `pithy restore` rebuilds it.
|
|
16
14
|
*
|
|
17
|
-
* Both
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* is") or paginating twice over.
|
|
15
|
+
* **Both return messages, ranked, not threads.** A thread is scored by its best-matching message, and the
|
|
16
|
+
* keyword half is fused with the semantic half by rank position in `store/hybrid.ts`. FTS5 ranks by `bm25`,
|
|
17
|
+
* which is reported raw. `LIKE` has no score, so its matches rank by recency, newest first.
|
|
21
18
|
*
|
|
22
|
-
*
|
|
23
|
-
* remember — an order number, an error string, a surname —
|
|
24
|
-
* there for anyone who wants meaning-matching, and it is the nicer demo and the wrong default: it
|
|
25
|
-
* cannot find `ORD-40912`.
|
|
19
|
+
* Keyword search is first-class beside semantic search: people search a support inbox for a word they
|
|
20
|
+
* remember — an order number, an error string, a surname — and semantic search cannot find `ORD-40912`.
|
|
26
21
|
*/
|
|
27
22
|
|
|
28
23
|
/** The longest search term accepted. Bounded because it becomes a scan predicate. */
|
|
@@ -53,35 +48,61 @@ export function ftsQuery(term: string): string {
|
|
|
53
48
|
return tokens.map((token) => `"${token.replace(/"/g, '""')}"`).join(" AND ");
|
|
54
49
|
}
|
|
55
50
|
|
|
56
|
-
/**
|
|
57
|
-
export
|
|
58
|
-
|
|
51
|
+
/** One message a backend matched, in rank order. */
|
|
52
|
+
export interface MessageHit {
|
|
53
|
+
/** The message. */
|
|
54
|
+
messageId: string;
|
|
55
|
+
/** Its thread. */
|
|
56
|
+
threadId: string;
|
|
57
|
+
/** The backend's own score for it, raw — `bm25` for FTS5, similarity for Vectorize, none for `LIKE`. */
|
|
58
|
+
score?: number;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Which keyword backend answered. */
|
|
62
|
+
export type KeywordBackend = "fts" | "like";
|
|
63
|
+
|
|
61
64
|
/**
|
|
62
|
-
*
|
|
65
|
+
* The messages matching `term`, best first, at most `limit` of them.
|
|
63
66
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
67
|
+
* A term that tokenizes to nothing — `???`, `@@@` — asked a question with no answer, and the honest answer
|
|
68
|
+
* is none: never the whole inbox rendered as search results.
|
|
66
69
|
*/
|
|
67
|
-
export function
|
|
68
|
-
|
|
70
|
+
export async function keywordHits(
|
|
71
|
+
db: SupportDatabase,
|
|
72
|
+
term: string,
|
|
73
|
+
options: { backend: KeywordBackend; limit: number },
|
|
74
|
+
): Promise<MessageHit[]> {
|
|
75
|
+
if (options.backend === "fts") {
|
|
76
|
+
const match = ftsQuery(term);
|
|
77
|
+
if (match.length === 0) return [];
|
|
69
78
|
// `<table> MATCH ?` naming the table in full is the documented FTS5 form, and the only one that
|
|
70
|
-
// works: the hidden match column keeps the table's own name, so
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
where pithy_support_search
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
// works: the hidden match column keeps the table's own name. `bm25()` is lower-is-better, so the
|
|
80
|
+
// ascending order is best first.
|
|
81
|
+
const { rows } = await sql<{ messageId: string; threadId: string; score: number }>`
|
|
82
|
+
select message_id as "messageId", thread_id as "threadId", bm25(pithy_support_search) as "score"
|
|
83
|
+
from pithy_support_search
|
|
84
|
+
where pithy_support_search match ${match}
|
|
85
|
+
order by "score" asc
|
|
86
|
+
limit ${options.limit}
|
|
87
|
+
`.execute(db);
|
|
88
|
+
return rows.map((row) => ({ messageId: row.messageId, threadId: row.threadId, score: Number(row.score) }));
|
|
78
89
|
}
|
|
90
|
+
if (term.trim().length === 0) return [];
|
|
79
91
|
const pattern = `%${escapeLike(term.slice(0, MAX_TERM))}%`;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
92
|
+
const rows = await db
|
|
93
|
+
.selectFrom(SUPPORT_MESSAGES_TABLE)
|
|
94
|
+
.select(["id", "threadId"])
|
|
95
|
+
.where((eb) =>
|
|
96
|
+
eb.or([
|
|
97
|
+
sql<SqlBool>`${eb.ref("subject")} like ${pattern} escape '\\'`,
|
|
98
|
+
sql<SqlBool>`${eb.ref("textBody")} like ${pattern} escape '\\'`,
|
|
99
|
+
]),
|
|
100
|
+
)
|
|
101
|
+
.orderBy("receivedAt", "desc")
|
|
102
|
+
.orderBy("id", "desc")
|
|
103
|
+
.limit(options.limit)
|
|
104
|
+
.execute();
|
|
105
|
+
return rows.map((row) => ({ messageId: row.id, threadId: row.threadId }));
|
|
85
106
|
}
|
|
86
107
|
|
|
87
108
|
/**
|
package/src/store/searchIndex.ts
CHANGED
|
@@ -154,10 +154,10 @@ export function searchIndexAction(state: SearchIndexState, wanted: boolean): Sea
|
|
|
154
154
|
/**
|
|
155
155
|
* The `CREATE TRIGGER` statements, one per {@link SEARCH_TRIGGERS} entry and in that order.
|
|
156
156
|
*
|
|
157
|
-
* **`BEGIN` and `END` are uppercase,
|
|
158
|
-
* compound-statement body
|
|
159
|
-
*
|
|
160
|
-
*
|
|
157
|
+
* **`BEGIN` and `END` are uppercase, by convention.** Remote D1 and Miniflare both accept a lowercase
|
|
158
|
+
* compound-statement body today, so nothing enforces the casing at run time. Older D1 rejected the
|
|
159
|
+
* lowercase form as `incomplete input [code: 7500]`, and uppercase is accepted everywhere, so the bodies
|
|
160
|
+
* keep it and `searchIndex.test.ts` pins it.
|
|
161
161
|
*
|
|
162
162
|
* **Every column is the physical snake_case name.** `CamelCasePlugin` snake-cases the identifiers the
|
|
163
163
|
* query builder emits and never touches a string inside a `sql` template, so this is the one place in
|