@wirecat/cli-core 0.386.0 → 0.388.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/CHANGELOG.md +23 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/meetings/mcp/index.d.ts +15 -15
- package/dist/search/question-plan.d.ts +5 -0
- package/dist/search/question-plan.js +11 -0
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/meeting-combined.d.ts +95 -0
- package/dist/services/meeting-combined.js +163 -0
- package/dist/services/meeting-hit-search.d.ts +26 -0
- package/dist/services/meeting-hit-search.js +5 -1
- package/dist/services/meeting-semantic.d.ts +1 -0
- package/dist/services/meeting-semantic.js +1 -1
- package/dist/store/sqlite/emails.d.ts +1 -1
- package/dist/store/sqlite/meeting-hit-search.js +2 -38
- package/dist/store/sqlite/meeting-selection.d.ts +8 -0
- package/dist/store/sqlite/meeting-selection.js +42 -0
- package/dist/store/sqlite/meeting-vectors.d.ts +17 -0
- package/dist/store/sqlite/meeting-vectors.js +66 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.388.0 — 22.10.2026
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`searchMeetingsCombined`: meeting search by words and meaning in one ranking.** Word hits and passage vectors of
|
|
15
|
+
one model are merged by reciprocal rank fusion over one selection of meetings: the date, meeting, series, event,
|
|
16
|
+
participant, person, project and tag filters apply to both. A word hit inside a passage the meaning search found
|
|
17
|
+
joins it (`by: ["words", "meaning"]`) and is cited by its own line. A question («что решили по цене?», "what did we
|
|
18
|
+
decide about pricing?") searches its content words; other text is the meeting query language, and text it refuses
|
|
19
|
+
is asked by meaning alone (`words.refused`). On a tie a line with the words ranks above a passage that only
|
|
20
|
+
resembles the query. The ranking covers
|
|
21
|
+
every vector of the selection up to `maxChunks` (default 10000), read in batches with text loaded only for the
|
|
22
|
+
passages kept; pages continue one ranking (`nextCursor`), bound to the query, filters and model.
|
|
23
|
+
- **`meetingVectors.ranked`** — the nearest passages over the whole selection, with meeting title and start.
|
|
24
|
+
`questionTerms` (`search/question-plan`) — a question's content words, or null.
|
|
25
|
+
|
|
26
|
+
## 0.387.0 — 22.10.2026
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- `dateWindow` and `DateWindow` from `./cli`: `--since-time` and `--until-time` read together, with the
|
|
31
|
+
`meta.timezone` and `meta.window` an answer reports them by (WC-TIME-3, WC-TIME-5).
|
|
32
|
+
|
|
10
33
|
## 0.386.0 — 22.10.2026
|
|
11
34
|
|
|
12
35
|
### Added
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export { guardedApp, guardedPress, guardedStart, type Pressed } from "../sends/b
|
|
|
27
27
|
export { guardedClose, guardedCreatePoll, guardedVote } from "../sends/polls.js";
|
|
28
28
|
export type { InboxReader } from "../services/inbox.js";
|
|
29
29
|
export { INBOX_CHATS, newIn, REVIEW_CHATS, reviewIn, unreadIn } from "../services/inbox.js";
|
|
30
|
-
export { momentOf } from "../services/moment.js";
|
|
30
|
+
export { type DateWindow, dateWindow, momentOf } from "../services/moment.js";
|
|
31
31
|
export { maskedAccount } from "../services/people.js";
|
|
32
32
|
export { unanswered } from "../services/questions.js";
|
|
33
33
|
export { openRequestTask } from "../services/task-rules.js";
|
package/dist/cli/index.js
CHANGED
|
@@ -22,7 +22,7 @@ export { currentRun, inRun, useAsProcessRun } from "../runs/scope.js";
|
|
|
22
22
|
export { guardedApp, guardedPress, guardedStart } from "../sends/buttons.js";
|
|
23
23
|
export { guardedClose, guardedCreatePoll, guardedVote } from "../sends/polls.js";
|
|
24
24
|
export { INBOX_CHATS, newIn, REVIEW_CHATS, reviewIn, unreadIn } from "../services/inbox.js";
|
|
25
|
-
export { momentOf } from "../services/moment.js";
|
|
25
|
+
export { dateWindow, momentOf } from "../services/moment.js";
|
|
26
26
|
export { maskedAccount } from "../services/people.js";
|
|
27
27
|
export { unanswered } from "../services/questions.js";
|
|
28
28
|
export { openRequestTask } from "../services/task-rules.js";
|
|
@@ -38,6 +38,7 @@ export declare const meetingTools: ({
|
|
|
38
38
|
max_read_bytes?: undefined;
|
|
39
39
|
cursor?: undefined;
|
|
40
40
|
} | {
|
|
41
|
+
limit?: undefined;
|
|
41
42
|
event_id?: undefined;
|
|
42
43
|
series_id?: undefined;
|
|
43
44
|
page?: undefined;
|
|
@@ -53,11 +54,11 @@ export declare const meetingTools: ({
|
|
|
53
54
|
format?: undefined;
|
|
54
55
|
transcript_id?: undefined;
|
|
55
56
|
query?: undefined;
|
|
56
|
-
limit?: undefined;
|
|
57
57
|
scan_limit?: undefined;
|
|
58
58
|
max_read_bytes?: undefined;
|
|
59
59
|
cursor?: undefined;
|
|
60
60
|
} | {
|
|
61
|
+
limit?: undefined;
|
|
61
62
|
event_id?: undefined;
|
|
62
63
|
series_id?: undefined;
|
|
63
64
|
page?: undefined;
|
|
@@ -75,11 +76,11 @@ export declare const meetingTools: ({
|
|
|
75
76
|
format?: undefined;
|
|
76
77
|
transcript_id?: undefined;
|
|
77
78
|
query?: undefined;
|
|
78
|
-
limit?: undefined;
|
|
79
79
|
scan_limit?: undefined;
|
|
80
80
|
max_read_bytes?: undefined;
|
|
81
81
|
cursor?: undefined;
|
|
82
82
|
} | {
|
|
83
|
+
limit?: undefined;
|
|
83
84
|
event_id?: undefined;
|
|
84
85
|
series_id?: undefined;
|
|
85
86
|
page?: undefined;
|
|
@@ -101,7 +102,6 @@ export declare const meetingTools: ({
|
|
|
101
102
|
minimum: number;
|
|
102
103
|
};
|
|
103
104
|
query?: undefined;
|
|
104
|
-
limit?: undefined;
|
|
105
105
|
scan_limit?: undefined;
|
|
106
106
|
max_read_bytes?: undefined;
|
|
107
107
|
cursor?: undefined;
|
|
@@ -188,6 +188,7 @@ export declare const meetingTools: ({
|
|
|
188
188
|
max_read_bytes?: undefined;
|
|
189
189
|
cursor?: undefined;
|
|
190
190
|
} | {
|
|
191
|
+
limit?: undefined;
|
|
191
192
|
event_id?: undefined;
|
|
192
193
|
series_id?: undefined;
|
|
193
194
|
page?: undefined;
|
|
@@ -203,11 +204,11 @@ export declare const meetingTools: ({
|
|
|
203
204
|
format?: undefined;
|
|
204
205
|
transcript_id?: undefined;
|
|
205
206
|
query?: undefined;
|
|
206
|
-
limit?: undefined;
|
|
207
207
|
scan_limit?: undefined;
|
|
208
208
|
max_read_bytes?: undefined;
|
|
209
209
|
cursor?: undefined;
|
|
210
210
|
} | {
|
|
211
|
+
limit?: undefined;
|
|
211
212
|
event_id?: undefined;
|
|
212
213
|
series_id?: undefined;
|
|
213
214
|
page?: undefined;
|
|
@@ -225,11 +226,11 @@ export declare const meetingTools: ({
|
|
|
225
226
|
format?: undefined;
|
|
226
227
|
transcript_id?: undefined;
|
|
227
228
|
query?: undefined;
|
|
228
|
-
limit?: undefined;
|
|
229
229
|
scan_limit?: undefined;
|
|
230
230
|
max_read_bytes?: undefined;
|
|
231
231
|
cursor?: undefined;
|
|
232
232
|
} | {
|
|
233
|
+
limit?: undefined;
|
|
233
234
|
event_id?: undefined;
|
|
234
235
|
series_id?: undefined;
|
|
235
236
|
page?: undefined;
|
|
@@ -251,7 +252,6 @@ export declare const meetingTools: ({
|
|
|
251
252
|
minimum: number;
|
|
252
253
|
};
|
|
253
254
|
query?: undefined;
|
|
254
|
-
limit?: undefined;
|
|
255
255
|
scan_limit?: undefined;
|
|
256
256
|
max_read_bytes?: undefined;
|
|
257
257
|
cursor?: undefined;
|
|
@@ -338,6 +338,7 @@ export declare const meetingTools: ({
|
|
|
338
338
|
max_read_bytes?: undefined;
|
|
339
339
|
cursor?: undefined;
|
|
340
340
|
} | {
|
|
341
|
+
limit?: undefined;
|
|
341
342
|
event_id?: undefined;
|
|
342
343
|
series_id?: undefined;
|
|
343
344
|
page?: undefined;
|
|
@@ -353,11 +354,11 @@ export declare const meetingTools: ({
|
|
|
353
354
|
format?: undefined;
|
|
354
355
|
transcript_id?: undefined;
|
|
355
356
|
query?: undefined;
|
|
356
|
-
limit?: undefined;
|
|
357
357
|
scan_limit?: undefined;
|
|
358
358
|
max_read_bytes?: undefined;
|
|
359
359
|
cursor?: undefined;
|
|
360
360
|
} | {
|
|
361
|
+
limit?: undefined;
|
|
361
362
|
event_id?: undefined;
|
|
362
363
|
series_id?: undefined;
|
|
363
364
|
page?: undefined;
|
|
@@ -375,11 +376,11 @@ export declare const meetingTools: ({
|
|
|
375
376
|
format?: undefined;
|
|
376
377
|
transcript_id?: undefined;
|
|
377
378
|
query?: undefined;
|
|
378
|
-
limit?: undefined;
|
|
379
379
|
scan_limit?: undefined;
|
|
380
380
|
max_read_bytes?: undefined;
|
|
381
381
|
cursor?: undefined;
|
|
382
382
|
} | {
|
|
383
|
+
limit?: undefined;
|
|
383
384
|
event_id?: undefined;
|
|
384
385
|
series_id?: undefined;
|
|
385
386
|
page?: undefined;
|
|
@@ -401,7 +402,6 @@ export declare const meetingTools: ({
|
|
|
401
402
|
minimum: number;
|
|
402
403
|
};
|
|
403
404
|
query?: undefined;
|
|
404
|
-
limit?: undefined;
|
|
405
405
|
scan_limit?: undefined;
|
|
406
406
|
max_read_bytes?: undefined;
|
|
407
407
|
cursor?: undefined;
|
|
@@ -488,6 +488,7 @@ export declare const meetingTools: ({
|
|
|
488
488
|
max_read_bytes?: undefined;
|
|
489
489
|
cursor?: undefined;
|
|
490
490
|
} | {
|
|
491
|
+
limit?: undefined;
|
|
491
492
|
event_id?: undefined;
|
|
492
493
|
series_id?: undefined;
|
|
493
494
|
page?: undefined;
|
|
@@ -503,11 +504,11 @@ export declare const meetingTools: ({
|
|
|
503
504
|
format?: undefined;
|
|
504
505
|
transcript_id?: undefined;
|
|
505
506
|
query?: undefined;
|
|
506
|
-
limit?: undefined;
|
|
507
507
|
scan_limit?: undefined;
|
|
508
508
|
max_read_bytes?: undefined;
|
|
509
509
|
cursor?: undefined;
|
|
510
510
|
} | {
|
|
511
|
+
limit?: undefined;
|
|
511
512
|
event_id?: undefined;
|
|
512
513
|
series_id?: undefined;
|
|
513
514
|
page?: undefined;
|
|
@@ -525,11 +526,11 @@ export declare const meetingTools: ({
|
|
|
525
526
|
format?: undefined;
|
|
526
527
|
transcript_id?: undefined;
|
|
527
528
|
query?: undefined;
|
|
528
|
-
limit?: undefined;
|
|
529
529
|
scan_limit?: undefined;
|
|
530
530
|
max_read_bytes?: undefined;
|
|
531
531
|
cursor?: undefined;
|
|
532
532
|
} | {
|
|
533
|
+
limit?: undefined;
|
|
533
534
|
event_id?: undefined;
|
|
534
535
|
series_id?: undefined;
|
|
535
536
|
page?: undefined;
|
|
@@ -551,7 +552,6 @@ export declare const meetingTools: ({
|
|
|
551
552
|
minimum: number;
|
|
552
553
|
};
|
|
553
554
|
query?: undefined;
|
|
554
|
-
limit?: undefined;
|
|
555
555
|
scan_limit?: undefined;
|
|
556
556
|
max_read_bytes?: undefined;
|
|
557
557
|
cursor?: undefined;
|
|
@@ -638,6 +638,7 @@ export declare const meetingTools: ({
|
|
|
638
638
|
max_read_bytes?: undefined;
|
|
639
639
|
cursor?: undefined;
|
|
640
640
|
} | {
|
|
641
|
+
limit?: undefined;
|
|
641
642
|
event_id?: undefined;
|
|
642
643
|
series_id?: undefined;
|
|
643
644
|
page?: undefined;
|
|
@@ -653,11 +654,11 @@ export declare const meetingTools: ({
|
|
|
653
654
|
format?: undefined;
|
|
654
655
|
transcript_id?: undefined;
|
|
655
656
|
query?: undefined;
|
|
656
|
-
limit?: undefined;
|
|
657
657
|
scan_limit?: undefined;
|
|
658
658
|
max_read_bytes?: undefined;
|
|
659
659
|
cursor?: undefined;
|
|
660
660
|
} | {
|
|
661
|
+
limit?: undefined;
|
|
661
662
|
event_id?: undefined;
|
|
662
663
|
series_id?: undefined;
|
|
663
664
|
page?: undefined;
|
|
@@ -675,11 +676,11 @@ export declare const meetingTools: ({
|
|
|
675
676
|
format?: undefined;
|
|
676
677
|
transcript_id?: undefined;
|
|
677
678
|
query?: undefined;
|
|
678
|
-
limit?: undefined;
|
|
679
679
|
scan_limit?: undefined;
|
|
680
680
|
max_read_bytes?: undefined;
|
|
681
681
|
cursor?: undefined;
|
|
682
682
|
} | {
|
|
683
|
+
limit?: undefined;
|
|
683
684
|
event_id?: undefined;
|
|
684
685
|
series_id?: undefined;
|
|
685
686
|
page?: undefined;
|
|
@@ -701,7 +702,6 @@ export declare const meetingTools: ({
|
|
|
701
702
|
minimum: number;
|
|
702
703
|
};
|
|
703
704
|
query?: undefined;
|
|
704
|
-
limit?: undefined;
|
|
705
705
|
scan_limit?: undefined;
|
|
706
706
|
max_read_bytes?: undefined;
|
|
707
707
|
cursor?: undefined;
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export declare const words: (text: string) => RegExpMatchArray | [];
|
|
2
|
+
/**
|
|
3
|
+
* A question's content words — «что решили по цене?» → решили, цене — or null when the text is not a question: it
|
|
4
|
+
* ends with «?» or starts with a question word.
|
|
5
|
+
*/
|
|
6
|
+
export declare const questionTerms: (text: string) => string[] | null;
|
|
2
7
|
export declare function planQuestion(text: string, strict?: boolean): {
|
|
3
8
|
changed: boolean;
|
|
4
9
|
natural: boolean;
|
|
@@ -4,6 +4,17 @@ import { SEARCH_QUERY } from "./lucene/types.js";
|
|
|
4
4
|
export const words = (text) => normalize(text).match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
5
5
|
const scaffolding = new Set(words("what which how many much who where when is are was were do does did can could should we the a an for of to under from in on at has have сколько какой какая какие какое кто кому кем где когда как что во по на в к с у через ли должен должна нужно идёт идет можно может могут"));
|
|
6
6
|
const questionWord = /\b(?:what|which|how|who|where|when|can|could|should)\b|(?:^|\s)(?:сколько|какой|какая|какие|какое|кто|кому|кем|где|когда|как|что|можно|может|могут|ли)(?:\s|$)/iu;
|
|
7
|
+
const QUESTION_STARTS = new Set(words("what which how who whom whose where when why can could should would did do does is are was were will " +
|
|
8
|
+
"что кто кого кому кем где когда как какой какая какое какие какую каким какого каких почему зачем откуда " +
|
|
9
|
+
"куда сколько ли можно разве чей чья чьё чьи"));
|
|
10
|
+
const QUESTION_FILLER = new Set(words("about any did does why whom whose would will это мы вы я они он она о об про ли"));
|
|
11
|
+
/**
|
|
12
|
+
* A question's content words — «что решили по цене?» → решили, цене — or null when the text is not a question: it
|
|
13
|
+
* ends with «?» or starts with a question word.
|
|
14
|
+
*/
|
|
15
|
+
export const questionTerms = (text) => /\?\s*$/u.test(text) || QUESTION_STARTS.has(words(text)[0] ?? "")
|
|
16
|
+
? words(text).filter((word) => !scaffolding.has(word) && !QUESTION_STARTS.has(word) && !QUESTION_FILLER.has(word))
|
|
17
|
+
: null;
|
|
7
18
|
export function planQuestion(text, strict = false) {
|
|
8
19
|
if (strict)
|
|
9
20
|
return {
|
package/dist/services/index.d.ts
CHANGED
|
@@ -139,6 +139,7 @@ export { countersService } from "./counters.js";
|
|
|
139
139
|
export { type DocumentTarget, openPictureEmbedderFor, type PictureOptions, removePicture, savePicture, } from "./document-pictures.js";
|
|
140
140
|
export type { ModelChoice } from "./embeddings.js";
|
|
141
141
|
export { createMeetingBatchReader } from "./meeting-batch.js";
|
|
142
|
+
export { type MeetingCombinedHit, type MeetingCombinedRequest, searchMeetingsCombined, } from "./meeting-combined.js";
|
|
142
143
|
export type { MeetingCommunicationCoverage, MeetingCommunicationExcerpt, MeetingCommunications, MeetingCommunicationsPort, MeetingCommunicationsRequest, } from "./meeting-communications-types.js";
|
|
143
144
|
export { createMeetingContextService, type MeetingContextService, type MeetingContextServiceStore, } from "./meeting-context.js";
|
|
144
145
|
export { type MeetingEvidenceCue, type MeetingEvidenceOptions, type MeetingEvidencePacket, readMeetingEvidence, } from "./meeting-evidence.js";
|
package/dist/services/index.js
CHANGED
|
@@ -105,6 +105,7 @@ export { GAP_BOUNDS, gapsService } from "./archive-gaps.js";
|
|
|
105
105
|
export { countersService } from "./counters.js";
|
|
106
106
|
export { openPictureEmbedderFor, removePicture, savePicture, } from "./document-pictures.js";
|
|
107
107
|
export { createMeetingBatchReader } from "./meeting-batch.js";
|
|
108
|
+
export { searchMeetingsCombined, } from "./meeting-combined.js";
|
|
108
109
|
export { createMeetingContextService, } from "./meeting-context.js";
|
|
109
110
|
export { readMeetingEvidence, } from "./meeting-evidence.js";
|
|
110
111
|
export { meetingHitContextLimits, readMeetingHitContext, } from "./meeting-hit-context.js";
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type MeetingHitSearchRequest, type SearchHit } from "../meetings/index.js";
|
|
2
|
+
import type { MessageStore } from "../store/store.js";
|
|
3
|
+
import type { MeetingEmbedder } from "./meeting-model.js";
|
|
4
|
+
export interface MeetingCombinedRequest extends MeetingHitSearchRequest {
|
|
5
|
+
/** Most stored passage vectors to compare, 1 through 100000 (default 10000). */
|
|
6
|
+
maxChunks?: number;
|
|
7
|
+
maxRows?: number;
|
|
8
|
+
maxTextBytes?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface MeetingCombinedHit {
|
|
11
|
+
/** A transcript line to cite: the best word hit in the passage, else the passage's first line. */
|
|
12
|
+
ref: string;
|
|
13
|
+
meetingId: number;
|
|
14
|
+
scope: SearchHit["scope"];
|
|
15
|
+
text: string;
|
|
16
|
+
startMs: number | null;
|
|
17
|
+
source: {
|
|
18
|
+
title: string | null;
|
|
19
|
+
startedAt: number | null;
|
|
20
|
+
url: string | null;
|
|
21
|
+
};
|
|
22
|
+
/** Which searches found it: `words`, `meaning` or both. */
|
|
23
|
+
by: ("words" | "meaning")[];
|
|
24
|
+
/** Similarity in meaning, when the meaning search found it. */
|
|
25
|
+
score: number | null;
|
|
26
|
+
/** The passage the meaning search matched, as its first and last line. */
|
|
27
|
+
passage?: {
|
|
28
|
+
first: string;
|
|
29
|
+
last: string;
|
|
30
|
+
};
|
|
31
|
+
match?: SearchHit["match"];
|
|
32
|
+
corrections?: SearchHit["corrections"];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Words and meaning, ranked together by reciprocal rank fusion over one selection of meetings. A question
|
|
36
|
+
* («что решили по цене?») searches its content words; other text is the meeting query language. A word hit inside
|
|
37
|
+
* a passage the meaning search found joins it, so one discussion found both ways ranks first.
|
|
38
|
+
*/
|
|
39
|
+
export declare const searchMeetingsCombined: (store: Pick<MessageStore, "meetings" | "meetingVectors"> & {
|
|
40
|
+
knowledge?: MessageStore["knowledge"];
|
|
41
|
+
}, embedder: MeetingEmbedder, query: string, request: MeetingCombinedRequest) => Promise<{
|
|
42
|
+
items: {
|
|
43
|
+
/** A transcript line to cite: the best word hit in the passage, else the passage's first line. */
|
|
44
|
+
ref: string;
|
|
45
|
+
meetingId: number;
|
|
46
|
+
scope: SearchHit["scope"];
|
|
47
|
+
text: string;
|
|
48
|
+
startMs: number | null;
|
|
49
|
+
source: {
|
|
50
|
+
title: string | null;
|
|
51
|
+
startedAt: number | null;
|
|
52
|
+
url: string | null;
|
|
53
|
+
};
|
|
54
|
+
/** Which searches found it: `words`, `meaning` or both. */
|
|
55
|
+
by: ("words" | "meaning")[];
|
|
56
|
+
/** Similarity in meaning, when the meaning search found it. */
|
|
57
|
+
score: number | null;
|
|
58
|
+
/** The passage the meaning search matched, as its first and last line. */
|
|
59
|
+
passage?: {
|
|
60
|
+
first: string;
|
|
61
|
+
last: string;
|
|
62
|
+
};
|
|
63
|
+
match?: SearchHit["match"];
|
|
64
|
+
corrections?: SearchHit["corrections"];
|
|
65
|
+
}[];
|
|
66
|
+
hasMore: boolean;
|
|
67
|
+
nextCursor: string | null;
|
|
68
|
+
selection: {
|
|
69
|
+
kind: import("../store/index.js").VocabularyKind;
|
|
70
|
+
id: number;
|
|
71
|
+
name: string;
|
|
72
|
+
by: "explicit-link";
|
|
73
|
+
}[];
|
|
74
|
+
words: {
|
|
75
|
+
query: string;
|
|
76
|
+
from?: "question" | undefined;
|
|
77
|
+
refused?: string | undefined;
|
|
78
|
+
};
|
|
79
|
+
model: {
|
|
80
|
+
key: string;
|
|
81
|
+
model: string;
|
|
82
|
+
kind: "local" | "remote";
|
|
83
|
+
dims: number;
|
|
84
|
+
};
|
|
85
|
+
coverage: {
|
|
86
|
+
archiveCompleteness: "unknown";
|
|
87
|
+
indexCompleteness: "unknown";
|
|
88
|
+
indexing: "pending" | "ready";
|
|
89
|
+
/** Every vector of the selection was compared; false when `maxChunks` stopped the scan. */
|
|
90
|
+
meaningScanComplete: boolean;
|
|
91
|
+
scanned: number;
|
|
92
|
+
/** Each side ranks this many at most; pages past them hold nothing more. */
|
|
93
|
+
depth: number;
|
|
94
|
+
};
|
|
95
|
+
}>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { CliError } from "@wirecat/cli-base";
|
|
3
|
+
import { meaningFloor } from "../embeddings/models.js";
|
|
4
|
+
import { formatMeetingReference, parseMeetingReference, } from "../meetings/index.js";
|
|
5
|
+
import { questionTerms } from "../search/question-plan.js";
|
|
6
|
+
import { refreshMeetingIndex, resolveMeetingSelection } from "./meeting-hit-search.js";
|
|
7
|
+
import { validateVector } from "./meeting-semantic.js";
|
|
8
|
+
import { RRF_K } from "./notes-search.js";
|
|
9
|
+
/** How many hits each side ranks before they are merged; a page past it has nothing more to show. */
|
|
10
|
+
const DEPTH = 200;
|
|
11
|
+
const cursorOf = (value, key) => {
|
|
12
|
+
if (value === undefined)
|
|
13
|
+
return 0;
|
|
14
|
+
let cursor;
|
|
15
|
+
try {
|
|
16
|
+
cursor = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new CliError("validation_error", "the cursor is not from a combined meeting search; search again");
|
|
20
|
+
}
|
|
21
|
+
if (cursor?.version !== 1 || cursor.key !== key || !Number.isSafeInteger(cursor.offset) || cursor.offset < 0)
|
|
22
|
+
throw new CliError("validation_error", "the cursor belongs to another query, filter or model; search again");
|
|
23
|
+
return cursor.offset;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Words and meaning, ranked together by reciprocal rank fusion over one selection of meetings. A question
|
|
27
|
+
* («что решили по цене?») searches its content words; other text is the meeting query language. A word hit inside
|
|
28
|
+
* a passage the meaning search found joins it, so one discussion found both ways ranks first.
|
|
29
|
+
*/
|
|
30
|
+
export const searchMeetingsCombined = async (store, embedder, query, request) => {
|
|
31
|
+
const { maxChunks, maxRows, maxTextBytes, ...words } = request;
|
|
32
|
+
const { input, selection } = await resolveMeetingSelection(store, words);
|
|
33
|
+
const { after, limit = 20, maxReadBytes, ...filters } = input;
|
|
34
|
+
const terms = questionTerms(query);
|
|
35
|
+
const wordQuery = terms === null ? query : terms.join(" ");
|
|
36
|
+
const key = createHash("sha256")
|
|
37
|
+
.update(JSON.stringify([query, embedder.key, Object.entries(filters).filter(([name]) => name !== "signal")]))
|
|
38
|
+
.digest("base64url");
|
|
39
|
+
const offset = cursorOf(after, key);
|
|
40
|
+
let byWords = [];
|
|
41
|
+
let indexing = "ready";
|
|
42
|
+
let refused;
|
|
43
|
+
if (wordQuery.trim()) {
|
|
44
|
+
await refreshMeetingIndex(store.meetings, input.accountId, input.signal);
|
|
45
|
+
try {
|
|
46
|
+
const page = await store.meetings.searchHits(wordQuery, { ...filters, limit: DEPTH, maxReadBytes });
|
|
47
|
+
byWords = page.items;
|
|
48
|
+
indexing = page.coverage.indexing;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
// Text the query language refuses is still a question to ask by meaning.
|
|
52
|
+
if (!(error instanceof CliError) || error.code !== "validation_error")
|
|
53
|
+
throw error;
|
|
54
|
+
refused = error.message;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
input.signal?.throwIfAborted();
|
|
58
|
+
const vectors = await embedder.embed([query], "query");
|
|
59
|
+
if (vectors.length !== 1)
|
|
60
|
+
throw new CliError("invalid_response", "Meeting query model returned another vector count");
|
|
61
|
+
const vector = vectors[0];
|
|
62
|
+
const ranked = await store.meetingVectors.ranked(embedder.key, validateVector(vector, embedder.dims), {
|
|
63
|
+
...filters,
|
|
64
|
+
limit: DEPTH,
|
|
65
|
+
...(maxChunks === undefined ? {} : { maxChunks }),
|
|
66
|
+
...(maxRows === undefined ? {} : { maxRows }),
|
|
67
|
+
...(maxTextBytes === undefined ? {} : { maxTextBytes }),
|
|
68
|
+
});
|
|
69
|
+
const floor = meaningFloor(embedder.key);
|
|
70
|
+
const byMeaning = ranked.items.filter(({ score }) => score > floor);
|
|
71
|
+
const merged = new Map();
|
|
72
|
+
byMeaning.forEach((hit, rank) => {
|
|
73
|
+
const reference = { type: "meeting", accountId: input.accountId, meetingId: hit.meetingId };
|
|
74
|
+
const first = formatMeetingReference({
|
|
75
|
+
...reference,
|
|
76
|
+
transcriptId: hit.transcriptId,
|
|
77
|
+
cuePosition: hit.firstPosition,
|
|
78
|
+
});
|
|
79
|
+
merged.set(`${hit.transcriptId}:${hit.firstPosition}`, {
|
|
80
|
+
ref: first,
|
|
81
|
+
meetingId: hit.meetingId,
|
|
82
|
+
scope: "transcript",
|
|
83
|
+
text: hit.text,
|
|
84
|
+
startMs: null,
|
|
85
|
+
source: { title: hit.title, startedAt: hit.startedAt, url: null },
|
|
86
|
+
by: ["meaning"],
|
|
87
|
+
score: hit.score,
|
|
88
|
+
passage: {
|
|
89
|
+
first,
|
|
90
|
+
last: formatMeetingReference({ ...reference, transcriptId: hit.transcriptId, cuePosition: hit.lastPosition }),
|
|
91
|
+
},
|
|
92
|
+
fused: 1 / (RRF_K + rank + 1),
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
byWords.forEach((hit, rank) => {
|
|
96
|
+
const share = 1 / (RRF_K + rank + 1);
|
|
97
|
+
const cue = hit.scope === "transcript" ? parseMeetingReference(hit.ref) : undefined;
|
|
98
|
+
const within = cue?.cuePosition === undefined
|
|
99
|
+
? undefined
|
|
100
|
+
: byMeaning.find((passage) => passage.transcriptId === cue.transcriptId &&
|
|
101
|
+
passage.firstPosition <= cue.cuePosition &&
|
|
102
|
+
cue.cuePosition <= passage.lastPosition);
|
|
103
|
+
const held = within && merged.get(`${within.transcriptId}:${within.firstPosition}`);
|
|
104
|
+
const found = {
|
|
105
|
+
ref: hit.ref,
|
|
106
|
+
text: hit.text,
|
|
107
|
+
startMs: hit.startMs,
|
|
108
|
+
...(hit.match === undefined ? {} : { match: hit.match }),
|
|
109
|
+
...(hit.corrections === undefined ? {} : { corrections: hit.corrections }),
|
|
110
|
+
};
|
|
111
|
+
if (held && !held.by.includes("words")) {
|
|
112
|
+
Object.assign(held, found, { by: ["words", "meaning"], fused: held.fused + share });
|
|
113
|
+
if (hit.source?.url)
|
|
114
|
+
held.source.url = hit.source.url;
|
|
115
|
+
}
|
|
116
|
+
else if (held)
|
|
117
|
+
held.fused += share;
|
|
118
|
+
else
|
|
119
|
+
merged.set(`w:${hit.ref}:${hit.scope}:${hit.id}`, {
|
|
120
|
+
...found,
|
|
121
|
+
meetingId: hit.meetingId,
|
|
122
|
+
scope: hit.scope,
|
|
123
|
+
source: {
|
|
124
|
+
title: hit.source?.title ?? null,
|
|
125
|
+
startedAt: hit.source?.startedAt ?? null,
|
|
126
|
+
url: hit.source?.url ?? null,
|
|
127
|
+
},
|
|
128
|
+
by: ["words"],
|
|
129
|
+
score: null,
|
|
130
|
+
fused: share,
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
// On a tie a line that has the words outranks a passage that only resembles the query.
|
|
134
|
+
const ordered = [...merged.values()].sort((a, b) => b.fused - a.fused ||
|
|
135
|
+
Number(b.by.includes("words")) - Number(a.by.includes("words")) ||
|
|
136
|
+
(b.score ?? -2) - (a.score ?? -2));
|
|
137
|
+
const items = ordered.slice(offset, offset + limit).map(({ fused: _, ...hit }) => hit);
|
|
138
|
+
const hasMore = ordered.length > offset + limit;
|
|
139
|
+
return {
|
|
140
|
+
items,
|
|
141
|
+
hasMore,
|
|
142
|
+
nextCursor: hasMore
|
|
143
|
+
? Buffer.from(JSON.stringify({ version: 1, key, offset: offset + limit })).toString("base64url")
|
|
144
|
+
: null,
|
|
145
|
+
selection,
|
|
146
|
+
words: {
|
|
147
|
+
query: wordQuery,
|
|
148
|
+
...(terms === null ? {} : { from: "question" }),
|
|
149
|
+
...(refused === undefined ? {} : { refused }),
|
|
150
|
+
},
|
|
151
|
+
model: { key: embedder.key, model: embedder.model, kind: embedder.kind, dims: embedder.dims },
|
|
152
|
+
coverage: {
|
|
153
|
+
archiveCompleteness: "unknown",
|
|
154
|
+
indexCompleteness: "unknown",
|
|
155
|
+
indexing,
|
|
156
|
+
/** Every vector of the selection was compared; false when `maxChunks` stopped the scan. */
|
|
157
|
+
meaningScanComplete: ranked.complete,
|
|
158
|
+
scanned: ranked.scanned,
|
|
159
|
+
/** Each side ranks this many at most; pages past them hold nothing more. */
|
|
160
|
+
depth: DEPTH,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
};
|
|
@@ -5,6 +5,32 @@ export type { MeetingHitSearchRequest } from "../meetings/index.js";
|
|
|
5
5
|
/** Each batch commits on its own, so a search cut short by its deadline keeps the progress it made. */
|
|
6
6
|
export declare const refreshMeetingIndex: (meetings: Pick<MessageStore["meetings"], "indexSearch">, accountId: number, signal?: AbortSignal) => Promise<void>;
|
|
7
7
|
/** Name resolution changes selection only; it never assigns projects or labels from transcript words. */
|
|
8
|
+
export declare const resolveMeetingSelection: (store: {
|
|
9
|
+
knowledge?: MessageStore["knowledge"];
|
|
10
|
+
}, request: MeetingHitSearchRequest) => Promise<{
|
|
11
|
+
input: {
|
|
12
|
+
accountId: number;
|
|
13
|
+
meetingId?: number;
|
|
14
|
+
participantIdentityId?: number;
|
|
15
|
+
personId?: number;
|
|
16
|
+
projectId?: number;
|
|
17
|
+
tagId?: number;
|
|
18
|
+
eventId?: number;
|
|
19
|
+
meetingSeriesId?: number;
|
|
20
|
+
since?: number;
|
|
21
|
+
until?: number;
|
|
22
|
+
limit?: number;
|
|
23
|
+
after?: string;
|
|
24
|
+
maxReadBytes?: number;
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
};
|
|
27
|
+
selection: {
|
|
28
|
+
kind: VocabularyKind;
|
|
29
|
+
id: number;
|
|
30
|
+
name: string;
|
|
31
|
+
by: "explicit-link";
|
|
32
|
+
}[];
|
|
33
|
+
}>;
|
|
8
34
|
export declare const searchMeetingHits: (store: Pick<MessageStore, "meetings"> & {
|
|
9
35
|
knowledge?: MessageStore["knowledge"];
|
|
10
36
|
}, query: string, request: MeetingHitSearchRequest, options?: {
|
|
@@ -16,7 +16,7 @@ export const refreshMeetingIndex = async (meetings, accountId, signal) => {
|
|
|
16
16
|
}
|
|
17
17
|
};
|
|
18
18
|
/** Name resolution changes selection only; it never assigns projects or labels from transcript words. */
|
|
19
|
-
export const
|
|
19
|
+
export const resolveMeetingSelection = async (store, request) => {
|
|
20
20
|
const { project, tag, topic, ...input } = request;
|
|
21
21
|
input.signal?.throwIfAborted();
|
|
22
22
|
if ((project !== undefined && input.projectId !== undefined) ||
|
|
@@ -51,6 +51,10 @@ export const searchMeetingHits = async (store, query, request, options = {}) =>
|
|
|
51
51
|
input.tagId = id;
|
|
52
52
|
selection.push({ kind, id, name: entry.name, by: "explicit-link" });
|
|
53
53
|
}
|
|
54
|
+
return { input, selection };
|
|
55
|
+
};
|
|
56
|
+
export const searchMeetingHits = async (store, query, request, options = {}) => {
|
|
57
|
+
const { input, selection } = await resolveMeetingSelection(store, request);
|
|
54
58
|
if (options.refreshIndex !== false)
|
|
55
59
|
await refreshMeetingIndex(store.meetings, input.accountId, input.signal);
|
|
56
60
|
const result = await store.meetings.searchHits(query, input);
|
|
@@ -16,6 +16,7 @@ export interface MeetingEmbeddingApplyOptions {
|
|
|
16
16
|
maxVectorBytes?: number;
|
|
17
17
|
signal?: AbortSignal;
|
|
18
18
|
}
|
|
19
|
+
export declare const validateVector: (vector: Float32Array | undefined, dims: number) => Float32Array;
|
|
19
20
|
/** Embeds only this explicit query; never rebuilds chunks or generates stored vectors. */
|
|
20
21
|
export declare const readMeetingSemantic: (store: Pick<MeetingVectors, "nearest">, accountId: number, query: string, embedder: MeetingEmbedder, options?: MeetingSemanticOptions) => Promise<{
|
|
21
22
|
ranking: "scanned-candidates";
|
|
@@ -25,7 +25,7 @@ const textBudget = (options) => ({
|
|
|
25
25
|
maxRows: positive(options.maxRows ?? 10000, "transcript row budget", 100000),
|
|
26
26
|
maxTextBytes: positive(options.maxTextBytes ?? 4 * 1024 * 1024, "transcript text byte budget", 64 * 1024 * 1024),
|
|
27
27
|
});
|
|
28
|
-
const validateVector = (vector, dims) => {
|
|
28
|
+
export const validateVector = (vector, dims) => {
|
|
29
29
|
if (!(vector instanceof Float32Array) ||
|
|
30
30
|
vector.length !== dims ||
|
|
31
31
|
!vector.some((value) => value !== 0) ||
|
|
@@ -151,7 +151,7 @@ export interface EmailFilter extends MailFilter {
|
|
|
151
151
|
mailbox?: string;
|
|
152
152
|
/** On the received time, or the sent time when that is unknown: IMAP's SINCE counts by the received day. */
|
|
153
153
|
since?: number;
|
|
154
|
-
/** Exclusive. */
|
|
154
|
+
/** Exclusive. `momentOf` and `dateWindow` with `end` give the last millisecond of a day, an inclusive end: add 1. */
|
|
155
155
|
until?: number;
|
|
156
156
|
/** Only emails with an attachment whose text extraction failed with one of these errors. */
|
|
157
157
|
attachmentErrors?: string[];
|
|
@@ -5,6 +5,7 @@ import { matchMeetingText, meetingQuerySql, resolveMeetingQuery } from "./meetin
|
|
|
5
5
|
import { indexedText } from "./meeting-index-values.js";
|
|
6
6
|
import { checkMeetingCancelled } from "./meeting-operation.js";
|
|
7
7
|
import { positiveReadInteger } from "./meeting-reads.js";
|
|
8
|
+
import { meetingSelectionSql } from "./meeting-selection.js";
|
|
8
9
|
import { meetingStemState } from "./meeting-stem-state.js";
|
|
9
10
|
import { read } from "./transaction.js";
|
|
10
11
|
const fail = (message) => {
|
|
@@ -112,45 +113,8 @@ export function meetingHitSearchOver({ database }) {
|
|
|
112
113
|
indexing: pending || !state.stemmer || state.through < state.watermark ? "pending" : "ready",
|
|
113
114
|
indexCompleteness: "unknown",
|
|
114
115
|
};
|
|
115
|
-
const clauses = ["m.account_id=?", "m.deleted_at IS NULL"];
|
|
116
116
|
const { sql: matching, params: queryParams } = meetingQuerySql(tree, state);
|
|
117
|
-
const params =
|
|
118
|
-
if (input.meetingId !== undefined) {
|
|
119
|
-
clauses.push("m.id=?");
|
|
120
|
-
params.push(input.meetingId);
|
|
121
|
-
}
|
|
122
|
-
if (input.eventId !== undefined) {
|
|
123
|
-
clauses.push("m.event_id=?");
|
|
124
|
-
params.push(input.eventId);
|
|
125
|
-
}
|
|
126
|
-
if (input.meetingSeriesId !== undefined) {
|
|
127
|
-
clauses.push("m.meeting_series_id=?");
|
|
128
|
-
params.push(input.meetingSeriesId);
|
|
129
|
-
}
|
|
130
|
-
if (input.since !== undefined) {
|
|
131
|
-
clauses.push("m.started_at>=?");
|
|
132
|
-
params.push(input.since);
|
|
133
|
-
}
|
|
134
|
-
if (input.until !== undefined) {
|
|
135
|
-
clauses.push("m.started_at<=?");
|
|
136
|
-
params.push(input.until);
|
|
137
|
-
}
|
|
138
|
-
if (input.participantIdentityId !== undefined) {
|
|
139
|
-
clauses.push("EXISTS (SELECT 1 FROM meeting_participants p WHERE p.meeting_id=m.id AND p.identity_id=?)");
|
|
140
|
-
params.push(input.participantIdentityId);
|
|
141
|
-
}
|
|
142
|
-
if (input.personId !== undefined) {
|
|
143
|
-
clauses.push("EXISTS (SELECT 1 FROM meeting_participants p JOIN identity_links i ON i.identity_id=p.identity_id JOIN persons person ON person.id=i.person_id WHERE p.meeting_id=m.id AND person.id=?)");
|
|
144
|
-
params.push(input.personId);
|
|
145
|
-
}
|
|
146
|
-
if (input.projectId !== undefined) {
|
|
147
|
-
clauses.push("EXISTS (SELECT 1 FROM links l JOIN projects p ON p.id=l.to_id WHERE l.from_type='meeting' AND l.from_id=m.id AND l.to_type='project' AND l.kind='member-of' AND l.anchor IS NULL AND l.confirmed=1 AND p.id=? AND p.account_id IS NULL AND p.deleted_at IS NULL)");
|
|
148
|
-
params.push(input.projectId);
|
|
149
|
-
}
|
|
150
|
-
if (input.tagId !== undefined) {
|
|
151
|
-
clauses.push("EXISTS (SELECT 1 FROM taggings g JOIN tags t ON t.id=g.tag_id WHERE g.taggable_type='meeting' AND g.taggable_id=m.id AND t.id=?)");
|
|
152
|
-
params.push(input.tagId);
|
|
153
|
-
}
|
|
117
|
+
const { clauses, params } = meetingSelectionSql(input);
|
|
154
118
|
const seek = after
|
|
155
119
|
? "WHERE h.tier>? OR (h.tier=? AND (h.started<? OR (h.started=? AND (h.meeting_id>? OR (h.meeting_id=? AND (h.kind>? OR (h.kind=? AND h.id>?)))))))"
|
|
156
120
|
: "";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MeetingSearchOptions } from "../../meetings/index.js";
|
|
2
|
+
import type { SqlValue } from "../driver.js";
|
|
3
|
+
export type MeetingSelection = Pick<MeetingSearchOptions, "accountId" | "meetingId" | "eventId" | "meetingSeriesId" | "since" | "until" | "participantIdentityId" | "personId" | "projectId" | "tagId">;
|
|
4
|
+
/** The meetings a search may read, over `meetings m`: the same filters for words and for meaning. */
|
|
5
|
+
export declare const meetingSelectionSql: (input: MeetingSelection) => {
|
|
6
|
+
clauses: string[];
|
|
7
|
+
params: SqlValue[];
|
|
8
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** The meetings a search may read, over `meetings m`: the same filters for words and for meaning. */
|
|
2
|
+
export const meetingSelectionSql = (input) => {
|
|
3
|
+
const clauses = ["m.account_id=?", "m.deleted_at IS NULL"];
|
|
4
|
+
const params = [input.accountId];
|
|
5
|
+
if (input.meetingId !== undefined) {
|
|
6
|
+
clauses.push("m.id=?");
|
|
7
|
+
params.push(input.meetingId);
|
|
8
|
+
}
|
|
9
|
+
if (input.eventId !== undefined) {
|
|
10
|
+
clauses.push("m.event_id=?");
|
|
11
|
+
params.push(input.eventId);
|
|
12
|
+
}
|
|
13
|
+
if (input.meetingSeriesId !== undefined) {
|
|
14
|
+
clauses.push("m.meeting_series_id=?");
|
|
15
|
+
params.push(input.meetingSeriesId);
|
|
16
|
+
}
|
|
17
|
+
if (input.since !== undefined) {
|
|
18
|
+
clauses.push("m.started_at>=?");
|
|
19
|
+
params.push(input.since);
|
|
20
|
+
}
|
|
21
|
+
if (input.until !== undefined) {
|
|
22
|
+
clauses.push("m.started_at<=?");
|
|
23
|
+
params.push(input.until);
|
|
24
|
+
}
|
|
25
|
+
if (input.participantIdentityId !== undefined) {
|
|
26
|
+
clauses.push("EXISTS (SELECT 1 FROM meeting_participants p WHERE p.meeting_id=m.id AND p.identity_id=?)");
|
|
27
|
+
params.push(input.participantIdentityId);
|
|
28
|
+
}
|
|
29
|
+
if (input.personId !== undefined) {
|
|
30
|
+
clauses.push("EXISTS (SELECT 1 FROM meeting_participants p JOIN identity_links i ON i.identity_id=p.identity_id JOIN persons person ON person.id=i.person_id WHERE p.meeting_id=m.id AND person.id=?)");
|
|
31
|
+
params.push(input.personId);
|
|
32
|
+
}
|
|
33
|
+
if (input.projectId !== undefined) {
|
|
34
|
+
clauses.push("EXISTS (SELECT 1 FROM links l JOIN projects p ON p.id=l.to_id WHERE l.from_type='meeting' AND l.from_id=m.id AND l.to_type='project' AND l.kind='member-of' AND l.anchor IS NULL AND l.confirmed=1 AND p.id=? AND p.account_id IS NULL AND p.deleted_at IS NULL)");
|
|
35
|
+
params.push(input.projectId);
|
|
36
|
+
}
|
|
37
|
+
if (input.tagId !== undefined) {
|
|
38
|
+
clauses.push("EXISTS (SELECT 1 FROM taggings g JOIN tags t ON t.id=g.tag_id WHERE g.taggable_type='meeting' AND g.taggable_id=m.id AND t.id=?)");
|
|
39
|
+
params.push(input.tagId);
|
|
40
|
+
}
|
|
41
|
+
return { clauses, params };
|
|
42
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type MeetingSelection } from "./meeting-selection.js";
|
|
1
2
|
import type { StoreContext } from "./open.js";
|
|
2
3
|
interface Scope {
|
|
3
4
|
accountId: number;
|
|
@@ -61,6 +62,22 @@ export interface MeetingVectors {
|
|
|
61
62
|
chunks: number;
|
|
62
63
|
embedded: number;
|
|
63
64
|
}>;
|
|
65
|
+
/**
|
|
66
|
+
* The `limit` passages nearest to `query` among every vector of `model` in the selected meetings, up to
|
|
67
|
+
* `maxChunks` of them: one ranking over the whole selection, with text read only for the passages kept.
|
|
68
|
+
*/
|
|
69
|
+
ranked(model: string, query: Float32Array, input: MeetingSelection & Scope & TextBudget & {
|
|
70
|
+
limit: number;
|
|
71
|
+
maxChunks?: number;
|
|
72
|
+
}): Promise<{
|
|
73
|
+
items: (MeetingVectorHit & {
|
|
74
|
+
text: string;
|
|
75
|
+
title: string | null;
|
|
76
|
+
startedAt: number | null;
|
|
77
|
+
})[];
|
|
78
|
+
scanned: number;
|
|
79
|
+
complete: boolean;
|
|
80
|
+
}>;
|
|
64
81
|
}
|
|
65
82
|
export declare const meetingVectorsOver: (context: StoreContext) => MeetingVectors;
|
|
66
83
|
export {};
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { chunkHash, cutChunks } from "../../conversations/chunks.js";
|
|
3
3
|
import { positiveReadInteger } from "./meeting-reads.js";
|
|
4
|
+
import { meetingSelectionSql } from "./meeting-selection.js";
|
|
4
5
|
import { read, write } from "./transaction.js";
|
|
5
6
|
import { dot, saveVectors } from "./vectors.js";
|
|
7
|
+
/** Vectors read per query while ranking, so a store of any size stays within a few megabytes at a time. */
|
|
8
|
+
const RANK_BATCH = 512;
|
|
6
9
|
const LIVE = "FROM chunks k JOIN meeting_transcripts t ON k.chunkable_type = 'meeting_transcript' AND t.id = k.chunkable_id JOIN meetings m ON m.id = t.meeting_id WHERE m.account_id = ? AND m.deleted_at IS NULL AND t.deleted_at IS NULL AND t.superseded_at IS NULL";
|
|
7
10
|
const limitOf = (value, name, max = 1000) => {
|
|
8
11
|
positiveReadInteger(value, name);
|
|
@@ -233,6 +236,69 @@ export const meetingVectorsOver = (context) => ({
|
|
|
233
236
|
};
|
|
234
237
|
});
|
|
235
238
|
},
|
|
239
|
+
async ranked(model, query, input) {
|
|
240
|
+
scope(input);
|
|
241
|
+
modelOf(model);
|
|
242
|
+
const limit = limitOf(input.limit, "limit"), maxChunks = limitOf(input.maxChunks ?? 10000, "maxChunks", 100000);
|
|
243
|
+
if (!query.length || query.length > 65536 || Array.from(query).some((value) => !Number.isFinite(value)))
|
|
244
|
+
throw new CliError("validation_error", "Query vector must have finite dimensions");
|
|
245
|
+
return read(context.database, () => {
|
|
246
|
+
const { clauses, params } = meetingSelectionSql(input);
|
|
247
|
+
const batch = context.database.prepare(`SELECT k.id,k.chunkable_id,k.content_hash,k.start_offset,k.end_offset,e.dims,e.vector FROM chunks k JOIN meeting_transcripts t ON k.chunkable_type='meeting_transcript' AND t.id=k.chunkable_id JOIN meetings m ON m.id=t.meeting_id JOIN embeddings e ON e.content_hash=k.content_hash AND e.model=? WHERE ${clauses.join(" AND ")} AND t.deleted_at IS NULL AND t.superseded_at IS NULL AND k.id>? ORDER BY k.id LIMIT ?`);
|
|
248
|
+
const best = [];
|
|
249
|
+
let scanned = 0;
|
|
250
|
+
let after = 0;
|
|
251
|
+
let complete = true;
|
|
252
|
+
for (;;) {
|
|
253
|
+
input.signal?.throwIfAborted();
|
|
254
|
+
const take = Math.min(RANK_BATCH, maxChunks - scanned);
|
|
255
|
+
if (take <= 0) {
|
|
256
|
+
complete = !batch.get(model, ...params, after, 1);
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
const rows = batch.all(model, ...params, after, take);
|
|
260
|
+
for (const row of rows) {
|
|
261
|
+
if (Number(row.dims) !== query.length ||
|
|
262
|
+
!(row.vector instanceof Uint8Array) ||
|
|
263
|
+
row.vector.byteLength !== query.length * 4)
|
|
264
|
+
throw new CliError("validation_error", "Stored vector dimensions do not match query");
|
|
265
|
+
const score = dot(query, row.vector);
|
|
266
|
+
if (!Number.isFinite(score))
|
|
267
|
+
throw new CliError("invalid_response", "Stored vector contains non-finite values");
|
|
268
|
+
if (best.length < limit || score > (best.at(-1)?.score ?? Number.NEGATIVE_INFINITY)) {
|
|
269
|
+
const { vector: _, ...kept } = row;
|
|
270
|
+
const at = best.findIndex((held) => held.score < score);
|
|
271
|
+
best.splice(at < 0 ? best.length : at, 0, { row: kept, score });
|
|
272
|
+
if (best.length > limit)
|
|
273
|
+
best.pop();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
scanned += rows.length;
|
|
277
|
+
after = Number(rows.at(-1)?.id ?? after);
|
|
278
|
+
if (rows.length < take)
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
const load = textLoader(context, input);
|
|
282
|
+
const meeting = context.database.prepare("SELECT title,started_at FROM meetings WHERE id=?");
|
|
283
|
+
const items = best.map(({ row, score }) => {
|
|
284
|
+
input.signal?.throwIfAborted();
|
|
285
|
+
const text = chunkText(context, input.accountId, row, input, input.signal, load(Number(row.chunkable_id)));
|
|
286
|
+
const about = meeting.get(text.meetingId);
|
|
287
|
+
return {
|
|
288
|
+
title: about?.title == null ? null : String(about.title),
|
|
289
|
+
startedAt: about?.started_at == null ? null : Number(about.started_at),
|
|
290
|
+
meetingId: text.meetingId,
|
|
291
|
+
transcriptId: Number(row.chunkable_id),
|
|
292
|
+
firstPosition: text.firstPosition,
|
|
293
|
+
lastPosition: text.lastPosition,
|
|
294
|
+
hash: String(row.content_hash),
|
|
295
|
+
score,
|
|
296
|
+
text: text.text,
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
return { items, scanned, complete };
|
|
300
|
+
});
|
|
301
|
+
},
|
|
236
302
|
async status(input) {
|
|
237
303
|
scope(input);
|
|
238
304
|
modelOf(input.model);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.388.0",
|
|
4
4
|
"description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Viacheslav Ptsarev",
|