recallnest 2.3.0 → 2.6.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 +187 -0
- package/README.md +49 -38
- package/README_CN.md +46 -31
- package/assets/ui/app.js +1031 -0
- package/assets/ui/index.html +224 -0
- package/assets/ui/styles.css +781 -0
- package/config.json.example +4 -1
- package/docs/api-reference.md +1291 -0
- package/docs/architecture.md +168 -0
- package/docs/connector-spec.md +126 -0
- package/eval/cases-canary.json +58 -0
- package/eval/cases-scope-robustness.json +278 -0
- package/eval/cases.json +117 -20
- package/package.json +25 -4
- package/src/__tests__/access-tracker-novelty.test.ts +304 -0
- package/src/__tests__/activity-counter.test.ts +125 -0
- package/src/__tests__/adaptive-fidelity.test.ts +102 -0
- package/src/__tests__/admission-control.test.ts +206 -0
- package/src/__tests__/anchor-generator.test.ts +67 -0
- package/src/__tests__/api-server-security.test.ts +102 -0
- package/src/__tests__/auto-consolidation.test.ts +168 -0
- package/src/__tests__/auto-recall.test.ts +131 -0
- package/src/__tests__/babel-integration.test.ts +144 -0
- package/src/__tests__/batch-dedup.test.ts +65 -0
- package/src/__tests__/belief-history.test.ts +220 -0
- package/src/__tests__/canary-eval.test.ts +114 -0
- package/src/__tests__/capture-engine.test.ts +1328 -0
- package/src/__tests__/capture-heuristic.test.ts +171 -0
- package/src/__tests__/cascade-forget.test.ts +210 -0
- package/src/__tests__/category-min-scores.test.ts +116 -0
- package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
- package/src/__tests__/circuit-breaker.test.ts +99 -0
- package/src/__tests__/cluster-consolidation.test.ts +945 -0
- package/src/__tests__/confidence-metadata.test.ts +125 -0
- package/src/__tests__/confidence-tracker.test.ts +261 -0
- package/src/__tests__/conflict-advisor.test.ts +206 -0
- package/src/__tests__/conflict-escalation.test.ts +149 -0
- package/src/__tests__/conflict-lifecycle.test.ts +83 -0
- package/src/__tests__/conflict-output.test.ts +85 -0
- package/src/__tests__/conflict-store.test.ts +418 -0
- package/src/__tests__/connector-v1.test.ts +181 -0
- package/src/__tests__/consolidation-engine.test.ts +430 -0
- package/src/__tests__/constructive-retrieval.test.ts +509 -0
- package/src/__tests__/context-collapse-renderer.test.ts +214 -0
- package/src/__tests__/context-composer-pins.test.ts +64 -0
- package/src/__tests__/context-composer-task-selection.test.ts +24 -0
- package/src/__tests__/context-reconstructor.test.ts +65 -0
- package/src/__tests__/context-renderer.test.ts +91 -0
- package/src/__tests__/continuity-eval.test.ts +275 -0
- package/src/__tests__/continuity-schema.test.ts +97 -0
- package/src/__tests__/contracts.test.ts +262 -0
- package/src/__tests__/conversation-importer.test.ts +692 -0
- package/src/__tests__/core-recall-eval.test.ts +235 -0
- package/src/__tests__/core-summary.test.ts +109 -0
- package/src/__tests__/data-checkup.test.ts +276 -0
- package/src/__tests__/decay-exemption.test.ts +129 -0
- package/src/__tests__/dedup-actions.test.ts +58 -0
- package/src/__tests__/dedup-guard.test.ts +39 -0
- package/src/__tests__/deterministic-id.test.ts +39 -0
- package/src/__tests__/distill-lock.test.ts +305 -0
- package/src/__tests__/distill-session-argshape.test.ts +79 -0
- package/src/__tests__/doctor.test.ts +319 -0
- package/src/__tests__/dream-pipeline.test.ts +780 -0
- package/src/__tests__/drill-down.test.ts +108 -0
- package/src/__tests__/embedder.test.ts +122 -0
- package/src/__tests__/emotion-decay.test.ts +46 -0
- package/src/__tests__/emotion-detector.test.ts +56 -0
- package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
- package/src/__tests__/emotion-retrieval.test.ts +56 -0
- package/src/__tests__/entity-resolver.test.ts +58 -0
- package/src/__tests__/env-config.test.ts +151 -0
- package/src/__tests__/error-signature-boost.test.ts +87 -0
- package/src/__tests__/error-signature.test.ts +95 -0
- package/src/__tests__/error-taxonomy.test.ts +129 -0
- package/src/__tests__/ethics-layer.test.ts +476 -0
- package/src/__tests__/event-segmenter.test.ts +153 -0
- package/src/__tests__/feature-flags-integration.test.ts +54 -0
- package/src/__tests__/frequency-tracker.test.ts +113 -0
- package/src/__tests__/freshness.test.ts +186 -0
- package/src/__tests__/graph-export.test.ts +489 -0
- package/src/__tests__/hotness-blend.test.ts +283 -0
- package/src/__tests__/ingest-chunking.test.ts +44 -0
- package/src/__tests__/ingest.test.ts +360 -0
- package/src/__tests__/interference-detector.test.ts +203 -0
- package/src/__tests__/kg-extractor.test.ts +192 -0
- package/src/__tests__/kg-store.test.ts +300 -0
- package/src/__tests__/llm-client-pivot.test.ts +628 -0
- package/src/__tests__/llm-consolidation.test.ts +226 -0
- package/src/__tests__/maintenance-locks.test.ts +189 -0
- package/src/__tests__/mcp-efficiency.test.ts +288 -0
- package/src/__tests__/mcp-related-scopes.test.ts +151 -0
- package/src/__tests__/memory-boundaries.test.ts +282 -0
- package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
- package/src/__tests__/memory-evolution.test.ts +271 -0
- package/src/__tests__/memory-health-rebalance.test.ts +144 -0
- package/src/__tests__/memory-lint.test.ts +421 -0
- package/src/__tests__/memory-output.test.ts +215 -0
- package/src/__tests__/memory-promotion.test.ts +315 -0
- package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
- package/src/__tests__/multi-vector.test.ts +242 -0
- package/src/__tests__/narrative-architecture.test.ts +456 -0
- package/src/__tests__/noise-filter.test.ts +205 -0
- package/src/__tests__/noise-prototype-bank.test.ts +280 -0
- package/src/__tests__/obsidian-connector.test.ts +363 -0
- package/src/__tests__/omitted-reporting.test.ts +98 -0
- package/src/__tests__/pending-review.test.ts +77 -0
- package/src/__tests__/pii-redact.test.ts +333 -0
- package/src/__tests__/pivot-apply.test.ts +373 -0
- package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
- package/src/__tests__/pivot-distill.test.ts +1178 -0
- package/src/__tests__/post-retrieval-filter.test.ts +178 -0
- package/src/__tests__/ppr-traversal.test.ts +256 -0
- package/src/__tests__/prediction-engine.test.ts +349 -0
- package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
- package/src/__tests__/preference-guard-smoke.test.ts +211 -0
- package/src/__tests__/preference-matcher.test.ts +282 -0
- package/src/__tests__/preference-slots.test.ts +205 -0
- package/src/__tests__/prospective-memory.test.ts +283 -0
- package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
- package/src/__tests__/query-entity-detector.test.ts +171 -0
- package/src/__tests__/query-expander-alias.test.ts +146 -0
- package/src/__tests__/query-expander.test.ts +34 -0
- package/src/__tests__/recall-gate.test.ts +266 -0
- package/src/__tests__/recall-governor.test.ts +227 -0
- package/src/__tests__/recall-mode.test.ts +64 -0
- package/src/__tests__/resolve-db-path.test.ts +130 -0
- package/src/__tests__/resolve-tier.test.ts +64 -0
- package/src/__tests__/result-synthesizer.test.ts +198 -0
- package/src/__tests__/resume-context.test.ts +4898 -0
- package/src/__tests__/retrieval-eval.test.ts +163 -0
- package/src/__tests__/retrieval-trace.test.ts +213 -0
- package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
- package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
- package/src/__tests__/retroactive-boost.test.ts +223 -0
- package/src/__tests__/salience-scoring.test.ts +106 -0
- package/src/__tests__/scope-inventory.test.ts +200 -0
- package/src/__tests__/scope-policy.test.ts +69 -0
- package/src/__tests__/scope-suggester.test.ts +80 -0
- package/src/__tests__/session-checkpoint.test.ts +291 -0
- package/src/__tests__/session-distiller.test.ts +640 -0
- package/src/__tests__/session-gc.test.ts +215 -0
- package/src/__tests__/skill-engine.test.ts +552 -0
- package/src/__tests__/skill-promotion.test.ts +654 -0
- package/src/__tests__/skill-verifier.test.ts +122 -0
- package/src/__tests__/source-heartbeat.test.ts +250 -0
- package/src/__tests__/stable-context-length-contract.test.ts +62 -0
- package/src/__tests__/store-patch-metadata.test.ts +205 -0
- package/src/__tests__/store-read-consistency.test.ts +122 -0
- package/src/__tests__/store-schema-migration.test.ts +87 -0
- package/src/__tests__/store-scope-audit.test.ts +81 -0
- package/src/__tests__/store-upsert.test.ts +201 -0
- package/src/__tests__/supersede-chain.test.ts +154 -0
- package/src/__tests__/temporal-parser.test.ts +241 -0
- package/src/__tests__/temporal-validity.test.ts +160 -0
- package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
- package/src/__tests__/term-registry.test.ts +121 -0
- package/src/__tests__/tool-error-surfacing.test.ts +98 -0
- package/src/__tests__/tool-output-compressor.test.ts +160 -0
- package/src/__tests__/topic-tag.test.ts +113 -0
- package/src/__tests__/trusted-memory.test.ts +839 -0
- package/src/__tests__/ui-server-security.test.ts +93 -0
- package/src/__tests__/usage-tracker.test.ts +287 -0
- package/src/__tests__/version-manager.test.ts +213 -0
- package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
- package/src/__tests__/workflow-observation.test.ts +433 -0
- package/src/__tests__/write-verifier.test.ts +122 -0
- package/src/access-tracker.ts +90 -34
- package/src/activity-counter.ts +46 -18
- package/src/adaptive-retrieval.ts +11 -0
- package/src/aliases.ts +133 -0
- package/src/api-server.ts +36 -9
- package/src/audit-log.ts +4 -2
- package/src/auto-consolidation.ts +8 -3
- package/src/auto-gc.ts +85 -21
- package/src/auto-recall.ts +61 -1
- package/src/belief-history.ts +164 -0
- package/src/capture-engine.ts +142 -32
- package/src/cli.ts +268 -41
- package/src/confidence-tracker.ts +36 -18
- package/src/conflict-engine.ts +24 -3
- package/src/consolidation-engine.ts +255 -23
- package/src/context-composer-stable-selection.ts +7 -1
- package/src/context-composer.ts +8 -1
- package/src/data-checkup.ts +96 -7
- package/src/decay-engine.ts +59 -7
- package/src/distill-lock.ts +194 -15
- package/src/doctor.ts +13 -4
- package/src/dream-pipeline.ts +473 -41
- package/src/env-config.ts +118 -0
- package/src/error-signature.ts +153 -0
- package/src/error-taxonomy.ts +115 -0
- package/src/eval.ts +402 -14
- package/src/freshness.ts +192 -0
- package/src/http-request-policy.ts +148 -0
- package/src/idempotency.ts +8 -0
- package/src/ingest.ts +308 -12
- package/src/kg-extractor.ts +13 -3
- package/src/kg-store.ts +236 -44
- package/src/llm-client.ts +106 -11
- package/src/llm-consolidation.ts +2 -1
- package/src/mcp-server.ts +87 -1800
- package/src/mcp-tool-deps.ts +48 -0
- package/src/mcp-tools-advanced.ts +954 -0
- package/src/mcp-tools-core.ts +637 -0
- package/src/mcp-tools-governance.ts +475 -0
- package/src/memory-boundaries.ts +26 -3
- package/src/memory-evolution.ts +73 -1
- package/src/memory-health-rebalance.ts +4 -0
- package/src/memory-lint.ts +183 -10
- package/src/memory-output.ts +129 -4
- package/src/memory-promotion.ts +246 -0
- package/src/memory-schema.ts +25 -2
- package/src/memory-store-port.ts +46 -0
- package/src/multi-vector.ts +2 -1
- package/src/narrative-schema.ts +2 -1
- package/src/noise-filter.ts +57 -6
- package/src/noise-prototype-bank.ts +408 -0
- package/src/pii-detector.ts +207 -5
- package/src/pivot-apply.ts +399 -0
- package/src/preference-matcher.ts +37 -3
- package/src/query-expander.ts +90 -9
- package/src/recall-gate.ts +305 -0
- package/src/result-synthesizer.ts +2 -1
- package/src/retention-policy.ts +2 -1
- package/src/retrieval-profiles.ts +46 -4
- package/src/retriever.ts +354 -157
- package/src/retroactive-boost.ts +6 -2
- package/src/runtime-config.ts +78 -6
- package/src/scope-suggester.ts +113 -0
- package/src/session-distiller.ts +23 -2
- package/src/session-engine.ts +2 -1
- package/src/session-schema.ts +1 -0
- package/src/session-store.ts +5 -0
- package/src/skill-engine.ts +78 -0
- package/src/skill-promotion.ts +304 -48
- package/src/skill-schema.ts +16 -3
- package/src/skill-verifier.ts +152 -0
- package/src/store.ts +321 -57
- package/src/term-registry.ts +35 -0
- package/src/tool-output-compressor.ts +3 -1
- package/src/ui-server.ts +9 -1
- package/src/usage-tracker.ts +242 -0
- package/src/version-manager.ts +3 -2
- package/src/version.ts +19 -0
- package/src/workflow-observation-engine.ts +2 -1
- package/src/workflow-observation-schema.ts +2 -1
- package/src/workflow-observation-store.ts +6 -2
- package/.env +0 -4
- package/CLAUDE.md +0 -46
- package/config.json +0 -55
- package/daily-recall.sh +0 -110
- package/logs/daily-recall.error.log +0 -0
- package/logs/daily-recall.log +0 -60
- package/logs/ingest-2026-04-11.log +0 -110
- package/logs/ingest-2026-04-12.log +0 -90
- package/logs/ingest-2026-04-13.log +0 -87
- package/logs/launchagent-stderr.log +0 -0
- package/logs/launchagent-stdout.log +0 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pivot Apply — dedicated structured write channel for reviewed pivot candidates.
|
|
3
|
+
*
|
|
4
|
+
* Purpose: apply LLM-distilled pivot candidates (reportId-keyed, reviewed via
|
|
5
|
+
* allowlist) into scope `memory:pivot` with deterministic dispositions.
|
|
6
|
+
*
|
|
7
|
+
* Why not the normal store_memory path (mutual-review P0, 2026-08-04):
|
|
8
|
+
* - persistMemory routes preferences through matchPreference (LLM semantic
|
|
9
|
+
* merge) → polymorphic dispositions the apply ledger cannot predict;
|
|
10
|
+
* - A-2 supersede detection calls the LLM per write (re-judging candidates is
|
|
11
|
+
* out of contract for apply);
|
|
12
|
+
* - default source=manual mislabels distilled content as 0.9/direct confidence;
|
|
13
|
+
* - kind/anchor/evidence/reportId/candidateHash/batchId are dropped;
|
|
14
|
+
* - the implicit preference dual-write (LME-1) is fire-and-forget, so its id
|
|
15
|
+
* can never reach the ledger.
|
|
16
|
+
*
|
|
17
|
+
* This channel therefore:
|
|
18
|
+
* - keeps: PII scan+redact (trust boundary), rule-based admission (minus rate
|
|
19
|
+
* limiting — bulk writes always trip it and it is not a quality signal),
|
|
20
|
+
* canonicalKey supersede semantics via writeDurableEntry, audit logging,
|
|
21
|
+
* synchronous post-write verification;
|
|
22
|
+
* - drops: every LLM call, F2 interference marking (must not silently mutate
|
|
23
|
+
* existing memories), async KG extraction, and the implicit preference
|
|
24
|
+
* dual-write (apply's contract is to write exactly the allowlisted entries
|
|
25
|
+
* and nothing else — a derived preference is not on the allowlist).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { z } from "zod";
|
|
29
|
+
|
|
30
|
+
import { checkAdmission } from "./admission-control.js";
|
|
31
|
+
import {
|
|
32
|
+
buildStructuredMetadata,
|
|
33
|
+
writeDurableEntry,
|
|
34
|
+
type PersistMemoryDeps,
|
|
35
|
+
} from "./capture-engine.js";
|
|
36
|
+
import { assignDefaultConfidence } from "./confidence-tracker.js";
|
|
37
|
+
import { detectLang, tokenizeFts } from "./language-hook.js";
|
|
38
|
+
import { scanForPII, redactSecrets } from "./pii-detector.js";
|
|
39
|
+
|
|
40
|
+
export const PIVOT_APPLY_SOURCE = "session_distill";
|
|
41
|
+
export const PIVOT_APPLY_CAPTURE = "pivot_apply_v1";
|
|
42
|
+
export const PIVOT_SCOPE = "memory:pivot";
|
|
43
|
+
|
|
44
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
45
|
+
|
|
46
|
+
export const PivotKindSchema = z.enum([
|
|
47
|
+
"judgment_shift",
|
|
48
|
+
"decision",
|
|
49
|
+
"preference_rule",
|
|
50
|
+
"case",
|
|
51
|
+
]);
|
|
52
|
+
export type PivotKind = z.infer<typeof PivotKindSchema>;
|
|
53
|
+
|
|
54
|
+
/** The only kind→category pairs that exist in the corpus (verified over all 8,971 candidates). */
|
|
55
|
+
const KIND_CATEGORY: Record<PivotKind, "cases" | "events" | "preferences"> = {
|
|
56
|
+
case: "cases",
|
|
57
|
+
decision: "events",
|
|
58
|
+
judgment_shift: "events",
|
|
59
|
+
preference_rule: "preferences",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const PivotCandidateInputSchema = z
|
|
63
|
+
.object({
|
|
64
|
+
kind: PivotKindSchema,
|
|
65
|
+
text: z.string().min(10).max(4000),
|
|
66
|
+
anchor: z.string().min(1).max(2000),
|
|
67
|
+
canonicalKey: z.string().min(1).max(120),
|
|
68
|
+
evidence: z.array(z.string().min(1).max(4000)).max(16),
|
|
69
|
+
proposedScope: z.literal(PIVOT_SCOPE),
|
|
70
|
+
proposedCategory: z.enum(["cases", "events", "preferences"]),
|
|
71
|
+
// candidate tags max out at 4 in the corpus; the channel appends 2 more and
|
|
72
|
+
// buildStructuredMetadata stores tags verbatim, so cap input at 6 to stay
|
|
73
|
+
// within the schema-wide 8-tag budget.
|
|
74
|
+
tags: z.array(z.string().min(1).max(40)).min(1).max(6),
|
|
75
|
+
sessionId: z.string().min(8).max(200),
|
|
76
|
+
reportId: z.string().regex(SHA256_HEX, "reportId must be 64-char sha256 hex"),
|
|
77
|
+
candidateHash: z.string().regex(SHA256_HEX, "candidateHash must be 64-char sha256 hex"),
|
|
78
|
+
batchId: z.string().min(1).max(64),
|
|
79
|
+
importance: z.number().min(0).max(1),
|
|
80
|
+
})
|
|
81
|
+
.refine((c) => KIND_CATEGORY[c.kind] === c.proposedCategory, {
|
|
82
|
+
message: "proposedCategory does not match kind (corpus-verified mapping violated)",
|
|
83
|
+
});
|
|
84
|
+
export type PivotCandidateInput = z.infer<typeof PivotCandidateInputSchema>;
|
|
85
|
+
|
|
86
|
+
export type PivotApplyDisposition =
|
|
87
|
+
| "stored"
|
|
88
|
+
| "updated"
|
|
89
|
+
| "deduped"
|
|
90
|
+
| "promoted"
|
|
91
|
+
| "conflict"
|
|
92
|
+
| "rejected";
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The stock embedder only retries connection-class errors; HTTP 429/5xx throw
|
|
96
|
+
* straight through (embedder.ts isTransientEmbeddingError). A bulk apply makes
|
|
97
|
+
* thousands of calls, so rate-limit blips must not void a whole batch — retry
|
|
98
|
+
* with backoff here, and only here (single-write callers keep stock behavior).
|
|
99
|
+
*/
|
|
100
|
+
async function embedWithRetry(
|
|
101
|
+
embedder: PersistMemoryDeps["embedder"],
|
|
102
|
+
text: string,
|
|
103
|
+
delaysMs: readonly number[] = [1000, 5000, 15000],
|
|
104
|
+
): Promise<number[]> {
|
|
105
|
+
let lastErr: unknown;
|
|
106
|
+
for (let attempt = 0; attempt <= delaysMs.length; attempt++) {
|
|
107
|
+
try {
|
|
108
|
+
return await embedder.embedPassage(text);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
lastErr = err;
|
|
111
|
+
const msg = String((err as Error)?.message ?? err);
|
|
112
|
+
const retriable = /\b(429|500|502|503|504)\b|rate.?limit|overloaded/i.test(msg);
|
|
113
|
+
if (!retriable || attempt === delaysMs.length) throw err;
|
|
114
|
+
await new Promise((r) => setTimeout(r, delaysMs[attempt]));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
throw lastErr;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface PivotApplyOutcome {
|
|
121
|
+
candidateHash: string;
|
|
122
|
+
batchId: string;
|
|
123
|
+
/** null when the write never reached the store (admission rejection). */
|
|
124
|
+
memoryId: string | null;
|
|
125
|
+
disposition: PivotApplyDisposition;
|
|
126
|
+
conflictId?: string;
|
|
127
|
+
rejectionReason?: string;
|
|
128
|
+
supersededOldId?: string;
|
|
129
|
+
piiRedactedCount: number;
|
|
130
|
+
canonicalKey: string;
|
|
131
|
+
category: "cases" | "events" | "preferences";
|
|
132
|
+
verified: boolean;
|
|
133
|
+
verifyIssues: string[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface PivotBatchReport {
|
|
137
|
+
batchId: string;
|
|
138
|
+
total: number;
|
|
139
|
+
written: number;
|
|
140
|
+
halted: boolean;
|
|
141
|
+
haltReason?: string;
|
|
142
|
+
outcomes: PivotApplyOutcome[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Persist one reviewed pivot candidate. Deterministic: no LLM calls, no writes
|
|
147
|
+
* outside the candidate itself (plus the audit log line).
|
|
148
|
+
*/
|
|
149
|
+
export async function persistPivotCandidate(
|
|
150
|
+
deps: PersistMemoryDeps,
|
|
151
|
+
rawInput: unknown,
|
|
152
|
+
): Promise<PivotApplyOutcome> {
|
|
153
|
+
const input = PivotCandidateInputSchema.parse(rawInput);
|
|
154
|
+
|
|
155
|
+
// F-3b ordering (same as persistMemory): scan first so piiWarning reflects
|
|
156
|
+
// the raw text, then redact before the text reaches embedding/storage.
|
|
157
|
+
const piiScan = scanForPII(input.text);
|
|
158
|
+
const redaction = redactSecrets(input.text);
|
|
159
|
+
const text = redaction.redacted > 0 ? redaction.text : input.text;
|
|
160
|
+
|
|
161
|
+
// Rule-based admission only. No rateLimiter on purpose: a bulk apply always
|
|
162
|
+
// exceeds per-scope write rates, which says nothing about entry quality.
|
|
163
|
+
const admission = checkAdmission(text, input.importance, PIVOT_SCOPE, undefined, deps.admissionConfig);
|
|
164
|
+
if (admission.verdict === "rejected") {
|
|
165
|
+
try {
|
|
166
|
+
deps.auditLogger?.log({
|
|
167
|
+
operation: "reject",
|
|
168
|
+
scope: PIVOT_SCOPE,
|
|
169
|
+
actor: PIVOT_APPLY_SOURCE,
|
|
170
|
+
details: `pivot-apply ${input.kind} ${input.candidateHash.slice(0, 12)}: ${admission.reason ?? "unknown"}`,
|
|
171
|
+
});
|
|
172
|
+
} catch {
|
|
173
|
+
// Audit must never block the outcome report.
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
candidateHash: input.candidateHash,
|
|
177
|
+
batchId: input.batchId,
|
|
178
|
+
memoryId: null,
|
|
179
|
+
disposition: "rejected",
|
|
180
|
+
rejectionReason: admission.reason,
|
|
181
|
+
piiRedactedCount: redaction.redacted,
|
|
182
|
+
canonicalKey: input.canonicalKey,
|
|
183
|
+
category: input.proposedCategory,
|
|
184
|
+
verified: false,
|
|
185
|
+
verifyIssues: [],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const vector = await embedWithRetry(deps.embedder, text);
|
|
190
|
+
// The store layer never validates vector shape (only importEntry does), so an
|
|
191
|
+
// empty/short vector would land silently and poison retrieval — assert here.
|
|
192
|
+
if (!Array.isArray(vector) || vector.length === 0) {
|
|
193
|
+
throw new Error(`embedding returned an empty vector for candidate ${input.candidateHash.slice(0, 12)}`);
|
|
194
|
+
}
|
|
195
|
+
const language = detectLang(text);
|
|
196
|
+
const fts_text = tokenizeFts(text, language);
|
|
197
|
+
|
|
198
|
+
let metadata = buildStructuredMetadata({
|
|
199
|
+
source: PIVOT_APPLY_SOURCE,
|
|
200
|
+
tags: [...input.tags, "pivot-apply", `batch:${input.batchId}`],
|
|
201
|
+
capture: PIVOT_APPLY_CAPTURE,
|
|
202
|
+
category: input.proposedCategory,
|
|
203
|
+
canonicalKey: input.canonicalKey,
|
|
204
|
+
extra: {
|
|
205
|
+
anchor: input.anchor,
|
|
206
|
+
pivotApply: {
|
|
207
|
+
kind: input.kind,
|
|
208
|
+
evidence: input.evidence,
|
|
209
|
+
sessionId: input.sessionId,
|
|
210
|
+
reportId: input.reportId,
|
|
211
|
+
candidateHash: input.candidateHash,
|
|
212
|
+
batchId: input.batchId,
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
{
|
|
217
|
+
// Same post-build patch pattern persistMemory uses for confidence/PII.
|
|
218
|
+
const parsed = JSON.parse(metadata) as Record<string, unknown>;
|
|
219
|
+
parsed.confidence = assignDefaultConfidence(PIVOT_APPLY_SOURCE);
|
|
220
|
+
if (piiScan.hasPII) {
|
|
221
|
+
parsed.piiWarning = {
|
|
222
|
+
summary: piiScan.summary,
|
|
223
|
+
detections: piiScan.detections.length,
|
|
224
|
+
severity: piiScan.detections.some((d) => d.severity === "high") ? "high" : "medium",
|
|
225
|
+
...(redaction.redacted > 0 ? { redacted: redaction.redacted } : {}),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
metadata = JSON.stringify(parsed);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const { entry, disposition, conflictId } = await writeDurableEntry(deps, {
|
|
232
|
+
text,
|
|
233
|
+
vector,
|
|
234
|
+
category: input.proposedCategory,
|
|
235
|
+
scope: PIVOT_SCOPE,
|
|
236
|
+
importance: input.importance,
|
|
237
|
+
metadata,
|
|
238
|
+
canonicalKey: input.canonicalKey,
|
|
239
|
+
source: PIVOT_APPLY_SOURCE,
|
|
240
|
+
language,
|
|
241
|
+
fts_text,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
deps.auditLogger?.log({
|
|
246
|
+
operation: "store",
|
|
247
|
+
scope: PIVOT_SCOPE,
|
|
248
|
+
memoryId: entry.id,
|
|
249
|
+
actor: PIVOT_APPLY_SOURCE,
|
|
250
|
+
details: `pivot-apply ${input.kind} ${input.candidateHash.slice(0, 12)} → ${disposition}`,
|
|
251
|
+
});
|
|
252
|
+
} catch {
|
|
253
|
+
// Audit must never block the outcome report.
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Post-write verification, synchronous on purpose: a bulk apply needs the
|
|
257
|
+
// verdict inside the ledger row, not on a console later. Deliberately NOT
|
|
258
|
+
// verifyWrite(): its checkEntry expects scope/importance inside metadata,
|
|
259
|
+
// which buildStructuredMetadata-shaped rows never carry (a pre-existing
|
|
260
|
+
// mismatch its async caller silently swallows). This check asserts the claim
|
|
261
|
+
// that matters instead: the row at entry.id holds THIS candidate's content.
|
|
262
|
+
let verified = true;
|
|
263
|
+
let verifyIssues: string[] = [];
|
|
264
|
+
if ((disposition === "stored" || disposition === "updated") && deps.store.get) {
|
|
265
|
+
verifyIssues = await verifyPivotRow(deps.store.get.bind(deps.store), entry.id, {
|
|
266
|
+
candidateHash: input.candidateHash,
|
|
267
|
+
text,
|
|
268
|
+
});
|
|
269
|
+
verified = verifyIssues.length === 0;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
candidateHash: input.candidateHash,
|
|
274
|
+
batchId: input.batchId,
|
|
275
|
+
memoryId: entry.id,
|
|
276
|
+
disposition,
|
|
277
|
+
...(conflictId ? { conflictId } : {}),
|
|
278
|
+
piiRedactedCount: redaction.redacted,
|
|
279
|
+
canonicalKey: input.canonicalKey,
|
|
280
|
+
category: input.proposedCategory,
|
|
281
|
+
verified,
|
|
282
|
+
verifyIssues,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function normalizeForCompare(text: string): string {
|
|
287
|
+
return text.replace(/\s+/g, " ").trim();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Read the row back and assert it is this candidate's write, not merely a well-formed row. */
|
|
291
|
+
async function verifyPivotRow(
|
|
292
|
+
get: (id: string) => Promise<{ text: string; scope: string; metadata: string } | null>,
|
|
293
|
+
id: string,
|
|
294
|
+
expected: { candidateHash: string; text: string },
|
|
295
|
+
): Promise<string[]> {
|
|
296
|
+
const issues: string[] = [];
|
|
297
|
+
let row: { text: string; scope: string; metadata: string } | null = null;
|
|
298
|
+
try {
|
|
299
|
+
row = await get(id);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
return [`readback threw: ${(err as Error).message}`];
|
|
302
|
+
}
|
|
303
|
+
if (!row) return ["row missing on readback"];
|
|
304
|
+
if (row.scope !== PIVOT_SCOPE) issues.push(`scope is "${row.scope}", expected "${PIVOT_SCOPE}"`);
|
|
305
|
+
if (normalizeForCompare(row.text) !== normalizeForCompare(expected.text)) {
|
|
306
|
+
issues.push("text on readback differs from written text");
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
const meta = JSON.parse(row.metadata || "{}") as Record<string, unknown>;
|
|
310
|
+
const pivotApply = meta.pivotApply as Record<string, unknown> | undefined;
|
|
311
|
+
if (pivotApply?.candidateHash !== expected.candidateHash) {
|
|
312
|
+
issues.push("pivotApply.candidateHash on readback does not match this candidate");
|
|
313
|
+
}
|
|
314
|
+
} catch {
|
|
315
|
+
issues.push("metadata unparseable on readback");
|
|
316
|
+
}
|
|
317
|
+
return issues;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export interface PivotBatchOptions {
|
|
321
|
+
/** Dispositions that do NOT halt the batch. Default: only "stored". */
|
|
322
|
+
expectedDispositions?: readonly PivotApplyDisposition[];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Persist a physical batch sequentially, fail-closed.
|
|
327
|
+
*
|
|
328
|
+
* Pre-screening guarantees the incoming batch has no key collisions with the
|
|
329
|
+
* live pool (R5) and no identical-text duplicates (R3 folding), so every
|
|
330
|
+
* disposition other than "stored" is unexpected by default and halts the batch
|
|
331
|
+
* immediately. Rows already written before the halt stay in the store — the
|
|
332
|
+
* caller reconciles via the returned outcomes (ledger) and the table snapshot
|
|
333
|
+
* taken before the batch.
|
|
334
|
+
*/
|
|
335
|
+
export async function persistPivotBatch(
|
|
336
|
+
deps: PersistMemoryDeps,
|
|
337
|
+
batchId: string,
|
|
338
|
+
rawInputs: readonly unknown[],
|
|
339
|
+
options?: PivotBatchOptions,
|
|
340
|
+
): Promise<PivotBatchReport> {
|
|
341
|
+
const expected = new Set<PivotApplyDisposition>(options?.expectedDispositions ?? ["stored"]);
|
|
342
|
+
const outcomes: PivotApplyOutcome[] = [];
|
|
343
|
+
let written = 0;
|
|
344
|
+
|
|
345
|
+
for (const raw of rawInputs) {
|
|
346
|
+
// Halt before the write when the row belongs to a different batch: the
|
|
347
|
+
// ledger keys every side effect by batchId, so a mixed batch would corrupt
|
|
348
|
+
// snapshot/rollback bookkeeping.
|
|
349
|
+
const claimed =
|
|
350
|
+
typeof raw === "object" && raw !== null && "batchId" in raw
|
|
351
|
+
? String((raw as Record<string, unknown>).batchId)
|
|
352
|
+
: "(missing)";
|
|
353
|
+
if (claimed !== batchId) {
|
|
354
|
+
return {
|
|
355
|
+
batchId,
|
|
356
|
+
total: rawInputs.length,
|
|
357
|
+
written,
|
|
358
|
+
halted: true,
|
|
359
|
+
haltReason: `candidate claims batchId "${claimed}" but batch is "${batchId}"`,
|
|
360
|
+
outcomes,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const outcome = await persistPivotCandidate(deps, raw);
|
|
365
|
+
outcomes.push(outcome);
|
|
366
|
+
if (outcome.memoryId !== null && (outcome.disposition === "stored" || outcome.disposition === "updated")) {
|
|
367
|
+
written += 1;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (!expected.has(outcome.disposition)) {
|
|
371
|
+
return {
|
|
372
|
+
batchId,
|
|
373
|
+
total: rawInputs.length,
|
|
374
|
+
written,
|
|
375
|
+
halted: true,
|
|
376
|
+
haltReason: `unexpected disposition "${outcome.disposition}" for candidate ${outcome.candidateHash.slice(0, 12)}`,
|
|
377
|
+
outcomes,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if (!outcome.verified && (outcome.disposition === "stored" || outcome.disposition === "updated")) {
|
|
381
|
+
return {
|
|
382
|
+
batchId,
|
|
383
|
+
total: rawInputs.length,
|
|
384
|
+
written,
|
|
385
|
+
halted: true,
|
|
386
|
+
haltReason: `post-write verification failed for ${outcome.candidateHash.slice(0, 12)}: ${outcome.verifyIssues.join("; ")}`,
|
|
387
|
+
outcomes,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
batchId,
|
|
394
|
+
total: rawInputs.length,
|
|
395
|
+
written,
|
|
396
|
+
halted: false,
|
|
397
|
+
outcomes,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
@@ -15,6 +15,9 @@ import type { MemoryStore, MemoryEntry } from "./store.js";
|
|
|
15
15
|
import type { Embedder } from "./embedder.js";
|
|
16
16
|
import type { LLMClient } from "./llm-client.js";
|
|
17
17
|
import { logInfo } from "./stderr-log.js";
|
|
18
|
+
import { isActiveMemory, parseEvolution } from "./memory-evolution.js";
|
|
19
|
+
import { archiveBeliefVersion, buildSupersedingBeliefMetadata } from "./belief-history.js";
|
|
20
|
+
import { detectLang, tokenizeFts } from "./language-hook.js";
|
|
18
21
|
|
|
19
22
|
// ============================================================================
|
|
20
23
|
// Configuration
|
|
@@ -77,8 +80,13 @@ export async function matchPreference(
|
|
|
77
80
|
[scope],
|
|
78
81
|
);
|
|
79
82
|
|
|
80
|
-
// Filter to only preferences category
|
|
81
|
-
|
|
83
|
+
// Filter to only preferences category. store.vectorSearch() filters by scope only, so
|
|
84
|
+
// superseded belief-history rows come back here too — matching against one would either
|
|
85
|
+
// silently drop the incoming preference as a duplicate of a belief no longer held, or
|
|
86
|
+
// merge new text into an archived version.
|
|
87
|
+
const prefMatches = candidates.filter(
|
|
88
|
+
c => c.entry.category === "preferences" && isActiveMemory(c.entry.metadata),
|
|
89
|
+
);
|
|
82
90
|
|
|
83
91
|
if (prefMatches.length === 0) {
|
|
84
92
|
return { action: "create", reason: "no-similar-preferences" };
|
|
@@ -132,6 +140,7 @@ export async function applyPreferenceMatch(
|
|
|
132
140
|
result: PreferenceMatchResult,
|
|
133
141
|
store: MemoryStore,
|
|
134
142
|
scope: string,
|
|
143
|
+
deps: { embedder: Pick<Embedder, "embedPassage"> },
|
|
135
144
|
): Promise<{ handled: boolean; entry?: MemoryEntry }> {
|
|
136
145
|
if (result.action === "skip") {
|
|
137
146
|
logInfo(`[INFO] Preference skipped (duplicate): ${result.reason}`);
|
|
@@ -139,9 +148,34 @@ export async function applyPreferenceMatch(
|
|
|
139
148
|
}
|
|
140
149
|
|
|
141
150
|
if (result.action === "merge" && result.mergeTargetId && result.mergedText) {
|
|
151
|
+
const existing = await store.getById(result.mergeTargetId);
|
|
152
|
+
if (!existing) return { handled: false };
|
|
153
|
+
|
|
154
|
+
// Merging rewrites the target's text, which is a belief change like any other —
|
|
155
|
+
// archive the version being replaced before overwriting it.
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
const previousEvo = parseEvolution(existing.metadata, existing.timestamp);
|
|
158
|
+
const { historyId } = await archiveBeliefVersion({ store, embedder: deps.embedder }, existing, { now });
|
|
159
|
+
|
|
160
|
+
// The old code updated `text` alone, leaving vector and fts_text describing the
|
|
161
|
+
// pre-merge wording — so a merged preference stayed unfindable by its own new text.
|
|
162
|
+
const language = detectLang(result.mergedText);
|
|
142
163
|
const updated = await store.update(
|
|
143
164
|
result.mergeTargetId,
|
|
144
|
-
{
|
|
165
|
+
{
|
|
166
|
+
text: result.mergedText,
|
|
167
|
+
vector: await deps.embedder.embedPassage(result.mergedText),
|
|
168
|
+
metadata: buildSupersedingBeliefMetadata(
|
|
169
|
+
existing.metadata,
|
|
170
|
+
previousEvo,
|
|
171
|
+
historyId,
|
|
172
|
+
now,
|
|
173
|
+
`preference-merge:${result.reason}`,
|
|
174
|
+
),
|
|
175
|
+
timestamp: now,
|
|
176
|
+
language,
|
|
177
|
+
fts_text: tokenizeFts(result.mergedText, language),
|
|
178
|
+
},
|
|
145
179
|
[scope],
|
|
146
180
|
);
|
|
147
181
|
if (updated) {
|
package/src/query-expander.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* v1.3: P0.3 — User-level alias-map (data/alias-map.json) for short query expansion.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { existsSync, readFileSync } from "fs";
|
|
14
|
-
import { join } from "path";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
14
|
+
import { dirname, join } from "path";
|
|
15
15
|
import { resolveQueryEntities } from "./entity-resolver.js";
|
|
16
16
|
|
|
17
17
|
interface SynonymEntry {
|
|
@@ -89,9 +89,14 @@ export function resetAliasMapCache(): void {
|
|
|
89
89
|
aliasMapCache = null;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/** Resolve the alias-map file path currently in effect. */
|
|
93
|
+
export function aliasMapFilePath(): string {
|
|
94
|
+
return aliasMapPath ?? join(process.cwd(), "data", "alias-map.json");
|
|
95
|
+
}
|
|
96
|
+
|
|
92
97
|
function loadAliasMap(): AliasEntry[] {
|
|
93
98
|
if (aliasMapCache !== null) return aliasMapCache;
|
|
94
|
-
const filePath =
|
|
99
|
+
const filePath = aliasMapFilePath();
|
|
95
100
|
try {
|
|
96
101
|
if (existsSync(filePath)) {
|
|
97
102
|
const raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
@@ -112,6 +117,75 @@ function loadAliasMap(): AliasEntry[] {
|
|
|
112
117
|
return aliasMapCache;
|
|
113
118
|
}
|
|
114
119
|
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// User alias management (P1-B manage_alias tool 后端)
|
|
122
|
+
//
|
|
123
|
+
// 这些别名只作用于 BM25 通道(expandQuery 在 hybridRetrieval 内部被调用),
|
|
124
|
+
// 刻意不进 vector embedding——随手加的用户别名若改变语义向量,排查
|
|
125
|
+
// retrieval drift 会非常困难。全通道的 builtin 规则在 src/aliases.ts。
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/** 写入校验:trigger 最短 2 字符(防高频泛词),expansions 最多 8 个。 */
|
|
129
|
+
export const USER_ALIAS_TRIGGER_MIN = 2;
|
|
130
|
+
export const USER_ALIAS_EXPANSIONS_MAX = 8;
|
|
131
|
+
|
|
132
|
+
export type AliasUpsertResult =
|
|
133
|
+
| { ok: true; action: "added" | "updated"; entry: AliasEntry }
|
|
134
|
+
| { ok: false; reason: string };
|
|
135
|
+
|
|
136
|
+
/** 新增/更新用户别名(写 alias-map.json;校验后缓存即时失效)。 */
|
|
137
|
+
export function upsertUserAlias(trigger: string, expansions: string[]): AliasUpsertResult {
|
|
138
|
+
const t = trigger.trim();
|
|
139
|
+
const exp = [...new Set(expansions.map(e => e.trim()).filter(e => e.length > 0))];
|
|
140
|
+
|
|
141
|
+
if (t.length < USER_ALIAS_TRIGGER_MIN) {
|
|
142
|
+
return { ok: false, reason: `trigger 太短(<${USER_ALIAS_TRIGGER_MIN} 字符)——高频泛词会污染所有查询` };
|
|
143
|
+
}
|
|
144
|
+
if (exp.length === 0) {
|
|
145
|
+
return { ok: false, reason: "expansions 为空" };
|
|
146
|
+
}
|
|
147
|
+
if (exp.length > USER_ALIAS_EXPANSIONS_MAX) {
|
|
148
|
+
return { ok: false, reason: `expansions 超过 ${USER_ALIAS_EXPANSIONS_MAX} 个——过长扩展会冲淡 BM25 原查询词权重` };
|
|
149
|
+
}
|
|
150
|
+
if (exp.every(e => e === t)) {
|
|
151
|
+
return { ok: false, reason: "expansions 与 trigger 相同,无扩展意义" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const rules = loadAliasMap();
|
|
155
|
+
const idx = rules.findIndex(r => r.trigger === t);
|
|
156
|
+
const entry: AliasEntry = { trigger: t, expansions: exp };
|
|
157
|
+
const next = idx >= 0 ? rules.map((r, i) => (i === idx ? entry : r)) : [...rules, entry];
|
|
158
|
+
|
|
159
|
+
const path = aliasMapFilePath();
|
|
160
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
161
|
+
writeFileSync(path, JSON.stringify(next, null, 2) + "\n", "utf-8");
|
|
162
|
+
aliasMapCache = null;
|
|
163
|
+
return { ok: true, action: idx >= 0 ? "updated" : "added", entry };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 删除用户别名;返回是否真的删了。 */
|
|
167
|
+
export function removeUserAlias(trigger: string): boolean {
|
|
168
|
+
const rules = loadAliasMap();
|
|
169
|
+
const next = rules.filter(r => r.trigger !== trigger.trim());
|
|
170
|
+
if (next.length === rules.length) return false;
|
|
171
|
+
const path = aliasMapFilePath();
|
|
172
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
173
|
+
writeFileSync(path, JSON.stringify(next, null, 2) + "\n", "utf-8");
|
|
174
|
+
aliasMapCache = null;
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 列出用户别名规则。 */
|
|
179
|
+
export function listUserAliases(): AliasEntry[] {
|
|
180
|
+
return [...loadAliasMap()];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 检视:query 命中了哪些用户别名(BM25-only 通道)。 */
|
|
184
|
+
export function explainUserAliases(query: string): AliasEntry[] {
|
|
185
|
+
const lower = query.toLowerCase();
|
|
186
|
+
return loadAliasMap().filter(e => lower.includes(e.trigger.toLowerCase()));
|
|
187
|
+
}
|
|
188
|
+
|
|
115
189
|
/**
|
|
116
190
|
* Expand a query by appending synonym terms from the dictionary.
|
|
117
191
|
* Returns the original query with additional terms appended.
|
|
@@ -123,7 +197,8 @@ export function expandQuery(query: string): string {
|
|
|
123
197
|
// Entity resolution pre-pass: normalize aliases before synonym expansion
|
|
124
198
|
const resolved = resolveQueryEntities(query);
|
|
125
199
|
const lower = resolved.toLowerCase();
|
|
126
|
-
const
|
|
200
|
+
const synonymAdditions = new Set<string>();
|
|
201
|
+
const aliasAdditions = new Set<string>();
|
|
127
202
|
|
|
128
203
|
// Static synonym map (built-in)
|
|
129
204
|
for (const entry of SYNONYM_MAP) {
|
|
@@ -138,7 +213,7 @@ export function expandQuery(query: string): string {
|
|
|
138
213
|
if (cnMatch || enMatch) {
|
|
139
214
|
for (const exp of entry.expansions) {
|
|
140
215
|
if (!lower.includes(exp.toLowerCase())) {
|
|
141
|
-
|
|
216
|
+
synonymAdditions.add(exp);
|
|
142
217
|
}
|
|
143
218
|
}
|
|
144
219
|
}
|
|
@@ -151,15 +226,21 @@ export function expandQuery(query: string): string {
|
|
|
151
226
|
if (lower.includes(triggerLower)) {
|
|
152
227
|
for (const exp of alias.expansions) {
|
|
153
228
|
if (!lower.includes(exp.toLowerCase())) {
|
|
154
|
-
|
|
229
|
+
aliasAdditions.add(exp);
|
|
155
230
|
}
|
|
156
231
|
}
|
|
157
232
|
}
|
|
158
233
|
}
|
|
159
234
|
|
|
160
|
-
if (
|
|
235
|
+
if (aliasAdditions.size === 0 && synonymAdditions.size === 0) return resolved;
|
|
161
236
|
|
|
162
|
-
// Cap expansion terms to prevent query bloat
|
|
163
|
-
|
|
237
|
+
// Cap expansion terms to prevent query bloat. User aliases rank first inside
|
|
238
|
+
// the cap — 用户为这个 trigger 显式配置的 canonical 名,不应被泛化同义词
|
|
239
|
+
// 挤出 MAX_EXPANSION_TERMS(P1-B:同义词"挂"曾把 alias 的实体名挤掉)。
|
|
240
|
+
const merged = [
|
|
241
|
+
...aliasAdditions,
|
|
242
|
+
...[...synonymAdditions].filter(t => !aliasAdditions.has(t)),
|
|
243
|
+
];
|
|
244
|
+
const limited = merged.slice(0, MAX_EXPANSION_TERMS);
|
|
164
245
|
return `${resolved} ${limited.join(" ")}`;
|
|
165
246
|
}
|