@chatpanel/events 0.101.0 → 0.103.1
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/adapters.js +1 -83
- package/adaptive-tool-policy.js +1 -45
- package/agent.js +2 -255
- package/attribution.js +1 -132
- package/backup-envelope.js +1 -221
- package/board-tool.js +2 -165
- package/budget.js +1 -116
- package/capability.js +1 -126
- package/citations.js +5 -127
- package/client-prefs.js +1 -148
- package/context-attachments.js +8 -144
- package/cowriter-router.js +1 -83
- package/cowriter-writer.js +19 -193
- package/cowriter.js +1 -190
- package/curate.js +3 -509
- package/distance.js +1 -124
- package/engine.js +1 -131
- package/entitlement.js +1 -332
- package/entity.js +1 -354
- package/event.js +1 -175
- package/extraction.js +4 -293
- package/failover.js +1 -93
- package/find-tool.js +1 -66
- package/flowchart.js +2 -644
- package/gate.js +1 -74
- package/harness.js +4 -101
- package/index.js +1 -345
- package/invariants.js +1 -174
- package/job.js +3 -147
- package/kernel.js +1 -255
- package/knowledge-derive.js +1 -267
- package/knowledge.js +3 -230
- package/library.js +11 -280
- package/loop.js +1 -132
- package/manifest.js +1 -107
- package/markdown-authoring.js +11 -288
- package/markdown-render.js +0 -0
- package/mcp-client.js +8 -358
- package/mcp-dispatch.js +1 -52
- package/mcp-errors.js +1 -106
- package/mcp-manager.js +1 -127
- package/media-transcript.js +8 -531
- package/meeting-analyzers.js +1 -83
- package/meeting-insights.js +7 -164
- package/meeting-shape.js +1 -282
- package/meeting-text.js +5 -94
- package/memory.js +2 -638
- package/model-candidates.js +1 -358
- package/model-health.js +1 -182
- package/model-ledger.js +1 -228
- package/model-picker.js +1 -188
- package/note-actions.js +15 -246
- package/note-graph.js +1 -158
- package/note-links.js +1 -68
- package/note-mentions.js +8 -114
- package/note-plan.js +9 -107
- package/note-research.js +1 -135
- package/observability.js +1 -119
- package/omni.js +1 -125
- package/order.js +1 -78
- package/package.json +1 -4
- package/pdf-layout.js +7 -253
- package/project-run.js +20 -444
- package/project.js +1 -170
- package/promotion.js +1 -171
- package/queue.js +1 -98
- package/reach.js +1 -23
- package/recipe-tool.js +2 -161
- package/recipe.js +1 -264
- package/record-list.js +2 -110
- package/recruit.js +3 -456
- package/redaction-tokens.js +1 -61
- package/ref.js +1 -55
- package/registry.js +1 -240
- package/route-graph.js +1 -115
- package/route-strategies.js +2 -232
- package/router.js +2 -880
- package/rrf.js +1 -89
- package/rules.js +1 -142
- package/schedule.js +1 -663
- package/scm-connection.js +1 -180
- package/scopes.js +1 -12
- package/scorecard.js +1 -335
- package/search-engines.js +1 -81
- package/skill-manifest.js +1 -330
- package/skill-scan.js +3 -209
- package/skill-sources.js +1 -162
- package/skill-vars.js +2 -252
- package/slash-commands.js +1 -158
- package/source-gate.js +1 -92
- package/sources-retrieval.js +5 -186
- package/sources.js +1 -273
- package/store.js +1 -171
- package/structured.js +4 -901
- package/subject-kinds.js +1 -5
- package/subject-name.js +1 -96
- package/sync-plan.js +1 -213
- package/synthesis.js +3 -123
- package/tags.js +1 -203
- package/team-board.js +6 -337
- package/team-cache.js +2 -63
- package/team-observe.js +1 -118
- package/team-org.js +1 -348
- package/team-plan.js +4 -131
- package/team-record.js +1 -225
- package/team-run.js +9 -652
- package/team-subtask.js +2 -191
- package/team-task.js +2 -124
- package/team-tool.js +2 -196
- package/team-trail.js +2 -73
- package/team-worklog.js +2 -149
- package/team.js +1 -302
- package/text-search.js +1 -155
- package/theme.js +17 -154
- package/titles.js +4 -285
- package/tool-discovery.js +1 -87
- package/tool-dispatch.js +5 -230
- package/tool-groups.js +1 -81
- package/tool-hints.js +10 -185
- package/tool-loop-guard.js +4 -182
- package/tool-need.js +2 -124
- package/tool-result.js +2 -438
- package/tool-round.js +0 -0
- package/tool-schema.js +1 -155
- package/tool-traits.js +1 -182
- package/toolset.js +2 -88
- package/trajectory.js +1 -530
- package/turn-loop.js +4 -440
- package/upcast.js +1 -37
- package/vault.js +2 -250
- package/view.js +1 -88
- package/voice-intents.js +2 -1488
- package/voice-speaker.js +1 -98
- package/weather-tool.js +1 -65
- package/weather.js +2 -211
- package/web-search-tool.js +14 -102
- package/web-search.js +1 -160
- package/widget.js +1 -153
package/curate.js
CHANGED
|
@@ -1,509 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
// looking for contradictions, which is both expensive and O(N²). Most of what it would
|
|
5
|
-
// "notice" is mechanical, though: a [[link]] pointing at nothing, a record connected to
|
|
6
|
-
// nothing, two titles that differ by a space, a tag spelled two ways, a subject mentioned
|
|
7
|
-
// everywhere with no page. None of that needs a model, and running it first is what keeps
|
|
8
|
-
// the model half small enough to bound — only candidates this pass FLAGGED are ever
|
|
9
|
-
// adjudicated, and only within one subject's claims (never N×N over the corpus).
|
|
10
|
-
//
|
|
11
|
-
// So this module is the deterministic layer, and `surveyCorpus()` is its first caller: the
|
|
12
|
-
// W0 read-only report over an existing corpus. It answers, for a real user's data, the
|
|
13
|
-
// question the design cannot answer from a chair — is this corpus dense enough that a
|
|
14
|
-
// derived layer beats re-deriving from retrieval every time? If the numbers are thin the
|
|
15
|
-
// honest conclusion is to finish retrieval instead, and this report is what says so.
|
|
16
|
-
//
|
|
17
|
-
// Pure input → output. No storage, no clock, no network, no model — it runs in a service
|
|
18
|
-
// worker, in Node against a backup, and in the gateway against the warm store, and gives
|
|
19
|
-
// the same answer in all three.
|
|
20
|
-
|
|
21
|
-
import { normalizeTag } from './tags.js';
|
|
22
|
-
// The bounded Levenshtein, from the module that holds only it. Two implementations of "how
|
|
23
|
-
// far apart are these strings" become two answers to "is this the same title" — but see
|
|
24
|
-
// distance.js for why it is not imported from voice-intents.js, which is where it grew up.
|
|
25
|
-
import { blockedPairs, editDistance } from './distance.js';
|
|
26
|
-
import {
|
|
27
|
-
DEFAULT_THRESHOLD, MAX_SUBJECTS, isSelfLabel, isSubjectCandidate,
|
|
28
|
-
normalizeSubject, rankSubjects, resolveSubjects,
|
|
29
|
-
} from './entity.js';
|
|
30
|
-
import { isRedactionToken } from './redaction-tokens.js';
|
|
31
|
-
|
|
32
|
-
/** Wikilink syntax, matching store-notes.js `extractLinks` exactly — one grammar, not two. */
|
|
33
|
-
const WIKILINK_RE = /\[\[([^[\]\n]+)\]\]/g;
|
|
34
|
-
|
|
35
|
-
/** Below this, two normalized titles are "the same title typed twice". */
|
|
36
|
-
export const NEAR_TITLE_DISTANCE = 2;
|
|
37
|
-
|
|
38
|
-
/** How much term overlap counts as a record answering a question, in `spanningQuestions`. */
|
|
39
|
-
export const SPAN_MIN_TERMS = 2;
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Accept either the extension's Source shape (`meta.tags` / `meta.terms` / `meta.people`)
|
|
43
|
-
* or a flat record, so a caller does not have to reshape a corpus to survey it.
|
|
44
|
-
*/
|
|
45
|
-
export function normalizeRecord(rec) {
|
|
46
|
-
if (!rec || !rec.id) return null;
|
|
47
|
-
const meta = rec.meta || {};
|
|
48
|
-
const list = (v) => (Array.isArray(v) ? v.map((x) => String(x || '').trim()).filter(Boolean) : []);
|
|
49
|
-
return {
|
|
50
|
-
id: String(rec.id),
|
|
51
|
-
type: String(rec.type || rec.kind || '').toLowerCase() || 'record',
|
|
52
|
-
title: String(rec.title || '').trim(),
|
|
53
|
-
date: Number(rec.date || rec.updatedAt || rec.startedAt || 0) || 0,
|
|
54
|
-
text: String(rec.text || rec.contentText || ''),
|
|
55
|
-
tags: list(rec.tags ?? meta.tags),
|
|
56
|
-
topics: list(rec.topics ?? meta.terms ?? meta.topics),
|
|
57
|
-
people: list(rec.people ?? meta.people),
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function normalizeRecords(records) {
|
|
62
|
-
// Tolerates null as well as an omitted argument: a caller reading a corpus that has not
|
|
63
|
-
// loaded yet passes null, and a survey that throws there reads as a broken tool rather
|
|
64
|
-
// than as an empty corpus.
|
|
65
|
-
return (Array.isArray(records) ? records : []).map(normalizeRecord).filter(Boolean);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Every `[[target]]` in a string, de-duplicated, in first-seen order — MINUS the redaction
|
|
70
|
-
* placeholders, which share the syntax exactly.
|
|
71
|
-
*
|
|
72
|
-
* `@chatpanel/pii` writes `[[PERSON_1]]`, so a redacted transcript looks like a document
|
|
73
|
-
* full of links to pages nobody wrote. Counting those as wanted pages filed the people we
|
|
74
|
-
* deliberately did not learn about as things we know. Matched by TYPE, not by shape, so a
|
|
75
|
-
* real `[[Q3_2026]]` link still resolves.
|
|
76
|
-
*/
|
|
77
|
-
export function wikilinksIn(text) {
|
|
78
|
-
const out = [];
|
|
79
|
-
WIKILINK_RE.lastIndex = 0;
|
|
80
|
-
let m;
|
|
81
|
-
while ((m = WIKILINK_RE.exec(String(text || '')))) {
|
|
82
|
-
// `[[Title|alias]]` is Obsidian's display form — the LINK is the part before the pipe.
|
|
83
|
-
const target = m[1].split('|')[0].trim();
|
|
84
|
-
if (!target || isRedactionToken(target)) continue;
|
|
85
|
-
if (!out.includes(target)) out.push(target);
|
|
86
|
-
}
|
|
87
|
-
return out;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* The placeholders a text carries, by type — what redaction COST the graph.
|
|
92
|
-
*
|
|
93
|
-
* Reported rather than silently dropped, because the loss is real and the user is the only
|
|
94
|
-
* one who can decide about it. `PERSON_1` is a genuine, stable entity inside its own
|
|
95
|
-
* conversation; what makes it unusable as a subject is that the vault is scoped to that
|
|
96
|
-
* conversation and is never persisted, so Monday's `PERSON_1` and Friday's are different
|
|
97
|
-
* people and merging them would attribute one person's decisions to another.
|
|
98
|
-
*
|
|
99
|
-
* Seeing "412 redacted mentions across 38 records" is what tells someone their redaction
|
|
100
|
-
* level is costing them a connected graph — a trade only they can make.
|
|
101
|
-
*/
|
|
102
|
-
export function redactedTokensIn(text) {
|
|
103
|
-
const out = new Map();
|
|
104
|
-
WIKILINK_RE.lastIndex = 0;
|
|
105
|
-
let m;
|
|
106
|
-
while ((m = WIKILINK_RE.exec(String(text || '')))) {
|
|
107
|
-
const target = m[1].split('|')[0].trim();
|
|
108
|
-
if (!isRedactionToken(target)) continue;
|
|
109
|
-
const type = /^([A-Z][A-Z0-9]*)_/.exec(target.replace(/^\[{1,2}|\]{1,2}$/g, ''))?.[1] || 'OTHER';
|
|
110
|
-
out.set(type, (out.get(type) || 0) + 1);
|
|
111
|
-
}
|
|
112
|
-
return out;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Corpus-wide: how many placeholders, of which types, across how many records. */
|
|
116
|
-
export function redactionCost(records = []) {
|
|
117
|
-
const byType = {};
|
|
118
|
-
let total = 0;
|
|
119
|
-
let recordsAffected = 0;
|
|
120
|
-
for (const r of normalizeRecords(records)) {
|
|
121
|
-
const found = redactedTokensIn(r.text);
|
|
122
|
-
if (!found.size) continue;
|
|
123
|
-
recordsAffected += 1;
|
|
124
|
-
for (const [type, n] of found) { byType[type] = (byType[type] || 0) + n; total += n; }
|
|
125
|
-
}
|
|
126
|
-
return { total, records: recordsAffected, byType };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Links that point at nothing — the corpus telling you which pages it wants.
|
|
131
|
-
*
|
|
132
|
-
* This is the highest-signal candidate source in the whole pass: a human already decided
|
|
133
|
-
* the subject was worth naming, and typed it. Returns most-wanted first.
|
|
134
|
-
*/
|
|
135
|
-
export function wantedPages(records = []) {
|
|
136
|
-
const recs = normalizeRecords(records);
|
|
137
|
-
const titles = new Set(recs.map((r) => normalizeSubject(r.title)).filter(Boolean));
|
|
138
|
-
const wanted = new Map();
|
|
139
|
-
for (const r of recs) {
|
|
140
|
-
for (const target of wikilinksIn(r.text)) {
|
|
141
|
-
const norm = normalizeSubject(target);
|
|
142
|
-
if (!norm || titles.has(norm)) continue;
|
|
143
|
-
let w = wanted.get(norm);
|
|
144
|
-
if (!w) { w = { target, norm, count: 0, records: new Set() }; wanted.set(norm, w); }
|
|
145
|
-
w.count += 1;
|
|
146
|
-
w.records.add(r.id);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return [...wanted.values()]
|
|
150
|
-
.map((w) => ({ target: w.target, norm: w.norm, count: w.count, recordCount: w.records.size }))
|
|
151
|
-
.sort((a, b) => b.recordCount - a.recordCount || b.count - a.count || a.norm.localeCompare(b.norm));
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Records connected to nothing else.
|
|
156
|
-
*
|
|
157
|
-
* "Connected" is deliberately generous — a resolvable wikilink in either direction, or a
|
|
158
|
-
* shared tag, or a shared topic. A record that fails all three is genuinely marooned: no
|
|
159
|
-
* path leads to it except full-text search, which is exactly the state a derived layer is
|
|
160
|
-
* supposed to fix. Notes' Stats tab computes a notes-only version of this today; this is
|
|
161
|
-
* the same question asked across chats, meetings and notes at once.
|
|
162
|
-
*/
|
|
163
|
-
export function orphanRecords(records = []) {
|
|
164
|
-
const recs = normalizeRecords(records);
|
|
165
|
-
const byTitle = new Map();
|
|
166
|
-
for (const r of recs) {
|
|
167
|
-
const norm = normalizeSubject(r.title);
|
|
168
|
-
if (norm && !byTitle.has(norm)) byTitle.set(norm, r.id);
|
|
169
|
-
}
|
|
170
|
-
const linked = new Set();
|
|
171
|
-
const shared = new Map(); // term -> record ids
|
|
172
|
-
for (const r of recs) {
|
|
173
|
-
for (const target of wikilinksIn(r.text)) {
|
|
174
|
-
const hit = byTitle.get(normalizeSubject(target));
|
|
175
|
-
if (hit && hit !== r.id) { linked.add(r.id); linked.add(hit); }
|
|
176
|
-
}
|
|
177
|
-
for (const t of [...r.tags.map(normalizeTag), ...r.topics.map(normalizeSubject)]) {
|
|
178
|
-
if (!t) continue;
|
|
179
|
-
if (!shared.has(t)) shared.set(t, new Set());
|
|
180
|
-
shared.get(t).add(r.id);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
for (const ids of shared.values()) {
|
|
184
|
-
if (ids.size > 1) for (const id of ids) linked.add(id);
|
|
185
|
-
}
|
|
186
|
-
return recs.filter((r) => !linked.has(r.id)).map((r) => ({ id: r.id, type: r.type, title: r.title, date: r.date }));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* "Atlas sync 3" and "Atlas sync 4" are a SERIES, not a typo of each other — and so are
|
|
191
|
-
* "Chat 1" … "Chat 8". They sit one or two edits apart, so without this guard the near-match
|
|
192
|
-
* pass reports every recurring meeting and every default chat title as one duplicate group,
|
|
193
|
-
* which is precisely the noise that makes a maintenance report get ignored.
|
|
194
|
-
*/
|
|
195
|
-
function sameSeries(a, b) {
|
|
196
|
-
const stem = (s) => s.replace(/\s*\d+$/, '').trim();
|
|
197
|
-
const sa = stem(a), sb = stem(b);
|
|
198
|
-
return sa !== a || sb !== b ? sa === sb : false;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Titles that are the same thing written twice — exact collisions after normalization, and
|
|
203
|
-
* near-misses within `NEAR_TITLE_DISTANCE`. Each group is a merge candidate, never a merge:
|
|
204
|
-
* "Q3 Planning" and "Q4 Planning" are one character apart and must NOT be merged, which is
|
|
205
|
-
* precisely why this pass reports and the promotion gate decides.
|
|
206
|
-
*/
|
|
207
|
-
export function duplicateTitles(records = [], { distance = NEAR_TITLE_DISTANCE } = {}) {
|
|
208
|
-
const recs = normalizeRecords(records).filter((r) => normalizeSubject(r.title));
|
|
209
|
-
const groups = new Map();
|
|
210
|
-
for (const r of recs) {
|
|
211
|
-
const norm = normalizeSubject(r.title);
|
|
212
|
-
if (!groups.has(norm)) groups.set(norm, []);
|
|
213
|
-
groups.get(norm).push(r);
|
|
214
|
-
}
|
|
215
|
-
const norms = [...groups.keys()].sort();
|
|
216
|
-
|
|
217
|
-
// Near-misses first, over BLOCKED candidate pairs rather than every pair — see
|
|
218
|
-
// MAX_PAIR_COMPARISONS for what that replaced.
|
|
219
|
-
const nearOf = new Map();
|
|
220
|
-
for (const [a, b] of blockedPairs(norms)) {
|
|
221
|
-
// A title short enough that `distance` edits rewrite most of it is not a near-miss.
|
|
222
|
-
if (Math.min(a.length, b.length) <= distance * 2) continue;
|
|
223
|
-
if (sameSeries(a, b)) continue;
|
|
224
|
-
if (editDistance(a, b, distance) > distance) continue;
|
|
225
|
-
if (!nearOf.has(a)) nearOf.set(a, []);
|
|
226
|
-
if (!nearOf.has(b)) nearOf.set(b, []);
|
|
227
|
-
nearOf.get(a).push(b);
|
|
228
|
-
nearOf.get(b).push(a);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const out = [];
|
|
232
|
-
const merged = new Set();
|
|
233
|
-
for (const norm of norms) {
|
|
234
|
-
if (merged.has(norm)) continue;
|
|
235
|
-
const cluster = [norm];
|
|
236
|
-
for (const other of nearOf.get(norm) || []) {
|
|
237
|
-
if (merged.has(other) || other === norm) continue;
|
|
238
|
-
cluster.push(other);
|
|
239
|
-
merged.add(other);
|
|
240
|
-
}
|
|
241
|
-
const items = cluster.flatMap((n) => groups.get(n));
|
|
242
|
-
if (items.length > 1) {
|
|
243
|
-
out.push({
|
|
244
|
-
norm,
|
|
245
|
-
titles: [...new Set(items.map((r) => r.title))],
|
|
246
|
-
ids: items.map((r) => r.id),
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
return out.sort((a, b) => b.ids.length - a.ids.length || a.norm.localeCompare(b.norm));
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* One vocabulary spelled several ways — "design-review" and "designreview" filed apart.
|
|
255
|
-
*
|
|
256
|
-
* Tags already normalize (tags.js), so a collision here is a real divergence in what the
|
|
257
|
-
* user typed, not a normalization bug. Topics come from a model and drift harder.
|
|
258
|
-
*/
|
|
259
|
-
export function vocabularyDrift(records = [], { distance = 1 } = {}) {
|
|
260
|
-
const recs = normalizeRecords(records);
|
|
261
|
-
const freq = new Map();
|
|
262
|
-
for (const r of recs) {
|
|
263
|
-
for (const raw of r.tags) {
|
|
264
|
-
const t = normalizeTag(raw);
|
|
265
|
-
if (t) freq.set(t, (freq.get(t) || 0) + 1);
|
|
266
|
-
}
|
|
267
|
-
for (const raw of r.topics) {
|
|
268
|
-
const t = normalizeTag(raw);
|
|
269
|
-
if (t) freq.set(t, (freq.get(t) || 0) + 1);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
const terms = [...freq.keys()].sort();
|
|
273
|
-
const bare = (t) => t.replace(/-/g, '');
|
|
274
|
-
|
|
275
|
-
// Same blocking as duplicateTitles. A vocabulary is smaller than a corpus, so this has not
|
|
276
|
-
// bitten yet — but it is the identical shape, and the identical shape is what bites.
|
|
277
|
-
const nearOf = new Map();
|
|
278
|
-
for (const [a, b] of blockedPairs(terms)) {
|
|
279
|
-
if (Math.min(a.length, b.length) <= 3) continue;
|
|
280
|
-
if (bare(a) !== bare(b) && editDistance(a, b, distance) > distance) continue;
|
|
281
|
-
if (!nearOf.has(a)) nearOf.set(a, []);
|
|
282
|
-
if (!nearOf.has(b)) nearOf.set(b, []);
|
|
283
|
-
nearOf.get(a).push(b);
|
|
284
|
-
nearOf.get(b).push(a);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const out = [];
|
|
288
|
-
const taken = new Set();
|
|
289
|
-
for (const term of terms) {
|
|
290
|
-
if (taken.has(term)) continue;
|
|
291
|
-
const near = (nearOf.get(term) || []).filter((t) => !taken.has(t) && t !== term);
|
|
292
|
-
for (const t of near) taken.add(t);
|
|
293
|
-
if (near.length) {
|
|
294
|
-
out.push({ terms: [term, ...near].map((t) => ({ term: t, count: freq.get(t) || 0 })) });
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
return out.sort((a, b) => b.terms.length - a.terms.length);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Every mention the corpus offers, without a model: who was on a call, what it was tagged,
|
|
302
|
-
* what topics were extracted for it, what someone [[linked]] to, and what the records are
|
|
303
|
-
* titled. These are the raw inputs `resolveSubjects()` folds into identities.
|
|
304
|
-
*/
|
|
305
|
-
export function mentionsFrom(records = []) {
|
|
306
|
-
const recs = normalizeRecords(records);
|
|
307
|
-
const out = [];
|
|
308
|
-
for (const r of recs) {
|
|
309
|
-
for (const name of r.people) out.push({ kind: 'person', name, recordId: r.id });
|
|
310
|
-
for (const name of r.tags) out.push({ kind: 'tag', name, recordId: r.id });
|
|
311
|
-
for (const name of r.topics) out.push({ kind: 'topic', name, recordId: r.id });
|
|
312
|
-
for (const name of wikilinksIn(r.text)) out.push({ kind: 'title', name, recordId: r.id });
|
|
313
|
-
}
|
|
314
|
-
// A person's self-label ("You") survives candidacy HERE and is decided by
|
|
315
|
-
// `resolveSubjects`, which is the only layer that knows whether we have a name to fold it
|
|
316
|
-
// into. Filtering it out at this level threw the user out of their own corpus.
|
|
317
|
-
return out.filter((m) => isSubjectCandidate(m.name, { kind: m.kind })
|
|
318
|
-
|| (m.kind === 'person' && isSelfLabel(m.name)));
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/** Query terms, folded the way `sources-retrieval.js queryTerms` folds them. */
|
|
322
|
-
function termsOf(text, { min = 3 } = {}) {
|
|
323
|
-
return [...new Set(
|
|
324
|
-
String(text || '').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w.length >= min),
|
|
325
|
-
)];
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* How often does answering a question require more than one record?
|
|
330
|
-
*
|
|
331
|
-
* The measurement that decides whether this layer is worth building at all. If almost every
|
|
332
|
-
* question is answered by a single record, retrieval already wins and synthesis buys
|
|
333
|
-
* nothing; if the typical question touches four, then every asking of it re-derives the
|
|
334
|
-
* same join, forever, and a maintained page pays for itself.
|
|
335
|
-
*
|
|
336
|
-
* Deterministic and crude on purpose — term overlap, no model, no embeddings. It is a
|
|
337
|
-
* ratio, not a search engine, and a ratio only has to be honest.
|
|
338
|
-
*/
|
|
339
|
-
export function spanningQuestions(records = [], questions = [], { minTerms = SPAN_MIN_TERMS, span = 3 } = {}) {
|
|
340
|
-
const recs = normalizeRecords(records);
|
|
341
|
-
const index = recs.map((r) => ({ id: r.id, terms: new Set(termsOf(`${r.title}\n${r.text}`)) }));
|
|
342
|
-
let spanning = 0;
|
|
343
|
-
let considered = 0;
|
|
344
|
-
const hits = [];
|
|
345
|
-
for (const q of questions) {
|
|
346
|
-
const terms = termsOf(q);
|
|
347
|
-
if (terms.length < minTerms) continue; // "thanks!" is not a question about the corpus
|
|
348
|
-
considered += 1;
|
|
349
|
-
let n = 0;
|
|
350
|
-
for (const rec of index) {
|
|
351
|
-
let overlap = 0;
|
|
352
|
-
for (const t of terms) if (rec.terms.has(t)) overlap += 1;
|
|
353
|
-
if (overlap >= minTerms) n += 1;
|
|
354
|
-
}
|
|
355
|
-
hits.push(n);
|
|
356
|
-
if (n > span) spanning += 1;
|
|
357
|
-
}
|
|
358
|
-
hits.sort((a, b) => a - b);
|
|
359
|
-
return {
|
|
360
|
-
considered,
|
|
361
|
-
spanning,
|
|
362
|
-
fraction: considered ? spanning / considered : 0,
|
|
363
|
-
medianRecords: hits.length ? hits[Math.floor(hits.length / 2)] : 0,
|
|
364
|
-
span,
|
|
365
|
-
};
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* W0 — the whole deterministic pass, as one read-only report.
|
|
370
|
-
*
|
|
371
|
-
* Nothing here writes, and nothing here calls a model. Run it before building the derived
|
|
372
|
-
* layer, and let its numbers set the thresholds in `entity.js` rather than the other way
|
|
373
|
-
* round.
|
|
374
|
-
*/
|
|
375
|
-
export function surveyCorpus(records = [], { questions = [], threshold = DEFAULT_THRESHOLD, limit = MAX_SUBJECTS } = {}) {
|
|
376
|
-
const recs = normalizeRecords(records);
|
|
377
|
-
const byType = {};
|
|
378
|
-
let chars = 0;
|
|
379
|
-
for (const r of recs) {
|
|
380
|
-
byType[r.type] = (byType[r.type] || 0) + 1;
|
|
381
|
-
chars += r.text.length;
|
|
382
|
-
}
|
|
383
|
-
const mentions = mentionsFrom(recs);
|
|
384
|
-
const subjects = resolveSubjects(mentions);
|
|
385
|
-
const qualifying = rankSubjects(subjects, { threshold, limit });
|
|
386
|
-
const byKind = {};
|
|
387
|
-
for (const s of qualifying) byKind[s.kind] = (byKind[s.kind] || 0) + 1;
|
|
388
|
-
const wanted = wantedPages(recs);
|
|
389
|
-
const orphans = orphanRecords(recs);
|
|
390
|
-
const redaction = redactionCost(recs);
|
|
391
|
-
|
|
392
|
-
return {
|
|
393
|
-
corpus: {
|
|
394
|
-
records: recs.length,
|
|
395
|
-
byType,
|
|
396
|
-
chars,
|
|
397
|
-
oldest: recs.reduce((m, r) => (r.date && (!m || r.date < m) ? r.date : m), 0),
|
|
398
|
-
newest: recs.reduce((m, r) => Math.max(m, r.date || 0), 0),
|
|
399
|
-
},
|
|
400
|
-
subjects: {
|
|
401
|
-
total: subjects.size,
|
|
402
|
-
qualifying: qualifying.length,
|
|
403
|
-
byKind,
|
|
404
|
-
threshold: { records: threshold.records ?? DEFAULT_THRESHOLD.records, mentions: threshold.mentions ?? DEFAULT_THRESHOLD.mentions },
|
|
405
|
-
capped: subjects.size > 0 && qualifying.length === limit,
|
|
406
|
-
top: qualifying.slice(0, 25).map((s) => ({
|
|
407
|
-
key: s.key, kind: s.kind, name: s.name, aliases: s.aliases,
|
|
408
|
-
records: s.recordCount, mentions: s.mentions,
|
|
409
|
-
})),
|
|
410
|
-
},
|
|
411
|
-
wantedPages: { total: wanted.length, top: wanted.slice(0, 25) },
|
|
412
|
-
orphans: {
|
|
413
|
-
total: orphans.length,
|
|
414
|
-
fraction: recs.length ? orphans.length / recs.length : 0,
|
|
415
|
-
byType: orphans.reduce((acc, r) => ({ ...acc, [r.type]: (acc[r.type] || 0) + 1 }), {}),
|
|
416
|
-
sample: orphans.slice(0, 10),
|
|
417
|
-
},
|
|
418
|
-
redaction,
|
|
419
|
-
duplicateTitles: duplicateTitles(recs),
|
|
420
|
-
vocabularyDrift: vocabularyDrift(recs),
|
|
421
|
-
questions: spanningQuestions(recs, questions),
|
|
422
|
-
};
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* Sensitivity — the same corpus at several thresholds, so the number is CHOSEN rather than
|
|
427
|
-
* inherited. A threshold that yields 4 subjects is useless and one that yields 4000 is a
|
|
428
|
-
* second corpus (C5); the report should make both visible at a glance.
|
|
429
|
-
*/
|
|
430
|
-
export function thresholdSweep(records = [], grid = [
|
|
431
|
-
{ records: 2, mentions: 2 }, { records: 3, mentions: 5 }, { records: 4, mentions: 8 }, { records: 6, mentions: 12 },
|
|
432
|
-
]) {
|
|
433
|
-
const subjects = resolveSubjects(mentionsFrom(records));
|
|
434
|
-
return grid.map((threshold) => {
|
|
435
|
-
const ranked = rankSubjects(subjects, { threshold, limit: Infinity });
|
|
436
|
-
const byKind = {};
|
|
437
|
-
for (const s of ranked) byKind[s.kind] = (byKind[s.kind] || 0) + 1;
|
|
438
|
-
return { threshold, qualifying: ranked.length, byKind };
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
const pct = (x) => `${(x * 100).toFixed(1)}%`;
|
|
443
|
-
const day = (ms) => (ms ? new Date(ms).toISOString().slice(0, 10) : '—');
|
|
444
|
-
|
|
445
|
-
/** The report as plain text — the form W0 is actually read in. */
|
|
446
|
-
export function formatSurvey(report, { sweep = null } = {}) {
|
|
447
|
-
if (!report) return 'no report';
|
|
448
|
-
const L = [];
|
|
449
|
-
const { corpus, subjects, wantedPages: wp, orphans, duplicateTitles: dupes, vocabularyDrift: drift, questions } = report;
|
|
450
|
-
|
|
451
|
-
L.push('CORPUS');
|
|
452
|
-
L.push(` ${corpus.records} records (${Object.entries(corpus.byType).map(([k, v]) => `${v} ${k}`).join(', ') || 'none'})`);
|
|
453
|
-
L.push(` ${corpus.chars.toLocaleString()} chars · ${day(corpus.oldest)} → ${day(corpus.newest)}`);
|
|
454
|
-
|
|
455
|
-
L.push('');
|
|
456
|
-
L.push(`SUBJECTS (earn a page at ≥${subjects.threshold.records} records and ≥${subjects.threshold.mentions} mentions)`);
|
|
457
|
-
L.push(` ${subjects.qualifying} of ${subjects.total} candidates qualify${subjects.capped ? ' (AT THE CEILING — raise the threshold)' : ''}`);
|
|
458
|
-
L.push(` by kind: ${Object.entries(subjects.byKind).map(([k, v]) => `${k} ${v}`).join(', ') || 'none'}`);
|
|
459
|
-
for (const s of subjects.top) {
|
|
460
|
-
L.push(` ${String(s.records).padStart(4)} rec ${String(s.mentions).padStart(5)} men ${s.kind}: ${s.name}${s.aliases.length ? ` (aka ${s.aliases.join(', ')})` : ''}`);
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
if (sweep?.length) {
|
|
464
|
-
L.push('');
|
|
465
|
-
L.push('THRESHOLD SWEEP');
|
|
466
|
-
for (const row of sweep) {
|
|
467
|
-
L.push(` ≥${row.threshold.records} rec / ≥${row.threshold.mentions} men → ${String(row.qualifying).padStart(5)} subjects (${Object.entries(row.byKind).map(([k, v]) => `${k} ${v}`).join(', ') || 'none'})`);
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
L.push('');
|
|
472
|
-
L.push(`WANTED PAGES ([[links]] resolving to nothing) — ${wp.total}`);
|
|
473
|
-
for (const w of wp.top) L.push(` ${String(w.recordCount).padStart(4)} rec ${w.target}`);
|
|
474
|
-
if (!wp.total) L.push(' none — either nobody uses [[links]] yet, or every one resolves');
|
|
475
|
-
|
|
476
|
-
L.push('');
|
|
477
|
-
L.push(`ORPHANS (no link, no shared tag, no shared topic) — ${orphans.total} of ${corpus.records} (${pct(orphans.fraction)})`);
|
|
478
|
-
L.push(` by type: ${Object.entries(orphans.byType).map(([k, v]) => `${k} ${v}`).join(', ') || 'none'}`);
|
|
479
|
-
|
|
480
|
-
L.push('');
|
|
481
|
-
L.push(`REDACTED MENTIONS (placeholders that cannot become subjects) — ${report.redaction.total} across ${report.redaction.records} records`);
|
|
482
|
-
L.push(` by type: ${Object.entries(report.redaction.byType).map(([k, v]) => `${k} ${v}`).join(', ') || 'none'}`);
|
|
483
|
-
if (report.redaction.total) {
|
|
484
|
-
L.push(' → the vault is per-conversation and is not persisted, so one PERSON_1 is not another.');
|
|
485
|
-
L.push(' Lowering the redaction level is what buys these back as real subjects.');
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
L.push('');
|
|
489
|
-
L.push(`DUPLICATE / NEAR-DUPLICATE TITLES — ${dupes.length} groups`);
|
|
490
|
-
for (const g of dupes.slice(0, 10)) L.push(` ${g.ids.length}× ${g.titles.join(' | ')}`);
|
|
491
|
-
|
|
492
|
-
L.push('');
|
|
493
|
-
L.push(`VOCABULARY DRIFT (one term filed several ways) — ${drift.length} clusters`);
|
|
494
|
-
for (const g of drift.slice(0, 10)) L.push(` ${g.terms.map((t) => `${t.term}(${t.count})`).join(' | ')}`);
|
|
495
|
-
|
|
496
|
-
L.push('');
|
|
497
|
-
L.push('QUESTIONS THAT SPAN RECORDS');
|
|
498
|
-
if (!questions.considered) {
|
|
499
|
-
L.push(' no questions supplied — pass the recent user turns to measure this');
|
|
500
|
-
} else {
|
|
501
|
-
L.push(` ${questions.spanning} of ${questions.considered} (${pct(questions.fraction)}) touch more than ${questions.span} records`);
|
|
502
|
-
L.push(` median records touched: ${questions.medianRecords}`);
|
|
503
|
-
L.push('');
|
|
504
|
-
L.push(questions.fraction >= 0.3
|
|
505
|
-
? ' → synthesis has something to compound: most asking re-derives a join across records.'
|
|
506
|
-
: ' → THIN. Most questions are answered by one record; finish retrieval (docs/retrieval.md) before building briefs.');
|
|
507
|
-
}
|
|
508
|
-
return L.join('\n');
|
|
509
|
-
}
|
|
1
|
+
import{normalizeTag as S}from"./tags.js";import{blockedPairs as T,editDistance as k}from"./distance.js";import{DEFAULT_THRESHOLD as b,MAX_SUBJECTS as O,isSelfLabel as v,isSubjectCandidate as M,normalizeSubject as g,rankSubjects as x,resolveSubjects as E}from"./entity.js";import{isRedactionToken as j}from"./redaction-tokens.js";const $=/\[\[([^[\]\n]+)\]\]/g,L=2,N=2;function D(r){if(!r||!r.id)return null;const c=r.meta||{},t=e=>Array.isArray(e)?e.map(n=>String(n||"").trim()).filter(Boolean):[];return{id:String(r.id),type:String(r.type||r.kind||"").toLowerCase()||"record",title:String(r.title||"").trim(),date:Number(r.date||r.updatedAt||r.startedAt||0)||0,text:String(r.text||r.contentText||""),tags:t(r.tags??c.tags),topics:t(r.topics??c.terms??c.topics),people:t(r.people??c.people)}}function m(r){return(Array.isArray(r)?r:[]).map(D).filter(Boolean)}function w(r){const c=[];$.lastIndex=0;let t;for(;t=$.exec(String(r||""));){const e=t[1].split("|")[0].trim();!e||j(e)||c.includes(e)||c.push(e)}return c}function P(r){const c=new Map;$.lastIndex=0;let t;for(;t=$.exec(String(r||""));){const e=t[1].split("|")[0].trim();if(!j(e))continue;const n=/^([A-Z][A-Z0-9]*)_/.exec(e.replace(/^\[{1,2}|\]{1,2}$/g,""))?.[1]||"OTHER";c.set(n,(c.get(n)||0)+1)}return c}function q(r=[]){const c={};let t=0,e=0;for(const n of m(r)){const i=P(n.text);if(i.size){e+=1;for(const[a,u]of i)c[a]=(c[a]||0)+u,t+=u}}return{total:t,records:e,byType:c}}function z(r=[]){const c=m(r),t=new Set(c.map(n=>g(n.title)).filter(Boolean)),e=new Map;for(const n of c)for(const i of w(n.text)){const a=g(i);if(!a||t.has(a))continue;let u=e.get(a);u||(u={target:i,norm:a,count:0,records:new Set},e.set(a,u)),u.count+=1,u.records.add(n.id)}return[...e.values()].map(n=>({target:n.target,norm:n.norm,count:n.count,recordCount:n.records.size})).sort((n,i)=>i.recordCount-n.recordCount||i.count-n.count||n.norm.localeCompare(i.norm))}function H(r=[]){const c=m(r),t=new Map;for(const i of c){const a=g(i.title);a&&!t.has(a)&&t.set(a,i.id)}const e=new Set,n=new Map;for(const i of c){for(const a of w(i.text)){const u=t.get(g(a));u&&u!==i.id&&(e.add(i.id),e.add(u))}for(const a of[...i.tags.map(S),...i.topics.map(g)])a&&(n.has(a)||n.set(a,new Set),n.get(a).add(i.id))}for(const i of n.values())if(i.size>1)for(const a of i)e.add(a);return c.filter(i=>!e.has(i.id)).map(i=>({id:i.id,type:i.type,title:i.title,date:i.date}))}function _(r,c){const t=i=>i.replace(/\s*\d+$/,"").trim(),e=t(r),n=t(c);return e!==r||n!==c?e===n:!1}function U(r=[],{distance:c=L}={}){const t=m(r).filter(p=>g(p.title)),e=new Map;for(const p of t){const o=g(p.title);e.has(o)||e.set(o,[]),e.get(o).push(p)}const n=[...e.keys()].sort(),i=new Map;for(const[p,o]of T(n))Math.min(p.length,o.length)<=c*2||_(p,o)||k(p,o,c)>c||(i.has(p)||i.set(p,[]),i.has(o)||i.set(o,[]),i.get(p).push(o),i.get(o).push(p));const a=[],u=new Set;for(const p of n){if(u.has(p))continue;const o=[p];for(const d of i.get(p)||[])u.has(d)||d===p||(o.push(d),u.add(d));const s=o.flatMap(d=>e.get(d));s.length>1&&a.push({norm:p,titles:[...new Set(s.map(d=>d.title))],ids:s.map(d=>d.id)})}return a.sort((p,o)=>o.ids.length-p.ids.length||p.norm.localeCompare(o.norm))}function B(r=[],{distance:c=1}={}){const t=m(r),e=new Map;for(const o of t){for(const s of o.tags){const d=S(s);d&&e.set(d,(e.get(d)||0)+1)}for(const s of o.topics){const d=S(s);d&&e.set(d,(e.get(d)||0)+1)}}const n=[...e.keys()].sort(),i=o=>o.replace(/-/g,""),a=new Map;for(const[o,s]of T(n))Math.min(o.length,s.length)<=3||i(o)!==i(s)&&k(o,s,c)>c||(a.has(o)||a.set(o,[]),a.has(s)||a.set(s,[]),a.get(o).push(s),a.get(s).push(o));const u=[],p=new Set;for(const o of n){if(p.has(o))continue;const s=(a.get(o)||[]).filter(d=>!p.has(d)&&d!==o);for(const d of s)p.add(d);s.length&&u.push({terms:[o,...s].map(d=>({term:d,count:e.get(d)||0}))})}return u.sort((o,s)=>s.terms.length-o.terms.length)}function A(r=[]){const c=m(r),t=[];for(const e of c){for(const n of e.people)t.push({kind:"person",name:n,recordId:e.id});for(const n of e.tags)t.push({kind:"tag",name:n,recordId:e.id});for(const n of e.topics)t.push({kind:"topic",name:n,recordId:e.id});for(const n of w(e.text))t.push({kind:"title",name:n,recordId:e.id})}return t.filter(e=>M(e.name,{kind:e.kind})||e.kind==="person"&&v(e.name))}function I(r,{min:c=3}={}){return[...new Set(String(r||"").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(t=>t.length>=c))]}function K(r=[],c=[],{minTerms:t=N,span:e=3}={}){const i=m(r).map(o=>({id:o.id,terms:new Set(I(`${o.title}
|
|
2
|
+
${o.text}`))}));let a=0,u=0;const p=[];for(const o of c){const s=I(o);if(s.length<t)continue;u+=1;let d=0;for(const l of i){let y=0;for(const f of s)l.terms.has(f)&&(y+=1);y>=t&&(d+=1)}p.push(d),d>e&&(a+=1)}return p.sort((o,s)=>o-s),{considered:u,spanning:a,fraction:u?a/u:0,medianRecords:p.length?p[Math.floor(p.length/2)]:0,span:e}}function Q(r=[],{questions:c=[],threshold:t=b,limit:e=O}={}){const n=m(r),i={};let a=0;for(const f of n)i[f.type]=(i[f.type]||0)+1,a+=f.text.length;const u=A(n),p=E(u),o=x(p,{threshold:t,limit:e}),s={};for(const f of o)s[f.kind]=(s[f.kind]||0)+1;const d=z(n),l=H(n),y=q(n);return{corpus:{records:n.length,byType:i,chars:a,oldest:n.reduce((f,h)=>h.date&&(!f||h.date<f)?h.date:f,0),newest:n.reduce((f,h)=>Math.max(f,h.date||0),0)},subjects:{total:p.size,qualifying:o.length,byKind:s,threshold:{records:t.records??b.records,mentions:t.mentions??b.mentions},capped:p.size>0&&o.length===e,top:o.slice(0,25).map(f=>({key:f.key,kind:f.kind,name:f.name,aliases:f.aliases,records:f.recordCount,mentions:f.mentions}))},wantedPages:{total:d.length,top:d.slice(0,25)},orphans:{total:l.length,fraction:n.length?l.length/n.length:0,byType:l.reduce((f,h)=>({...f,[h.type]:(f[h.type]||0)+1}),{}),sample:l.slice(0,10)},redaction:y,duplicateTitles:U(n),vocabularyDrift:B(n),questions:K(n,c)}}function Z(r=[],c=[{records:2,mentions:2},{records:3,mentions:5},{records:4,mentions:8},{records:6,mentions:12}]){const t=E(A(r));return c.map(e=>{const n=x(t,{threshold:e,limit:1/0}),i={};for(const a of n)i[a.kind]=(i[a.kind]||0)+1;return{threshold:e,qualifying:n.length,byKind:i}})}const C=r=>`${(r*100).toFixed(1)}%`,R=r=>r?new Date(r).toISOString().slice(0,10):"—";function V(r,{sweep:c=null}={}){if(!r)return"no report";const t=[],{corpus:e,subjects:n,wantedPages:i,orphans:a,duplicateTitles:u,vocabularyDrift:p,questions:o}=r;t.push("CORPUS"),t.push(` ${e.records} records (${Object.entries(e.byType).map(([s,d])=>`${d} ${s}`).join(", ")||"none"})`),t.push(` ${e.chars.toLocaleString()} chars · ${R(e.oldest)} → ${R(e.newest)}`),t.push(""),t.push(`SUBJECTS (earn a page at ≥${n.threshold.records} records and ≥${n.threshold.mentions} mentions)`),t.push(` ${n.qualifying} of ${n.total} candidates qualify${n.capped?" (AT THE CEILING — raise the threshold)":""}`),t.push(` by kind: ${Object.entries(n.byKind).map(([s,d])=>`${s} ${d}`).join(", ")||"none"}`);for(const s of n.top)t.push(` ${String(s.records).padStart(4)} rec ${String(s.mentions).padStart(5)} men ${s.kind}: ${s.name}${s.aliases.length?` (aka ${s.aliases.join(", ")})`:""}`);if(c?.length){t.push(""),t.push("THRESHOLD SWEEP");for(const s of c)t.push(` ≥${s.threshold.records} rec / ≥${s.threshold.mentions} men → ${String(s.qualifying).padStart(5)} subjects (${Object.entries(s.byKind).map(([d,l])=>`${d} ${l}`).join(", ")||"none"})`)}t.push(""),t.push(`WANTED PAGES ([[links]] resolving to nothing) — ${i.total}`);for(const s of i.top)t.push(` ${String(s.recordCount).padStart(4)} rec ${s.target}`);i.total||t.push(" none — either nobody uses [[links]] yet, or every one resolves"),t.push(""),t.push(`ORPHANS (no link, no shared tag, no shared topic) — ${a.total} of ${e.records} (${C(a.fraction)})`),t.push(` by type: ${Object.entries(a.byType).map(([s,d])=>`${s} ${d}`).join(", ")||"none"}`),t.push(""),t.push(`REDACTED MENTIONS (placeholders that cannot become subjects) — ${r.redaction.total} across ${r.redaction.records} records`),t.push(` by type: ${Object.entries(r.redaction.byType).map(([s,d])=>`${s} ${d}`).join(", ")||"none"}`),r.redaction.total&&(t.push(" → the vault is per-conversation and is not persisted, so one PERSON_1 is not another."),t.push(" Lowering the redaction level is what buys these back as real subjects.")),t.push(""),t.push(`DUPLICATE / NEAR-DUPLICATE TITLES — ${u.length} groups`);for(const s of u.slice(0,10))t.push(` ${s.ids.length}× ${s.titles.join(" | ")}`);t.push(""),t.push(`VOCABULARY DRIFT (one term filed several ways) — ${p.length} clusters`);for(const s of p.slice(0,10))t.push(` ${s.terms.map(d=>`${d.term}(${d.count})`).join(" | ")}`);return t.push(""),t.push("QUESTIONS THAT SPAN RECORDS"),o.considered?(t.push(` ${o.spanning} of ${o.considered} (${C(o.fraction)}) touch more than ${o.span} records`),t.push(` median records touched: ${o.medianRecords}`),t.push(""),t.push(o.fraction>=.3?" → synthesis has something to compound: most asking re-derives a join across records.":" → THIN. Most questions are answered by one record; finish retrieval (docs/retrieval.md) before building briefs.")):t.push(" no questions supplied — pass the recent user turns to measure this"),t.join(`
|
|
3
|
+
`)}export{L as NEAR_TITLE_DISTANCE,N as SPAN_MIN_TERMS,U as duplicateTitles,V as formatSurvey,A as mentionsFrom,D as normalizeRecord,m as normalizeRecords,H as orphanRecords,P as redactedTokensIn,q as redactionCost,K as spanningQuestions,Q as surveyCorpus,Z as thresholdSweep,B as vocabularyDrift,z as wantedPages,w as wikilinksIn};
|
package/distance.js
CHANGED
|
@@ -1,124 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// It exists as its own file for a load-time reason, and the reason is worth recording
|
|
4
|
-
// because it is the second time this rule has been learned here. `voice-intents.js` needed
|
|
5
|
-
// a bounded Levenshtein for wake-word matching and grew one. When the maintenance pass
|
|
6
|
-
// needed the same question answered for near-duplicate titles, importing it from there was
|
|
7
|
-
// the correct instinct — reuse, don't reinvent — and it pulled `voice-intents.js` (79 KB)
|
|
8
|
-
// and its `structured.js` dependency (41 KB) onto the MV3 service worker's cold start, for
|
|
9
|
-
// forty lines of arithmetic.
|
|
10
|
-
//
|
|
11
|
-
// So: a primitive two unrelated features need belongs in a module of its own, not in
|
|
12
|
-
// whichever feature happened to need it first. Reuse is right; reuse through a large module
|
|
13
|
-
// is a 120 KB import of one function.
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Bounded Levenshtein. Returns early once the distance cannot come in under `max`, so a
|
|
17
|
-
* wake scan over a long transcript — or a pairwise title sweep over a corpus — stays linear
|
|
18
|
-
* in practice rather than paying for an exact answer nobody reads.
|
|
19
|
-
*
|
|
20
|
-
* Past `max` the return is `max + 1`: "far", not a number. Callers compare against `max`.
|
|
21
|
-
*/
|
|
22
|
-
export function editDistance(a, b, max = Infinity) {
|
|
23
|
-
if (a === b) return 0;
|
|
24
|
-
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
25
|
-
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
26
|
-
for (let i = 1; i <= a.length; i++) {
|
|
27
|
-
const cur = [i];
|
|
28
|
-
let best = i;
|
|
29
|
-
for (let j = 1; j <= b.length; j++) {
|
|
30
|
-
cur[j] = Math.min(
|
|
31
|
-
prev[j] + 1,
|
|
32
|
-
cur[j - 1] + 1,
|
|
33
|
-
prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
34
|
-
);
|
|
35
|
-
if (cur[j] < best) best = cur[j];
|
|
36
|
-
}
|
|
37
|
-
if (best > max) return max + 1;
|
|
38
|
-
prev = cur;
|
|
39
|
-
}
|
|
40
|
-
return prev[b.length];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* A ceiling on pairwise work, and the reason it exists.
|
|
45
|
-
*
|
|
46
|
-
* Comparing every pair is the N×N scan the knowledge design forbids, and it behaves exactly
|
|
47
|
-
* as that rule predicts. Two passes learned it the hard way: near-duplicate titles ran 40s
|
|
48
|
-
* over 12,000 records, and merge suggestions did not finish 12,000 SUBJECTS in two minutes.
|
|
49
|
-
* Both on the UI thread, which is an unresponsive tab rather than a slow report.
|
|
50
|
-
*/
|
|
51
|
-
export const MAX_PAIR_COMPARISONS = 200_000;
|
|
52
|
-
|
|
53
|
-
const BLOCK_KEY_CHARS = 4;
|
|
54
|
-
// Every block gets at least this many comparisons before the budget can starve it.
|
|
55
|
-
const MIN_BLOCK_BUDGET = 2_000;
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* The keys a string is filed under for candidate generation.
|
|
59
|
-
*
|
|
60
|
-
* Three, and each earns its place: two strings within a couple of edits still agree on their
|
|
61
|
-
* first few characters unless the typo is at the front — in which case they agree on their
|
|
62
|
-
* last few — and two forms of one person's name ("alex rivera", "a rivera") agree on the
|
|
63
|
-
* LAST TOKEN even when neither end matches. Drop the third and abbreviated first names stop
|
|
64
|
-
* being found at all.
|
|
65
|
-
*/
|
|
66
|
-
export function blockKeys(norm) {
|
|
67
|
-
const s = String(norm || '');
|
|
68
|
-
if (!s) return [];
|
|
69
|
-
const keys = new Set([`p:${s.slice(0, BLOCK_KEY_CHARS)}`, `s:${s.slice(-BLOCK_KEY_CHARS)}`]);
|
|
70
|
-
const last = s.split(' ').filter(Boolean).pop();
|
|
71
|
-
if (last && last.length >= 2) keys.add(`t:${last}`);
|
|
72
|
-
return [...keys];
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* BLOCKING — the standard record-linkage answer to "which pairs are worth comparing".
|
|
77
|
-
*
|
|
78
|
-
* Files every string under `blockKeys` and yields only pairs that share one, so the work is
|
|
79
|
-
* proportional to the corpus rather than to its square. Each unordered pair is yielded at
|
|
80
|
-
* most once even when two strings share several keys.
|
|
81
|
-
*
|
|
82
|
-
* `budget` is the backstop for the pathological case — ten thousand titles that all start the
|
|
83
|
-
* same way land in one block, and a block is compared pairwise. A weird corpus then costs a
|
|
84
|
-
* truncated report instead of a hung page.
|
|
85
|
-
*/
|
|
86
|
-
export function* blockedPairs(values, { budget = MAX_PAIR_COMPARISONS } = {}) {
|
|
87
|
-
const blocks = new Map();
|
|
88
|
-
for (const v of values) {
|
|
89
|
-
for (const key of blockKeys(v)) {
|
|
90
|
-
if (!blocks.has(key)) blocks.set(key, []);
|
|
91
|
-
blocks.get(key).push(v);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
let spent = 0;
|
|
95
|
-
const seen = new Set();
|
|
96
|
-
// SMALLEST BLOCKS FIRST, and a per-block share of the budget. Both are about RECALL, not
|
|
97
|
-
// speed, and the first version got this wrong: nine thousand subjects all beginning
|
|
98
|
-
// "Unrelated Person" land in one bucket under the prefix key, and that single
|
|
99
|
-
// non-discriminating block spent the entire budget before the buckets holding the real
|
|
100
|
-
// findings were ever reached — so a big corpus returned five hundred suggestions and not
|
|
101
|
-
// one of the ones that mattered.
|
|
102
|
-
//
|
|
103
|
-
// A small block is a discriminating one: "rivera" as a last token says far more than
|
|
104
|
-
// "unre" as a prefix. Working through them in size order means the specific evidence is
|
|
105
|
-
// spent first and the vague evidence gets whatever is left.
|
|
106
|
-
const buckets = [...blocks.values()].filter((b) => b.length > 1).sort((a, b) => a.length - b.length);
|
|
107
|
-
for (const bucket of buckets) {
|
|
108
|
-
if (spent >= budget) return;
|
|
109
|
-
// No single block may consume the whole budget, however it is ordered.
|
|
110
|
-
const blockBudget = Math.min(budget - spent, Math.max(MIN_BLOCK_BUDGET, Math.floor(budget / 8)));
|
|
111
|
-
let blockSpent = 0;
|
|
112
|
-
for (let i = 0; i < bucket.length && blockSpent < blockBudget; i += 1) {
|
|
113
|
-
for (let j = i + 1; j < bucket.length && blockSpent < blockBudget; j += 1) {
|
|
114
|
-
const a = bucket[i]; const b = bucket[j];
|
|
115
|
-
const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
|
|
116
|
-
if (seen.has(key)) continue;
|
|
117
|
-
seen.add(key);
|
|
118
|
-
blockSpent += 1;
|
|
119
|
-
spent += 1;
|
|
120
|
-
yield [a, b];
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
1
|
+
function y(c,e,o=1/0){if(c===e)return 0;if(Math.abs(c.length-e.length)>o)return o+1;let s=Array.from({length:e.length+1},(l,r)=>r);for(let l=1;l<=c.length;l++){const r=[l];let n=l;for(let t=1;t<=e.length;t++)r[t]=Math.min(s[t]+1,r[t-1]+1,s[t-1]+(c[l-1]===e[t-1]?0:1)),r[t]<n&&(n=r[t]);if(n>o)return o+1;s=r}return s[e.length]}const k=2e5,p=4,M=2e3;function _(c){const e=String(c||"");if(!e)return[];const o=new Set([`p:${e.slice(0,p)}`,`s:${e.slice(-p)}`]),s=e.split(" ").filter(Boolean).pop();return s&&s.length>=2&&o.add(`t:${s}`),[...o]}function*S(c,{budget:e=k}={}){const o=new Map;for(const n of c)for(const t of _(n))o.has(t)||o.set(t,[]),o.get(t).push(n);let s=0;const l=new Set,r=[...o.values()].filter(n=>n.length>1).sort((n,t)=>n.length-t.length);for(const n of r){if(s>=e)return;const t=Math.min(e-s,Math.max(M,Math.floor(e/8)));let u=0;for(let i=0;i<n.length&&u<t;i+=1)for(let g=i+1;g<n.length&&u<t;g+=1){const f=n[i],h=n[g],a=f<h?`${f}\0${h}`:`${h}\0${f}`;l.has(a)||(l.add(a),u+=1,s+=1,yield[f,h])}}}export{k as MAX_PAIR_COMPARISONS,_ as blockKeys,S as blockedPairs,y as editDistance};
|