@psiclawops/hypermem 0.9.6 → 0.9.9
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 +23 -0
- package/INSTALL.md +29 -9
- package/README.md +5 -1
- package/assets/default-config.json +20 -5
- package/assets/runtime-validation-fixture.json +123 -0
- package/bin/hypermem-cleanup.mjs +334 -0
- package/bin/hypermem-doctor.mjs +71 -0
- package/bin/hypermem-validate-runtime.mjs +282 -0
- package/dist/compositor.d.ts +43 -5
- package/dist/compositor.d.ts.map +1 -1
- package/dist/compositor.js +802 -30
- package/dist/entity-bridge-backfill.d.ts +66 -0
- package/dist/entity-bridge-backfill.d.ts.map +1 -0
- package/dist/entity-bridge-backfill.js +145 -0
- package/dist/entity-bridge-store.d.ts +164 -0
- package/dist/entity-bridge-store.d.ts.map +1 -0
- package/dist/entity-bridge-store.js +488 -0
- package/dist/entity-extractor.d.ts +124 -0
- package/dist/entity-extractor.d.ts.map +1 -0
- package/dist/entity-extractor.js +382 -0
- package/dist/entity-ppr.d.ts +55 -0
- package/dist/entity-ppr.d.ts.map +1 -0
- package/dist/entity-ppr.js +180 -0
- package/dist/hybrid-retrieval.d.ts +27 -0
- package/dist/hybrid-retrieval.d.ts.map +1 -1
- package/dist/hybrid-retrieval.js +26 -1
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +63 -13
- package/dist/message-store.d.ts +36 -0
- package/dist/message-store.d.ts.map +1 -1
- package/dist/message-store.js +155 -1
- package/dist/open-domain.d.ts +13 -4
- package/dist/open-domain.d.ts.map +1 -1
- package/dist/open-domain.js +222 -20
- package/dist/profiles.js +13 -13
- package/dist/question-shape.d.ts +73 -0
- package/dist/question-shape.d.ts.map +1 -0
- package/dist/question-shape.js +230 -0
- package/dist/schema.d.ts +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +92 -1
- package/dist/topic-detector.d.ts.map +1 -1
- package/dist/topic-detector.js +22 -9
- package/dist/types.d.ts +176 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/vector-store.d.ts +6 -0
- package/dist/vector-store.d.ts.map +1 -1
- package/dist/vector-store.js +3 -0
- package/docs/DIAGNOSTICS.md +47 -0
- package/docs/INTEGRATION_VALIDATION.md +24 -4
- package/docs/TUNING.md +21 -21
- package/memory-plugin/dist/index.d.ts +3 -3
- package/memory-plugin/dist/index.js +4 -2
- package/memory-plugin/openclaw.plugin.json +5 -0
- package/memory-plugin/package.json +10 -6
- package/package.json +22 -5
- package/plugin/dist/index.d.ts +3 -3
- package/plugin/dist/index.d.ts.map +1 -1
- package/plugin/dist/index.js +115 -13
- package/plugin/dist/index.js.map +1 -1
- package/plugin/package.json +10 -6
- package/scripts/install-runtime.mjs +4 -1
package/dist/open-domain.js
CHANGED
|
@@ -17,9 +17,113 @@
|
|
|
17
17
|
* raw message history regardless of quality gate.
|
|
18
18
|
*/
|
|
19
19
|
// ── Open-domain signal patterns ───────────────────────────────────────────
|
|
20
|
-
const BROAD_INTERROGATIVE = /\b(what did|what does|what has|what was|what were|what is|how did|how does|how has|tell me about|describe|explain|summarize|overview|recap|what do you know about|what have|who is|who was|who did)\b/i;
|
|
21
|
-
|
|
20
|
+
const BROAD_INTERROGATIVE = /\b(what did|what does|what has|what was|what were|what is|what are|how did|how does|how has|tell me about|describe|explain|summarize|overview|recap|what do you know about|what have|who is|who was|who did)\b/i;
|
|
21
|
+
// LoCoMo category-3/open-domain questions are often inferential rather than
|
|
22
|
+
// classic WH recall. Keep this benchmark-agnostic: these are question shapes
|
|
23
|
+
// that need raw dialogue evidence, not answer terms.
|
|
24
|
+
const INFERENTIAL_OPEN_DOMAIN = /\b(what might|would\b.*\b(enjoy|consider|considered|likely|pursue|be)\b|could\b.*\b(enjoy|consider|likely|pursue|be)\b|should\b.*\b(enjoy|consider|likely|pursue|be)\b|is it likely|which country|in what country|what fields?|suspected health|financial status)\b/i;
|
|
25
|
+
const SPECIFIC_NON_DIALOG_ANCHOR = /\b(v\d+\.\d+|#\d{2,}|https?:\/\/|[A-Z]{2,}-\d+)\b/;
|
|
22
26
|
const TEMPORAL_SIGNALS = /\b(before|after|when|last\s+\w+|yesterday|today|recently|between|since|until|ago|this\s+week|this\s+month|in\s+(january|february|march|april|may|june|july|august|september|october|november|december))\b/i;
|
|
27
|
+
const OPEN_DOMAIN_FACETS = [
|
|
28
|
+
{
|
|
29
|
+
name: 'education-career',
|
|
30
|
+
pattern: /\b(educat\w*|field|fields|career|pursue|certification|training|study|school|college|class|degree)\b/i,
|
|
31
|
+
terms: ['education', 'school', 'college', 'study', 'class', 'degree', 'training', 'certificate', 'certification', 'career', 'work', 'job', 'interest', 'interested'],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'financial-status',
|
|
35
|
+
pattern: /\b(financial|status|wealth|wealthy|money|afford|income|class|expensive|cost)\b/i,
|
|
36
|
+
terms: ['money', 'financial', 'finance', 'wealth', 'wealthy', 'income', 'afford', 'expensive', 'cost', 'job', 'work', 'salary', 'rent', 'house', 'apartment', 'vacation', 'donate', 'donation', 'charity', 'fundraiser'],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'social-circle',
|
|
40
|
+
pattern: /\b(friend|friends|besides|teammate|teammates|team|group|social)\b/i,
|
|
41
|
+
terms: ['friend', 'friends', 'teammate', 'teammates', 'team', 'group', 'club', 'community', 'classmate', 'coworker', 'game', 'games', 'gaming', 'video', 'online', 'player', 'players'],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'reading-preference',
|
|
45
|
+
pattern: /\b(read|reading|book|books|author|novel|writer|lewis|greene|green)\b/i,
|
|
46
|
+
terms: ['read', 'reading', 'book', 'books', 'author', 'authors', 'novel', 'writer', 'story', 'stories', 'fiction', 'fantasy', 'literature', 'library', 'recommendation', 'recommend'],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'activity-pet',
|
|
50
|
+
pattern: /\b(indoor|activity|activities|dog|dogs|puppy|pet|happy|hobby|hobbies|treat|treats)\b/i,
|
|
51
|
+
terms: ['indoor', 'activity', 'activities', 'dog', 'dogs', 'puppy', 'pet', 'happy', 'hobby', 'hobbies', 'cook', 'cooking', 'bake', 'baking', 'recipe', 'treat', 'treats', 'kitchen', 'homemade', 'cookie', 'cookies', 'biscuit', 'biscuits'],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'health-status',
|
|
55
|
+
pattern: /\b(health|problem|problems|suspected|medical|condition|weight|exercise|diet|symptom|symptoms)\b/i,
|
|
56
|
+
terms: ['health', 'medical', 'condition', 'problem', 'problems', 'weight', 'exercise', 'diet', 'doctor', 'symptom', 'symptoms'],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'travel-country',
|
|
60
|
+
pattern: /\b(country|visiting|visit|visited|travel|trip|vacation|pendant|souvenir|mother)\b/i,
|
|
61
|
+
terms: ['country', 'visit', 'visited', 'visiting', 'travel', 'trip', 'vacation', 'souvenir', 'pendant', 'mother', 'abroad'],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'civic-patriotic',
|
|
65
|
+
pattern: /\b(patriotic|patriot|country|flag|military|veteran|service|civic|community)\b/i,
|
|
66
|
+
terms: ['patriotic', 'patriot', 'country', 'flag', 'military', 'veteran', 'service', 'civic', 'community', 'charity', 'fundraiser', 'volunteer', 'memorial', 'parade', 'independence', 'america', 'american', 'national', 'vote', 'voting', 'election'],
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
const QUERY_INITIAL_WORDS = new Set([
|
|
70
|
+
'what', 'which', 'would', 'could', 'should', 'is', 'in', 'how', 'who',
|
|
71
|
+
]);
|
|
72
|
+
export function extractOpenDomainAnchors(query) {
|
|
73
|
+
const anchors = [];
|
|
74
|
+
const tokens = query.match(/\b[A-Z][a-zA-Z]{2,}\b/g) ?? [];
|
|
75
|
+
for (const token of tokens) {
|
|
76
|
+
const lower = token.toLowerCase();
|
|
77
|
+
if (QUERY_INITIAL_WORDS.has(lower))
|
|
78
|
+
continue;
|
|
79
|
+
anchors.push(lower);
|
|
80
|
+
}
|
|
81
|
+
return [...new Set(anchors)];
|
|
82
|
+
}
|
|
83
|
+
function matchedOpenDomainFacets(query) {
|
|
84
|
+
return OPEN_DOMAIN_FACETS.filter(facet => facet.pattern.test(query));
|
|
85
|
+
}
|
|
86
|
+
export function expandOpenDomainQueryTerms(query, terms) {
|
|
87
|
+
const expanded = [...extractOpenDomainAnchors(query), ...terms];
|
|
88
|
+
for (const facet of matchedOpenDomainFacets(query)) {
|
|
89
|
+
expanded.push(...facet.terms);
|
|
90
|
+
}
|
|
91
|
+
return [...new Set(expanded)].slice(0, 40);
|
|
92
|
+
}
|
|
93
|
+
function toFtsAndQuery(anchorTerms, facetTerms, limit) {
|
|
94
|
+
const anchors = [...new Set(anchorTerms)]
|
|
95
|
+
.map(w => w.replace(/"/g, '').trim())
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.slice(0, 4);
|
|
98
|
+
const facets = [...new Set(facetTerms)]
|
|
99
|
+
.map(w => w.replace(/"/g, '').trim())
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
.slice(0, limit);
|
|
102
|
+
if (anchors.length === 0 || facets.length === 0)
|
|
103
|
+
return null;
|
|
104
|
+
const anchorQuery = anchors.map(w => `"${w}"*`).join(' OR ');
|
|
105
|
+
const facetQuery = facets.map(w => `"${w}"*`).join(' OR ');
|
|
106
|
+
return `(${anchorQuery}) AND (${facetQuery})`;
|
|
107
|
+
}
|
|
108
|
+
export function scoreOpenDomainEvidence(content, query, baseTerms) {
|
|
109
|
+
const lower = content.toLowerCase();
|
|
110
|
+
let score = 0;
|
|
111
|
+
for (const anchor of extractOpenDomainAnchors(query)) {
|
|
112
|
+
if (lower.includes(anchor))
|
|
113
|
+
score += 8;
|
|
114
|
+
}
|
|
115
|
+
for (const term of baseTerms) {
|
|
116
|
+
if (lower.includes(term))
|
|
117
|
+
score += term.length >= 6 ? 2 : 1;
|
|
118
|
+
}
|
|
119
|
+
for (const facet of matchedOpenDomainFacets(query)) {
|
|
120
|
+
for (const term of facet.terms) {
|
|
121
|
+
if (lower.includes(term))
|
|
122
|
+
score += 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return score;
|
|
126
|
+
}
|
|
23
127
|
/**
|
|
24
128
|
* Returns true if the query looks like an open-domain question:
|
|
25
129
|
* broad, exploratory, no specific anchors, no temporal signals.
|
|
@@ -27,18 +131,25 @@ const TEMPORAL_SIGNALS = /\b(before|after|when|last\s+\w+|yesterday|today|recent
|
|
|
27
131
|
export function isOpenDomainQuery(query) {
|
|
28
132
|
if (!query || query.trim().length < 8)
|
|
29
133
|
return false;
|
|
30
|
-
// Has temporal signals → temporal path handles it
|
|
31
|
-
|
|
134
|
+
// Has temporal signals → temporal path handles it, unless the query is also
|
|
135
|
+
// a broad/inferential open-domain question. LoCoMo category-3 questions often
|
|
136
|
+
// mention dates while still requiring raw-message inference rather than a
|
|
137
|
+
// pure temporal answer.
|
|
138
|
+
const broad = BROAD_INTERROGATIVE.test(query) || INFERENTIAL_OPEN_DOMAIN.test(query);
|
|
139
|
+
if (TEMPORAL_SIGNALS.test(query) && !broad)
|
|
32
140
|
return false;
|
|
33
|
-
//
|
|
34
|
-
|
|
141
|
+
// Version, ticket, and URL anchors usually belong to specific retrieval paths.
|
|
142
|
+
// Do not exclude named people/places here: LoCoMo open-domain questions often
|
|
143
|
+
// ask broad questions about a named speaker, and the entity is the useful
|
|
144
|
+
// retrieval anchor rather than a reason to bypass raw-message recall.
|
|
145
|
+
if (SPECIFIC_NON_DIALOG_ANCHOR.test(query))
|
|
35
146
|
return false;
|
|
36
147
|
// Must match a broad interrogative pattern
|
|
37
|
-
if (!
|
|
148
|
+
if (!broad)
|
|
38
149
|
return false;
|
|
39
150
|
// Sanity: query should not be too long (long queries are usually specific)
|
|
40
151
|
const wordCount = query.trim().split(/\s+/).length;
|
|
41
|
-
if (wordCount >
|
|
152
|
+
if (wordCount > 28)
|
|
42
153
|
return false;
|
|
43
154
|
return true;
|
|
44
155
|
}
|
|
@@ -48,23 +159,55 @@ export function isOpenDomainQuery(query) {
|
|
|
48
159
|
* Strips stop words, question words, and punctuation.
|
|
49
160
|
* Returns up to 6 prefix-matched terms joined with OR.
|
|
50
161
|
*/
|
|
51
|
-
|
|
162
|
+
function tokenizeOpenDomainQuery(query) {
|
|
52
163
|
const STOP_WORDS = new Set([
|
|
53
164
|
'what', 'did', 'does', 'has', 'was', 'were', 'is', 'are', 'how',
|
|
54
165
|
'tell', 'me', 'about', 'describe', 'explain', 'summarize', 'overview',
|
|
55
166
|
'recap', 'who', 'do', 'you', 'know', 'have', 'the', 'a', 'an', 'of',
|
|
56
167
|
'in', 'on', 'at', 'to', 'for', 'and', 'or', 'but', 'with', 'from',
|
|
168
|
+
'their', 'them', 'they', 'your', 'his', 'her', 'him', 'she', 'he',
|
|
169
|
+
'would', 'could', 'should', 'might', 'likely', 'considered', 'consider',
|
|
170
|
+
'besides', 'while', 'make', 'doing', 'person',
|
|
57
171
|
]);
|
|
58
172
|
const terms = query
|
|
59
173
|
.toLowerCase()
|
|
60
|
-
.replace(/[^a-z0-9\s]/g, ' ')
|
|
174
|
+
.replace(/[^a-z0-9\s-]/g, ' ')
|
|
175
|
+
.replace(/-/g, ' ')
|
|
61
176
|
.split(/\s+/)
|
|
62
|
-
.
|
|
63
|
-
.
|
|
64
|
-
|
|
65
|
-
|
|
177
|
+
.map(w => w.trim())
|
|
178
|
+
.filter(w => w.length >= 3 && !STOP_WORDS.has(w));
|
|
179
|
+
return expandOpenDomainQueryTerms(query, terms);
|
|
180
|
+
}
|
|
181
|
+
function toFtsOrQuery(terms, limit) {
|
|
182
|
+
const unique = [...new Set(terms)]
|
|
183
|
+
.slice(0, limit)
|
|
184
|
+
.map(w => `"${w.replace(/"/g, '')}"*`);
|
|
185
|
+
if (unique.length === 0)
|
|
66
186
|
return null;
|
|
67
|
-
return
|
|
187
|
+
return unique.join(' OR ');
|
|
188
|
+
}
|
|
189
|
+
export function buildOpenDomainFtsQuery(query) {
|
|
190
|
+
return toFtsOrQuery(tokenizeOpenDomainQuery(query), 8);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Build multiple prompt-only FTS probes for broad open-domain questions.
|
|
194
|
+
* The primary query favors specific terms; the secondary query preserves the
|
|
195
|
+
* natural query order so shorter but important entity/activity terms are not
|
|
196
|
+
* lost when the broad question contains many long words.
|
|
197
|
+
*/
|
|
198
|
+
export function buildOpenDomainFtsQueries(query) {
|
|
199
|
+
const terms = tokenizeOpenDomainQuery(query);
|
|
200
|
+
const anchors = extractOpenDomainAnchors(query);
|
|
201
|
+
const baseTerms = terms.filter(term => !anchors.includes(term));
|
|
202
|
+
const facetQueries = matchedOpenDomainFacets(query)
|
|
203
|
+
.map(facet => toFtsAndQuery(anchors, facet.terms, 10))
|
|
204
|
+
.filter((q) => Boolean(q));
|
|
205
|
+
const queries = [
|
|
206
|
+
...facetQueries,
|
|
207
|
+
toFtsOrQuery(terms, 10),
|
|
208
|
+
toFtsOrQuery(baseTerms, 12),
|
|
209
|
+
].filter((q) => Boolean(q));
|
|
210
|
+
return [...new Set(queries)];
|
|
68
211
|
}
|
|
69
212
|
/**
|
|
70
213
|
* Search raw message history via FTS5 for open-domain queries.
|
|
@@ -76,11 +219,12 @@ export function buildOpenDomainFtsQuery(query) {
|
|
|
76
219
|
* @param limit — max results (default 10)
|
|
77
220
|
*/
|
|
78
221
|
export function searchOpenDomain(db, query, existingContent, limit = 10) {
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
222
|
+
const ftsQueries = buildOpenDomainFtsQueries(query);
|
|
223
|
+
if (ftsQueries.length === 0)
|
|
81
224
|
return [];
|
|
82
225
|
try {
|
|
83
|
-
const
|
|
226
|
+
const rowsById = new Map();
|
|
227
|
+
const hitStmt = db.prepare(`
|
|
84
228
|
WITH fts_matches AS (
|
|
85
229
|
SELECT rowid, rank
|
|
86
230
|
FROM messages_fts
|
|
@@ -89,9 +233,13 @@ export function searchOpenDomain(db, query, existingContent, limit = 10) {
|
|
|
89
233
|
LIMIT ?
|
|
90
234
|
)
|
|
91
235
|
SELECT
|
|
236
|
+
m.id,
|
|
237
|
+
m.conversation_id AS conversationId,
|
|
92
238
|
m.role,
|
|
93
239
|
m.text_content AS content,
|
|
94
|
-
m.created_at AS createdAt
|
|
240
|
+
m.created_at AS createdAt,
|
|
241
|
+
m.message_index AS messageIndex,
|
|
242
|
+
fts_matches.rank AS rank
|
|
95
243
|
FROM messages m
|
|
96
244
|
JOIN fts_matches ON m.id = fts_matches.rowid
|
|
97
245
|
WHERE m.role IN ('user', 'assistant')
|
|
@@ -99,7 +247,61 @@ export function searchOpenDomain(db, query, existingContent, limit = 10) {
|
|
|
99
247
|
AND trim(m.text_content) != ''
|
|
100
248
|
AND m.is_heartbeat = 0
|
|
101
249
|
ORDER BY fts_matches.rank
|
|
102
|
-
`)
|
|
250
|
+
`);
|
|
251
|
+
const neighborStmt = db.prepare(`
|
|
252
|
+
SELECT
|
|
253
|
+
id,
|
|
254
|
+
conversation_id AS conversationId,
|
|
255
|
+
role,
|
|
256
|
+
text_content AS content,
|
|
257
|
+
created_at AS createdAt,
|
|
258
|
+
message_index AS messageIndex
|
|
259
|
+
FROM messages
|
|
260
|
+
WHERE conversation_id = ?
|
|
261
|
+
AND message_index BETWEEN ? AND ?
|
|
262
|
+
AND role IN ('user', 'assistant')
|
|
263
|
+
AND text_content IS NOT NULL
|
|
264
|
+
AND trim(text_content) != ''
|
|
265
|
+
AND is_heartbeat = 0
|
|
266
|
+
ORDER BY message_index ASC
|
|
267
|
+
`);
|
|
268
|
+
for (const ftsQuery of ftsQueries) {
|
|
269
|
+
const hits = hitStmt.all(ftsQuery, limit * 2);
|
|
270
|
+
for (const hit of hits) {
|
|
271
|
+
if (!rowsById.has(hit.id))
|
|
272
|
+
rowsById.set(hit.id, hit);
|
|
273
|
+
// Preserve local dialogue context. Open-domain answers often live in the
|
|
274
|
+
// assistant turn adjacent to a broad user turn, or vice versa.
|
|
275
|
+
if (hit.conversationId == null)
|
|
276
|
+
continue;
|
|
277
|
+
const messageIndex = hit.messageIndex ?? 0;
|
|
278
|
+
const neighbors = neighborStmt.all(hit.conversationId, messageIndex - 2, messageIndex + 2);
|
|
279
|
+
for (const neighbor of neighbors) {
|
|
280
|
+
if (!rowsById.has(neighbor.id))
|
|
281
|
+
rowsById.set(neighbor.id, {
|
|
282
|
+
...neighbor,
|
|
283
|
+
rank: hit.rank,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const baseTerms = tokenizeOpenDomainQuery(query);
|
|
289
|
+
for (const row of rowsById.values()) {
|
|
290
|
+
row.anchorScore = scoreOpenDomainEvidence(row.content ?? '', query, baseTerms);
|
|
291
|
+
}
|
|
292
|
+
const rows = [...rowsById.values()].sort((a, b) => {
|
|
293
|
+
const scoreA = a.anchorScore ?? 0;
|
|
294
|
+
const scoreB = b.anchorScore ?? 0;
|
|
295
|
+
if (scoreA !== scoreB)
|
|
296
|
+
return scoreB - scoreA;
|
|
297
|
+
const rankA = a.rank ?? Number.MAX_SAFE_INTEGER;
|
|
298
|
+
const rankB = b.rank ?? Number.MAX_SAFE_INTEGER;
|
|
299
|
+
if (rankA !== rankB)
|
|
300
|
+
return rankA - rankB;
|
|
301
|
+
if ((a.conversationId ?? 0) !== (b.conversationId ?? 0))
|
|
302
|
+
return (a.conversationId ?? 0) - (b.conversationId ?? 0);
|
|
303
|
+
return (a.messageIndex ?? 0) - (b.messageIndex ?? 0);
|
|
304
|
+
});
|
|
103
305
|
// Deduplicate against existing context and filter short content
|
|
104
306
|
const seen = new Set();
|
|
105
307
|
const results = [];
|
package/dist/profiles.js
CHANGED
|
@@ -108,21 +108,21 @@ export const lightProfile = {
|
|
|
108
108
|
// ---------------------------------------------------------------------------
|
|
109
109
|
const STANDARD_COMPOSITOR = {
|
|
110
110
|
// ── Primary budget controls ──
|
|
111
|
-
budgetFraction: 0.
|
|
111
|
+
budgetFraction: 0.60, // operational default: ~77k effective at 128k before reserve
|
|
112
112
|
reserveFraction: 0.25, // balanced — leaves room for large tool results
|
|
113
113
|
historyFraction: 0.40, // ~27k tokens of conversation history
|
|
114
114
|
memoryFraction: 0.40, // ~27k tokens for facts/wiki/semantic
|
|
115
115
|
// ── Absolute fallback ──
|
|
116
116
|
defaultTokenBudget: 90000,
|
|
117
117
|
// ── History internals ──
|
|
118
|
-
maxHistoryMessages:
|
|
119
|
-
warmHistoryBudgetFraction: 0.
|
|
120
|
-
keystoneHistoryFraction: 0.
|
|
121
|
-
keystoneMaxMessages:
|
|
118
|
+
maxHistoryMessages: 250,
|
|
119
|
+
warmHistoryBudgetFraction: 0.27,
|
|
120
|
+
keystoneHistoryFraction: 0.15,
|
|
121
|
+
keystoneMaxMessages: 12,
|
|
122
122
|
keystoneMinSignificance: 0.5,
|
|
123
123
|
// ── Memory internals ──
|
|
124
|
-
maxFacts:
|
|
125
|
-
maxCrossSessionContext:
|
|
124
|
+
maxFacts: 25,
|
|
125
|
+
maxCrossSessionContext: 0,
|
|
126
126
|
maxTotalTriggerTokens: 4000,
|
|
127
127
|
wikiTokenCap: 600,
|
|
128
128
|
// ── Tool gradient (internal — safe floor enforced automatically) ──
|
|
@@ -173,14 +173,14 @@ const EXTENDED_COMPOSITOR = {
|
|
|
173
173
|
// ── Absolute fallback ──
|
|
174
174
|
defaultTokenBudget: 160000,
|
|
175
175
|
// ── History internals ──
|
|
176
|
-
maxHistoryMessages:
|
|
177
|
-
warmHistoryBudgetFraction: 0.
|
|
178
|
-
keystoneHistoryFraction: 0.
|
|
179
|
-
keystoneMaxMessages:
|
|
176
|
+
maxHistoryMessages: 500,
|
|
177
|
+
warmHistoryBudgetFraction: 0.27,
|
|
178
|
+
keystoneHistoryFraction: 0.15,
|
|
179
|
+
keystoneMaxMessages: 12,
|
|
180
180
|
keystoneMinSignificance: 0.4,
|
|
181
181
|
// ── Memory internals ──
|
|
182
|
-
maxFacts:
|
|
183
|
-
maxCrossSessionContext:
|
|
182
|
+
maxFacts: 25,
|
|
183
|
+
maxCrossSessionContext: 4000,
|
|
184
184
|
maxTotalTriggerTokens: 10000,
|
|
185
185
|
wikiTokenCap: 800,
|
|
186
186
|
// ── Tool gradient (internal — safe floor enforced automatically) ──
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* question-shape.ts — Heuristic v1 multi-hop question shape detector
|
|
3
|
+
*
|
|
4
|
+
* Sprint A of the multi-hop closure plan: deterministic, no model call.
|
|
5
|
+
* Classifies a query as 'multi-hop' when it appears to require bridging
|
|
6
|
+
* evidence across two or more distinct named entities or entity+facet pairs.
|
|
7
|
+
*
|
|
8
|
+
* Detection logic (all deterministic):
|
|
9
|
+
* - Extract named entities (TitleCase spans, quoted strings, capitalized 2+ tokens)
|
|
10
|
+
* - Extract facet terms from the LoCoMo answer-bearing noun lexicon
|
|
11
|
+
* - Multi-hop if: (2+ entities) OR (1 entity + 1 facet), PLUS a relation word
|
|
12
|
+
*
|
|
13
|
+
* False-positive gate: temporal-anchor / single-hop-span queries are NOT
|
|
14
|
+
* multi-hop even when they have multiple entity tokens. A FP-rate check
|
|
15
|
+
* hook is available via `questionShapeFalsePositiveScore`.
|
|
16
|
+
*
|
|
17
|
+
* Exported symbols:
|
|
18
|
+
* detectQuestionShape(query) → QuestionShape
|
|
19
|
+
* questionShapeFalsePositiveScore(query) → number (0 = likely real, 1 = likely FP)
|
|
20
|
+
*/
|
|
21
|
+
export interface QuestionShape {
|
|
22
|
+
/** 'multi-hop' = requires bridging evidence across 2+ entities or entity+facet */
|
|
23
|
+
kind: 'multi-hop' | 'single-hop';
|
|
24
|
+
/** Named entity tokens extracted from the query */
|
|
25
|
+
entities: string[];
|
|
26
|
+
/** Facet terms matched from the LoCoMo facet lexicon */
|
|
27
|
+
facets: string[];
|
|
28
|
+
/**
|
|
29
|
+
* Confidence in the multi-hop classification. 0–1.
|
|
30
|
+
* For 'single-hop', this is a confidence in NOT being multi-hop.
|
|
31
|
+
*/
|
|
32
|
+
confidence: number;
|
|
33
|
+
}
|
|
34
|
+
export declare const QUESTION_SHAPE_FACETS: Array<{
|
|
35
|
+
name: string;
|
|
36
|
+
terms: string[];
|
|
37
|
+
}>;
|
|
38
|
+
/**
|
|
39
|
+
* Extract named entity candidates from a query string.
|
|
40
|
+
* Returns deduplicated lowercase entity tokens.
|
|
41
|
+
*/
|
|
42
|
+
export declare function extractQueryEntities(query: string): string[];
|
|
43
|
+
/**
|
|
44
|
+
* Extract facet terms from a query string using the LoCoMo facet lexicon.
|
|
45
|
+
* Returns matched facet group names (deduplicated).
|
|
46
|
+
*/
|
|
47
|
+
export declare function extractQueryFacets(query: string): string[];
|
|
48
|
+
/**
|
|
49
|
+
* Extract matched facet terms (raw tokens, not group names) from a query.
|
|
50
|
+
* Used for structured handoff header annotation.
|
|
51
|
+
*/
|
|
52
|
+
export declare function extractQueryFacetTerms(query: string): string[];
|
|
53
|
+
/**
|
|
54
|
+
* Estimate the probability that a 'multi-hop' classification is a false positive.
|
|
55
|
+
* Returns 0.0 (clearly multi-hop) to 1.0 (clearly single-hop / FP).
|
|
56
|
+
*
|
|
57
|
+
* High FP score → do not apply structured handoff even if multi-hop shape detected.
|
|
58
|
+
* Spec threshold: FP rate > 0.30 on held-out single-hop-multi-entity set →
|
|
59
|
+
* add a negative check. This function provides that check.
|
|
60
|
+
*/
|
|
61
|
+
export declare function questionShapeFalsePositiveScore(query: string): number;
|
|
62
|
+
/**
|
|
63
|
+
* Detect whether a query has multi-hop shape.
|
|
64
|
+
*
|
|
65
|
+
* Multi-hop criteria (all must be true):
|
|
66
|
+
* 1. 2+ named entities OR (1 entity + 1 facet group)
|
|
67
|
+
* 2. At least one relation/intersection word
|
|
68
|
+
* 3. False-positive score < 0.60
|
|
69
|
+
*
|
|
70
|
+
* Returns a QuestionShape with extracted entities, facets, and confidence.
|
|
71
|
+
*/
|
|
72
|
+
export declare function detectQuestionShape(query: string): QuestionShape;
|
|
73
|
+
//# sourceMappingURL=question-shape.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"question-shape.d.ts","sourceRoot":"","sources":["../src/question-shape.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,MAAM,WAAW,aAAa;IAC5B,kFAAkF;IAClF,IAAI,EAAE,WAAW,GAAG,YAAY,CAAC;IACjC,mDAAmD;IACnD,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,wDAAwD;IACxD,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB;;;OAGG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAiBD,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,CAmC1E,CAAC;AA+CF;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAuB5D;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAc1D;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAS9D;AAYD;;;;;;;GAOG;AACH,wBAAgB,+BAA+B,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAyBrE;AAID;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,CA0BhE"}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* question-shape.ts — Heuristic v1 multi-hop question shape detector
|
|
3
|
+
*
|
|
4
|
+
* Sprint A of the multi-hop closure plan: deterministic, no model call.
|
|
5
|
+
* Classifies a query as 'multi-hop' when it appears to require bridging
|
|
6
|
+
* evidence across two or more distinct named entities or entity+facet pairs.
|
|
7
|
+
*
|
|
8
|
+
* Detection logic (all deterministic):
|
|
9
|
+
* - Extract named entities (TitleCase spans, quoted strings, capitalized 2+ tokens)
|
|
10
|
+
* - Extract facet terms from the LoCoMo answer-bearing noun lexicon
|
|
11
|
+
* - Multi-hop if: (2+ entities) OR (1 entity + 1 facet), PLUS a relation word
|
|
12
|
+
*
|
|
13
|
+
* False-positive gate: temporal-anchor / single-hop-span queries are NOT
|
|
14
|
+
* multi-hop even when they have multiple entity tokens. A FP-rate check
|
|
15
|
+
* hook is available via `questionShapeFalsePositiveScore`.
|
|
16
|
+
*
|
|
17
|
+
* Exported symbols:
|
|
18
|
+
* detectQuestionShape(query) → QuestionShape
|
|
19
|
+
* questionShapeFalsePositiveScore(query) → number (0 = likely real, 1 = likely FP)
|
|
20
|
+
*/
|
|
21
|
+
// ── Relation lexicon ──────────────────────────────────────────────────────
|
|
22
|
+
// Spec: "relation/intersection word" — signals that the question asks about
|
|
23
|
+
// a shared attribute, comparison, or intersection across entities.
|
|
24
|
+
const RELATION_WORDS = new Set([
|
|
25
|
+
'common', 'share', 'shared', 'both', 'same', 'between',
|
|
26
|
+
'bought', 'lost', 'planned', 'pursued', 'interested',
|
|
27
|
+
'also', 'together', 'neither', 'either',
|
|
28
|
+
'compare', 'comparing', 'overlap', 'overlapping', 'link', 'connect',
|
|
29
|
+
'relationship', 'relation', 'difference', 'similar', 'similarity',
|
|
30
|
+
]);
|
|
31
|
+
// ── Facet lexicon ─────────────────────────────────────────────────────────
|
|
32
|
+
// LoCoMo answer-bearing nouns. Kept minimal for Sprint A; promoted in Sprint B.
|
|
33
|
+
export const QUESTION_SHAPE_FACETS = [
|
|
34
|
+
{
|
|
35
|
+
name: 'job',
|
|
36
|
+
terms: ['job', 'jobs', 'work', 'career', 'occupation', 'profession', 'employment', 'fired', 'hired', 'promotion'],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'death',
|
|
40
|
+
terms: ['death', 'died', 'passed away', 'funeral', 'loss', 'deceased', 'passing'],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'hobby',
|
|
44
|
+
terms: ['hobby', 'hobbies', 'activity', 'activities', 'interest', 'interests', 'passion', 'pastime', 'free time'],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'purchase',
|
|
48
|
+
terms: ['bought', 'purchase', 'purchased', 'buy', 'buying', 'item', 'items', 'shopping', 'order', 'ordered'],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'venue',
|
|
52
|
+
terms: ['place', 'places', 'venue', 'venues', 'location', 'where', 'meet', 'met', 'visited', 'restaurant', 'bar', 'club'],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'activity',
|
|
56
|
+
terms: ['planned', 'planning', 'event', 'events', 'trip', 'trips', 'vacation', 'travel'],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'time',
|
|
60
|
+
terms: ['month', 'months', 'year', 'years', 'january', 'february', 'march', 'april', 'may', 'june',
|
|
61
|
+
'july', 'august', 'september', 'october', 'november', 'december', 'spring', 'summer', 'fall', 'winter'],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'relationship',
|
|
65
|
+
terms: ['friend', 'friends', 'partner', 'boyfriend', 'girlfriend', 'husband', 'wife', 'family',
|
|
66
|
+
'sibling', 'brother', 'sister', 'parent', 'mother', 'father', 'colleague', 'coworker'],
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
// Flat set for quick lookup
|
|
70
|
+
const FACET_TERM_SET = new Set(QUESTION_SHAPE_FACETS.flatMap(f => f.terms));
|
|
71
|
+
// ── Temporal-anchor / single-hop span patterns ────────────────────────────
|
|
72
|
+
// Used by the FP-score hook. A temporal question about one entity's history
|
|
73
|
+
// looks multi-hop by entity count but should not trigger structured handoff.
|
|
74
|
+
const TEMPORAL_SINGLE_HOP_PATTERNS = [
|
|
75
|
+
/\bwhen (did|was|were|is|are)\b/i,
|
|
76
|
+
/^what (year|month|date) did\b/i,
|
|
77
|
+
/\b(first|last)\s+(time|year|month|day|week)\b/i,
|
|
78
|
+
/\bhow long (ago|since|has|have)\b/i,
|
|
79
|
+
/\b(how many|what number|count of)\b/i,
|
|
80
|
+
/\b(date|dates|year|years)\s+(of|for|when|that)\b/i,
|
|
81
|
+
];
|
|
82
|
+
// Strong single-hop signals — query is about a single subject's attribute
|
|
83
|
+
const SINGLE_HOP_SUBJECT_PATTERNS = [
|
|
84
|
+
/^(what|who|where|which|when|how) (is|was|are|were|did|does|do) [A-Z][a-z]+/,
|
|
85
|
+
/^(tell me|describe|explain|summarize)/i,
|
|
86
|
+
/\b(his|her|their|its) (name|job|career|hobby|hobbies|death|purchase|friend|partner)\b/i,
|
|
87
|
+
];
|
|
88
|
+
// ── Entity extraction ─────────────────────────────────────────────────────
|
|
89
|
+
/** TitleCase word pattern (starts with capital, >= 2 chars) */
|
|
90
|
+
const TITLE_CASE_WORD = /\b[A-Z][a-z][a-zA-Z]*\b/g;
|
|
91
|
+
/** Quoted string pattern */
|
|
92
|
+
const QUOTED_STRING = /["']([^"']{2,30})["']/g;
|
|
93
|
+
/** ALL-CAPS abbreviation (e.g. VR, NBA, UCSF) */
|
|
94
|
+
const ALLCAPS_ABBREV = /\b[A-Z]{2,6}\b/g;
|
|
95
|
+
const COMMON_TITLE_CASE_STOP_WORDS = new Set([
|
|
96
|
+
'The', 'A', 'An', 'In', 'On', 'At', 'To', 'For', 'Of', 'And', 'Or', 'But',
|
|
97
|
+
'By', 'Is', 'It', 'If', 'So', 'Do', 'Be', 'My', 'We', 'He', 'She', 'They',
|
|
98
|
+
'You', 'Me', 'Us', 'His', 'Her', 'Its', 'Our', 'Your', 'Who', 'What', 'How',
|
|
99
|
+
'When', 'Where', 'Which', 'Why', 'Would', 'Could', 'Should', 'Did', 'Does',
|
|
100
|
+
'Was', 'Were', 'Has', 'Have', 'Had', 'Will', 'Can', 'May', 'Might', 'Shall',
|
|
101
|
+
'Just', 'Also', 'Both', 'Each', 'With', 'From', 'That', 'This', 'These', 'Those',
|
|
102
|
+
'Any', 'All', 'Not', 'Now', 'Well', 'Too', 'Very', 'More', 'Most', 'Some',
|
|
103
|
+
'Same', 'Last', 'Next', 'New', 'Old', 'Then', 'Than', 'Into', 'Upon',
|
|
104
|
+
]);
|
|
105
|
+
/**
|
|
106
|
+
* Extract named entity candidates from a query string.
|
|
107
|
+
* Returns deduplicated lowercase entity tokens.
|
|
108
|
+
*/
|
|
109
|
+
export function extractQueryEntities(query) {
|
|
110
|
+
const candidates = new Set();
|
|
111
|
+
// Quoted strings first (highest confidence)
|
|
112
|
+
for (const m of query.matchAll(QUOTED_STRING)) {
|
|
113
|
+
const val = m[1].trim();
|
|
114
|
+
if (val.length >= 2)
|
|
115
|
+
candidates.add(val.toLowerCase());
|
|
116
|
+
}
|
|
117
|
+
// TitleCase words (excluding common stop words)
|
|
118
|
+
for (const m of query.matchAll(TITLE_CASE_WORD)) {
|
|
119
|
+
const word = m[0];
|
|
120
|
+
if (!COMMON_TITLE_CASE_STOP_WORDS.has(word)) {
|
|
121
|
+
candidates.add(word.toLowerCase());
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// ALL-CAPS abbreviations
|
|
125
|
+
for (const m of query.matchAll(ALLCAPS_ABBREV)) {
|
|
126
|
+
candidates.add(m[0].toLowerCase());
|
|
127
|
+
}
|
|
128
|
+
return [...candidates];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Extract facet terms from a query string using the LoCoMo facet lexicon.
|
|
132
|
+
* Returns matched facet group names (deduplicated).
|
|
133
|
+
*/
|
|
134
|
+
export function extractQueryFacets(query) {
|
|
135
|
+
const lower = query.toLowerCase();
|
|
136
|
+
const matchedFacets = new Set();
|
|
137
|
+
for (const facet of QUESTION_SHAPE_FACETS) {
|
|
138
|
+
for (const term of facet.terms) {
|
|
139
|
+
if (lower.includes(term)) {
|
|
140
|
+
matchedFacets.add(facet.name);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return [...matchedFacets];
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Extract matched facet terms (raw tokens, not group names) from a query.
|
|
149
|
+
* Used for structured handoff header annotation.
|
|
150
|
+
*/
|
|
151
|
+
export function extractQueryFacetTerms(query) {
|
|
152
|
+
const lower = query.toLowerCase();
|
|
153
|
+
const matched = [];
|
|
154
|
+
for (const term of FACET_TERM_SET) {
|
|
155
|
+
if (lower.includes(term))
|
|
156
|
+
matched.push(term);
|
|
157
|
+
}
|
|
158
|
+
return [...new Set(matched)];
|
|
159
|
+
}
|
|
160
|
+
// ── Relation word detection ───────────────────────────────────────────────
|
|
161
|
+
function hasRelationWord(query) {
|
|
162
|
+
const lower = query.toLowerCase();
|
|
163
|
+
const words = lower.split(/\s+/);
|
|
164
|
+
return words.some(w => RELATION_WORDS.has(w.replace(/[^a-z]/g, '')));
|
|
165
|
+
}
|
|
166
|
+
// ── False-positive scoring ────────────────────────────────────────────────
|
|
167
|
+
/**
|
|
168
|
+
* Estimate the probability that a 'multi-hop' classification is a false positive.
|
|
169
|
+
* Returns 0.0 (clearly multi-hop) to 1.0 (clearly single-hop / FP).
|
|
170
|
+
*
|
|
171
|
+
* High FP score → do not apply structured handoff even if multi-hop shape detected.
|
|
172
|
+
* Spec threshold: FP rate > 0.30 on held-out single-hop-multi-entity set →
|
|
173
|
+
* add a negative check. This function provides that check.
|
|
174
|
+
*/
|
|
175
|
+
export function questionShapeFalsePositiveScore(query) {
|
|
176
|
+
let fpScore = 0;
|
|
177
|
+
for (const pattern of TEMPORAL_SINGLE_HOP_PATTERNS) {
|
|
178
|
+
if (pattern.test(query)) {
|
|
179
|
+
fpScore += 0.35;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
for (const pattern of SINGLE_HOP_SUBJECT_PATTERNS) {
|
|
184
|
+
if (pattern.test(query)) {
|
|
185
|
+
fpScore += 0.25;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Short queries with < 7 words are unlikely to be true multi-hop
|
|
190
|
+
const wordCount = query.trim().split(/\s+/).length;
|
|
191
|
+
if (wordCount < 7)
|
|
192
|
+
fpScore += 0.15;
|
|
193
|
+
// If query has no relation word at all, reduce confidence
|
|
194
|
+
if (!hasRelationWord(query))
|
|
195
|
+
fpScore += 0.30;
|
|
196
|
+
return Math.min(1.0, fpScore);
|
|
197
|
+
}
|
|
198
|
+
// ── Main detector ─────────────────────────────────────────────────────────
|
|
199
|
+
/**
|
|
200
|
+
* Detect whether a query has multi-hop shape.
|
|
201
|
+
*
|
|
202
|
+
* Multi-hop criteria (all must be true):
|
|
203
|
+
* 1. 2+ named entities OR (1 entity + 1 facet group)
|
|
204
|
+
* 2. At least one relation/intersection word
|
|
205
|
+
* 3. False-positive score < 0.60
|
|
206
|
+
*
|
|
207
|
+
* Returns a QuestionShape with extracted entities, facets, and confidence.
|
|
208
|
+
*/
|
|
209
|
+
export function detectQuestionShape(query) {
|
|
210
|
+
if (!query || !query.trim()) {
|
|
211
|
+
return { kind: 'single-hop', entities: [], facets: [], confidence: 0.9 };
|
|
212
|
+
}
|
|
213
|
+
const entities = extractQueryEntities(query);
|
|
214
|
+
const facets = extractQueryFacets(query);
|
|
215
|
+
const fpScore = questionShapeFalsePositiveScore(query);
|
|
216
|
+
const hasEnoughSignals = entities.length >= 2 ||
|
|
217
|
+
(entities.length >= 1 && facets.length >= 1);
|
|
218
|
+
const hasRelation = hasRelationWord(query);
|
|
219
|
+
const isSafe = fpScore < 0.35;
|
|
220
|
+
if (hasEnoughSignals && hasRelation && isSafe) {
|
|
221
|
+
// Confidence: scale down by FP score
|
|
222
|
+
const baseConfidence = Math.min(1.0, 0.5 + (entities.length * 0.15) + (facets.length * 0.10));
|
|
223
|
+
const confidence = Math.max(0.1, baseConfidence * (1 - fpScore));
|
|
224
|
+
return { kind: 'multi-hop', entities, facets, confidence };
|
|
225
|
+
}
|
|
226
|
+
// Single-hop: confidence is inverse of multi-hop signals
|
|
227
|
+
const singleHopConfidence = Math.min(1.0, 0.5 + fpScore * 0.5);
|
|
228
|
+
return { kind: 'single-hop', entities, facets, confidence: singleHopConfidence };
|
|
229
|
+
}
|
|
230
|
+
//# sourceMappingURL=question-shape.js.map
|