@wirecat/cli-core 0.379.0 → 0.380.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 +52 -0
- package/dist/cli/meeting-preparation-answer.d.ts +5 -0
- package/dist/cli/meeting-preparation-projects.d.ts +5 -0
- package/dist/cli/meeting-preparation-read.d.ts +5 -0
- package/dist/cli/messenger/chats-stats-command.js +5 -1
- package/dist/cli/messenger/contacts-command.js +8 -4
- package/dist/cli/messenger/stats-answers.d.ts +2 -1
- package/dist/cli/messenger/stats-answers.js +1 -1
- package/dist/cli/messenger/stats-command.js +4 -9
- package/dist/entities/index.d.ts +15 -0
- package/dist/entities/meeting-preparation.d.ts +5 -0
- package/dist/mcp/meeting-preparation.d.ts +5 -0
- package/dist/mcp/tools/stats.js +9 -6
- package/dist/meetings/answers.d.ts +6 -0
- package/dist/meetings/answers.js +5 -0
- package/dist/meetings/cli/index.d.ts +2 -0
- package/dist/meetings/cli/index.js +5 -2
- package/dist/meetings/cli/search.js +1 -1
- package/dist/meetings/hit-query.d.ts +12 -0
- package/dist/meetings/hit-query.js +58 -0
- package/dist/meetings/import.d.ts +2 -0
- package/dist/meetings/import.js +8 -2
- package/dist/meetings/index.d.ts +2 -0
- package/dist/meetings/index.js +2 -0
- package/dist/meetings/mcp/search.d.ts +6 -0
- package/dist/meetings/mcp/search.js +5 -1
- package/dist/meetings/project-evidence-answer.d.ts +5 -0
- package/dist/meetings/pull.d.ts +2 -0
- package/dist/meetings/recording-schedule.js +6 -5
- package/dist/meetings/search-store.d.ts +3 -1
- package/dist/meetings/store.d.ts +7 -0
- package/dist/meetings/transcript-text.d.ts +20 -0
- package/dist/meetings/transcript-text.js +59 -0
- package/dist/render/markdown.js +2 -2
- package/dist/search/garbled.d.ts +29 -0
- package/dist/search/garbled.js +125 -0
- package/dist/search/russian-dictionary.d.ts +5 -0
- package/dist/search/russian-dictionary.js +66 -0
- package/dist/services/meeting-project-discovery.js +2 -2
- package/dist/services/moment.d.ts +21 -0
- package/dist/services/moment.js +19 -0
- package/dist/services/retention.js +6 -3
- package/dist/store/sqlite/meeting-hit-query.d.ts +60 -0
- package/dist/store/sqlite/meeting-hit-query.js +262 -0
- package/dist/store/sqlite/meeting-hit-search.js +28 -45
- package/package.json +4 -3
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { CliError, singleLine } from "@wirecat/cli-base";
|
|
2
|
+
import { isNumberWord, widenWords } from "../../search/garbled.js";
|
|
3
|
+
import { knownRussianWord } from "../../search/russian-dictionary.js";
|
|
4
|
+
import { fold, normalize } from "../normalize.js";
|
|
5
|
+
import { meetingStemmableText } from "./meeting-stem-state.js";
|
|
6
|
+
const WORD = /[\p{L}\p{N}\p{M}]+/gu;
|
|
7
|
+
const MOST_WORDS = 128;
|
|
8
|
+
const quote = (word) => `"${word.replaceAll('"', '""')}"`;
|
|
9
|
+
/** The candidates `from:` picks among: a linked person with all their identities, else one identity or name. */
|
|
10
|
+
const speakers = (database, accountId) => {
|
|
11
|
+
const found = new Map();
|
|
12
|
+
const add = (key, label, names, participant, raw) => {
|
|
13
|
+
const speaker = found.get(key) ?? { label, names: new Set(), participants: new Set(), raw: new Set() };
|
|
14
|
+
for (const name of names)
|
|
15
|
+
if (name?.trim())
|
|
16
|
+
speaker.names.add(fold(name.trim()));
|
|
17
|
+
if (participant !== null)
|
|
18
|
+
speaker.participants.add(participant);
|
|
19
|
+
if (raw)
|
|
20
|
+
speaker.raw.add(raw);
|
|
21
|
+
found.set(key, speaker);
|
|
22
|
+
};
|
|
23
|
+
const participants = database
|
|
24
|
+
.prepare(`SELECT p.id,p.identity_id,p.display_name,i.name AS identity_name,l.person_id,person.name AS person_name
|
|
25
|
+
FROM meeting_participants p JOIN meetings m ON m.id=p.meeting_id
|
|
26
|
+
LEFT JOIN identities i ON i.id=p.identity_id LEFT JOIN identity_links l ON l.identity_id=p.identity_id
|
|
27
|
+
LEFT JOIN persons person ON person.id=l.person_id WHERE m.account_id=? AND m.deleted_at IS NULL`)
|
|
28
|
+
.all(accountId);
|
|
29
|
+
const personNames = database.prepare("SELECT i.name FROM identity_links l JOIN identities i ON i.id=l.identity_id WHERE l.person_id=?");
|
|
30
|
+
for (const row of participants) {
|
|
31
|
+
const names = [row.display_name, row.identity_name, row.person_name].map((name) => name === null || name === undefined ? null : String(name));
|
|
32
|
+
const label = names.find((name) => name?.trim()) ?? "unnamed";
|
|
33
|
+
if (row.person_id !== null && row.person_id !== undefined) {
|
|
34
|
+
const others = personNames.all(Number(row.person_id)).map((one) => (one.name === null ? null : String(one.name)));
|
|
35
|
+
add(`person:${row.person_id}`, String(row.person_name ?? label), [...names, ...others], Number(row.id));
|
|
36
|
+
}
|
|
37
|
+
else if (row.identity_id !== null && row.identity_id !== undefined)
|
|
38
|
+
add(`identity:${row.identity_id}`, label, names, Number(row.id));
|
|
39
|
+
else
|
|
40
|
+
add(`name:${fold(label)}`, label, names, Number(row.id));
|
|
41
|
+
}
|
|
42
|
+
const unlinked = database
|
|
43
|
+
.prepare(`SELECT DISTINCT r.speaker_name AS name FROM meeting_transcript_rows r
|
|
44
|
+
JOIN meeting_transcripts t ON t.id=r.meeting_transcript_id JOIN meetings m ON m.id=t.meeting_id
|
|
45
|
+
WHERE m.account_id=? AND m.deleted_at IS NULL AND r.speaker_participant_id IS NULL AND r.speaker_name IS NOT NULL
|
|
46
|
+
UNION SELECT DISTINCT c.sender_name FROM meeting_chat_messages c JOIN meetings m ON m.id=c.meeting_id
|
|
47
|
+
WHERE m.account_id=? AND m.deleted_at IS NULL AND c.sender_participant_id IS NULL AND c.sender_name IS NOT NULL`)
|
|
48
|
+
.all(accountId, accountId);
|
|
49
|
+
for (const row of unlinked) {
|
|
50
|
+
const name = String(row.name);
|
|
51
|
+
if (!name.trim())
|
|
52
|
+
continue;
|
|
53
|
+
const same = [...found.values()].find((speaker) => speaker.names.has(fold(name.trim())));
|
|
54
|
+
if (same)
|
|
55
|
+
same.raw.add(name);
|
|
56
|
+
else
|
|
57
|
+
add(`name:${fold(name.trim())}`, name, [name], null, name);
|
|
58
|
+
}
|
|
59
|
+
return [...found.values()];
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* As `pickPerson` for messages: a name matched as a fragment, the exact name first, and an ambiguous one is an
|
|
63
|
+
* error with the candidates rather than a guess.
|
|
64
|
+
*/
|
|
65
|
+
const pickSpeaker = (database, accountId, reference) => {
|
|
66
|
+
const wanted = fold(reference.trim());
|
|
67
|
+
const everyone = speakers(database, accountId).sort((a, b) => a.label.localeCompare(b.label));
|
|
68
|
+
const exact = everyone.filter((speaker) => speaker.names.has(wanted));
|
|
69
|
+
const matches = exact.length
|
|
70
|
+
? exact
|
|
71
|
+
: everyone.filter((speaker) => [...speaker.names].some((n) => n.includes(wanted)));
|
|
72
|
+
const [one] = matches;
|
|
73
|
+
if (matches.length === 1 && one)
|
|
74
|
+
return { kind: "speaker", participants: [...one.participants].sort((a, b) => a - b), names: [...one.raw].sort() };
|
|
75
|
+
if (matches.length === 0)
|
|
76
|
+
throw new CliError("not_found", `from:${reference} — nobody spoke as "${reference}"`);
|
|
77
|
+
throw new CliError("validation_error", `from:${reference} matches ${matches.length} speakers — write more of the name:\n${matches
|
|
78
|
+
.map((speaker) => ` ${singleLine(speaker.label)}`)
|
|
79
|
+
.join("\n")}`, { candidates: matches.map((speaker) => ({ name: speaker.label })) });
|
|
80
|
+
};
|
|
81
|
+
/** The parsed query, its names resolved and its words stemmed and widened, before any text is read. */
|
|
82
|
+
export const resolveMeetingQuery = (database, accountId, ast, state) => {
|
|
83
|
+
const all = [];
|
|
84
|
+
const wordsOf = (value, exact) => {
|
|
85
|
+
const words = (value.match(WORD) ?? [])
|
|
86
|
+
.filter((raw) => normalize(raw))
|
|
87
|
+
.map((raw) => {
|
|
88
|
+
const literal = normalize(raw);
|
|
89
|
+
return {
|
|
90
|
+
raw,
|
|
91
|
+
literal,
|
|
92
|
+
whole: exact || isNumberWord(literal),
|
|
93
|
+
stems: state.stemmer && raw.length <= 1024 && !exact
|
|
94
|
+
? [...new Set(state.stemmer.explain(raw).map((word) => word.stem))]
|
|
95
|
+
: [],
|
|
96
|
+
widened: [],
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
for (const term of words)
|
|
100
|
+
all.push(term);
|
|
101
|
+
if (all.length > MOST_WORDS)
|
|
102
|
+
throw new CliError("validation_error", "Meeting search supports at most 128 query words");
|
|
103
|
+
return words;
|
|
104
|
+
};
|
|
105
|
+
const resolve = (node) => {
|
|
106
|
+
if (node.kind === "boolean")
|
|
107
|
+
return {
|
|
108
|
+
kind: "boolean",
|
|
109
|
+
must: node.clauses.filter((c) => c.occur === "must").map((c) => resolve(c.node)),
|
|
110
|
+
should: node.clauses.filter((c) => c.occur === "should").map((c) => resolve(c.node)),
|
|
111
|
+
not: node.clauses.filter((c) => c.occur === "must_not").map((c) => resolve(c.node)),
|
|
112
|
+
};
|
|
113
|
+
if (node.field === "from")
|
|
114
|
+
return pickSpeaker(database, accountId, node.value);
|
|
115
|
+
const exact = node.field === "exact";
|
|
116
|
+
const words = wordsOf(node.value, exact);
|
|
117
|
+
if (node.operator === "phrase" && node.slop !== undefined && words.length > 1)
|
|
118
|
+
return { kind: "near", exact, words, lines: node.slop };
|
|
119
|
+
return { kind: node.operator === "phrase" && words.length > 1 ? "phrase" : "words", exact, words };
|
|
120
|
+
};
|
|
121
|
+
const tree = resolve(ast.root);
|
|
122
|
+
const stemmer = state.stemmer;
|
|
123
|
+
const widening = [];
|
|
124
|
+
const collect = (node) => {
|
|
125
|
+
if (node.kind === "boolean")
|
|
126
|
+
[...node.must, ...node.should, ...node.not].forEach(collect);
|
|
127
|
+
else if ((node.kind === "words" || node.kind === "near") && !node.exact)
|
|
128
|
+
for (const term of node.words)
|
|
129
|
+
widening.push(term);
|
|
130
|
+
};
|
|
131
|
+
collect(tree);
|
|
132
|
+
if (stemmer && state.through > 0 && widening.length) {
|
|
133
|
+
const widened = widenWords(widening, database
|
|
134
|
+
.prepare("SELECT term,doc FROM meeting_words_vocab WHERE col='normalized_text'")
|
|
135
|
+
.all()
|
|
136
|
+
.map((row) => ({ term: String(row.term), docs: Number(row.doc) })), (word) => [...new Set(stemmer.explain(word).map((part) => part.stem))], knownRussianWord);
|
|
137
|
+
widening.forEach((word, at) => {
|
|
138
|
+
word.widened = widened[at] ?? [];
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return tree;
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* A `(rid, tier)` select: tier 0 the typed form, 1 another form the stems found, 2 a garbled spelling. A passage
|
|
145
|
+
* all of a query's required parts reach takes the worst tier among them; one any optional part reaches, the best.
|
|
146
|
+
*/
|
|
147
|
+
export const meetingQuerySql = (tree, state) => {
|
|
148
|
+
const params = [];
|
|
149
|
+
const stems = state.stemmer !== null && state.through > 0;
|
|
150
|
+
const word = (term) => {
|
|
151
|
+
params.push(`normalized_text : (${quote(term.literal)}${term.whole ? "" : "*"})`);
|
|
152
|
+
let select = "SELECT rowid AS rid,0 AS tier FROM meeting_words WHERE meeting_words MATCH ?";
|
|
153
|
+
if (stems && term.stems.length) {
|
|
154
|
+
params.push(`stems : (${term.stems.map(quote).join(" OR ")})`, state.through);
|
|
155
|
+
select += " UNION SELECT rowid,1 FROM meeting_stems WHERE meeting_stems MATCH ? AND rowid<=?";
|
|
156
|
+
}
|
|
157
|
+
if (term.widened.length) {
|
|
158
|
+
params.push(`normalized_text : (${term.widened.map(quote).join(" OR ")})`);
|
|
159
|
+
select += " UNION SELECT rowid,2 FROM meeting_words WHERE meeting_words MATCH ?";
|
|
160
|
+
}
|
|
161
|
+
return `SELECT rid,min(tier) AS tier FROM (${select}) GROUP BY rid`;
|
|
162
|
+
};
|
|
163
|
+
const all = (parts) => parts.length === 1
|
|
164
|
+
? parts[0]
|
|
165
|
+
: `SELECT rid,max(tier) AS tier FROM (${parts.join(" UNION ALL ")}) GROUP BY rid HAVING count(*)=${parts.length}`;
|
|
166
|
+
const any = (parts) => parts.length === 1
|
|
167
|
+
? parts[0]
|
|
168
|
+
: `SELECT rid,min(tier) AS tier FROM (${parts.join(" UNION ALL ")}) GROUP BY rid`;
|
|
169
|
+
const nothing = "SELECT NULL AS rid,0 AS tier WHERE 0";
|
|
170
|
+
const compile = (node) => {
|
|
171
|
+
if (node.kind === "words")
|
|
172
|
+
return node.words.length ? all(node.words.map(word)) : nothing;
|
|
173
|
+
if (node.kind === "near") {
|
|
174
|
+
const lines = node.words.map((term, i) => `w${i} AS MATERIALIZED (SELECT r.meeting_transcript_id AS t,r.position AS p,r.id,h.tier FROM (${word(term)}) h JOIN meeting_transcript_rows r ON h.rid%4=1 AND r.id=h.rid/4)`);
|
|
175
|
+
const nearest = node.words.map((_, i) => `(SELECT min(x.tier) FROM w${i} x WHERE x.t=c.t AND x.p BETWEEN c.p-${node.lines} AND c.p+${node.lines})`);
|
|
176
|
+
return `SELECT rid,tier FROM (WITH ${lines.join(",")}, c AS (${node.words
|
|
177
|
+
.map((_, i) => `SELECT t,p,id FROM w${i}`)
|
|
178
|
+
.join(" UNION ")}) SELECT c.id*4+1 AS rid,max(${nearest.join(",")}) AS tier FROM c) WHERE tier IS NOT NULL`;
|
|
179
|
+
}
|
|
180
|
+
if (node.kind === "phrase") {
|
|
181
|
+
params.push(`normalized_text : ${quote(node.words.map((term) => term.literal).join(" "))}`);
|
|
182
|
+
let select = "SELECT rowid AS rid,0 AS tier FROM meeting_words WHERE meeting_words MATCH ?";
|
|
183
|
+
const phrases = node.exact ? undefined : state.stemmer?.phrases(node.words.map((term) => term.raw).join(" "));
|
|
184
|
+
if (stems && phrases) {
|
|
185
|
+
params.push(`stems : (${phrases})`, state.through);
|
|
186
|
+
select += " UNION SELECT rowid,1 FROM meeting_stems WHERE meeting_stems MATCH ? AND rowid<=?";
|
|
187
|
+
}
|
|
188
|
+
return `SELECT rid,min(tier) AS tier FROM (${select}) GROUP BY rid`;
|
|
189
|
+
}
|
|
190
|
+
if (node.kind === "speaker") {
|
|
191
|
+
params.push(JSON.stringify(node.participants), JSON.stringify(node.names));
|
|
192
|
+
params.push(JSON.stringify(node.participants), JSON.stringify(node.names));
|
|
193
|
+
return `SELECT r.id*4+1 AS rid,0 AS tier FROM meeting_transcript_rows r
|
|
194
|
+
WHERE r.speaker_participant_id IN (SELECT value FROM json_each(?)) OR (r.speaker_participant_id IS NULL AND r.speaker_name IN (SELECT value FROM json_each(?)))
|
|
195
|
+
UNION ALL SELECT c.id*4+2,0 FROM meeting_chat_messages c
|
|
196
|
+
WHERE c.sender_participant_id IN (SELECT value FROM json_each(?)) OR (c.sender_participant_id IS NULL AND c.sender_name IN (SELECT value FROM json_each(?)))`;
|
|
197
|
+
}
|
|
198
|
+
// Lucene: optional clauses only count when nothing is required; a part that is only exclusions matches nothing.
|
|
199
|
+
const positive = node.must.length
|
|
200
|
+
? all(node.must.map(compile))
|
|
201
|
+
: node.should.length
|
|
202
|
+
? any(node.should.map(compile))
|
|
203
|
+
: nothing;
|
|
204
|
+
if (!node.not.length)
|
|
205
|
+
return positive;
|
|
206
|
+
return `SELECT rid,tier FROM (${positive}) WHERE rid NOT IN (${node.not.map((part) => `SELECT rid FROM (${compile(part)})`).join(" UNION ")})`;
|
|
207
|
+
};
|
|
208
|
+
return { sql: compile(tree), params };
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* The tree checked again against a passage's current text — an index lags an edit — and the garbled spellings
|
|
212
|
+
* that let it match. A speaker cannot be read from the text, so it passes here.
|
|
213
|
+
*/
|
|
214
|
+
export const matchMeetingText = (tree, text, stemmer) => {
|
|
215
|
+
const tokens = normalize(text).match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
216
|
+
let stemTokens;
|
|
217
|
+
const stemsOf = () => (stemTokens ??= stemmer
|
|
218
|
+
? new Set(stemmer.explain(meetingStemmableText(text)).map((word) => word.stem))
|
|
219
|
+
: new Set());
|
|
220
|
+
const word = (term) => {
|
|
221
|
+
if (term.whole ? tokens.includes(term.literal) : tokens.some((token) => token.startsWith(term.literal)))
|
|
222
|
+
return [];
|
|
223
|
+
if (term.stems.some((stem) => stemsOf().has(stem)))
|
|
224
|
+
return [];
|
|
225
|
+
const to = term.widened.filter((widened) => tokens.includes(widened));
|
|
226
|
+
return to.length ? [{ from: term.raw, to }] : null;
|
|
227
|
+
};
|
|
228
|
+
const check = (node) => {
|
|
229
|
+
if (node.kind === "speaker")
|
|
230
|
+
return [];
|
|
231
|
+
// The other words are on neighbouring lines, so only this line's garbled spellings are named.
|
|
232
|
+
if (node.kind === "near")
|
|
233
|
+
return node.words.flatMap((term) => word(term) ?? []);
|
|
234
|
+
if (node.kind === "words" || node.kind === "phrase") {
|
|
235
|
+
const found = [];
|
|
236
|
+
for (const term of node.words) {
|
|
237
|
+
const one = word(term);
|
|
238
|
+
if (!one)
|
|
239
|
+
return null;
|
|
240
|
+
for (const fix of one)
|
|
241
|
+
found.push(fix);
|
|
242
|
+
}
|
|
243
|
+
return found;
|
|
244
|
+
}
|
|
245
|
+
if (node.not.some((part) => check(part) !== null))
|
|
246
|
+
return null;
|
|
247
|
+
if (node.must.length) {
|
|
248
|
+
const found = [];
|
|
249
|
+
for (const part of node.must) {
|
|
250
|
+
const one = check(part);
|
|
251
|
+
if (!one)
|
|
252
|
+
return null;
|
|
253
|
+
for (const fix of one)
|
|
254
|
+
found.push(fix);
|
|
255
|
+
}
|
|
256
|
+
return found;
|
|
257
|
+
}
|
|
258
|
+
const matched = node.should.map(check).filter((one) => one !== null);
|
|
259
|
+
return matched.length ? matched.flat() : null;
|
|
260
|
+
};
|
|
261
|
+
return check(tree);
|
|
262
|
+
};
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { CliError } from "@wirecat/cli-base";
|
|
3
|
-
import { formatMeetingReference } from "../../meetings/index.js";
|
|
4
|
-
import {
|
|
3
|
+
import { formatMeetingReference, parseMeetingQuery } from "../../meetings/index.js";
|
|
4
|
+
import { matchMeetingText, meetingQuerySql, resolveMeetingQuery } from "./meeting-hit-query.js";
|
|
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 {
|
|
8
|
+
import { meetingStemState } from "./meeting-stem-state.js";
|
|
9
9
|
import { read } from "./transaction.js";
|
|
10
10
|
const fail = (message) => {
|
|
11
11
|
throw new CliError("validation_error", message);
|
|
@@ -42,8 +42,9 @@ const cursorOf = (value, key) => {
|
|
|
42
42
|
catch {
|
|
43
43
|
return fail("Invalid meeting search cursor");
|
|
44
44
|
}
|
|
45
|
-
if (parsed?.version !==
|
|
45
|
+
if (parsed?.version !== 2 ||
|
|
46
46
|
parsed.key !== key ||
|
|
47
|
+
![0, 1, 2].includes(parsed.tier) ||
|
|
47
48
|
!Number.isSafeInteger(parsed.started) ||
|
|
48
49
|
Math.abs(parsed.started) > 8640000000000000 ||
|
|
49
50
|
!Number.isSafeInteger(parsed.meeting) ||
|
|
@@ -86,21 +87,14 @@ export function meetingHitSearchOver({ database }) {
|
|
|
86
87
|
fail("Meeting search limit must be from 1 through 1000");
|
|
87
88
|
if (!Number.isSafeInteger(maxReadBytes) || maxReadBytes < 1 || maxReadBytes > 64 * 1024 ** 2)
|
|
88
89
|
fail("Meeting search byte limit must be from 1 through 67108864");
|
|
89
|
-
if (typeof rawQuery
|
|
90
|
+
if (typeof rawQuery === "string" && Buffer.byteLength(rawQuery) > 65536)
|
|
90
91
|
fail("Meeting search query must be a bounded string");
|
|
91
|
-
const
|
|
92
|
-
if (rawWords.length > 128)
|
|
93
|
-
fail("Meeting search supports at most 128 query words");
|
|
92
|
+
const ast = typeof rawQuery === "string" ? parseMeetingQuery(rawQuery) : rawQuery;
|
|
94
93
|
return read(database, () => {
|
|
95
94
|
checkMeetingCancelled(input.signal);
|
|
96
95
|
const state = meetingStemState(database, input.accountId);
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
stems: state.stemmer && raw.length <= 1024
|
|
100
|
-
? [...new Set(state.stemmer.explain(raw).map((word) => word.stem))]
|
|
101
|
-
: [],
|
|
102
|
-
}));
|
|
103
|
-
const key = fingerprint(JSON.stringify(terms), input, state.identity);
|
|
96
|
+
const tree = resolveMeetingQuery(database, input.accountId, ast, state);
|
|
97
|
+
const key = fingerprint(JSON.stringify(tree), input, state.identity);
|
|
104
98
|
const after = cursorOf(input.after, key);
|
|
105
99
|
const pending = database
|
|
106
100
|
.prepare(`SELECT 1 FROM meeting_index_pending p
|
|
@@ -118,20 +112,8 @@ export function meetingHitSearchOver({ database }) {
|
|
|
118
112
|
indexing: pending || !state.stemmer || state.through < state.watermark ? "pending" : "ready",
|
|
119
113
|
indexCompleteness: "unknown",
|
|
120
114
|
};
|
|
121
|
-
if (terms.length === 0)
|
|
122
|
-
return { items: [], hasMore: false, nextCursor: null, coverage };
|
|
123
115
|
const clauses = ["m.account_id=?", "m.deleted_at IS NULL"];
|
|
124
|
-
const queryParams =
|
|
125
|
-
const termTables = terms.map((term, i) => {
|
|
126
|
-
queryParams.push(`normalized_text : ("${term.literal}"*)`);
|
|
127
|
-
let select = "SELECT rowid AS rid FROM meeting_words WHERE meeting_words MATCH ?";
|
|
128
|
-
if (state.stemmer && state.through > 0 && term.stems.length) {
|
|
129
|
-
queryParams.push(`stems : (${term.stems.map((stem) => `"${stem.replaceAll('"', '""')}"`).join(" OR ")})`, state.through);
|
|
130
|
-
select += " UNION SELECT rowid AS rid FROM meeting_stems WHERE meeting_stems MATCH ? AND rowid<=?";
|
|
131
|
-
}
|
|
132
|
-
return `term_${i} AS (${select})`;
|
|
133
|
-
});
|
|
134
|
-
const matching = terms.map((_, i) => `SELECT rid FROM term_${i}`).join(" INTERSECT ");
|
|
116
|
+
const { sql: matching, params: queryParams } = meetingQuerySql(tree, state);
|
|
135
117
|
const params = [input.accountId];
|
|
136
118
|
if (input.meetingId !== undefined) {
|
|
137
119
|
clauses.push("m.id=?");
|
|
@@ -170,17 +152,17 @@ export function meetingHitSearchOver({ database }) {
|
|
|
170
152
|
params.push(input.tagId);
|
|
171
153
|
}
|
|
172
154
|
const seek = after
|
|
173
|
-
? "WHERE h.started<? OR (h.started=? AND (h.meeting_id>? OR (h.meeting_id=? AND (h.kind>? OR (h.kind=? AND h.id>?)))))"
|
|
155
|
+
? "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>?)))))))"
|
|
174
156
|
: "";
|
|
175
157
|
if (after)
|
|
176
|
-
params.push(after.started, after.started, after.meeting, after.meeting, after.kind, after.kind, after.id);
|
|
177
|
-
const sql = `WITH
|
|
178
|
-
SELECT m.started,m.id AS meeting_id,0 AS kind,r.id,r.start_ms,${bytes("r", ["text"])} AS bytes,t.id AS transcript_id,r.position FROM matching h JOIN meeting_transcript_rows r ON h.rid%4=1 AND r.id=h.rid/4 JOIN meeting_transcripts t ON t.id=r.meeting_transcript_id AND t.superseded_at IS NULL AND t.deleted_at IS NULL JOIN selected m ON m.id=t.meeting_id
|
|
179
|
-
UNION ALL SELECT m.started,m.id,1,c.id,NULL,${bytes("c", ["text"])},NULL,NULL FROM matching h JOIN meeting_chat_messages c ON h.rid%4=2 AND c.id=h.rid/4 JOIN selected m ON m.id=c.meeting_id
|
|
180
|
-
UNION ALL SELECT m.started,m.id,2,s.id,NULL,${bytes("s", fields)},NULL,NULL FROM matching h JOIN meeting_summaries s ON h.rid%4=3 AND s.id=h.rid/4 JOIN selected m ON m.id=s.meeting_id)
|
|
158
|
+
params.push(after.tier, after.tier, after.started, after.started, after.meeting, after.meeting, after.kind, after.kind, after.id);
|
|
159
|
+
const sql = `WITH matches AS (${matching}), matching AS (SELECT rid,tier FROM matches h WHERE NOT EXISTS(SELECT 1 FROM meeting_index_pending p WHERE p.id=h.rid/4 AND p.indexable_type=CASE h.rid%4 WHEN 1 THEN 'meeting_transcript_row' WHEN 2 THEN 'meeting_chat_message' ELSE 'meeting_summary' END)), selected AS (SELECT m.id,coalesce(m.started_at,0) AS started FROM meetings m WHERE ${clauses.join(" AND ")}), hits AS (
|
|
160
|
+
SELECT h.tier,m.started,m.id AS meeting_id,0 AS kind,r.id,r.start_ms,${bytes("r", ["text"])} AS bytes,t.id AS transcript_id,r.position FROM matching h JOIN meeting_transcript_rows r ON h.rid%4=1 AND r.id=h.rid/4 JOIN meeting_transcripts t ON t.id=r.meeting_transcript_id AND t.superseded_at IS NULL AND t.deleted_at IS NULL JOIN selected m ON m.id=t.meeting_id
|
|
161
|
+
UNION ALL SELECT h.tier,m.started,m.id,1,c.id,NULL,${bytes("c", ["text"])},NULL,NULL FROM matching h JOIN meeting_chat_messages c ON h.rid%4=2 AND c.id=h.rid/4 JOIN selected m ON m.id=c.meeting_id
|
|
162
|
+
UNION ALL SELECT h.tier,m.started,m.id,2,s.id,NULL,${bytes("s", fields)},NULL,NULL FROM matching h JOIN meeting_summaries s ON h.rid%4=3 AND s.id=h.rid/4 JOIN selected m ON m.id=s.meeting_id)
|
|
181
163
|
SELECT h.*,${bytes("m", ["title"])}+${bytes("s", ["doc_url"])} AS context_bytes FROM hits h
|
|
182
164
|
JOIN meetings m ON m.id=h.meeting_id LEFT JOIN meeting_summaries s ON h.kind=2 AND s.id=h.id
|
|
183
|
-
${seek} ORDER BY h.started DESC,h.meeting_id,h.kind,h.id LIMIT ?`;
|
|
165
|
+
${seek} ORDER BY h.tier,h.started DESC,h.meeting_id,h.kind,h.id LIMIT ?`;
|
|
184
166
|
const rows = database.prepare(sql).all(...queryParams, ...params, limit + 1);
|
|
185
167
|
let total = 0;
|
|
186
168
|
const meetingMetadata = database.prepare("SELECT title,started_at FROM meetings WHERE id=? AND account_id=? AND deleted_at IS NULL");
|
|
@@ -232,15 +214,15 @@ export function meetingHitSearchOver({ database }) {
|
|
|
232
214
|
startMs: row.start_ms === null ? null : Number(row.start_ms),
|
|
233
215
|
};
|
|
234
216
|
});
|
|
235
|
-
const fresh = items.
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
217
|
+
const fresh = items.flatMap((item) => {
|
|
218
|
+
const corrections = matchMeetingText(tree, item.text, state.stemmer);
|
|
219
|
+
if (!corrections)
|
|
220
|
+
return [];
|
|
221
|
+
return [
|
|
222
|
+
corrections.length
|
|
223
|
+
? { ...item, match: "corrected", corrections }
|
|
224
|
+
: { ...item, match: "words" },
|
|
225
|
+
];
|
|
244
226
|
});
|
|
245
227
|
if (fresh.length !== items.length)
|
|
246
228
|
coverage.indexing = "pending";
|
|
@@ -248,8 +230,9 @@ export function meetingHitSearchOver({ database }) {
|
|
|
248
230
|
const hasMore = rows.length > limit;
|
|
249
231
|
const nextCursor = hasMore && last
|
|
250
232
|
? Buffer.from(JSON.stringify({
|
|
251
|
-
version:
|
|
233
|
+
version: 2,
|
|
252
234
|
key,
|
|
235
|
+
tier: Number(last.tier),
|
|
253
236
|
started: Number(last.started),
|
|
254
237
|
meeting: Number(last.meeting_id),
|
|
255
238
|
kind: Number(last.kind),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.380.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",
|
|
@@ -173,6 +173,7 @@
|
|
|
173
173
|
"@xmldom/xmldom": "0.9.12",
|
|
174
174
|
"chardet": "2.2.0",
|
|
175
175
|
"defuddle": "0.19.5",
|
|
176
|
+
"dictionary-ru": "3.0.0",
|
|
176
177
|
"echarts": "6.1.0",
|
|
177
178
|
"fflate": "0.8.3",
|
|
178
179
|
"iconv-lite": "0.7.3",
|
|
@@ -202,7 +203,7 @@
|
|
|
202
203
|
"@types/node": "^22.20.4",
|
|
203
204
|
"@types/pngjs": "6.0.5",
|
|
204
205
|
"@vitest/coverage-v8": "^5.0.2",
|
|
205
|
-
"@wirecat/cli-base": "0.
|
|
206
|
+
"@wirecat/cli-base": "0.49.0",
|
|
206
207
|
"commander": "15.0.0",
|
|
207
208
|
"cspell": "10.3.6",
|
|
208
209
|
"drizzle-kit": "1.0.0-rc.4",
|
|
@@ -222,7 +223,7 @@
|
|
|
222
223
|
},
|
|
223
224
|
"peerDependencies": {
|
|
224
225
|
"@napi-rs/canvas": ">=0.1.69 <2",
|
|
225
|
-
"@wirecat/cli-base": ">=0.
|
|
226
|
+
"@wirecat/cli-base": ">=0.49.0 <0.50.0",
|
|
226
227
|
"commander": ">=15"
|
|
227
228
|
},
|
|
228
229
|
"bin": {
|