@gmickel/gno 0.18.0 → 0.19.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 +7 -1
- package/package.json +1 -1
- package/src/cli/commands/ask.ts +3 -0
- package/src/cli/program.ts +24 -0
- package/src/mcp/tools/index.ts +3 -0
- package/src/mcp/tools/query.ts +2 -0
- package/src/mcp/tools/search.ts +2 -0
- package/src/mcp/tools/vsearch.ts +2 -0
- package/src/pipeline/exclude.ts +69 -0
- package/src/pipeline/hybrid.ts +21 -0
- package/src/pipeline/search.ts +17 -0
- package/src/pipeline/types.ts +6 -0
- package/src/pipeline/vsearch.ts +27 -0
- package/src/serve/public/lib/retrieval-filters.ts +3 -0
- package/src/serve/public/pages/Ask.tsx +150 -1
- package/src/serve/public/pages/Search.tsx +22 -0
- package/src/serve/routes/api.ts +116 -48
package/README.md
CHANGED
|
@@ -32,7 +32,12 @@ GNO is a local knowledge engine that turns your documents into a searchable, con
|
|
|
32
32
|
|
|
33
33
|
---
|
|
34
34
|
|
|
35
|
-
## What's New in v0.
|
|
35
|
+
## What's New in v0.19
|
|
36
|
+
|
|
37
|
+
- **Exclusion Filters**: explicit `exclude` controls across CLI, API, Web, and MCP to hard-prune unwanted docs by title/path/body text
|
|
38
|
+
- **Ask Query-Mode Parity**: Ask now supports structured `term` / `intent` / `hyde` controls in both API and Web UI
|
|
39
|
+
|
|
40
|
+
### v0.18
|
|
36
41
|
|
|
37
42
|
- **Intent Steering**: optional `intent` control for ambiguous queries across CLI, API, Web, and MCP query flows
|
|
38
43
|
- **Rerank Controls**: `candidateLimit` lets you tune rerank cost vs. recall on slower or memory-constrained machines
|
|
@@ -162,6 +167,7 @@ gno vsearch "error handling patterns" # Semantic similarity
|
|
|
162
167
|
gno query "database optimization" # Full pipeline
|
|
163
168
|
gno query "meeting decisions" --since "last month" --category "meeting,notes" --author "gordon"
|
|
164
169
|
gno query "performance" --intent "web performance and latency"
|
|
170
|
+
gno query "performance" --exclude "reviews,hiring"
|
|
165
171
|
gno ask "what did we decide" --answer # AI synthesis
|
|
166
172
|
```
|
|
167
173
|
|
package/package.json
CHANGED
package/src/cli/commands/ask.ts
CHANGED
|
@@ -199,6 +199,7 @@ export async function ask(
|
|
|
199
199
|
author: options.author,
|
|
200
200
|
tagsAll: options.tagsAll,
|
|
201
201
|
tagsAny: options.tagsAny,
|
|
202
|
+
exclude: options.exclude,
|
|
202
203
|
noExpand: options.noExpand,
|
|
203
204
|
noRerank: options.noRerank,
|
|
204
205
|
candidateLimit: options.candidateLimit,
|
|
@@ -262,6 +263,8 @@ export async function ask(
|
|
|
262
263
|
vectorsUsed: searchResult.value.meta.vectorsUsed ?? false,
|
|
263
264
|
intent: searchResult.value.meta.intent,
|
|
264
265
|
candidateLimit: searchResult.value.meta.candidateLimit,
|
|
266
|
+
exclude: searchResult.value.meta.exclude,
|
|
267
|
+
queryModes: searchResult.value.meta.queryModes,
|
|
265
268
|
answerGenerated,
|
|
266
269
|
totalResults: results.length,
|
|
267
270
|
answerContext,
|
package/src/cli/program.ts
CHANGED
|
@@ -225,6 +225,10 @@ function wireSearchCommands(program: Command): void {
|
|
|
225
225
|
.option("--category <values>", "require category match (comma-separated)")
|
|
226
226
|
.option("--author <text>", "filter by author (case-insensitive contains)")
|
|
227
227
|
.option("--intent <text>", "disambiguating context for ambiguous queries")
|
|
228
|
+
.option(
|
|
229
|
+
"--exclude <values>",
|
|
230
|
+
"exclude docs containing any term (comma-separated)"
|
|
231
|
+
)
|
|
228
232
|
.option("--tags-all <tags>", "require ALL tags (comma-separated)")
|
|
229
233
|
.option("--tags-any <tags>", "require ANY tag (comma-separated)")
|
|
230
234
|
.option("--full", "include full content")
|
|
@@ -270,6 +274,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
270
274
|
? parsePositiveInt("limit", cmdOpts.limit)
|
|
271
275
|
: getDefaultLimit(format);
|
|
272
276
|
const categories = parseCsvValues(cmdOpts.category);
|
|
277
|
+
const exclude = parseCsvValues(cmdOpts.exclude);
|
|
273
278
|
|
|
274
279
|
const { search, formatSearch } = await import("./commands/search");
|
|
275
280
|
const result = await search(queryText, {
|
|
@@ -282,6 +287,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
282
287
|
categories,
|
|
283
288
|
author: cmdOpts.author as string | undefined,
|
|
284
289
|
intent: cmdOpts.intent as string | undefined,
|
|
290
|
+
exclude,
|
|
285
291
|
tagsAll,
|
|
286
292
|
tagsAny,
|
|
287
293
|
full: Boolean(cmdOpts.full),
|
|
@@ -332,6 +338,10 @@ function wireSearchCommands(program: Command): void {
|
|
|
332
338
|
.option("--category <values>", "require category match (comma-separated)")
|
|
333
339
|
.option("--author <text>", "filter by author (case-insensitive contains)")
|
|
334
340
|
.option("--intent <text>", "disambiguating context for ambiguous queries")
|
|
341
|
+
.option(
|
|
342
|
+
"--exclude <values>",
|
|
343
|
+
"exclude docs containing any term (comma-separated)"
|
|
344
|
+
)
|
|
335
345
|
.option("--tags-all <tags>", "require ALL tags (comma-separated)")
|
|
336
346
|
.option("--tags-any <tags>", "require ANY tag (comma-separated)")
|
|
337
347
|
.option("--full", "include full content")
|
|
@@ -377,6 +387,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
377
387
|
? parsePositiveInt("limit", cmdOpts.limit)
|
|
378
388
|
: getDefaultLimit(format);
|
|
379
389
|
const categories = parseCsvValues(cmdOpts.category);
|
|
390
|
+
const exclude = parseCsvValues(cmdOpts.exclude);
|
|
380
391
|
|
|
381
392
|
const { vsearch, formatVsearch } = await import("./commands/vsearch");
|
|
382
393
|
const result = await vsearch(queryText, {
|
|
@@ -389,6 +400,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
389
400
|
categories,
|
|
390
401
|
author: cmdOpts.author as string | undefined,
|
|
391
402
|
intent: cmdOpts.intent as string | undefined,
|
|
403
|
+
exclude,
|
|
392
404
|
tagsAll,
|
|
393
405
|
tagsAny,
|
|
394
406
|
full: Boolean(cmdOpts.full),
|
|
@@ -434,6 +446,10 @@ function wireSearchCommands(program: Command): void {
|
|
|
434
446
|
.option("--category <values>", "require category match (comma-separated)")
|
|
435
447
|
.option("--author <text>", "filter by author (case-insensitive contains)")
|
|
436
448
|
.option("--intent <text>", "disambiguating context for ambiguous queries")
|
|
449
|
+
.option(
|
|
450
|
+
"--exclude <values>",
|
|
451
|
+
"exclude docs containing any term (comma-separated)"
|
|
452
|
+
)
|
|
437
453
|
.option("--tags-all <tags>", "require ALL tags (comma-separated)")
|
|
438
454
|
.option("--tags-any <tags>", "require ANY tag (comma-separated)")
|
|
439
455
|
.option("--full", "include full content")
|
|
@@ -505,6 +521,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
505
521
|
? parsePositiveInt("candidate-limit", cmdOpts.candidateLimit)
|
|
506
522
|
: undefined;
|
|
507
523
|
const categories = parseCsvValues(cmdOpts.category);
|
|
524
|
+
const exclude = parseCsvValues(cmdOpts.exclude);
|
|
508
525
|
|
|
509
526
|
// Determine expansion/rerank settings based on flags
|
|
510
527
|
// Priority: --fast > --thorough > --no-expand/--no-rerank > default
|
|
@@ -541,6 +558,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
541
558
|
categories,
|
|
542
559
|
author: cmdOpts.author as string | undefined,
|
|
543
560
|
intent: cmdOpts.intent as string | undefined,
|
|
561
|
+
exclude,
|
|
544
562
|
tagsAll,
|
|
545
563
|
tagsAny,
|
|
546
564
|
full: Boolean(cmdOpts.full),
|
|
@@ -586,6 +604,10 @@ function wireSearchCommands(program: Command): void {
|
|
|
586
604
|
.option("--category <values>", "require category match (comma-separated)")
|
|
587
605
|
.option("--author <text>", "filter by author (case-insensitive contains)")
|
|
588
606
|
.option("--intent <text>", "disambiguating context for ambiguous queries")
|
|
607
|
+
.option(
|
|
608
|
+
"--exclude <values>",
|
|
609
|
+
"exclude docs containing any term (comma-separated)"
|
|
610
|
+
)
|
|
589
611
|
.option("--fast", "skip expansion and reranking (fastest)")
|
|
590
612
|
.option("--thorough", "enable query expansion (slower)")
|
|
591
613
|
.option("-C, --candidate-limit <num>", "max candidates passed to reranking")
|
|
@@ -616,6 +638,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
616
638
|
? parsePositiveInt("max-answer-tokens", cmdOpts.maxAnswerTokens)
|
|
617
639
|
: undefined;
|
|
618
640
|
const categories = parseCsvValues(cmdOpts.category);
|
|
641
|
+
const exclude = parseCsvValues(cmdOpts.exclude);
|
|
619
642
|
|
|
620
643
|
// Determine expansion/rerank settings based on flags
|
|
621
644
|
// Default: skip expansion (balanced mode)
|
|
@@ -641,6 +664,7 @@ function wireSearchCommands(program: Command): void {
|
|
|
641
664
|
categories,
|
|
642
665
|
author: cmdOpts.author as string | undefined,
|
|
643
666
|
intent: cmdOpts.intent as string | undefined,
|
|
667
|
+
exclude,
|
|
644
668
|
noExpand,
|
|
645
669
|
noRerank,
|
|
646
670
|
candidateLimit,
|
package/src/mcp/tools/index.ts
CHANGED
|
@@ -57,6 +57,7 @@ const searchInputSchema = z.object({
|
|
|
57
57
|
minScore: z.number().min(0).max(1).optional(),
|
|
58
58
|
lang: z.string().optional(),
|
|
59
59
|
intent: z.string().optional(),
|
|
60
|
+
exclude: z.array(z.string()).optional(),
|
|
60
61
|
since: z.string().optional(),
|
|
61
62
|
until: z.string().optional(),
|
|
62
63
|
categories: z.array(z.string()).optional(),
|
|
@@ -107,6 +108,7 @@ const vsearchInputSchema = z.object({
|
|
|
107
108
|
minScore: z.number().min(0).max(1).optional(),
|
|
108
109
|
lang: z.string().optional(),
|
|
109
110
|
intent: z.string().optional(),
|
|
111
|
+
exclude: z.array(z.string()).optional(),
|
|
110
112
|
since: z.string().optional(),
|
|
111
113
|
until: z.string().optional(),
|
|
112
114
|
categories: z.array(z.string()).optional(),
|
|
@@ -128,6 +130,7 @@ export const queryInputSchema = z.object({
|
|
|
128
130
|
lang: z.string().optional(),
|
|
129
131
|
intent: z.string().optional(),
|
|
130
132
|
candidateLimit: z.number().int().min(1).max(100).optional(),
|
|
133
|
+
exclude: z.array(z.string()).optional(),
|
|
131
134
|
since: z.string().optional(),
|
|
132
135
|
until: z.string().optional(),
|
|
133
136
|
categories: z.array(z.string()).optional(),
|
package/src/mcp/tools/query.ts
CHANGED
|
@@ -38,6 +38,7 @@ interface QueryInput {
|
|
|
38
38
|
lang?: string;
|
|
39
39
|
intent?: string;
|
|
40
40
|
candidateLimit?: number;
|
|
41
|
+
exclude?: string[];
|
|
41
42
|
since?: string;
|
|
42
43
|
until?: string;
|
|
43
44
|
categories?: string[];
|
|
@@ -251,6 +252,7 @@ export function handleQuery(
|
|
|
251
252
|
queryLanguageHint: args.lang, // Affects expansion prompt, not retrieval
|
|
252
253
|
intent: args.intent,
|
|
253
254
|
candidateLimit: args.candidateLimit,
|
|
255
|
+
exclude: args.exclude,
|
|
254
256
|
since: args.since,
|
|
255
257
|
until: args.until,
|
|
256
258
|
categories: args.categories,
|
package/src/mcp/tools/search.ts
CHANGED
|
@@ -20,6 +20,7 @@ interface SearchInput {
|
|
|
20
20
|
minScore?: number;
|
|
21
21
|
lang?: string;
|
|
22
22
|
intent?: string;
|
|
23
|
+
exclude?: string[];
|
|
23
24
|
since?: string;
|
|
24
25
|
until?: string;
|
|
25
26
|
categories?: string[];
|
|
@@ -110,6 +111,7 @@ export function handleSearch(
|
|
|
110
111
|
collection: args.collection,
|
|
111
112
|
lang: args.lang,
|
|
112
113
|
intent: args.intent,
|
|
114
|
+
exclude: args.exclude,
|
|
113
115
|
since: args.since,
|
|
114
116
|
until: args.until,
|
|
115
117
|
categories: args.categories,
|
package/src/mcp/tools/vsearch.ts
CHANGED
|
@@ -29,6 +29,7 @@ interface VsearchInput {
|
|
|
29
29
|
minScore?: number;
|
|
30
30
|
lang?: string;
|
|
31
31
|
intent?: string;
|
|
32
|
+
exclude?: string[];
|
|
32
33
|
since?: string;
|
|
33
34
|
until?: string;
|
|
34
35
|
categories?: string[];
|
|
@@ -194,6 +195,7 @@ export function handleVsearch(
|
|
|
194
195
|
minScore: args.minScore,
|
|
195
196
|
collection: args.collection,
|
|
196
197
|
intent: args.intent,
|
|
198
|
+
exclude: args.exclude,
|
|
197
199
|
since: args.since,
|
|
198
200
|
until: args.until,
|
|
199
201
|
categories: args.categories,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit exclusion helpers for retrieval filters.
|
|
3
|
+
*
|
|
4
|
+
* @module src/pipeline/exclude
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ChunkRow } from "../store/types";
|
|
8
|
+
|
|
9
|
+
export function normalizeExcludeTerms(values: string[]): string[] {
|
|
10
|
+
const out: string[] = [];
|
|
11
|
+
const seen = new Set<string>();
|
|
12
|
+
|
|
13
|
+
for (const value of values) {
|
|
14
|
+
for (const part of value.split(",")) {
|
|
15
|
+
const trimmed = part.trim();
|
|
16
|
+
if (!trimmed) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const key = trimmed.toLowerCase();
|
|
20
|
+
if (seen.has(key)) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
seen.add(key);
|
|
24
|
+
out.push(trimmed);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function includesTerm(haystack: string, term: string): boolean {
|
|
32
|
+
return haystack.toLowerCase().includes(term.toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function matchesExcludedText(
|
|
36
|
+
haystacks: string[],
|
|
37
|
+
excludeTerms: string[] | undefined
|
|
38
|
+
): boolean {
|
|
39
|
+
if (!excludeTerms?.length) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const haystack of haystacks) {
|
|
44
|
+
if (!haystack) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
for (const term of excludeTerms) {
|
|
48
|
+
if (includesTerm(haystack, term)) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function matchesExcludedChunks(
|
|
58
|
+
chunks: ChunkRow[],
|
|
59
|
+
excludeTerms: string[] | undefined
|
|
60
|
+
): boolean {
|
|
61
|
+
if (!excludeTerms?.length || chunks.length === 0) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return matchesExcludedText(
|
|
66
|
+
chunks.map((chunk) => chunk.text),
|
|
67
|
+
excludeTerms
|
|
68
|
+
);
|
|
69
|
+
}
|
package/src/pipeline/hybrid.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
import { err, ok } from "../store/types";
|
|
22
22
|
import { createChunkLookup } from "./chunk-lookup";
|
|
23
23
|
import { formatQueryForEmbedding } from "./contextual";
|
|
24
|
+
import { matchesExcludedChunks, matchesExcludedText } from "./exclude";
|
|
24
25
|
import { expandQuery } from "./expansion";
|
|
25
26
|
import {
|
|
26
27
|
buildExplainResults,
|
|
@@ -670,6 +671,25 @@ export async function searchHybrid(
|
|
|
670
671
|
continue;
|
|
671
672
|
}
|
|
672
673
|
|
|
674
|
+
const excluded =
|
|
675
|
+
matchesExcludedText(
|
|
676
|
+
[
|
|
677
|
+
doc.title ?? "",
|
|
678
|
+
doc.relPath,
|
|
679
|
+
doc.author ?? "",
|
|
680
|
+
doc.contentType ?? "",
|
|
681
|
+
...(doc.categories ?? []),
|
|
682
|
+
],
|
|
683
|
+
options.exclude
|
|
684
|
+
) ||
|
|
685
|
+
matchesExcludedChunks(
|
|
686
|
+
chunksMap.get(candidate.mirrorHash) ?? [],
|
|
687
|
+
options.exclude
|
|
688
|
+
);
|
|
689
|
+
if (excluded) {
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
|
|
673
693
|
// For --full mode, de-dupe by docid (keep best scoring candidate per doc)
|
|
674
694
|
if (options.full && seenDocids.has(doc.docid)) {
|
|
675
695
|
continue;
|
|
@@ -810,6 +830,7 @@ export async function searchHybrid(
|
|
|
810
830
|
vectorsUsed: vectorAvailable,
|
|
811
831
|
totalResults: finalResults.length,
|
|
812
832
|
intent: options.intent,
|
|
833
|
+
exclude: options.exclude,
|
|
813
834
|
collection: options.collection,
|
|
814
835
|
lang: options.lang,
|
|
815
836
|
since: temporalRange.since,
|
package/src/pipeline/search.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
|
|
18
18
|
import { err, ok } from "../store/types";
|
|
19
19
|
import { createChunkLookup } from "./chunk-lookup";
|
|
20
|
+
import { matchesExcludedChunks, matchesExcludedText } from "./exclude";
|
|
20
21
|
import { selectBestChunkForSteering } from "./intent";
|
|
21
22
|
import { detectQueryLanguage } from "./query-language";
|
|
22
23
|
import {
|
|
@@ -237,6 +238,21 @@ export async function searchBm25(
|
|
|
237
238
|
) ?? rawChunk)
|
|
238
239
|
: rawChunk;
|
|
239
240
|
|
|
241
|
+
const excluded =
|
|
242
|
+
matchesExcludedText(
|
|
243
|
+
[fts.title ?? "", fts.relPath ?? "", fts.snippet ?? ""],
|
|
244
|
+
options.exclude
|
|
245
|
+
) ||
|
|
246
|
+
matchesExcludedChunks(
|
|
247
|
+
chunksMapResult.ok && fts.mirrorHash
|
|
248
|
+
? (chunksMapResult.value.get(fts.mirrorHash) ?? [])
|
|
249
|
+
: [],
|
|
250
|
+
options.exclude
|
|
251
|
+
);
|
|
252
|
+
if (excluded) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
240
256
|
// For --full, de-dupe by docid (keep best scoring chunk per doc)
|
|
241
257
|
// Raw BM25: smaller (more negative) is better
|
|
242
258
|
if (options.full) {
|
|
@@ -309,6 +325,7 @@ export async function searchBm25(
|
|
|
309
325
|
mode: "bm25",
|
|
310
326
|
totalResults: Math.min(filteredResults.length, limit),
|
|
311
327
|
intent: options.intent,
|
|
328
|
+
exclude: options.exclude,
|
|
312
329
|
collection: options.collection,
|
|
313
330
|
lang: options.lang,
|
|
314
331
|
since: temporalRange.since,
|
package/src/pipeline/types.ts
CHANGED
|
@@ -79,6 +79,8 @@ export interface SearchMeta {
|
|
|
79
79
|
author?: string;
|
|
80
80
|
/** Rerank candidate limit used */
|
|
81
81
|
candidateLimit?: number;
|
|
82
|
+
/** Explicit exclusion terms applied */
|
|
83
|
+
exclude?: string[];
|
|
82
84
|
/** Explain data (when --explain is used) */
|
|
83
85
|
explain?: {
|
|
84
86
|
lines: ExplainLine[];
|
|
@@ -124,6 +126,8 @@ export interface SearchOptions {
|
|
|
124
126
|
author?: string;
|
|
125
127
|
/** Optional disambiguating context that steers scoring/snippets, but is not searched directly */
|
|
126
128
|
intent?: string;
|
|
129
|
+
/** Explicit exclusion terms for hard candidate pruning */
|
|
130
|
+
exclude?: string[];
|
|
127
131
|
}
|
|
128
132
|
|
|
129
133
|
/** Structured query mode identifier */
|
|
@@ -317,6 +321,8 @@ export interface AskMeta {
|
|
|
317
321
|
vectorsUsed: boolean;
|
|
318
322
|
intent?: string;
|
|
319
323
|
candidateLimit?: number;
|
|
324
|
+
exclude?: string[];
|
|
325
|
+
queryModes?: QueryModeSummary;
|
|
320
326
|
answerGenerated?: boolean;
|
|
321
327
|
totalResults?: number;
|
|
322
328
|
answerContext?: AnswerContextExplain;
|
package/src/pipeline/vsearch.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { SearchOptions, SearchResult, SearchResults } from "./types";
|
|
|
14
14
|
import { err, ok } from "../store/types";
|
|
15
15
|
import { createChunkLookup } from "./chunk-lookup";
|
|
16
16
|
import { formatQueryForEmbedding } from "./contextual";
|
|
17
|
+
import { matchesExcludedChunks, matchesExcludedText } from "./exclude";
|
|
17
18
|
import { selectBestChunkForSteering } from "./intent";
|
|
18
19
|
import { detectQueryLanguage } from "./query-language";
|
|
19
20
|
import {
|
|
@@ -174,6 +175,25 @@ export async function searchVectorWithEmbedding(
|
|
|
174
175
|
continue;
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
const excluded =
|
|
179
|
+
matchesExcludedText(
|
|
180
|
+
[
|
|
181
|
+
doc.title ?? "",
|
|
182
|
+
doc.relPath,
|
|
183
|
+
doc.author ?? "",
|
|
184
|
+
doc.contentType ?? "",
|
|
185
|
+
...(doc.categories ?? []),
|
|
186
|
+
],
|
|
187
|
+
options.exclude
|
|
188
|
+
) ||
|
|
189
|
+
matchesExcludedChunks(
|
|
190
|
+
chunksMap.get(vec.mirrorHash) ?? [],
|
|
191
|
+
options.exclude
|
|
192
|
+
);
|
|
193
|
+
if (excluded) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
177
197
|
// For --full, de-dupe by docid (keep best scoring chunk per doc)
|
|
178
198
|
if (options.full) {
|
|
179
199
|
const existing = bestByDocid.get(doc.docid);
|
|
@@ -301,6 +321,7 @@ export async function searchVectorWithEmbedding(
|
|
|
301
321
|
vectorsUsed: true,
|
|
302
322
|
totalResults: finalResults.length,
|
|
303
323
|
intent: options.intent,
|
|
324
|
+
exclude: options.exclude,
|
|
304
325
|
collection: options.collection,
|
|
305
326
|
lang: options.lang,
|
|
306
327
|
since: temporalRange.since,
|
|
@@ -362,6 +383,9 @@ interface DocumentInfo {
|
|
|
362
383
|
title: string | null;
|
|
363
384
|
collection: string;
|
|
364
385
|
relPath: string;
|
|
386
|
+
author: string | null;
|
|
387
|
+
contentType: string | null;
|
|
388
|
+
categories: string[] | null;
|
|
365
389
|
sourceHash: string;
|
|
366
390
|
sourceMime: string;
|
|
367
391
|
sourceExt: string;
|
|
@@ -491,6 +515,9 @@ async function buildDocumentMap(
|
|
|
491
515
|
title: doc.title,
|
|
492
516
|
collection: doc.collection,
|
|
493
517
|
relPath: doc.relPath,
|
|
518
|
+
author: doc.author ?? null,
|
|
519
|
+
contentType: doc.contentType ?? null,
|
|
520
|
+
categories: doc.categories ?? null,
|
|
494
521
|
sourceHash: doc.sourceHash,
|
|
495
522
|
sourceMime: doc.sourceMime,
|
|
496
523
|
sourceExt: doc.sourceExt,
|
|
@@ -11,6 +11,7 @@ export interface RetrievalFiltersState {
|
|
|
11
11
|
collection: string;
|
|
12
12
|
intent: string;
|
|
13
13
|
candidateLimit: string;
|
|
14
|
+
exclude: string;
|
|
14
15
|
since: string;
|
|
15
16
|
until: string;
|
|
16
17
|
category: string;
|
|
@@ -126,6 +127,7 @@ export function parseFiltersFromSearch(
|
|
|
126
127
|
intent: params.get("intent") ?? defaults.intent ?? "",
|
|
127
128
|
candidateLimit:
|
|
128
129
|
params.get("candidateLimit") ?? defaults.candidateLimit ?? "",
|
|
130
|
+
exclude: params.get("exclude") ?? defaults.exclude ?? "",
|
|
129
131
|
since: params.get("since") ?? defaults.since ?? "",
|
|
130
132
|
until: params.get("until") ?? defaults.until ?? "",
|
|
131
133
|
category: params.get("category") ?? defaults.category ?? "",
|
|
@@ -152,6 +154,7 @@ export function applyFiltersToUrl(
|
|
|
152
154
|
setOrDelete("collection", filters.collection);
|
|
153
155
|
setOrDelete("intent", filters.intent);
|
|
154
156
|
setOrDelete("candidateLimit", filters.candidateLimit);
|
|
157
|
+
setOrDelete("exclude", filters.exclude);
|
|
155
158
|
setOrDelete("since", filters.since);
|
|
156
159
|
setOrDelete("until", filters.until);
|
|
157
160
|
setOrDelete("category", filters.category);
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
FileText,
|
|
7
7
|
SlidersHorizontal,
|
|
8
8
|
Sparkles,
|
|
9
|
+
XIcon,
|
|
9
10
|
} from "lucide-react";
|
|
10
11
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
11
12
|
|
|
@@ -40,7 +41,12 @@ import {
|
|
|
40
41
|
import { Textarea } from "../components/ui/textarea";
|
|
41
42
|
import { apiFetch } from "../hooks/use-api";
|
|
42
43
|
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
|
43
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
parseTagsCsv,
|
|
46
|
+
type QueryModeEntry,
|
|
47
|
+
type QueryModeType,
|
|
48
|
+
type TagMode,
|
|
49
|
+
} from "../lib/retrieval-filters";
|
|
44
50
|
import { cn } from "../lib/utils";
|
|
45
51
|
|
|
46
52
|
interface PageProps {
|
|
@@ -79,6 +85,11 @@ interface AskResponse {
|
|
|
79
85
|
vectorsUsed: boolean;
|
|
80
86
|
answerGenerated: boolean;
|
|
81
87
|
totalResults: number;
|
|
88
|
+
queryModes?: {
|
|
89
|
+
term: number;
|
|
90
|
+
intent: number;
|
|
91
|
+
hyde: boolean;
|
|
92
|
+
};
|
|
82
93
|
};
|
|
83
94
|
}
|
|
84
95
|
|
|
@@ -103,6 +114,12 @@ interface Collection {
|
|
|
103
114
|
|
|
104
115
|
const THOROUGHNESS_ORDER: Thoroughness[] = ["fast", "balanced", "thorough"];
|
|
105
116
|
|
|
117
|
+
const QUERY_MODE_LABEL: Record<QueryModeType, string> = {
|
|
118
|
+
term: "Term",
|
|
119
|
+
intent: "Intent",
|
|
120
|
+
hyde: "HyDE",
|
|
121
|
+
};
|
|
122
|
+
|
|
106
123
|
/**
|
|
107
124
|
* Render answer text with clickable citation badges.
|
|
108
125
|
* Citations like [1] become clickable to navigate to source.
|
|
@@ -166,12 +183,17 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
166
183
|
const [selectedCollection, setSelectedCollection] = useState("");
|
|
167
184
|
const [intent, setIntent] = useState("");
|
|
168
185
|
const [candidateLimit, setCandidateLimit] = useState("");
|
|
186
|
+
const [exclude, setExclude] = useState("");
|
|
169
187
|
const [since, setSince] = useState("");
|
|
170
188
|
const [until, setUntil] = useState("");
|
|
171
189
|
const [category, setCategory] = useState("");
|
|
172
190
|
const [author, setAuthor] = useState("");
|
|
173
191
|
const [tagMode, setTagMode] = useState<TagMode>("any");
|
|
174
192
|
const [tagsInput, setTagsInput] = useState("");
|
|
193
|
+
const [queryModes, setQueryModes] = useState<QueryModeEntry[]>([]);
|
|
194
|
+
const [queryModeDraft, setQueryModeDraft] = useState<QueryModeType>("term");
|
|
195
|
+
const [queryModeText, setQueryModeText] = useState("");
|
|
196
|
+
const [queryModeError, setQueryModeError] = useState<string | null>(null);
|
|
175
197
|
|
|
176
198
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
177
199
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
@@ -220,6 +242,28 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
220
242
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
221
243
|
}, [conversation]);
|
|
222
244
|
|
|
245
|
+
const handleAddQueryMode = useCallback(() => {
|
|
246
|
+
const text = queryModeText.trim();
|
|
247
|
+
if (!text) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (
|
|
251
|
+
queryModeDraft === "hyde" &&
|
|
252
|
+
queryModes.some((queryMode) => queryMode.mode === "hyde")
|
|
253
|
+
) {
|
|
254
|
+
setQueryModeError("Only one HyDE mode is allowed.");
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
setQueryModes((prev) => [...prev, { mode: queryModeDraft, text }]);
|
|
258
|
+
setQueryModeText("");
|
|
259
|
+
setQueryModeError(null);
|
|
260
|
+
}, [queryModeDraft, queryModeText, queryModes]);
|
|
261
|
+
|
|
262
|
+
const handleRemoveQueryMode = useCallback((index: number) => {
|
|
263
|
+
setQueryModes((prev) => prev.filter((_, i) => i !== index));
|
|
264
|
+
setQueryModeError(null);
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
223
267
|
const handleSubmit = useCallback(
|
|
224
268
|
async (e: React.FormEvent) => {
|
|
225
269
|
e.preventDefault();
|
|
@@ -250,6 +294,9 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
250
294
|
if (candidateLimit.trim()) {
|
|
251
295
|
requestBody.candidateLimit = Number(candidateLimit);
|
|
252
296
|
}
|
|
297
|
+
if (exclude.trim()) {
|
|
298
|
+
requestBody.exclude = exclude.trim();
|
|
299
|
+
}
|
|
253
300
|
if (since) {
|
|
254
301
|
requestBody.since = since;
|
|
255
302
|
}
|
|
@@ -282,6 +329,9 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
282
329
|
requestBody.noExpand = false;
|
|
283
330
|
requestBody.noRerank = false;
|
|
284
331
|
}
|
|
332
|
+
if (queryModes.length > 0) {
|
|
333
|
+
requestBody.queryModes = queryModes;
|
|
334
|
+
}
|
|
285
335
|
|
|
286
336
|
const { data, error } = await apiFetch<AskResponse>("/api/ask", {
|
|
287
337
|
method: "POST",
|
|
@@ -305,8 +355,10 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
305
355
|
author,
|
|
306
356
|
candidateLimit,
|
|
307
357
|
category,
|
|
358
|
+
exclude,
|
|
308
359
|
intent,
|
|
309
360
|
query,
|
|
361
|
+
queryModes,
|
|
310
362
|
selectedCollection,
|
|
311
363
|
since,
|
|
312
364
|
tagMode,
|
|
@@ -327,12 +379,16 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
327
379
|
setSelectedCollection("");
|
|
328
380
|
setIntent("");
|
|
329
381
|
setCandidateLimit("");
|
|
382
|
+
setExclude("");
|
|
330
383
|
setSince("");
|
|
331
384
|
setUntil("");
|
|
332
385
|
setCategory("");
|
|
333
386
|
setAuthor("");
|
|
334
387
|
setTagsInput("");
|
|
335
388
|
setTagMode("any");
|
|
389
|
+
setQueryModes([]);
|
|
390
|
+
setQueryModeText("");
|
|
391
|
+
setQueryModeError(null);
|
|
336
392
|
};
|
|
337
393
|
|
|
338
394
|
const answerAvailable = capabilities?.answer ?? false;
|
|
@@ -341,6 +397,8 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
341
397
|
selectedCollection ? `collection:${selectedCollection}` : null,
|
|
342
398
|
intent.trim() ? `intent:${intent.trim()}` : null,
|
|
343
399
|
candidateLimit.trim() ? `candidates:${candidateLimit.trim()}` : null,
|
|
400
|
+
exclude.trim() ? `exclude:${exclude.trim()}` : null,
|
|
401
|
+
queryModes.length > 0 ? `${queryModes.length} query mode(s)` : null,
|
|
344
402
|
since ? `since:${since}` : null,
|
|
345
403
|
until ? `until:${until}` : null,
|
|
346
404
|
category.trim() ? `category:${category.trim()}` : null,
|
|
@@ -466,6 +524,17 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
466
524
|
/>
|
|
467
525
|
</div>
|
|
468
526
|
|
|
527
|
+
<div className="md:col-span-2">
|
|
528
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
529
|
+
Exclude
|
|
530
|
+
</p>
|
|
531
|
+
<Input
|
|
532
|
+
onChange={(e) => setExclude(e.target.value)}
|
|
533
|
+
placeholder="team reviews, hiring, onboarding"
|
|
534
|
+
value={exclude}
|
|
535
|
+
/>
|
|
536
|
+
</div>
|
|
537
|
+
|
|
469
538
|
<div>
|
|
470
539
|
<p className="mb-1 text-muted-foreground text-xs">
|
|
471
540
|
Category
|
|
@@ -556,6 +625,75 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
556
625
|
Clear filters
|
|
557
626
|
</Button>
|
|
558
627
|
</div>
|
|
628
|
+
|
|
629
|
+
<div className="space-y-2">
|
|
630
|
+
<p className="text-muted-foreground text-xs">
|
|
631
|
+
Query modes (term, intent, hyde)
|
|
632
|
+
</p>
|
|
633
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
634
|
+
<Select
|
|
635
|
+
onValueChange={(value) =>
|
|
636
|
+
setQueryModeDraft(value as QueryModeType)
|
|
637
|
+
}
|
|
638
|
+
value={queryModeDraft}
|
|
639
|
+
>
|
|
640
|
+
<SelectTrigger className="w-[120px]">
|
|
641
|
+
<SelectValue />
|
|
642
|
+
</SelectTrigger>
|
|
643
|
+
<SelectContent>
|
|
644
|
+
<SelectItem value="term">Term</SelectItem>
|
|
645
|
+
<SelectItem value="intent">Intent</SelectItem>
|
|
646
|
+
<SelectItem value="hyde">HyDE</SelectItem>
|
|
647
|
+
</SelectContent>
|
|
648
|
+
</Select>
|
|
649
|
+
<Input
|
|
650
|
+
className="min-w-[220px] flex-1"
|
|
651
|
+
onChange={(e) => setQueryModeText(e.target.value)}
|
|
652
|
+
onKeyDown={(e) => {
|
|
653
|
+
if (e.key === "Enter") {
|
|
654
|
+
e.preventDefault();
|
|
655
|
+
handleAddQueryMode();
|
|
656
|
+
}
|
|
657
|
+
}}
|
|
658
|
+
placeholder="Add query mode text"
|
|
659
|
+
value={queryModeText}
|
|
660
|
+
/>
|
|
661
|
+
<Button
|
|
662
|
+
onClick={handleAddQueryMode}
|
|
663
|
+
size="sm"
|
|
664
|
+
type="button"
|
|
665
|
+
variant="outline"
|
|
666
|
+
>
|
|
667
|
+
Add mode
|
|
668
|
+
</Button>
|
|
669
|
+
</div>
|
|
670
|
+
|
|
671
|
+
{queryModeError && (
|
|
672
|
+
<p className="text-destructive text-xs">
|
|
673
|
+
{queryModeError}
|
|
674
|
+
</p>
|
|
675
|
+
)}
|
|
676
|
+
|
|
677
|
+
{queryModes.length > 0 && (
|
|
678
|
+
<div className="flex flex-wrap gap-2">
|
|
679
|
+
{queryModes.map((queryMode, index) => (
|
|
680
|
+
<button
|
|
681
|
+
className={cn(
|
|
682
|
+
"group inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10",
|
|
683
|
+
"px-2.5 py-1 font-mono text-[11px] text-primary transition-all duration-150",
|
|
684
|
+
"hover:border-primary/50 hover:bg-primary/20"
|
|
685
|
+
)}
|
|
686
|
+
key={`${queryMode.mode}:${queryMode.text}:${index}`}
|
|
687
|
+
onClick={() => handleRemoveQueryMode(index)}
|
|
688
|
+
type="button"
|
|
689
|
+
>
|
|
690
|
+
<span>{`${QUERY_MODE_LABEL[queryMode.mode]}: ${queryMode.text}`}</span>
|
|
691
|
+
<XIcon className="size-3 opacity-60 transition-opacity group-hover:opacity-100" />
|
|
692
|
+
</button>
|
|
693
|
+
))}
|
|
694
|
+
</div>
|
|
695
|
+
)}
|
|
696
|
+
</div>
|
|
559
697
|
</CardContent>
|
|
560
698
|
</Card>
|
|
561
699
|
</CollapsibleContent>
|
|
@@ -683,6 +821,17 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
683
821
|
expanded
|
|
684
822
|
</Badge>
|
|
685
823
|
)}
|
|
824
|
+
{entry.response.meta.queryModes &&
|
|
825
|
+
(entry.response.meta.queryModes.term > 0 ||
|
|
826
|
+
entry.response.meta.queryModes.intent > 0 ||
|
|
827
|
+
entry.response.meta.queryModes.hyde) && (
|
|
828
|
+
<Badge
|
|
829
|
+
className="font-mono text-[9px]"
|
|
830
|
+
variant="outline"
|
|
831
|
+
>
|
|
832
|
+
query modes
|
|
833
|
+
</Badge>
|
|
834
|
+
)}
|
|
686
835
|
</div>
|
|
687
836
|
|
|
688
837
|
{!entry.response.answer &&
|
|
@@ -155,6 +155,7 @@ export default function Search({ navigate }: PageProps) {
|
|
|
155
155
|
initialFilters.collection ||
|
|
156
156
|
initialFilters.intent ||
|
|
157
157
|
initialFilters.candidateLimit ||
|
|
158
|
+
initialFilters.exclude ||
|
|
158
159
|
initialFilters.since ||
|
|
159
160
|
initialFilters.until ||
|
|
160
161
|
initialFilters.category ||
|
|
@@ -172,6 +173,7 @@ export default function Search({ navigate }: PageProps) {
|
|
|
172
173
|
const [candidateLimit, setCandidateLimit] = useState(
|
|
173
174
|
initialFilters.candidateLimit
|
|
174
175
|
);
|
|
176
|
+
const [exclude, setExclude] = useState(initialFilters.exclude);
|
|
175
177
|
const [since, setSince] = useState(initialFilters.since);
|
|
176
178
|
const [until, setUntil] = useState(initialFilters.until);
|
|
177
179
|
const [category, setCategory] = useState(initialFilters.category);
|
|
@@ -196,6 +198,7 @@ export default function Search({ navigate }: PageProps) {
|
|
|
196
198
|
collection: selectedCollection,
|
|
197
199
|
intent,
|
|
198
200
|
candidateLimit,
|
|
201
|
+
exclude,
|
|
199
202
|
since,
|
|
200
203
|
until,
|
|
201
204
|
category,
|
|
@@ -210,6 +213,7 @@ export default function Search({ navigate }: PageProps) {
|
|
|
210
213
|
author,
|
|
211
214
|
candidateLimit,
|
|
212
215
|
category,
|
|
216
|
+
exclude,
|
|
213
217
|
intent,
|
|
214
218
|
queryModes,
|
|
215
219
|
selectedCollection,
|
|
@@ -314,6 +318,9 @@ export default function Search({ navigate }: PageProps) {
|
|
|
314
318
|
if (candidateLimit.trim()) {
|
|
315
319
|
body.candidateLimit = Number(candidateLimit);
|
|
316
320
|
}
|
|
321
|
+
if (exclude.trim()) {
|
|
322
|
+
body.exclude = exclude.trim();
|
|
323
|
+
}
|
|
317
324
|
if (since) {
|
|
318
325
|
body.since = since;
|
|
319
326
|
}
|
|
@@ -373,6 +380,7 @@ export default function Search({ navigate }: PageProps) {
|
|
|
373
380
|
author,
|
|
374
381
|
candidateLimit,
|
|
375
382
|
category,
|
|
383
|
+
exclude,
|
|
376
384
|
intent,
|
|
377
385
|
query,
|
|
378
386
|
queryModes,
|
|
@@ -395,6 +403,7 @@ export default function Search({ navigate }: PageProps) {
|
|
|
395
403
|
author,
|
|
396
404
|
candidateLimit,
|
|
397
405
|
category,
|
|
406
|
+
exclude,
|
|
398
407
|
intent,
|
|
399
408
|
queryModes,
|
|
400
409
|
selectedCollection,
|
|
@@ -414,6 +423,7 @@ export default function Search({ navigate }: PageProps) {
|
|
|
414
423
|
selectedCollection ? `collection:${selectedCollection}` : null,
|
|
415
424
|
intent.trim() ? `intent:${intent.trim()}` : null,
|
|
416
425
|
candidateLimit.trim() ? `candidates:${candidateLimit.trim()}` : null,
|
|
426
|
+
exclude.trim() ? `exclude:${exclude.trim()}` : null,
|
|
417
427
|
since ? `since:${since}` : null,
|
|
418
428
|
until ? `until:${until}` : null,
|
|
419
429
|
category.trim() ? `category:${category.trim()}` : null,
|
|
@@ -428,6 +438,7 @@ export default function Search({ navigate }: PageProps) {
|
|
|
428
438
|
setSelectedCollection("");
|
|
429
439
|
setIntent("");
|
|
430
440
|
setCandidateLimit("");
|
|
441
|
+
setExclude("");
|
|
431
442
|
setSince("");
|
|
432
443
|
setUntil("");
|
|
433
444
|
setCategory("");
|
|
@@ -591,6 +602,17 @@ export default function Search({ navigate }: PageProps) {
|
|
|
591
602
|
/>
|
|
592
603
|
</div>
|
|
593
604
|
|
|
605
|
+
<div className="md:col-span-2">
|
|
606
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
607
|
+
Exclude
|
|
608
|
+
</p>
|
|
609
|
+
<Input
|
|
610
|
+
onChange={(e) => setExclude(e.target.value)}
|
|
611
|
+
placeholder="team reviews, hiring, onboarding"
|
|
612
|
+
value={exclude}
|
|
613
|
+
/>
|
|
614
|
+
</div>
|
|
615
|
+
|
|
594
616
|
<div>
|
|
595
617
|
<p className="mb-1 text-muted-foreground text-xs">
|
|
596
618
|
Category
|
package/src/serve/routes/api.ts
CHANGED
|
@@ -67,6 +67,7 @@ export interface SearchRequestBody {
|
|
|
67
67
|
minScore?: number;
|
|
68
68
|
collection?: string;
|
|
69
69
|
intent?: string;
|
|
70
|
+
exclude?: string;
|
|
70
71
|
since?: string;
|
|
71
72
|
until?: string;
|
|
72
73
|
/** Comma-separated category filters */
|
|
@@ -86,6 +87,7 @@ export interface QueryRequestBody {
|
|
|
86
87
|
lang?: string;
|
|
87
88
|
intent?: string;
|
|
88
89
|
candidateLimit?: number;
|
|
90
|
+
exclude?: string;
|
|
89
91
|
since?: string;
|
|
90
92
|
until?: string;
|
|
91
93
|
/** Comma-separated category filters */
|
|
@@ -107,6 +109,8 @@ export interface AskRequestBody {
|
|
|
107
109
|
lang?: string;
|
|
108
110
|
intent?: string;
|
|
109
111
|
candidateLimit?: number;
|
|
112
|
+
exclude?: string;
|
|
113
|
+
queryModes?: QueryModeInput[];
|
|
110
114
|
since?: string;
|
|
111
115
|
until?: string;
|
|
112
116
|
/** Comma-separated category filters */
|
|
@@ -174,6 +178,73 @@ function parseCommaSeparatedValues(input: string): string[] {
|
|
|
174
178
|
);
|
|
175
179
|
}
|
|
176
180
|
|
|
181
|
+
function parseQueryModesInput(value: unknown): {
|
|
182
|
+
queryModes?: QueryModeInput[];
|
|
183
|
+
error?: Response;
|
|
184
|
+
} {
|
|
185
|
+
if (value === undefined) {
|
|
186
|
+
return {};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!Array.isArray(value)) {
|
|
190
|
+
return {
|
|
191
|
+
error: errorResponse(
|
|
192
|
+
"VALIDATION",
|
|
193
|
+
"queryModes must be an array of { mode, text } objects"
|
|
194
|
+
),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const queryModes: QueryModeInput[] = [];
|
|
199
|
+
let hydeCount = 0;
|
|
200
|
+
|
|
201
|
+
for (const [index, entry] of value.entries()) {
|
|
202
|
+
if (!entry || typeof entry !== "object") {
|
|
203
|
+
return {
|
|
204
|
+
error: errorResponse(
|
|
205
|
+
"VALIDATION",
|
|
206
|
+
`queryModes[${index}] must be an object`
|
|
207
|
+
),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const mode = (entry as { mode?: unknown }).mode;
|
|
212
|
+
const text = (entry as { text?: unknown }).text;
|
|
213
|
+
if (mode !== "term" && mode !== "intent" && mode !== "hyde") {
|
|
214
|
+
return {
|
|
215
|
+
error: errorResponse(
|
|
216
|
+
"VALIDATION",
|
|
217
|
+
`queryModes[${index}].mode must be one of: term, intent, hyde`
|
|
218
|
+
),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (typeof text !== "string" || !text.trim()) {
|
|
222
|
+
return {
|
|
223
|
+
error: errorResponse(
|
|
224
|
+
"VALIDATION",
|
|
225
|
+
`queryModes[${index}].text must be a non-empty string`
|
|
226
|
+
),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (mode === "hyde") {
|
|
231
|
+
hydeCount += 1;
|
|
232
|
+
if (hydeCount > 1) {
|
|
233
|
+
return {
|
|
234
|
+
error: errorResponse(
|
|
235
|
+
"VALIDATION",
|
|
236
|
+
"Only one hyde mode is allowed in queryModes"
|
|
237
|
+
),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
queryModes.push({ mode, text: text.trim() });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { queryModes };
|
|
246
|
+
}
|
|
247
|
+
|
|
177
248
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
178
249
|
// Route Handlers
|
|
179
250
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1101,6 +1172,12 @@ export async function handleSearch(
|
|
|
1101
1172
|
if (body.intent !== undefined && typeof body.intent !== "string") {
|
|
1102
1173
|
return errorResponse("VALIDATION", "intent must be a string");
|
|
1103
1174
|
}
|
|
1175
|
+
if (body.exclude !== undefined && typeof body.exclude !== "string") {
|
|
1176
|
+
return errorResponse(
|
|
1177
|
+
"VALIDATION",
|
|
1178
|
+
"exclude must be a comma-separated string"
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1104
1181
|
if (body.category !== undefined && typeof body.category !== "string") {
|
|
1105
1182
|
return errorResponse(
|
|
1106
1183
|
"VALIDATION",
|
|
@@ -1140,6 +1217,9 @@ export async function handleSearch(
|
|
|
1140
1217
|
const categories = body.category
|
|
1141
1218
|
? parseCommaSeparatedValues(body.category)
|
|
1142
1219
|
: undefined;
|
|
1220
|
+
const exclude = body.exclude
|
|
1221
|
+
? parseCommaSeparatedValues(body.exclude)
|
|
1222
|
+
: undefined;
|
|
1143
1223
|
const author = body.author?.trim() || undefined;
|
|
1144
1224
|
|
|
1145
1225
|
// Only BM25 supported in web UI (vector/hybrid require LLM ports)
|
|
@@ -1148,6 +1228,7 @@ export async function handleSearch(
|
|
|
1148
1228
|
minScore: body.minScore,
|
|
1149
1229
|
collection: body.collection,
|
|
1150
1230
|
intent: body.intent?.trim() || undefined,
|
|
1231
|
+
exclude,
|
|
1151
1232
|
tagsAll,
|
|
1152
1233
|
tagsAny,
|
|
1153
1234
|
since: body.since,
|
|
@@ -1220,6 +1301,12 @@ export async function handleQuery(
|
|
|
1220
1301
|
if (body.intent !== undefined && typeof body.intent !== "string") {
|
|
1221
1302
|
return errorResponse("VALIDATION", "intent must be a string");
|
|
1222
1303
|
}
|
|
1304
|
+
if (body.exclude !== undefined && typeof body.exclude !== "string") {
|
|
1305
|
+
return errorResponse(
|
|
1306
|
+
"VALIDATION",
|
|
1307
|
+
"exclude must be a comma-separated string"
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1223
1310
|
if (
|
|
1224
1311
|
body.candidateLimit !== undefined &&
|
|
1225
1312
|
(typeof body.candidateLimit !== "number" || body.candidateLimit < 1)
|
|
@@ -1239,54 +1326,11 @@ export async function handleQuery(
|
|
|
1239
1326
|
return errorResponse("VALIDATION", "author must be a string");
|
|
1240
1327
|
}
|
|
1241
1328
|
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
"VALIDATION",
|
|
1248
|
-
"queryModes must be an array of { mode, text } objects"
|
|
1249
|
-
);
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
queryModes = [];
|
|
1253
|
-
let hydeCount = 0;
|
|
1254
|
-
|
|
1255
|
-
for (const [index, entry] of body.queryModes.entries()) {
|
|
1256
|
-
if (!entry || typeof entry !== "object") {
|
|
1257
|
-
return errorResponse(
|
|
1258
|
-
"VALIDATION",
|
|
1259
|
-
`queryModes[${index}] must be an object`
|
|
1260
|
-
);
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
const mode = (entry as { mode?: unknown }).mode;
|
|
1264
|
-
const text = (entry as { text?: unknown }).text;
|
|
1265
|
-
if (mode !== "term" && mode !== "intent" && mode !== "hyde") {
|
|
1266
|
-
return errorResponse(
|
|
1267
|
-
"VALIDATION",
|
|
1268
|
-
`queryModes[${index}].mode must be one of: term, intent, hyde`
|
|
1269
|
-
);
|
|
1270
|
-
}
|
|
1271
|
-
if (typeof text !== "string" || !text.trim()) {
|
|
1272
|
-
return errorResponse(
|
|
1273
|
-
"VALIDATION",
|
|
1274
|
-
`queryModes[${index}].text must be a non-empty string`
|
|
1275
|
-
);
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
if (mode === "hyde") {
|
|
1279
|
-
hydeCount += 1;
|
|
1280
|
-
if (hydeCount > 1) {
|
|
1281
|
-
return errorResponse(
|
|
1282
|
-
"VALIDATION",
|
|
1283
|
-
"Only one hyde mode is allowed in queryModes"
|
|
1284
|
-
);
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
queryModes.push({ mode, text: text.trim() });
|
|
1289
|
-
}
|
|
1329
|
+
const { queryModes, error: queryModesError } = parseQueryModesInput(
|
|
1330
|
+
body.queryModes
|
|
1331
|
+
);
|
|
1332
|
+
if (queryModesError) {
|
|
1333
|
+
return queryModesError;
|
|
1290
1334
|
}
|
|
1291
1335
|
|
|
1292
1336
|
// Parse tag filters
|
|
@@ -1318,6 +1362,9 @@ export async function handleQuery(
|
|
|
1318
1362
|
const categories = body.category
|
|
1319
1363
|
? parseCommaSeparatedValues(body.category)
|
|
1320
1364
|
: undefined;
|
|
1365
|
+
const exclude = body.exclude
|
|
1366
|
+
? parseCommaSeparatedValues(body.exclude)
|
|
1367
|
+
: undefined;
|
|
1321
1368
|
const author = body.author?.trim() || undefined;
|
|
1322
1369
|
|
|
1323
1370
|
const result = await searchHybrid(
|
|
@@ -1340,6 +1387,7 @@ export async function handleQuery(
|
|
|
1340
1387
|
body.candidateLimit !== undefined
|
|
1341
1388
|
? Math.min(body.candidateLimit, 100)
|
|
1342
1389
|
: undefined,
|
|
1390
|
+
exclude,
|
|
1343
1391
|
queryModes,
|
|
1344
1392
|
noExpand: body.noExpand,
|
|
1345
1393
|
noRerank: body.noRerank,
|
|
@@ -1406,6 +1454,12 @@ export async function handleAsk(
|
|
|
1406
1454
|
if (body.intent !== undefined && typeof body.intent !== "string") {
|
|
1407
1455
|
return errorResponse("VALIDATION", "intent must be a string");
|
|
1408
1456
|
}
|
|
1457
|
+
if (body.exclude !== undefined && typeof body.exclude !== "string") {
|
|
1458
|
+
return errorResponse(
|
|
1459
|
+
"VALIDATION",
|
|
1460
|
+
"exclude must be a comma-separated string"
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1409
1463
|
if (
|
|
1410
1464
|
body.candidateLimit !== undefined &&
|
|
1411
1465
|
(typeof body.candidateLimit !== "number" || body.candidateLimit < 1)
|
|
@@ -1425,6 +1479,13 @@ export async function handleAsk(
|
|
|
1425
1479
|
return errorResponse("VALIDATION", "author must be a string");
|
|
1426
1480
|
}
|
|
1427
1481
|
|
|
1482
|
+
const { queryModes, error: queryModesError } = parseQueryModesInput(
|
|
1483
|
+
body.queryModes
|
|
1484
|
+
);
|
|
1485
|
+
if (queryModesError) {
|
|
1486
|
+
return queryModesError;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1428
1489
|
if (body.tagsAll) {
|
|
1429
1490
|
try {
|
|
1430
1491
|
tagsAll = parseAndValidateTagFilter(body.tagsAll);
|
|
@@ -1450,6 +1511,9 @@ export async function handleAsk(
|
|
|
1450
1511
|
const categories = body.category
|
|
1451
1512
|
? parseCommaSeparatedValues(body.category)
|
|
1452
1513
|
: undefined;
|
|
1514
|
+
const exclude = body.exclude
|
|
1515
|
+
? parseCommaSeparatedValues(body.exclude)
|
|
1516
|
+
: undefined;
|
|
1453
1517
|
const author = body.author?.trim() || undefined;
|
|
1454
1518
|
|
|
1455
1519
|
const limit = Math.min(body.limit ?? 5, 20);
|
|
@@ -1476,6 +1540,8 @@ export async function handleAsk(
|
|
|
1476
1540
|
body.candidateLimit !== undefined
|
|
1477
1541
|
? Math.min(body.candidateLimit, 100)
|
|
1478
1542
|
: undefined,
|
|
1543
|
+
exclude,
|
|
1544
|
+
queryModes,
|
|
1479
1545
|
tagsAll,
|
|
1480
1546
|
tagsAny,
|
|
1481
1547
|
since: body.since,
|
|
@@ -1528,6 +1594,8 @@ export async function handleAsk(
|
|
|
1528
1594
|
vectorsUsed: searchResult.value.meta.vectorsUsed ?? false,
|
|
1529
1595
|
intent: searchResult.value.meta.intent,
|
|
1530
1596
|
candidateLimit: searchResult.value.meta.candidateLimit,
|
|
1597
|
+
exclude: searchResult.value.meta.exclude,
|
|
1598
|
+
queryModes: searchResult.value.meta.queryModes,
|
|
1531
1599
|
answerGenerated,
|
|
1532
1600
|
totalResults: results.length,
|
|
1533
1601
|
answerContext,
|