akm-cli 0.9.13 → 0.9.14-beta.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/CHANGELOG.md +39 -0
- package/dist/commands/improve/eligibility.js +27 -15
- package/dist/commands/read/curate.js +3 -2
- package/dist/commands/read/show.js +26 -9
- package/dist/core/adapter/adapters/akm-adapter.js +5 -1
- package/dist/core/asset/markdown-fragments.js +146 -0
- package/dist/core/lexical-score.js +25 -0
- package/dist/core/type-presentation.js +36 -4
- package/dist/indexer/index-written-assets.js +4 -0
- package/dist/indexer/indexer.js +5 -2
- package/dist/indexer/passes/metadata.js +64 -1
- package/dist/indexer/scan/doc-to-entry.js +3 -0
- package/dist/indexer/scan/drain-dir.js +33 -22
- package/dist/indexer/search/db-search.js +72 -14
- package/dist/indexer/search/name-match.js +35 -0
- package/dist/indexer/search/ranking-contributors.js +15 -12
- package/dist/indexer/search/ranking.js +42 -18
- package/dist/indexer/usage/show-usage.js +14 -2
- package/dist/llm/graph-extract.js +18 -67
- package/dist/scripts/akm-migrate-node.js +334 -90
- package/dist/scripts/akm-migrate.js +334 -90
- package/dist/storage/repositories/index-connection.js +23 -8
- package/dist/storage/repositories/index-entries-repository.js +3 -2
- package/dist/storage/repositories/index-entry-schema.js +43 -3
- package/dist/storage/repositories/index-fts-repository.js +160 -14
- package/dist/storage/repositories/index-schema.js +8 -18
- package/docs/migration/release-notes/0.9.14.md +26 -0
- package/docs/migration/release-notes/README.md +2 -0
- package/package.json +1 -1
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
* Pure, type-only imports (no cycle participation).
|
|
35
35
|
*/
|
|
36
36
|
import path from "node:path";
|
|
37
|
+
import { getMarkdownFragmentContent, hasMarkdownFragmentContent, setMarkdownFragmentContent, } from "../passes/metadata.js";
|
|
37
38
|
/**
|
|
38
39
|
* Reconstruct the `IndexDocument` an `IndexDocument` was mapped from. First-class
|
|
39
40
|
* IndexDocument members and the `documentJson`-carried extras are both restored;
|
|
@@ -60,6 +61,8 @@ export function indexDocumentToStashEntry(doc) {
|
|
|
60
61
|
entry.content = doc.content;
|
|
61
62
|
if (doc.contentTruncated !== undefined)
|
|
62
63
|
entry.contentTruncated = doc.contentTruncated;
|
|
64
|
+
if (hasMarkdownFragmentContent(doc))
|
|
65
|
+
setMarkdownFragmentContent(entry, getMarkdownFragmentContent(doc));
|
|
63
66
|
if (doc.ownsPresentation !== undefined)
|
|
64
67
|
entry.ownsPresentation = doc.ownsPresentation;
|
|
65
68
|
if (doc.updated !== undefined)
|
|
@@ -30,7 +30,7 @@ import path from "node:path";
|
|
|
30
30
|
import { akmAdapter } from "../../core/adapter/adapters/akm-adapter.js";
|
|
31
31
|
import { compareCodePoints } from "../../core/common.js";
|
|
32
32
|
import { canonicalizeWorkflowName } from "../../core/recognition-util.js";
|
|
33
|
-
import {
|
|
33
|
+
import { resolveWorkflowSourceDomains, workflowNameForSourcePath } from "../../workflows/source-files.js";
|
|
34
34
|
import { compileWorkflowSource } from "../../workflows/source-ir/compile.js";
|
|
35
35
|
import { buildMetadataSkipWarning } from "../passes/metadata.js";
|
|
36
36
|
import { buildFileContext } from "../walk/file-context.js";
|
|
@@ -53,32 +53,41 @@ export function drainDirDocuments(adapter, component, fileContexts) {
|
|
|
53
53
|
const conceptIdByFile = new Map();
|
|
54
54
|
const rejectedPaths = new Set();
|
|
55
55
|
const rejectedConceptIds = new Set();
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
// A full directory drain may contain both peer workflow formats for one
|
|
57
|
+
// canonical ref. Ownership arbitration must happen *before* recognition:
|
|
58
|
+
// otherwise both documents reach the persistence fold and SQLite's final
|
|
59
|
+
// row is determined by the walk/readdir order. Resolve exactly the paths
|
|
60
|
+
// this drain owns, so a full scan retains only the deterministic `.md`
|
|
61
|
+
// winner while a targeted one-file reindex deliberately keeps its written
|
|
62
|
+
// source and therefore marks an existing peer row stale for read fallback.
|
|
63
|
+
const workflowOwnerPathByCanonicalName = new Map(resolveWorkflowSourceDomains(component.root, adapter.id, fileContexts.map((file) => file.absPath))
|
|
64
|
+
.filter((resolution) => resolution.source !== undefined)
|
|
65
|
+
.map((resolution) => [resolution.canonicalName, path.resolve(resolution.source.path)]));
|
|
66
|
+
const invalidWorkflowOwnerNames = new Set();
|
|
67
|
+
const orderedFileContexts = [...fileContexts].sort((left, right) => {
|
|
68
|
+
const leftName = workflowNameForSourcePath(component.root, adapter.id, left.absPath);
|
|
69
|
+
const rightName = workflowNameForSourcePath(component.root, adapter.id, right.absPath);
|
|
70
|
+
const leftOwner = leftName !== undefined &&
|
|
71
|
+
workflowOwnerPathByCanonicalName.get(canonicalizeWorkflowName(leftName)) === path.resolve(left.absPath);
|
|
72
|
+
const rightOwner = rightName !== undefined &&
|
|
73
|
+
workflowOwnerPathByCanonicalName.get(canonicalizeWorkflowName(rightName)) === path.resolve(right.absPath);
|
|
74
|
+
if (leftOwner !== rightOwner)
|
|
75
|
+
return leftOwner ? -1 : 1;
|
|
76
|
+
return compareCodePoints(left.absPath, right.absPath);
|
|
77
|
+
});
|
|
78
|
+
for (const file of orderedFileContexts) {
|
|
79
|
+
if (rejectedPaths.has(file.absPath))
|
|
80
|
+
continue;
|
|
58
81
|
const workflowName = workflowNameForSourcePath(component.root, adapter.id, file.absPath);
|
|
59
82
|
if (workflowName !== undefined) {
|
|
60
83
|
const canonicalName = canonicalizeWorkflowName(workflowName);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
try {
|
|
67
|
-
resolveUniqueWorkflowSource(component.root, adapter.id, workflowName);
|
|
68
|
-
}
|
|
69
|
-
catch (error) {
|
|
70
|
-
if (!(error instanceof WorkflowSourceRejectionError))
|
|
71
|
-
throw error;
|
|
72
|
-
rejectedConceptIds.add(adapter.id === "akm" ? `workflows/${canonicalName}` : canonicalName);
|
|
73
|
-
for (const relativePath of error.sourcePaths) {
|
|
74
|
-
rejectedPaths.add(path.join(component.root, relativePath));
|
|
84
|
+
const ownerPath = workflowOwnerPathByCanonicalName.get(canonicalName);
|
|
85
|
+
if (ownerPath !== undefined &&
|
|
86
|
+
ownerPath !== path.resolve(file.absPath) &&
|
|
87
|
+
!invalidWorkflowOwnerNames.has(canonicalName)) {
|
|
88
|
+
continue;
|
|
75
89
|
}
|
|
76
|
-
warnings.push(error.message);
|
|
77
90
|
}
|
|
78
|
-
}
|
|
79
|
-
for (const file of fileContexts) {
|
|
80
|
-
if (rejectedPaths.has(file.absPath))
|
|
81
|
-
continue;
|
|
82
91
|
const doc = adapter.recognize(component, file);
|
|
83
92
|
if (doc === null)
|
|
84
93
|
continue;
|
|
@@ -92,6 +101,8 @@ export function drainDirDocuments(adapter, component, fileContexts) {
|
|
|
92
101
|
const dropWarning = handleWorkflowDoc(doc, file, component.root);
|
|
93
102
|
if (dropWarning !== null) {
|
|
94
103
|
warnings.push(dropWarning);
|
|
104
|
+
if (workflowName !== undefined)
|
|
105
|
+
invalidWorkflowOwnerNames.add(canonicalizeWorkflowName(workflowName));
|
|
95
106
|
continue;
|
|
96
107
|
}
|
|
97
108
|
if (doc.hash !== undefined)
|
|
@@ -17,10 +17,11 @@ import path from "node:path";
|
|
|
17
17
|
import { buildActionFromContributors, defaultActionContributors } from "../../core/action-contributors.js";
|
|
18
18
|
import { stashDirFor } from "../../core/asset/asset-placement.js";
|
|
19
19
|
import { displayRef } from "../../core/asset/resolve-ref.js";
|
|
20
|
+
import { compareCodePoints } from "../../core/common.js";
|
|
20
21
|
import { classifyPathAccess } from "../../core/path-access.js";
|
|
21
22
|
import { getDbPath } from "../../core/paths.js";
|
|
22
23
|
import { systemErrorCode } from "../../core/system-error.js";
|
|
23
|
-
import { defaultRendererRegistry } from "../../core/type-presentation.js";
|
|
24
|
+
import { allowsFragmentRef, defaultRendererRegistry } from "../../core/type-presentation.js";
|
|
24
25
|
import { normalizeEmbeddingEndpoint } from "../../llm/embedders/remote.js";
|
|
25
26
|
import { assertIndexPathReadable, closeDatabase, openExistingDatabase, } from "../../storage/repositories/index-connection.js";
|
|
26
27
|
import { getAllEntries, getBaseBeliefStatesForDerivedTwins, getEntryById, getEntryCount, getPositiveFeedbackCountsByIds, } from "../../storage/repositories/index-entries-repository.js";
|
|
@@ -186,13 +187,47 @@ export async function searchLocal(input) {
|
|
|
186
187
|
}
|
|
187
188
|
// ── Database search ─────────────────────────────────────────────────────────
|
|
188
189
|
/**
|
|
189
|
-
* Keep
|
|
190
|
-
*
|
|
191
|
-
*
|
|
190
|
+
* Keep public scores in [0, 1] without flattening every boosted result to the
|
|
191
|
+
* same hard-clamped value. The ranking pipeline deliberately keeps its raw
|
|
192
|
+
* score for deterministic ordering before stable path deduplication; this
|
|
193
|
+
* monotone display projection preserves that order and leaves visible
|
|
194
|
+
* separation for graph, type, and project-context signals.
|
|
192
195
|
*/
|
|
196
|
+
function displaySearchScore(score) {
|
|
197
|
+
return 1 - Math.exp(-Math.max(0, score));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* A final deterministic key for genuinely tied candidates. It deliberately
|
|
201
|
+
* excludes the asset name, filename, path, durable ref, and SQLite id: callers
|
|
202
|
+
* such as the memory-pack adapter generate each of those from an opaque source
|
|
203
|
+
* id, so using one here makes an otherwise equal search depend on that id.
|
|
204
|
+
*
|
|
205
|
+
* The normal AKM Markdown adapter keeps an H1 title in `content`; strip that
|
|
206
|
+
* one synthetic title too, because the adapter may derive it from the opaque
|
|
207
|
+
* filename. Identical remaining bodies are semantically indistinguishable at
|
|
208
|
+
* this ranking stage and intentionally continue to the existing name/path
|
|
209
|
+
* fallback for repeatable local presentation.
|
|
210
|
+
*/
|
|
211
|
+
function asciiCaseFold(value) {
|
|
212
|
+
// SQLite's built-in lower() folds ASCII only unless a build opts into ICU.
|
|
213
|
+
// Keep this key deliberately in that portable shared subset instead of
|
|
214
|
+
// introducing locale-dependent JavaScript ordering for non-ASCII content.
|
|
215
|
+
return value.replace(/[A-Z]/g, (letter) => String.fromCharCode(letter.charCodeAt(0) + 32));
|
|
216
|
+
}
|
|
217
|
+
/** The portable byte-level title/body rule mirrored in index-fts-repository. */
|
|
218
|
+
export function canonicalContentTieKey(entry) {
|
|
219
|
+
const content = entry.content ?? "";
|
|
220
|
+
const newline = content.startsWith("# ") ? content.indexOf("\n") : -1;
|
|
221
|
+
// SQLite uses ltrim(value, char(13) || char(10) || ' ') after an exact '# '
|
|
222
|
+
// title and trim(value, ' ') otherwise. Keep exactly that deliberately
|
|
223
|
+
// narrow byte contract; do not use locale or Unicode-whitespace helpers.
|
|
224
|
+
const body = newline >= 0 ? content.slice(newline + 1).replace(/^[\r\n ]+/, "") : content;
|
|
225
|
+
const source = (body || entry.description || "").replace(/^ +| +$/g, "");
|
|
226
|
+
return Buffer.from(asciiCaseFold(source), "utf8").toString("hex");
|
|
227
|
+
}
|
|
193
228
|
function buildSearchResultComparator(query) {
|
|
194
229
|
const queryTokens = buildLexicalQueryPlan(query).tokens.map((token) => token.toLowerCase());
|
|
195
|
-
const displayScore = (score) => Math.round(
|
|
230
|
+
const displayScore = (score) => Math.round(displaySearchScore(score) * 10000) / 10000;
|
|
196
231
|
const stableRankScore = (score) => Math.round(score * 10000) / 10000;
|
|
197
232
|
return (a, b) => {
|
|
198
233
|
const aNameTier = lexicalNameMatchTier(a.entry, queryTokens);
|
|
@@ -208,11 +243,28 @@ function buildSearchResultComparator(query) {
|
|
|
208
243
|
const rawScoreDiff = stableRankScore(b.score) - stableRankScore(a.score);
|
|
209
244
|
if (rawScoreDiff !== 0)
|
|
210
245
|
return rawScoreDiff;
|
|
246
|
+
// Ceiling values are intentionally allowed to demote visibility, but not
|
|
247
|
+
// to erase relevance. Prefer the score before a relaxed body-only ceiling;
|
|
248
|
+
// a later belief-state ceiling has its own minScore handoff and must not
|
|
249
|
+
// overwrite this ordering evidence. Belief-only ceilings fall back to
|
|
250
|
+
// their `preCeilingScore`.
|
|
251
|
+
const preCeilingRelevance = (item) => item.preRelaxedCeilingScore ?? item.preCeilingScore ?? item.score;
|
|
252
|
+
const ceilingDiff = stableRankScore(preCeilingRelevance(b)) - stableRankScore(preCeilingRelevance(a));
|
|
253
|
+
if (ceilingDiff !== 0)
|
|
254
|
+
return ceilingDiff;
|
|
211
255
|
const nameDiff = bNameTier - aNameTier;
|
|
212
256
|
if (nameDiff !== 0)
|
|
213
257
|
return nameDiff;
|
|
214
258
|
const typeDiff = typeBoostFor(b.entry.type) - typeBoostFor(a.entry.type);
|
|
215
|
-
|
|
259
|
+
if (typeDiff !== 0)
|
|
260
|
+
return typeDiff;
|
|
261
|
+
// Keep opaque generated IDs out of the final relevance tie-break. This
|
|
262
|
+
// runs only after every ranking contributor (including the #940 preserved
|
|
263
|
+
// pre-ceiling evidence), exact-name, and type comparison has tied.
|
|
264
|
+
const contentDiff = compareCodePoints(canonicalContentTieKey(a.entry), canonicalContentTieKey(b.entry));
|
|
265
|
+
if (contentDiff !== 0)
|
|
266
|
+
return contentDiff;
|
|
267
|
+
return a.filePath.localeCompare(b.filePath);
|
|
216
268
|
};
|
|
217
269
|
}
|
|
218
270
|
async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false, beliefFilter = "all", restrictToSources = false, includeExcludedTypes = false, disableProjectContext = false, disableScopedUtility = false) {
|
|
@@ -279,8 +331,9 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
279
331
|
const { ftsResults, embeddingScores, embedMs, mode, semanticWarning } = await collectSearchSignals(db, query, limit * 3, typeFilter, defaultExcludes, config);
|
|
280
332
|
const tRank0 = Date.now();
|
|
281
333
|
// ── Score normalization ──────────────────────────────────────────────
|
|
282
|
-
//
|
|
283
|
-
// (FTS 0.7, vector 0.3)
|
|
334
|
+
// Stable bounded BM25 transform + cosine similarity with weighted addition
|
|
335
|
+
// (FTS 0.7, vector 0.3). The lexical transform is per-row, so widening the
|
|
336
|
+
// candidate set cannot alter a pre-existing row's base score.
|
|
284
337
|
const ftsScoreMap = normalizeFtsScores(ftsResults);
|
|
285
338
|
// Build embedding score map (cosine similarities already 0-1)
|
|
286
339
|
const embedScoreMap = new Map();
|
|
@@ -400,11 +453,11 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
400
453
|
const selected = beliefFiltered.slice(0, limit);
|
|
401
454
|
const hits = await Promise.all(selected.map((ranked) => {
|
|
402
455
|
const { entry, filePath, score, rankingMode, utilityBoosted } = ranked;
|
|
403
|
-
// CLAUDE.md locks SearchHit.score in [0,1]. The boost loop
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
const finalScore =
|
|
456
|
+
// CLAUDE.md locks SearchHit.score in [0,1]. The boost loop deliberately
|
|
457
|
+
// remains raw for ranking, then takes a monotone bounded projection at
|
|
458
|
+
// the public boundary so contributors do not collapse into hard-clamped
|
|
459
|
+
// ties.
|
|
460
|
+
const finalScore = displaySearchScore(score);
|
|
408
461
|
return buildDbHit({
|
|
409
462
|
entry,
|
|
410
463
|
path: filePath,
|
|
@@ -413,6 +466,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
413
466
|
query,
|
|
414
467
|
rankingMode,
|
|
415
468
|
lexicalMatch: ranked.lexicalMatch,
|
|
469
|
+
fragmentId: ranked.fragmentId,
|
|
416
470
|
defaultStashDir: stashDir,
|
|
417
471
|
allSourceDirs,
|
|
418
472
|
sources,
|
|
@@ -728,7 +782,11 @@ export async function buildDbHit(input) {
|
|
|
728
782
|
(source && path.resolve(source.path) === path.resolve(input.defaultStashDir)
|
|
729
783
|
? (input.bundleId ?? undefined)
|
|
730
784
|
: undefined);
|
|
731
|
-
const
|
|
785
|
+
const parentRef = resolveSearchHitRef(input.entry, input, defaultBundleId);
|
|
786
|
+
// Fragments prove lexical relevance, but executable assets must retain the
|
|
787
|
+
// parent ref consumed by their advertised action (for example workflow run).
|
|
788
|
+
// The central type-presentation contract opts those types out explicitly.
|
|
789
|
+
const ref = input.fragmentId && allowsFragmentRef(input.entry.type) ? `${parentRef}#${input.fragmentId}` : parentRef;
|
|
732
790
|
const editable = isEditable(absolutePath, input.config, input.sources);
|
|
733
791
|
const estimatedTokens = typeof input.entry.fileSize === "number" ? Math.round(input.entry.fileSize / 4) : undefined;
|
|
734
792
|
const hit = {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
import { buildLexicalQueryPlan } from "./fts-query.js";
|
|
5
|
+
/**
|
|
6
|
+
* Tokenize a display name through the same Unicode-aware lexical planner as a
|
|
7
|
+
* query. Ranking must not invent a second, punctuation-dependent name grammar.
|
|
8
|
+
*/
|
|
9
|
+
export function lexicalNameTokens(name) {
|
|
10
|
+
return buildLexicalQueryPlan(name).tokens.map((token) => token.toLowerCase());
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Structural name-token evidence. Exact tokens are always meaningful; fuzzy
|
|
14
|
+
* prefix evidence requires three code points on both sides so a question's
|
|
15
|
+
* short words or digits cannot match an opaque generated storage name.
|
|
16
|
+
*/
|
|
17
|
+
export function structuralNameTokenMatch(left, right) {
|
|
18
|
+
return (left === right ||
|
|
19
|
+
(Math.min([...left].length, [...right].length) >= 3 && (left.startsWith(right) || right.startsWith(left))));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Match a query phrase only across complete, contiguous name tokens. This is
|
|
23
|
+
* deliberately not raw substring matching: `000` must not become name
|
|
24
|
+
* evidence merely because an opaque token happens to be `z9xq000`.
|
|
25
|
+
*/
|
|
26
|
+
export function structuralNamePhraseMatch(nameTokens, queryTokens) {
|
|
27
|
+
if (queryTokens.length === 0 || queryTokens.length > nameTokens.length)
|
|
28
|
+
return false;
|
|
29
|
+
for (let start = 0; start <= nameTokens.length - queryTokens.length; start += 1) {
|
|
30
|
+
if (queryTokens.every((queryToken, index) => structuralNameTokenMatch(nameTokens[start + index], queryToken))) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
import { isKnownType } from "../../core/recognition-util.js";
|
|
5
5
|
import { computeGraphBoost } from "../graph/graph-boost.js";
|
|
6
|
+
import { lexicalNameTokens, structuralNamePhraseMatch, structuralNameTokenMatch } from "./name-match.js";
|
|
6
7
|
import { attachSearchHitAttribution } from "./search-attribution.js";
|
|
7
8
|
/**
|
|
8
9
|
* Chunk 1.5 (D1.5-5) — retyped from `Record<string, number>` to a FULL
|
|
@@ -105,13 +106,11 @@ function beliefStateBoost(item) {
|
|
|
105
106
|
* stash-conventions-code-spec.md — corrections demotion).
|
|
106
107
|
*
|
|
107
108
|
* Why the additive {@link beliefStateBoost} penalties alone are not enough:
|
|
108
|
-
* keyword base scores
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* stays clamp-pinned at 1.0 above its own correction no matter what additive
|
|
114
|
-
* penalty it receives — defeating the corrections pattern's point ("so the
|
|
109
|
+
* keyword base scores have a bounded lexical floor (`normalizeFtsScores`),
|
|
110
|
+
* while the boost sum then MULTIPLIES the base (`score *= 1 + boostSum`,
|
|
111
|
+
* {@link applyScoreContributors}). A superseded incumbent can still earn
|
|
112
|
+
* enough independent boosts to outrank its own correction, so additive
|
|
113
|
+
* penalties alone cannot guarantee the corrections pattern's point ("so the
|
|
115
114
|
* ranker demotes the stale version instead of letting it outrank your fix").
|
|
116
115
|
*
|
|
117
116
|
* The ceilings guarantee the demotion while keeping flagged entries VISIBLE:
|
|
@@ -161,11 +160,10 @@ const exactNameRankingContributor = {
|
|
|
161
160
|
if (nameBase === ctx.queryLower || nameLower === ctx.queryLower) {
|
|
162
161
|
return 2.0;
|
|
163
162
|
}
|
|
164
|
-
|
|
163
|
+
const nameTokens = lexicalNameTokens(nameBase);
|
|
164
|
+
if (structuralNamePhraseMatch(nameTokens, ctx.queryTokens))
|
|
165
165
|
return 1.0;
|
|
166
|
-
|
|
167
|
-
const nameTokens = nameBase.split(/[-_\s]+/).filter(Boolean);
|
|
168
|
-
const matchCount = ctx.queryTokens.filter((qt) => nameTokens.some((nt) => nt === qt || nt.includes(qt))).length;
|
|
166
|
+
const matchCount = ctx.queryTokens.filter((qt) => nameTokens.some((nt) => structuralNameTokenMatch(nt, qt))).length;
|
|
169
167
|
return matchCount > 0 ? Math.min(0.9, matchCount * 0.3) : 0;
|
|
170
168
|
},
|
|
171
169
|
};
|
|
@@ -245,7 +243,12 @@ const aliasRankingContributor = {
|
|
|
245
243
|
const descriptionRankingContributor = {
|
|
246
244
|
name: "description-ranking",
|
|
247
245
|
appliesTo(item) {
|
|
248
|
-
|
|
246
|
+
// A relaxed FTS query admits an OR pool. Awarding a flat +0.1 merely
|
|
247
|
+
// because of a partial description coincidence double-counts a weak
|
|
248
|
+
// signal and can outrank materially stronger BM25 body evidence. The FTS
|
|
249
|
+
// score already accounts for descriptions; retain this secondary boost
|
|
250
|
+
// only for conjunctive candidates.
|
|
251
|
+
return (item.lexicalMatch !== "relaxed" && typeof item.entry.description === "string" && item.entry.description.length > 0);
|
|
249
252
|
},
|
|
250
253
|
adjust(item, ctx) {
|
|
251
254
|
const descLower = item.entry.description?.toLowerCase() ?? "";
|
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
import { stableFtsScore } from "../../core/lexical-score.js";
|
|
4
5
|
import { getUtilityScoresByIds } from "../../storage/repositories/index-utility-repository.js";
|
|
5
6
|
import { buildLexicalQueryPlan } from "./fts-query.js";
|
|
7
|
+
import { lexicalNameTokens, structuralNameTokenMatch } from "./name-match.js";
|
|
6
8
|
import { applyBeliefStateScoreCeiling, applyScoreContributors, applyUtilityContributors, defaultRankingContributors, defaultUtilityRankingContributors, } from "./ranking-contributors.js";
|
|
9
|
+
/**
|
|
10
|
+
* Lower bounds keep a lexical hit competitive with a vector-only neighbour;
|
|
11
|
+
* the upper bound deliberately leaves room for the ranking contributors that
|
|
12
|
+
* run after retrieval (notably the bounded graph boost). This is a
|
|
13
|
+
* calibration for the one search pipeline, not a claim that BM25 is
|
|
14
|
+
* comparable across different queries or FTS tables.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Convert FTS5's negative BM25 value into the lexical contribution used by
|
|
18
|
+
* this pipeline. The transform is fixed and monotone: it depends only on a
|
|
19
|
+
* row's own BM25 value, so appending weaker candidates cannot rewrite an
|
|
20
|
+
* existing row's score. FTS5 commonly emits relevance near 1e-6 for broad
|
|
21
|
+
* queries, so first put relevance on a log scale around that observed value.
|
|
22
|
+
* The shape constant intentionally makes the curve approach its ceiling
|
|
23
|
+
* slowly: rare-term scores retain separation instead of all reading as 0.8.
|
|
24
|
+
*
|
|
25
|
+
* FTS5 produces finite non-positive values in normal operation. Keeping the
|
|
26
|
+
* defensive cases here finite makes this boundary safe if a driver or fixture
|
|
27
|
+
* hands us an invalid value: `-Infinity` is the strongest possible match,
|
|
28
|
+
* while NaN, +Infinity, and positive scores contribute no lexical evidence.
|
|
29
|
+
*/
|
|
7
30
|
export function normalizeFtsScores(results) {
|
|
8
31
|
const ftsScoreMap = new Map();
|
|
9
|
-
if (results.length === 0)
|
|
10
|
-
return ftsScoreMap;
|
|
11
|
-
const bestBm25 = results[0].bm25Score;
|
|
12
|
-
const worstBm25 = results[results.length - 1].bm25Score;
|
|
13
|
-
const range = bestBm25 - worstBm25;
|
|
14
32
|
for (const result of results) {
|
|
15
|
-
|
|
16
|
-
const ftsScore = 0.3 + normalized * 0.7;
|
|
17
|
-
ftsScoreMap.set(result.id, { score: ftsScore, result });
|
|
33
|
+
ftsScoreMap.set(result.id, { score: result.lexicalScore ?? stableFtsScore(result.bm25Score), result });
|
|
18
34
|
}
|
|
19
35
|
return ftsScoreMap;
|
|
20
36
|
}
|
|
@@ -38,6 +54,7 @@ export function combineSearchScores(options) {
|
|
|
38
54
|
itemRef: result.itemRef,
|
|
39
55
|
bundleId: result.bundleId,
|
|
40
56
|
conceptId: result.conceptId,
|
|
57
|
+
fragmentId: result.fragmentId,
|
|
41
58
|
});
|
|
42
59
|
}
|
|
43
60
|
for (const [id, cosine] of options.embedScoreMap) {
|
|
@@ -98,9 +115,8 @@ export function applyRankingRules(options) {
|
|
|
98
115
|
applyRelaxedLexicalScoreCeiling(item, queryTokens);
|
|
99
116
|
// SPEC-5: demoting belief states (superseded/contradicted/archived/
|
|
100
117
|
// deprecated) cap the FINAL score. The additive belief penalty inside the
|
|
101
|
-
// multiplicative boost sum
|
|
102
|
-
//
|
|
103
|
-
// keyword match outranks its own correction forever.
|
|
118
|
+
// multiplicative boost sum can still overwhelm an additive belief penalty,
|
|
119
|
+
// so without the ceiling a superseded incumbent can outrank its correction.
|
|
104
120
|
applyBeliefStateScoreCeiling(item);
|
|
105
121
|
}
|
|
106
122
|
return options.items;
|
|
@@ -115,14 +131,12 @@ export function lexicalNameMatchTier(entry, queryTokens) {
|
|
|
115
131
|
if (queryTokens.length === 0)
|
|
116
132
|
return 0;
|
|
117
133
|
const nameBase = entry.name.toLowerCase().split("/").pop() ?? entry.name.toLowerCase();
|
|
118
|
-
const nameTokens =
|
|
119
|
-
const tokenMatches = (left, right) => left === right ||
|
|
120
|
-
(Math.min([...left].length, [...right].length) >= 3 && (left.startsWith(right) || right.startsWith(left)));
|
|
134
|
+
const nameTokens = lexicalNameTokens(nameBase);
|
|
121
135
|
if (nameTokens.length === queryTokens.length &&
|
|
122
|
-
nameTokens.every((token, index) =>
|
|
136
|
+
nameTokens.every((token, index) => structuralNameTokenMatch(token, queryTokens[index]))) {
|
|
123
137
|
return 3;
|
|
124
138
|
}
|
|
125
|
-
const matched = queryTokens.filter((token) => nameTokens.some((nameToken) =>
|
|
139
|
+
const matched = queryTokens.filter((token) => nameTokens.some((nameToken) => structuralNameTokenMatch(nameToken, token))).length;
|
|
126
140
|
if (matched === queryTokens.length)
|
|
127
141
|
return 2;
|
|
128
142
|
return matched > 0 ? 1 : 0;
|
|
@@ -130,10 +144,20 @@ export function lexicalNameMatchTier(entry, queryTokens) {
|
|
|
130
144
|
/**
|
|
131
145
|
* A relaxed OR query admits intentionally weak candidates. Candidates with no
|
|
132
146
|
* query token in their name remain visible for body-only recall, but cannot
|
|
133
|
-
*
|
|
147
|
+
* share the same bounded displayed score as stronger name-bearing recoveries.
|
|
148
|
+
* The raw ceiling is 0.65; the public score projection is applied later, so
|
|
149
|
+
* callers never literally receive `0.65` just because this ceiling bound.
|
|
150
|
+
*
|
|
151
|
+
* Preserve the pre-ceiling relevance separately from `preCeilingScore`, which
|
|
152
|
+
* belongs to belief-state demotion and may be written afterwards. A relaxed,
|
|
153
|
+
* belief-demoted candidate otherwise loses both its body relevance and its
|
|
154
|
+
* ordering signal when the second ceiling overwrites the first.
|
|
134
155
|
*/
|
|
135
156
|
function applyRelaxedLexicalScoreCeiling(item, queryTokens) {
|
|
136
157
|
if (item.lexicalMatch !== "relaxed" || lexicalNameMatchTier(item.entry, queryTokens) > 0)
|
|
137
158
|
return;
|
|
138
|
-
|
|
159
|
+
if (item.score > RELAXED_NON_NAME_SCORE_CEILING) {
|
|
160
|
+
item.preRelaxedCeilingScore = item.score;
|
|
161
|
+
item.score = RELAXED_NON_NAME_SCORE_CEILING;
|
|
162
|
+
}
|
|
139
163
|
}
|
|
@@ -22,6 +22,18 @@ export function recentShowCount(ref) {
|
|
|
22
22
|
return 0;
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
+
/** Compare search/display selectors by their durable parent asset identity. */
|
|
26
|
+
function traceParentRef(ref) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = parseBundleRef(ref);
|
|
29
|
+
return makeBundleRef(parsed.bundle, parsed.conceptId);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Registry result refs are not local bundle refs and cannot have a local
|
|
33
|
+
// selector. Keep their existing exact comparison behavior.
|
|
34
|
+
return ref;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
25
37
|
function appendShowTrace(ref, type, name) {
|
|
26
38
|
appendEvent({ eventType: "show", ref, metadata: { type, name } });
|
|
27
39
|
try {
|
|
@@ -34,7 +46,7 @@ function appendShowTrace(ref, type, name) {
|
|
|
34
46
|
if (!event.ts || new Date(event.ts).getTime() < cutoffMs)
|
|
35
47
|
return false;
|
|
36
48
|
const refs = event.metadata?.resultRefs ?? [];
|
|
37
|
-
return refs.
|
|
49
|
+
return refs.some((candidate) => traceParentRef(candidate) === ref);
|
|
38
50
|
});
|
|
39
51
|
if (!matchingSearch)
|
|
40
52
|
return;
|
|
@@ -45,7 +57,7 @@ function appendShowTrace(ref, type, name) {
|
|
|
45
57
|
metadata: {
|
|
46
58
|
query: matchingSearch.metadata?.query,
|
|
47
59
|
searchTs: matchingSearch.ts,
|
|
48
|
-
rankPosition: resultRefs.
|
|
60
|
+
rankPosition: resultRefs.findIndex((candidate) => traceParentRef(candidate) === ref),
|
|
49
61
|
},
|
|
50
62
|
});
|
|
51
63
|
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import systemPromptTemplate from "../assets/prompts/graph-extract-system.md" with { type: "text" };
|
|
22
22
|
import userPromptTemplate from "../assets/prompts/graph-extract-user-prompt.md" with { type: "text" };
|
|
23
|
+
import { splitMarkdownFragmentStats } from "../core/asset/markdown-fragments.js";
|
|
23
24
|
import { toErrorMessage } from "../core/common.js";
|
|
24
25
|
import { ConfigError } from "../core/errors.js";
|
|
25
26
|
import { parseEmbeddedJsonResponse } from "../core/parse.js";
|
|
@@ -117,79 +118,29 @@ function normalizeBatchState(state) {
|
|
|
117
118
|
state.nonArrayBatchFailures = Math.max(0, state.nonArrayBatchFailures ?? 0);
|
|
118
119
|
return state;
|
|
119
120
|
}
|
|
120
|
-
function splitParagraph(text, maxChars) {
|
|
121
|
-
if (text.length <= maxChars)
|
|
122
|
-
return { chunks: [text], truncationCount: 0 };
|
|
123
|
-
const chunks = [];
|
|
124
|
-
let truncationCount = 0;
|
|
125
|
-
let remaining = text;
|
|
126
|
-
while (remaining.length > maxChars) {
|
|
127
|
-
let splitAt = remaining.lastIndexOf(" ", maxChars);
|
|
128
|
-
if (splitAt < Math.floor(maxChars * 0.6))
|
|
129
|
-
splitAt = maxChars;
|
|
130
|
-
const piece = remaining.slice(0, splitAt).trim();
|
|
131
|
-
if (piece)
|
|
132
|
-
chunks.push(piece);
|
|
133
|
-
remaining = remaining.slice(splitAt).trim();
|
|
134
|
-
truncationCount += 1;
|
|
135
|
-
}
|
|
136
|
-
if (remaining)
|
|
137
|
-
chunks.push(remaining);
|
|
138
|
-
return { chunks, truncationCount };
|
|
139
|
-
}
|
|
140
121
|
function splitBodyIntoChunks(body, maxChars = MAX_CHUNK_BODY_CHARS) {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
122
|
+
const split = splitMarkdownFragmentStats(body, maxChars);
|
|
123
|
+
// Graph extraction keeps its historical cost shape: adjacent safe fragments
|
|
124
|
+
// share one LLM call whenever they fit. The fragment splitter is still the
|
|
125
|
+
// sole boundary authority; this is only prompt packing, never a second
|
|
126
|
+
// parser/chunker. Hard splits stay isolated and telemetry remains the core
|
|
127
|
+
// split count rather than counting ordinary heading boundaries.
|
|
147
128
|
const chunks = [];
|
|
148
129
|
let current = "";
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
chunks.push(trimmed);
|
|
154
|
-
current = "";
|
|
155
|
-
};
|
|
156
|
-
for (const section of sections) {
|
|
157
|
-
if (section.length <= maxChars) {
|
|
158
|
-
const candidate = current ? `${current}\n\n${section}` : section;
|
|
159
|
-
if (candidate.length <= maxChars)
|
|
160
|
-
current = candidate;
|
|
161
|
-
else {
|
|
162
|
-
flush();
|
|
163
|
-
current = section;
|
|
164
|
-
}
|
|
165
|
-
continue;
|
|
130
|
+
for (const fragment of split.fragments) {
|
|
131
|
+
const candidate = current ? `${current}\n\n${fragment.text}` : fragment.text;
|
|
132
|
+
if (candidate.length <= maxChars) {
|
|
133
|
+
current = candidate;
|
|
166
134
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
.
|
|
171
|
-
for (const paragraph of paragraphs) {
|
|
172
|
-
if (paragraph.length <= maxChars) {
|
|
173
|
-
const candidate = current ? `${current}\n\n${paragraph}` : paragraph;
|
|
174
|
-
if (candidate.length <= maxChars)
|
|
175
|
-
current = candidate;
|
|
176
|
-
else {
|
|
177
|
-
flush();
|
|
178
|
-
current = paragraph;
|
|
179
|
-
}
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
flush();
|
|
183
|
-
const split = splitParagraph(paragraph, maxChars);
|
|
184
|
-
truncationCount += split.truncationCount;
|
|
185
|
-
for (const piece of split.chunks) {
|
|
186
|
-
if (piece.length <= maxChars)
|
|
187
|
-
chunks.push(piece);
|
|
188
|
-
}
|
|
135
|
+
else {
|
|
136
|
+
if (current)
|
|
137
|
+
chunks.push(current);
|
|
138
|
+
current = fragment.text;
|
|
189
139
|
}
|
|
190
140
|
}
|
|
191
|
-
|
|
192
|
-
|
|
141
|
+
if (current)
|
|
142
|
+
chunks.push(current);
|
|
143
|
+
return { chunks, truncationCount: split.hardSplitCount };
|
|
193
144
|
}
|
|
194
145
|
/** Consistency weight for blending chunk-agreement with LLM confidence. */
|
|
195
146
|
const CONSISTENCY_WEIGHT = 0.4;
|