@wirecat/cli-core 0.390.0 → 0.391.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 +32 -0
- package/dist/cli/effects.d.ts +2 -0
- package/dist/cli/effects.js +8 -2
- package/dist/cli/messenger/catch-up.d.ts +0 -19
- package/dist/cli/messenger/catch-up.js +0 -43
- package/dist/cli/messenger/context.js +6 -3
- package/dist/cli/messenger/inbox.js +6 -11
- package/dist/cli/messenger/messages-command.js +5 -15
- package/dist/cli/messenger/review.d.ts +1 -1
- package/dist/cli/messenger/review.js +5 -10
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- package/dist/cli/messenger/tags-command.js +2 -2
- package/dist/cli/settings.d.ts +0 -4
- package/dist/cli/settings.js +7 -5
- package/dist/meetings/index.d.ts +2 -0
- package/dist/meetings/index.js +2 -0
- package/dist/meetings/links.d.ts +18 -0
- package/dist/meetings/links.js +1 -0
- package/dist/meetings/review.d.ts +45 -0
- package/dist/meetings/review.js +1 -0
- package/dist/search/lucene/registry.d.ts +5 -1
- package/dist/search/lucene/registry.js +10 -4
- package/dist/search/lucene/types.d.ts +8 -0
- package/dist/search/lucene/types.js +4 -0
- package/dist/search/question-plan.js +3 -3
- package/dist/services/excerpt.js +4 -1
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +2 -0
- package/dist/services/meeting-links.d.ts +33 -0
- package/dist/services/meeting-links.js +41 -0
- package/dist/services/meeting-review.d.ts +12 -0
- package/dist/services/meeting-review.js +8 -0
- package/dist/services/messages-search.js +2 -2
- package/dist/services/searches.js +3 -3
- package/dist/store/sqlite/lucene-fields.d.ts +3 -1
- package/dist/store/sqlite/lucene-fields.js +78 -11
- package/dist/store/sqlite/lucene.js +16 -11
- package/dist/store/sqlite/meeting-chat-lines.d.ts +3 -0
- package/dist/store/sqlite/meeting-chat-lines.js +34 -0
- package/dist/store/sqlite/meeting-review.d.ts +4 -0
- package/dist/store/sqlite/meeting-review.js +97 -0
- package/dist/store/sqlite/meetings.d.ts +2 -2
- package/dist/store/sqlite/meetings.js +4 -0
- package/package.json +1 -1
- package/parity.json +0 -11
|
@@ -15,6 +15,10 @@ export const SEARCH_QUERY = {
|
|
|
15
15
|
command: "search",
|
|
16
16
|
guide: "https://github.com/WireCatLabs/cli-core/blob/main/docs/search/query-language.md",
|
|
17
17
|
};
|
|
18
|
+
/** How message search parses typed text: `"a b"~N` is words at most N apart; notes still refuse `~`. */
|
|
19
|
+
export const MESSAGE_PARSING = { ...SEARCH_QUERY, proximity: true };
|
|
20
|
+
/** Further apart than this, two words are rarely about one thing, even in a long message. */
|
|
21
|
+
export const MOST_WORDS_APART = 50;
|
|
18
22
|
export function searchError(reason, span, alternative) {
|
|
19
23
|
queryError(reason, span, alternative, SEARCH_QUERY);
|
|
20
24
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { implicitTextTerms, parseLucene, walkQuery } from "@wirecat/cli-base/query";
|
|
2
2
|
import { normalize } from "../store/normalize.js";
|
|
3
|
-
import {
|
|
3
|
+
import { MESSAGE_PARSING } 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;
|
|
@@ -27,7 +27,7 @@ export function planQuestion(text, strict = false) {
|
|
|
27
27
|
removed: [],
|
|
28
28
|
aliases: [],
|
|
29
29
|
};
|
|
30
|
-
const inputAst = parseLucene(text,
|
|
30
|
+
const inputAst = parseLucene(text, MESSAGE_PARSING);
|
|
31
31
|
const rawTextual = walkQuery(inputAst.root).filter((node) => node.field === "text" || node.field === "exact");
|
|
32
32
|
const body = rawTextual.map((node) => node.value).join(" ");
|
|
33
33
|
const prefix = words(body).slice(0, 2);
|
|
@@ -66,7 +66,7 @@ export function planQuestion(text, strict = false) {
|
|
|
66
66
|
if (last?.operator === "wildcard" && text[mark] === "?")
|
|
67
67
|
cleaned = text.slice(0, mark) + text.slice(mark + 1);
|
|
68
68
|
}
|
|
69
|
-
const ast = cleaned === text ? inputAst : parseLucene(cleaned,
|
|
69
|
+
const ast = cleaned === text ? inputAst : parseLucene(cleaned, MESSAGE_PARSING);
|
|
70
70
|
const predicates = walkQuery(ast.root);
|
|
71
71
|
const textual = predicates.filter((p) => p.field === "text" || p.field === "exact");
|
|
72
72
|
const fields = predicates.filter((p) => p.field !== "text" && p.field !== "exact");
|
package/dist/services/excerpt.js
CHANGED
|
@@ -3,7 +3,10 @@ import { fold } from "../store/normalize.js";
|
|
|
3
3
|
export const queryWords = (query) => query
|
|
4
4
|
.toLowerCase()
|
|
5
5
|
.split(/\s+/)
|
|
6
|
-
.map((word) => word
|
|
6
|
+
.map((word) => word
|
|
7
|
+
.replace(/^[\p{L}_]+:/u, "")
|
|
8
|
+
.replace(/~\d+$/u, "")
|
|
9
|
+
.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ""))
|
|
7
10
|
.filter((word) => word.length > 2 && !["and", "or", "not"].includes(word));
|
|
8
11
|
/**
|
|
9
12
|
* About `size` characters of `text` on one line, around the first word of `query` it holds, or its start when it
|
package/dist/services/index.d.ts
CHANGED
|
@@ -65,8 +65,10 @@ export type { InboxService } from "./inbox.js";
|
|
|
65
65
|
export { inboxService } from "./inbox.js";
|
|
66
66
|
export { readMail } from "./mail-search.js";
|
|
67
67
|
export { type MeetingHitSearchRequest, refreshMeetingIndex, searchMeetingHits } from "./meeting-hit-search.js";
|
|
68
|
+
export { type MeetingLink, type MeetingLinksRequest, readMeetingLinks } from "./meeting-links.js";
|
|
68
69
|
export { createMeetingProjectCandidateReader } from "./meeting-project-candidates.js";
|
|
69
70
|
export { readMeetingProjectDiscovery } from "./meeting-project-discovery.js";
|
|
71
|
+
export { type MeetingReviewRequest, readMeetingReview } from "./meeting-review.js";
|
|
70
72
|
export { type MeetingStatsRequest, readMeetingStats } from "./meeting-stats.js";
|
|
71
73
|
export { chatIdIn, storedChatId } from "./message-resolution.js";
|
|
72
74
|
export type { AroundWindow, FoundMessage, ListWindow, MessagesService, MessageTarget, Pinned, Reacted, SearchFound, SearchQuery, SendRequest, Starred, } from "./messages.js";
|
package/dist/services/index.js
CHANGED
|
@@ -46,8 +46,10 @@ export { foldersService } from "./folders.js";
|
|
|
46
46
|
export { inboxService } from "./inbox.js";
|
|
47
47
|
export { readMail } from "./mail-search.js";
|
|
48
48
|
export { refreshMeetingIndex, searchMeetingHits } from "./meeting-hit-search.js";
|
|
49
|
+
export { readMeetingLinks } from "./meeting-links.js";
|
|
49
50
|
export { createMeetingProjectCandidateReader } from "./meeting-project-candidates.js";
|
|
50
51
|
export { readMeetingProjectDiscovery } from "./meeting-project-discovery.js";
|
|
52
|
+
export { readMeetingReview } from "./meeting-review.js";
|
|
51
53
|
export { readMeetingStats } from "./meeting-stats.js";
|
|
52
54
|
export { chatIdIn, storedChatId } from "./message-resolution.js";
|
|
53
55
|
export { DELETE_AT_ONCE, messagesService } from "./messages.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { MeetingChatLinesInput, MeetingHitSearchRequest } from "../meetings/index.js";
|
|
2
|
+
import type { MessageStore } from "../store/store.js";
|
|
3
|
+
export type MeetingLinksRequest = Omit<MeetingHitSearchRequest, "limit" | "after" | "maxReadBytes"> & Pick<MeetingChatLinesInput, "maxMessages">;
|
|
4
|
+
/** One link as the meetings' chat holds it: where and how often it was shared. */
|
|
5
|
+
export interface MeetingLink {
|
|
6
|
+
url: string;
|
|
7
|
+
domain: string | null;
|
|
8
|
+
/** The Google Docs, Sheets, Slides, Forms or Drive file it names. */
|
|
9
|
+
documentId: string | null;
|
|
10
|
+
count: number;
|
|
11
|
+
firstAt: number;
|
|
12
|
+
lastAt: number;
|
|
13
|
+
senders: string[];
|
|
14
|
+
meetings: {
|
|
15
|
+
meetingId: number;
|
|
16
|
+
accountId: number;
|
|
17
|
+
title: string | null;
|
|
18
|
+
}[];
|
|
19
|
+
}
|
|
20
|
+
/** Links shared in the chat of the selected meetings, most shared first; found when read, not stored. */
|
|
21
|
+
export declare const readMeetingLinks: (store: Pick<MessageStore, "meetings"> & {
|
|
22
|
+
knowledge?: MessageStore["knowledge"];
|
|
23
|
+
}, request: MeetingLinksRequest) => Promise<{
|
|
24
|
+
items: MeetingLink[];
|
|
25
|
+
messages: number;
|
|
26
|
+
meetings: number;
|
|
27
|
+
selection: {
|
|
28
|
+
kind: import("../store/index.js").VocabularyKind;
|
|
29
|
+
id: number;
|
|
30
|
+
name: string;
|
|
31
|
+
by: "explicit-link";
|
|
32
|
+
}[];
|
|
33
|
+
}>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { googleDocumentId } from "../domain/google-documents.js";
|
|
2
|
+
import { findMentions } from "../domain/mentions.js";
|
|
3
|
+
import { resolveMeetingSelection } from "./meeting-hit-search.js";
|
|
4
|
+
/** Links shared in the chat of the selected meetings, most shared first; found when read, not stored. */
|
|
5
|
+
export const readMeetingLinks = async (store, request) => {
|
|
6
|
+
const { maxMessages, ...selecting } = request;
|
|
7
|
+
const { input, selection } = await resolveMeetingSelection(store, selecting);
|
|
8
|
+
const lines = await store.meetings.meetingChatLines({
|
|
9
|
+
...input,
|
|
10
|
+
...(maxMessages === undefined ? {} : { maxMessages }),
|
|
11
|
+
});
|
|
12
|
+
const links = new Map();
|
|
13
|
+
for (const line of lines)
|
|
14
|
+
for (const found of findMentions(line.text)) {
|
|
15
|
+
if (found.kind !== "url")
|
|
16
|
+
continue;
|
|
17
|
+
const link = links.get(found.value) ?? {
|
|
18
|
+
url: found.value,
|
|
19
|
+
domain: found.domain,
|
|
20
|
+
documentId: googleDocumentId(found.value),
|
|
21
|
+
count: 0,
|
|
22
|
+
firstAt: line.sentAt,
|
|
23
|
+
lastAt: line.sentAt,
|
|
24
|
+
senders: [],
|
|
25
|
+
meetings: [],
|
|
26
|
+
};
|
|
27
|
+
link.count += 1;
|
|
28
|
+
link.lastAt = line.sentAt;
|
|
29
|
+
if (line.sender !== null && !link.senders.includes(line.sender))
|
|
30
|
+
link.senders.push(line.sender);
|
|
31
|
+
if (!link.meetings.some((one) => one.meetingId === line.meetingId))
|
|
32
|
+
link.meetings.push({ meetingId: line.meetingId, accountId: line.accountId, title: line.title });
|
|
33
|
+
links.set(found.value, link);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
items: [...links.values()].sort((a, b) => b.count - a.count || b.lastAt - a.lastAt),
|
|
37
|
+
messages: lines.length,
|
|
38
|
+
meetings: new Set(lines.map((line) => line.meetingId)).size,
|
|
39
|
+
selection,
|
|
40
|
+
};
|
|
41
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { MeetingHitSearchRequest, MeetingReview } from "../meetings/index.js";
|
|
2
|
+
import type { MessageStore } from "../store/store.js";
|
|
3
|
+
import { resolveMeetingSelection } from "./meeting-hit-search.js";
|
|
4
|
+
export type MeetingReviewRequest = Omit<MeetingHitSearchRequest, "limit" | "after" | "maxReadBytes"> & {
|
|
5
|
+
limit?: number;
|
|
6
|
+
};
|
|
7
|
+
/** The newest meetings of the selection with their summaries, talk and projects; names resolved as search does. */
|
|
8
|
+
export declare const readMeetingReview: (store: Pick<MessageStore, "meetings"> & {
|
|
9
|
+
knowledge?: MessageStore["knowledge"];
|
|
10
|
+
}, request: MeetingReviewRequest) => Promise<MeetingReview & {
|
|
11
|
+
selection: Awaited<ReturnType<typeof resolveMeetingSelection>>["selection"];
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { resolveMeetingSelection } from "./meeting-hit-search.js";
|
|
2
|
+
/** The newest meetings of the selection with their summaries, talk and projects; names resolved as search does. */
|
|
3
|
+
export const readMeetingReview = async (store, request) => {
|
|
4
|
+
const { limit, ...selecting } = request;
|
|
5
|
+
const { input, selection } = await resolveMeetingSelection(store, selecting);
|
|
6
|
+
const review = await store.meetings.meetingReview({ ...input, ...(limit === undefined ? {} : { limit }) });
|
|
7
|
+
return { ...review, selection };
|
|
8
|
+
};
|
|
@@ -14,7 +14,7 @@ import { chatAmong, senderAmong } from "./message-resolution.js";
|
|
|
14
14
|
import { accountsOfKind, listedAccounts, MAIL } from "./search-kind.js";
|
|
15
15
|
const MAIL_FIELDS = new Set(["to", "cc", "bcc", "subject", "mailbox", "label", "is"]);
|
|
16
16
|
import { dateRange, parseLucene, timezoneOf, walkQuery } from "@wirecat/cli-base/query";
|
|
17
|
-
import {
|
|
17
|
+
import { MESSAGE_PARSING, searchError } from "../search/lucene/types.js";
|
|
18
18
|
const positiveSources = (node) => node.kind === "predicate"
|
|
19
19
|
? node.field === "in"
|
|
20
20
|
? [node.value.toLowerCase()]
|
|
@@ -47,7 +47,7 @@ export const prepareLucene = async (store, account, request, messenger) => {
|
|
|
47
47
|
span: { start: 0, end: 0 },
|
|
48
48
|
},
|
|
49
49
|
}
|
|
50
|
-
: validateFields(parseLucene(request.text, { ...
|
|
50
|
+
: validateFields(parseLucene(request.text, { ...MESSAGE_PARSING, defaultField: request.exact ? "exact" : "text" }));
|
|
51
51
|
const timezone = timezoneOf(request.timezone);
|
|
52
52
|
const stored = await store.accounts.accounts();
|
|
53
53
|
// A fresh store has no row yet for the account it runs as; "every account" still means that one.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CliError, singleLine } from "@wirecat/cli-base";
|
|
2
2
|
import { parseLucene } from "@wirecat/cli-base/query";
|
|
3
3
|
import { validateAst, validateFields } from "../search/lucene/registry.js";
|
|
4
|
-
import {
|
|
4
|
+
import { MESSAGE_PARSING } from "../search/lucene/types.js";
|
|
5
5
|
import { isAdminSelection, readAdminSelection } from "./admin-statistics.js";
|
|
6
6
|
import { searchRecordOf } from "./search-record.js";
|
|
7
7
|
/** A saved search or a run as every searches command answers it. */
|
|
@@ -31,7 +31,7 @@ const checked = (params) => {
|
|
|
31
31
|
if (params.ast !== undefined)
|
|
32
32
|
validateAst(params.ast);
|
|
33
33
|
else if (params.text !== undefined)
|
|
34
|
-
validateFields(parseLucene(params.text, { ...
|
|
34
|
+
validateFields(parseLucene(params.text, { ...MESSAGE_PARSING, defaultField: params.exact ? "exact" : "text" }));
|
|
35
35
|
return params;
|
|
36
36
|
};
|
|
37
37
|
const named = (search, check) => {
|
|
@@ -52,7 +52,7 @@ const both = (stored, words) => ({
|
|
|
52
52
|
kind: "boolean",
|
|
53
53
|
clauses: [
|
|
54
54
|
{ occur: "must", node: stored.root },
|
|
55
|
-
{ occur: "must", node: parseLucene(words,
|
|
55
|
+
{ occur: "must", node: parseLucene(words, MESSAGE_PARSING).root },
|
|
56
56
|
],
|
|
57
57
|
span: { start: 0, end: 0 },
|
|
58
58
|
},
|
|
@@ -32,6 +32,8 @@ export interface Corpus {
|
|
|
32
32
|
/** A voice note's transcript is its attachment's text, written by speech recognition (`speech:<model>`). */
|
|
33
33
|
export declare const SPEECH = "coalesce(att.extractor,'') LIKE 'speech:%'";
|
|
34
34
|
export declare const quoted: (value: string) => string;
|
|
35
|
+
/** A leaf's words as an FTS5 match: a phrase, or with `~N` its words at most N apart, in any order. */
|
|
36
|
+
export declare const ftsMatch: (node: ResolvedPredicate) => string;
|
|
35
37
|
export declare const combine: (parts: Fragment[], operator: "AND" | "OR") => Fragment;
|
|
36
38
|
export declare const prefixOf: (pattern: string) => string;
|
|
37
39
|
export declare const wordMatch: (corpus: Corpus, match: string) => Fragment;
|
|
@@ -50,7 +52,7 @@ export interface FieldScope {
|
|
|
50
52
|
sql: string;
|
|
51
53
|
params: SqlValue[];
|
|
52
54
|
};
|
|
53
|
-
everywhere: (node: ResolvedPredicate,
|
|
55
|
+
everywhere: (node: ResolvedPredicate, fragment: Fragment) => Fragment;
|
|
54
56
|
/** Counts the patterns' expanded words against the query's budget. */
|
|
55
57
|
expand: (count: number) => number;
|
|
56
58
|
}
|
|
@@ -8,12 +8,20 @@ import { parseBytes } from "../../search/lucene/registry.js";
|
|
|
8
8
|
import { isStemmed } from "../../search/lucene/resolved.js";
|
|
9
9
|
import { exhausted, QUERY_LIMITS, searchError } from "../../search/lucene/types.js";
|
|
10
10
|
import { inSource } from "../../search/query.js";
|
|
11
|
+
import { STEM_SEPARATOR } from "../../search/stem.js";
|
|
11
12
|
import { normalize } from "../normalize.js";
|
|
12
13
|
import { linkMatch } from "./mention-links.js";
|
|
13
14
|
import { valueMatch } from "./phone-countries.js";
|
|
14
15
|
/** A voice note's transcript is its attachment's text, written by speech recognition (`speech:<model>`). */
|
|
15
16
|
export const SPEECH = "coalesce(att.extractor,'') LIKE 'speech:%'";
|
|
16
17
|
export const quoted = (value) => `"${value.replaceAll('"', '""')}"`;
|
|
18
|
+
/** A leaf's words as an FTS5 match: a phrase, or with `~N` its words at most N apart, in any order. */
|
|
19
|
+
export const ftsMatch = (node) => {
|
|
20
|
+
const text = normalize(node.value);
|
|
21
|
+
const words = node.slop === undefined ? [] : (text.match(/[\p{L}\p{N}\p{Co}\p{M}]+/gu) ?? []);
|
|
22
|
+
return words.length > 1 ? nearMatch(words, node.slop) : quoted(text);
|
|
23
|
+
};
|
|
24
|
+
const nearMatch = (words, apart) => `NEAR(${words.map(quoted).join(" ")}, ${apart})`;
|
|
17
25
|
export const combine = (parts, operator) => ({
|
|
18
26
|
sql: parts.length ? `(${parts.map(({ sql }) => sql).join(` ${operator} `)})` : operator === "AND" ? "1" : "0",
|
|
19
27
|
params: parts.flatMap(({ params }) => params),
|
|
@@ -59,20 +67,79 @@ const mentioned = ({ mail }, where, params) => mail
|
|
|
59
67
|
: bound(`m.id IN (SELECT mn.source_id FROM mentions mn WHERE mn.source_type='message' AND ${where} ` +
|
|
60
68
|
"UNION SELECT a.attachable_id FROM mentions mn JOIN attachments a ON a.id=mn.source_id " +
|
|
61
69
|
`AND a.attachable_type='message' WHERE mn.source_type='attachment' AND ${where})`, ...params, ...params);
|
|
62
|
-
const stemsOf = ({ execution },
|
|
70
|
+
const stemsOf = ({ execution: { stemmer } }, node) => {
|
|
71
|
+
if (node.slop === undefined)
|
|
72
|
+
return stemmer?.phrases(node.value);
|
|
73
|
+
const sequences = (stemmer?.stemSequences(node.value) ?? []).filter((sequence) => sequence.length);
|
|
74
|
+
return sequences.length
|
|
75
|
+
? sequences
|
|
76
|
+
.map((sequence) => (sequence.length > 1 ? nearMatch(sequence, node.slop) : quoted(sequence.join(""))))
|
|
77
|
+
.join(" OR ")
|
|
78
|
+
: undefined;
|
|
79
|
+
};
|
|
80
|
+
/** Whether some stem sequence of a text holds every stem of `wanted` with at most `apart` other words between. */
|
|
81
|
+
const near = (sequence, wanted, apart) => {
|
|
82
|
+
const events = sequence.flatMap((stem, index) => (wanted.includes(stem) ? [{ index, stem }] : []));
|
|
83
|
+
const held = new Map();
|
|
84
|
+
const distinct = new Set(wanted).size;
|
|
85
|
+
let left = 0;
|
|
86
|
+
for (const event of events) {
|
|
87
|
+
held.set(event.stem, (held.get(event.stem) ?? 0) + 1);
|
|
88
|
+
while (held.size === distinct) {
|
|
89
|
+
const first = events[left];
|
|
90
|
+
if (event.index - first.index + 1 - distinct <= apart)
|
|
91
|
+
return true;
|
|
92
|
+
const count = (held.get(first.stem) ?? 1) - 1;
|
|
93
|
+
if (count)
|
|
94
|
+
held.set(first.stem, count);
|
|
95
|
+
else
|
|
96
|
+
held.delete(first.stem);
|
|
97
|
+
left++;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* `NEAR` over the stems can bridge `STEM_SEPARATOR`, which parts a text's Latin readings: the end of one and the
|
|
104
|
+
* start of the next are one word apart. A row without the separator is the index's answer; one with it is read.
|
|
105
|
+
*/
|
|
106
|
+
const nearStems = (scope, node, stems) => {
|
|
107
|
+
const { database, corpus, execution } = scope;
|
|
108
|
+
const stemmer = execution.stemmer;
|
|
109
|
+
const rows = database
|
|
110
|
+
.prepare(`SELECT m.id AS id, m.text AS text FROM ${corpus.rows} m WHERE m.id IN (SELECT rowid FROM ${corpus.stems} WHERE ${corpus.stems} MATCH ?) LIMIT ?`)
|
|
111
|
+
.all(`stems : ((${stems}) AND ${quoted(STEM_SEPARATOR)})`, QUERY_LIMITS.candidates + 1);
|
|
112
|
+
if (rows.length > QUERY_LIMITS.candidates)
|
|
113
|
+
exhausted("candidate rows");
|
|
114
|
+
const wanted = stemmer.stemSequences(node.value).filter((sequence) => sequence.length > 1);
|
|
115
|
+
const kept = rows.flatMap(({ id, text }) => {
|
|
116
|
+
scope.check();
|
|
117
|
+
const sequences = stemmer.stemSequences(String(text));
|
|
118
|
+
return wanted.some((query) => sequences.some((sequence) => near(sequence, query, node.slop)))
|
|
119
|
+
? [Number(id)]
|
|
120
|
+
: [];
|
|
121
|
+
});
|
|
122
|
+
return bound(`m.id IN (SELECT rowid FROM ${corpus.stems} WHERE ${corpus.stems} MATCH ?) OR m.id IN (SELECT value FROM json_each(?))`, `stems : ((${stems}) NOT ${quoted(STEM_SEPARATOR)})`, JSON.stringify(kept));
|
|
123
|
+
};
|
|
63
124
|
const stemmedText = (node, scope) => {
|
|
64
125
|
const { execution, corpus } = scope;
|
|
65
126
|
if (!execution.stemmer && !execution.unstemmed)
|
|
66
127
|
throw new Error("a stemmed leaf reached the compiler without the store's stemmer");
|
|
67
128
|
const text = normalize(node.value);
|
|
68
|
-
const
|
|
129
|
+
const match = ftsMatch(node);
|
|
130
|
+
const stems = stemsOf(scope, node);
|
|
131
|
+
const byStems = stems && node.slop !== undefined
|
|
132
|
+
? nearStems(scope, node, stems)
|
|
133
|
+
: stems
|
|
134
|
+
? bound(`m.id IN (SELECT rowid FROM ${corpus.stems} WHERE ${corpus.stems} MATCH ?)`, `stems : (${stems})`)
|
|
135
|
+
: undefined;
|
|
69
136
|
// Words OR stems: two spellings can fold one word to different stems, and every exact hit must stay.
|
|
70
137
|
return always(/[\p{L}\p{N}]/u.test(text)
|
|
71
|
-
? scope.everywhere(node,
|
|
72
|
-
sql: `m.id IN (SELECT rowid FROM ${corpus.words} WHERE ${corpus.words} MATCH ?)${
|
|
73
|
-
params: [`normalized_text : (${
|
|
138
|
+
? scope.everywhere(node, {
|
|
139
|
+
sql: `m.id IN (SELECT rowid FROM ${corpus.words} WHERE ${corpus.words} MATCH ?)${byStems ? ` OR ${byStems.sql}` : ""}`,
|
|
140
|
+
params: [`normalized_text : (${match})`, ...(byStems?.params ?? [])],
|
|
74
141
|
exact: true,
|
|
75
|
-
fts:
|
|
142
|
+
fts: match,
|
|
76
143
|
...(stems ? { stems } : {}),
|
|
77
144
|
})
|
|
78
145
|
: bound("0"));
|
|
@@ -86,9 +153,9 @@ const words = (node, scope) => {
|
|
|
86
153
|
return stemmedText(node, scope);
|
|
87
154
|
if (operator === "term" || operator === "phrase") {
|
|
88
155
|
const text = normalize(value);
|
|
89
|
-
const stems = stemsOf(scope,
|
|
156
|
+
const stems = stemsOf(scope, node);
|
|
90
157
|
return always(/[\p{L}\p{N}]/u.test(text)
|
|
91
|
-
? scope.everywhere(node,
|
|
158
|
+
? scope.everywhere(node, { ...wordMatch(corpus, ftsMatch(node)), ...(stems ? { stems } : {}) })
|
|
92
159
|
: bound("0"));
|
|
93
160
|
}
|
|
94
161
|
const pattern = operator === "wildcard" ? wildcardPattern(normalize(value)) : foldRegex(value, node.span);
|
|
@@ -181,10 +248,10 @@ const size = (node, { corpus }) => {
|
|
|
181
248
|
const range = combine(conditions, "AND");
|
|
182
249
|
return always(bound(`EXISTS (SELECT 1 FROM attachments att WHERE att.attachable_type='${corpus.item}' AND att.attachable_id=m.id AND att.size IS NOT NULL AND ${range.sql})`, ...range.params));
|
|
183
250
|
};
|
|
184
|
-
const attachmentText = (which) => (
|
|
185
|
-
const text = normalize(value);
|
|
251
|
+
const attachmentText = (which) => (node, scope) => {
|
|
252
|
+
const text = normalize(node.value);
|
|
186
253
|
// Never `fts`: the ranking ANDs every required word into message_words, where a file's words are not.
|
|
187
|
-
const set = scope.attachmentWords(
|
|
254
|
+
const set = scope.attachmentWords(ftsMatch(node), which);
|
|
188
255
|
return always(/[\p{L}\p{N}]/u.test(text) ? bound(`m.id IN (${set.sql})`, ...set.params) : bound("0"));
|
|
189
256
|
};
|
|
190
257
|
const date = (node) => {
|
|
@@ -4,7 +4,7 @@ import { isStemmed } from "../../search/lucene/resolved.js";
|
|
|
4
4
|
import { exhausted, QUERY_LIMITS, searchError } from "../../search/lucene/types.js";
|
|
5
5
|
import { normalize } from "../normalize.js";
|
|
6
6
|
import { emailHitsByPk } from "./emails.js";
|
|
7
|
-
import { combine, FIELD_COMPILERS,
|
|
7
|
+
import { combine, FIELD_COMPILERS, ftsMatch, SPEECH, } from "./lucene-fields.js";
|
|
8
8
|
import { hitsByPk } from "./search.js";
|
|
9
9
|
import { read } from "./transaction.js";
|
|
10
10
|
export { prefixOf } from "./lucene-fields.js";
|
|
@@ -83,11 +83,12 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
|
|
|
83
83
|
params: [match],
|
|
84
84
|
});
|
|
85
85
|
/** A bare word also matches a transcript, a file's text and a word of a file's name. */
|
|
86
|
-
const everywhere = (node,
|
|
86
|
+
const everywhere = (node, fragment) => {
|
|
87
87
|
if (!node.implicit || fragment.sql === "0")
|
|
88
88
|
return fragment;
|
|
89
|
-
const
|
|
90
|
-
const
|
|
89
|
+
const match = ftsMatch(node);
|
|
90
|
+
const files = attachmentWords(match, "any");
|
|
91
|
+
const elsewhere = { sql: `${files.sql} UNION ${namedSql}`, params: [...files.params, match] };
|
|
91
92
|
return {
|
|
92
93
|
...fragment,
|
|
93
94
|
sql: `(${fragment.sql} OR m.id IN (${elsewhere.sql}))`,
|
|
@@ -285,10 +286,10 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
|
|
|
285
286
|
}
|
|
286
287
|
// Among hits the message text does not hold: a transcript, then a file's name, then a file's text. bm25 scores
|
|
287
288
|
// of different indexes do not compare, and a long spreadsheet must not crowd out what was said.
|
|
288
|
-
const places = [...fragments.values()].flatMap(({ node, fragment }) => fragment.elsewhere && fragment.fts ? [
|
|
289
|
+
const places = [...fragments.values()].flatMap(({ node, fragment }) => fragment.elsewhere && fragment.fts ? [ftsMatch(node)] : []);
|
|
289
290
|
if (places.length && !execution.newest) {
|
|
290
291
|
ranked.sql.push(`s(id, tier) AS MATERIALIZED (SELECT att.attachable_id, min(CASE WHEN ${SPEECH} THEN 1 ELSE 3 END) FROM attachments att WHERE att.attachable_type='${corpus.item}' AND att.id IN (SELECT rowid FROM attachment_words WHERE attachment_words MATCH ?) GROUP BY att.attachable_id UNION ALL SELECT attachable_id, 2 FROM (${namedSql}))`);
|
|
291
|
-
ranked.params.push(places.
|
|
292
|
+
ranked.params.push(places.join(" OR "), places.join(" OR "));
|
|
292
293
|
}
|
|
293
294
|
const placeTier = places.length && !execution.newest ? "coalesce((SELECT min(tier) FROM s WHERE s.id=m.id), 4)," : "";
|
|
294
295
|
if (exactTier) {
|
|
@@ -342,10 +343,10 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
|
|
|
342
343
|
if (!placeLeaves.length || !pks.length || found.length !== pks.length)
|
|
343
344
|
return found;
|
|
344
345
|
const keys = JSON.stringify(pks);
|
|
345
|
-
const
|
|
346
|
-
if (!
|
|
346
|
+
const wordy = placeLeaves.filter(({ node }) => /[\p{L}\p{N}]/u.test(normalize(node.value)));
|
|
347
|
+
if (!wordy.length)
|
|
347
348
|
return found;
|
|
348
|
-
const match =
|
|
349
|
+
const match = wordy.map(({ node }) => ftsMatch(node)).join(" OR ");
|
|
349
350
|
const implicit = placeLeaves.some(({ node }) => node.implicit);
|
|
350
351
|
const inText = new Set(implicit
|
|
351
352
|
? database
|
|
@@ -369,7 +370,6 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
|
|
|
369
370
|
.prepare(`SELECT att.attachable_id AS id, att.name AS name FROM attachments att WHERE att.attachable_type='${corpus.item}' AND att.attachable_id IN (SELECT value FROM json_each(?)) AND att.name IS NOT NULL ORDER BY att.position`)
|
|
370
371
|
.all(keys)
|
|
371
372
|
: [];
|
|
372
|
-
const wanted = texts.map((text) => text.split(/[^\p{L}\p{N}]+/u).filter(Boolean));
|
|
373
373
|
return found.map((hit, index) => {
|
|
374
374
|
const pk = pks[index];
|
|
375
375
|
const transcript = files.find((row) => Number(row.id) === pk && Number(row.speech) === 1);
|
|
@@ -378,7 +378,12 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
|
|
|
378
378
|
if (Number(row.id) !== pk)
|
|
379
379
|
return false;
|
|
380
380
|
const words = normalize(String(row.name)).split(/[^\p{L}\p{N}]+/u);
|
|
381
|
-
return
|
|
381
|
+
return wordy.some(({ node }) => {
|
|
382
|
+
const phrase = normalize(node.value).match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
383
|
+
if (node.slop !== undefined)
|
|
384
|
+
return phrase.every((word) => words.includes(word));
|
|
385
|
+
return words.some((_, at) => phrase.every((word, offset) => words[at + offset] === word));
|
|
386
|
+
});
|
|
382
387
|
});
|
|
383
388
|
const place = inText.has(pk)
|
|
384
389
|
? ["text", hit.text]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { CHAT_MESSAGES_READ } from "../../meetings/index.js";
|
|
3
|
+
import { checkMeetingCancelled } from "./meeting-operation.js";
|
|
4
|
+
import { positiveReadInteger } from "./meeting-reads.js";
|
|
5
|
+
import { meetingSelectionSql } from "./meeting-selection.js";
|
|
6
|
+
import { read } from "./transaction.js";
|
|
7
|
+
export const meetingChatLinesOver = ({ database }) => ({
|
|
8
|
+
async meetingChatLines(input) {
|
|
9
|
+
checkMeetingCancelled(input.signal);
|
|
10
|
+
positiveReadInteger(input.accountId, "accountId");
|
|
11
|
+
const most = input.maxMessages ?? CHAT_MESSAGES_READ;
|
|
12
|
+
if (!Number.isSafeInteger(most) || most < 1 || most > CHAT_MESSAGES_READ)
|
|
13
|
+
throw new CliError("validation_error", `read 1 through ${CHAT_MESSAGES_READ} chat messages`);
|
|
14
|
+
return read(database, () => {
|
|
15
|
+
const { clauses, params } = meetingSelectionSql(input);
|
|
16
|
+
const rows = database
|
|
17
|
+
.prepare(`SELECT c.meeting_id, m.account_id, m.title, c.sent_at, coalesce(c.sender_name, p.display_name) AS sender, c.text
|
|
18
|
+
FROM meetings m JOIN meeting_chat_messages c ON c.meeting_id = m.id
|
|
19
|
+
LEFT JOIN meeting_participants p ON p.id = c.sender_participant_id
|
|
20
|
+
WHERE ${clauses.join(" AND ")} ORDER BY c.sent_at, c.id LIMIT ?`)
|
|
21
|
+
.all(...params, most + 1);
|
|
22
|
+
if (rows.length > most)
|
|
23
|
+
throw new CliError("validation_error", `the selection has more than ${most} chat messages; narrow it`);
|
|
24
|
+
return rows.map((row) => ({
|
|
25
|
+
meetingId: Number(row.meeting_id),
|
|
26
|
+
accountId: Number(row.account_id),
|
|
27
|
+
title: row.title === null ? null : String(row.title),
|
|
28
|
+
sentAt: Number(row.sent_at),
|
|
29
|
+
sender: row.sender === null ? null : String(row.sender),
|
|
30
|
+
text: String(row.text),
|
|
31
|
+
}));
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type MeetingReviewStore } from "../../meetings/index.js";
|
|
2
|
+
import type { StoreContext } from "./open.js";
|
|
3
|
+
/** Talk and words come from one transcript per meeting, its earliest current one, as in meeting stats. */
|
|
4
|
+
export declare const meetingReviewOver: ({ database }: StoreContext) => MeetingReviewStore;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { REVIEW_LIMIT } from "../../meetings/index.js";
|
|
3
|
+
import { fromJson } from "./events.js";
|
|
4
|
+
import { checkMeetingCancelled } from "./meeting-operation.js";
|
|
5
|
+
import { positiveReadInteger } from "./meeting-reads.js";
|
|
6
|
+
import { meetingSelectionSql } from "./meeting-selection.js";
|
|
7
|
+
import { read } from "./transaction.js";
|
|
8
|
+
const number = (value) => (value === null || value === undefined ? null : Number(value));
|
|
9
|
+
const text = (value) => (value === null || value === undefined ? null : String(value));
|
|
10
|
+
/** Talk and words come from one transcript per meeting, its earliest current one, as in meeting stats. */
|
|
11
|
+
export const meetingReviewOver = ({ database }) => ({
|
|
12
|
+
async meetingReview(input) {
|
|
13
|
+
checkMeetingCancelled(input.signal);
|
|
14
|
+
positiveReadInteger(input.accountId, "accountId");
|
|
15
|
+
const limit = input.limit ?? REVIEW_LIMIT.default;
|
|
16
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > REVIEW_LIMIT.most)
|
|
17
|
+
throw new CliError("validation_error", `a review shows 1 through ${REVIEW_LIMIT.most} meetings`);
|
|
18
|
+
return read(database, () => {
|
|
19
|
+
const { clauses, params } = meetingSelectionSql(input);
|
|
20
|
+
const meetings = database
|
|
21
|
+
.prepare(`SELECT m.id, m.account_id, m.title, m.started_at, m.duration_ms, m.participants_count FROM meetings m
|
|
22
|
+
WHERE ${clauses.join(" AND ")} ORDER BY m.started_at DESC, m.id DESC LIMIT ?`)
|
|
23
|
+
.all(...params, limit + 1);
|
|
24
|
+
const shown = meetings.slice(0, limit);
|
|
25
|
+
const items = new Map();
|
|
26
|
+
for (const row of shown)
|
|
27
|
+
items.set(Number(row.id), {
|
|
28
|
+
meetingId: Number(row.id),
|
|
29
|
+
accountId: Number(row.account_id),
|
|
30
|
+
title: text(row.title),
|
|
31
|
+
startedAt: number(row.started_at),
|
|
32
|
+
durationMs: number(row.duration_ms),
|
|
33
|
+
participantsCount: number(row.participants_count),
|
|
34
|
+
projects: [],
|
|
35
|
+
transcript: null,
|
|
36
|
+
speakers: [],
|
|
37
|
+
summary: null,
|
|
38
|
+
});
|
|
39
|
+
if (items.size) {
|
|
40
|
+
const ids = [...items.keys()];
|
|
41
|
+
const marks = ids.map(() => "?").join(",");
|
|
42
|
+
checkMeetingCancelled(input.signal);
|
|
43
|
+
const talk = database
|
|
44
|
+
.prepare(`WITH chosen AS (SELECT min(t.id) AS id, t.meeting_id FROM meeting_transcripts t
|
|
45
|
+
WHERE t.meeting_id IN (${marks}) AND t.deleted_at IS NULL AND t.superseded_at IS NULL GROUP BY t.meeting_id)
|
|
46
|
+
SELECT c.meeting_id, coalesce(person.name, i.name, r.speaker_name, p.display_name) AS speaker,
|
|
47
|
+
count(*) AS lines, sum(max(r.end_ms - r.start_ms, 0)) AS talk_ms,
|
|
48
|
+
sum(length(trim(r.text)) - length(replace(trim(r.text), ' ', '')) + (length(trim(r.text)) > 0)) AS words
|
|
49
|
+
FROM chosen c JOIN meeting_transcript_rows r ON r.meeting_transcript_id = c.id
|
|
50
|
+
LEFT JOIN meeting_participants p ON p.id = r.speaker_participant_id
|
|
51
|
+
LEFT JOIN identities i ON i.id = p.identity_id
|
|
52
|
+
LEFT JOIN identity_links l ON l.identity_id = p.identity_id
|
|
53
|
+
LEFT JOIN persons person ON person.id = l.person_id
|
|
54
|
+
GROUP BY c.meeting_id, speaker ORDER BY c.meeting_id, talk_ms DESC`)
|
|
55
|
+
.all(...ids);
|
|
56
|
+
for (const row of talk) {
|
|
57
|
+
const item = items.get(Number(row.meeting_id));
|
|
58
|
+
item.transcript ??= { lines: 0, words: 0 };
|
|
59
|
+
item.transcript.lines += Number(row.lines);
|
|
60
|
+
item.transcript.words += Number(row.words);
|
|
61
|
+
if (item.speakers.length < 3)
|
|
62
|
+
item.speakers.push({ name: text(row.speaker), talkMs: Number(row.talk_ms) });
|
|
63
|
+
}
|
|
64
|
+
const transcribed = database
|
|
65
|
+
.prepare(`SELECT DISTINCT meeting_id FROM meeting_transcripts
|
|
66
|
+
WHERE meeting_id IN (${marks}) AND deleted_at IS NULL AND superseded_at IS NULL`)
|
|
67
|
+
.all(...ids);
|
|
68
|
+
for (const row of transcribed) {
|
|
69
|
+
const item = items.get(Number(row.meeting_id));
|
|
70
|
+
item.transcript ??= { lines: 0, words: 0 };
|
|
71
|
+
}
|
|
72
|
+
const summaries = database
|
|
73
|
+
.prepare(`SELECT meeting_id, source, title, overview, next_steps, doc_url FROM meeting_summaries
|
|
74
|
+
WHERE meeting_id IN (${marks}) ORDER BY coalesce(external_updated_at, updated_at) DESC, id DESC`)
|
|
75
|
+
.all(...ids);
|
|
76
|
+
for (const row of summaries) {
|
|
77
|
+
const item = items.get(Number(row.meeting_id));
|
|
78
|
+
item.summary ??= {
|
|
79
|
+
source: String(row.source),
|
|
80
|
+
title: text(row.title),
|
|
81
|
+
overview: text(row.overview),
|
|
82
|
+
nextSteps: fromJson(row.next_steps) ?? [],
|
|
83
|
+
url: text(row.doc_url),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const projects = database
|
|
87
|
+
.prepare(`SELECT l.from_id, p.name FROM links l JOIN projects p ON p.id = l.to_id
|
|
88
|
+
WHERE l.from_type = 'meeting' AND l.from_id IN (${marks}) AND l.to_type = 'project' AND l.kind = 'member-of'
|
|
89
|
+
AND l.anchor IS NULL AND l.confirmed = 1 AND p.account_id IS NULL AND p.deleted_at IS NULL ORDER BY p.name`)
|
|
90
|
+
.all(...ids);
|
|
91
|
+
for (const row of projects)
|
|
92
|
+
items.get(Number(row.from_id)).projects.push(String(row.name));
|
|
93
|
+
}
|
|
94
|
+
return { items: [...items.values()], hasMore: meetings.length > limit };
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Attachment, MeetingContextStore, MeetingParticipantSearchStore, MeetingPreparationContextStore, MeetingPreparationKnowledgeStore, MeetingPreparationNotesStore, MeetingRelatedHistoryStore, MeetingSearchIndexStore, MeetingSearchStore, MeetingSeriesHistoryStore, MeetingStatsStore, MeetingStore, MeetingTranscriptReceiptStore, MeetingTranscriptStore, NewRecord } from "../../meetings/index.js";
|
|
1
|
+
import type { Attachment, MeetingChatLinesStore, MeetingContextStore, MeetingParticipantSearchStore, MeetingPreparationContextStore, MeetingPreparationKnowledgeStore, MeetingPreparationNotesStore, MeetingRelatedHistoryStore, MeetingReviewStore, MeetingSearchIndexStore, MeetingSearchStore, MeetingSeriesHistoryStore, MeetingStatsStore, MeetingStore, MeetingTranscriptReceiptStore, MeetingTranscriptStore, NewRecord } from "../../meetings/index.js";
|
|
2
2
|
import type { CacheDatabase } from "../driver.js";
|
|
3
3
|
import { type MeetingContextReadCapabilities, type MeetingReadCapabilities } from "./meeting-reads.js";
|
|
4
4
|
import type { StoreContext } from "./open.js";
|
|
@@ -11,7 +11,7 @@ export declare const saveAttachment: (database: CacheDatabase, type: string, par
|
|
|
11
11
|
export declare const drainMeetingIndex: (database: CacheDatabase, batch?: number) => number;
|
|
12
12
|
/** Each word of the query, as a prefix, all required. */
|
|
13
13
|
export declare const wordsQuery: (query: string) => string | null;
|
|
14
|
-
export type StoredMeetings = MeetingStore & MeetingPreparationContextStore & MeetingPreparationKnowledgeStore & MeetingPreparationNotesStore & MeetingRelatedHistoryStore & MeetingSeriesHistoryStore & MeetingTranscriptStore & MeetingReadCapabilities & MeetingContextReadCapabilities & MeetingParticipantSearchStore & MeetingSearchStore & MeetingTranscriptReceiptStore & MeetingContextStore & MeetingSearchIndexStore & MeetingStatsStore;
|
|
14
|
+
export type StoredMeetings = MeetingStore & MeetingPreparationContextStore & MeetingPreparationKnowledgeStore & MeetingPreparationNotesStore & MeetingRelatedHistoryStore & MeetingSeriesHistoryStore & MeetingTranscriptStore & MeetingReadCapabilities & MeetingContextReadCapabilities & MeetingParticipantSearchStore & MeetingSearchStore & MeetingTranscriptReceiptStore & MeetingContextStore & MeetingSearchIndexStore & MeetingStatsStore & MeetingReviewStore & MeetingChatLinesStore;
|
|
15
15
|
/** The `MeetingStore` of `src/meetings/` over the shared store. */
|
|
16
16
|
export declare const meetingStoreOver: (context: StoreContext) => StoredMeetings;
|
|
17
17
|
export {};
|