opencandle 0.11.0 → 0.11.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/README.md +5 -4
- package/dist/analysts/orchestrator.d.ts +2 -0
- package/dist/analysts/orchestrator.js +19 -0
- package/dist/analysts/orchestrator.js.map +1 -1
- package/dist/doctor/report.js +1 -1
- package/dist/doctor/report.js.map +1 -1
- package/dist/infra/cache.d.ts +6 -0
- package/dist/infra/cache.js +16 -6
- package/dist/infra/cache.js.map +1 -1
- package/dist/infra/freshness.d.ts +21 -0
- package/dist/infra/freshness.js +119 -0
- package/dist/infra/freshness.js.map +1 -0
- package/dist/infra/market-calendar.d.ts +14 -0
- package/dist/infra/market-calendar.js +137 -0
- package/dist/infra/market-calendar.js.map +1 -0
- package/dist/infra/rate-limiter.js +1 -0
- package/dist/infra/rate-limiter.js.map +1 -1
- package/dist/market-state/summaries.d.ts +8 -0
- package/dist/market-state/summaries.js +70 -0
- package/dist/market-state/summaries.js.map +1 -0
- package/dist/onboarding/providers.d.ts +25 -1
- package/dist/onboarding/providers.js +26 -0
- package/dist/onboarding/providers.js.map +1 -1
- package/dist/pi/opencandle-extension.js +44 -5
- package/dist/pi/opencandle-extension.js.map +1 -1
- package/dist/providers/alpha-vantage.js +6 -0
- package/dist/providers/alpha-vantage.js.map +1 -1
- package/dist/providers/coingecko.js +1 -0
- package/dist/providers/coingecko.js.map +1 -1
- package/dist/providers/polymarket.d.ts +2 -0
- package/dist/providers/polymarket.js +163 -0
- package/dist/providers/polymarket.js.map +1 -0
- package/dist/providers/wrap-provider.js +5 -3
- package/dist/providers/wrap-provider.js.map +1 -1
- package/dist/providers/yahoo-finance.d.ts +3 -0
- package/dist/providers/yahoo-finance.js +206 -10
- package/dist/providers/yahoo-finance.js.map +1 -1
- package/dist/routing/entity-extractor.js +16 -0
- package/dist/routing/entity-extractor.js.map +1 -1
- package/dist/routing/route-manifest.js +1 -1
- package/dist/routing/route-manifest.js.map +1 -1
- package/dist/routing/router-llm-client.js +18 -3
- package/dist/routing/router-llm-client.js.map +1 -1
- package/dist/routing/router.d.ts +1 -1
- package/dist/routing/router.js +274 -5
- package/dist/routing/router.js.map +1 -1
- package/dist/routing/slot-resolver.d.ts +1 -0
- package/dist/routing/slot-resolver.js +1 -1
- package/dist/routing/slot-resolver.js.map +1 -1
- package/dist/runtime/evidence.d.ts +1 -0
- package/dist/runtime/evidence.js.map +1 -1
- package/dist/runtime/numeric-claims.d.ts +23 -0
- package/dist/runtime/numeric-claims.js +100 -0
- package/dist/runtime/numeric-claims.js.map +1 -0
- package/dist/runtime/planning-evidence.js +1 -76
- package/dist/runtime/planning-evidence.js.map +1 -1
- package/dist/runtime/prompt-step.d.ts +10 -1
- package/dist/runtime/prompt-step.js +116 -2
- package/dist/runtime/prompt-step.js.map +1 -1
- package/dist/runtime/session-coordinator.d.ts +11 -1
- package/dist/runtime/session-coordinator.js +352 -49
- package/dist/runtime/session-coordinator.js.map +1 -1
- package/dist/runtime/validation.d.ts +3 -1
- package/dist/runtime/workflow-events.d.ts +1 -1
- package/dist/runtime/workflow-events.js.map +1 -1
- package/dist/runtime/workflow-types.d.ts +3 -0
- package/dist/runtime/workflow-types.js.map +1 -1
- package/dist/tools/fundamentals/comps.d.ts +1 -0
- package/dist/tools/fundamentals/comps.js +20 -9
- package/dist/tools/fundamentals/comps.js.map +1 -1
- package/dist/tools/fundamentals/dcf.js +183 -112
- package/dist/tools/fundamentals/dcf.js.map +1 -1
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/macro/event-probabilities.d.ts +8 -0
- package/dist/tools/macro/event-probabilities.js +121 -0
- package/dist/tools/macro/event-probabilities.js.map +1 -0
- package/dist/tools/market/crypto-price.d.ts +4 -1
- package/dist/tools/market/crypto-price.js +10 -1
- package/dist/tools/market/crypto-price.js.map +1 -1
- package/dist/tools/market/screen-stocks.js +14 -3
- package/dist/tools/market/screen-stocks.js.map +1 -1
- package/dist/tools/market/stock-quote.d.ts +4 -1
- package/dist/tools/market/stock-quote.js +9 -4
- package/dist/tools/market/stock-quote.js.map +1 -1
- package/dist/tools/options/option-chain.d.ts +4 -1
- package/dist/tools/options/option-chain.js +12 -1
- package/dist/tools/options/option-chain.js.map +1 -1
- package/dist/tools/portfolio/risk-analysis.js +1 -1
- package/dist/tools/portfolio/risk-analysis.js.map +1 -1
- package/dist/tools/technical/indicators.js +1 -1
- package/dist/tools/technical/indicators.js.map +1 -1
- package/dist/types/fundamentals.d.ts +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/market.d.ts +2 -0
- package/dist/types/options.d.ts +1 -0
- package/dist/types/prediction-markets.d.ts +13 -0
- package/dist/types/prediction-markets.js +2 -0
- package/dist/types/prediction-markets.js.map +1 -0
- package/gui/server/chat-event-adapter.ts +89 -5
- package/gui/server/http-routes.ts +285 -37
- package/gui/server/invoke-tool.ts +14 -13
- package/gui/server/live-chat-event-adapter.ts +37 -2
- package/gui/server/market-state-api.ts +63 -5
- package/gui/server/projector.ts +132 -3
- package/gui/server/session-actions.ts +32 -40
- package/gui/server/session-entry-wait.ts +22 -3
- package/gui/server/ws-hub.ts +16 -4
- package/gui/shared/chat-events.ts +10 -1
- package/gui/shared/event-reducer.ts +1 -0
- package/gui/web/dist/assets/CatalogOverlay-DZ1niyQm.js +1 -0
- package/gui/web/dist/assets/index-D4F9AJnn.css +2 -0
- package/gui/web/dist/assets/index-DtqMMBTr.js +65 -0
- package/gui/web/dist/index.html +2 -2
- package/package.json +8 -13
- package/src/analysts/orchestrator.ts +20 -0
- package/src/doctor/report.ts +1 -1
- package/src/infra/cache.ts +24 -11
- package/src/infra/freshness.ts +165 -0
- package/src/infra/market-calendar.ts +193 -0
- package/src/infra/rate-limiter.ts +1 -0
- package/src/market-state/summaries.ts +75 -0
- package/src/onboarding/providers.ts +29 -1
- package/src/pi/opencandle-extension.ts +55 -5
- package/src/providers/alpha-vantage.ts +6 -0
- package/src/providers/coingecko.ts +2 -0
- package/src/providers/polymarket.ts +214 -0
- package/src/providers/wrap-provider.ts +5 -3
- package/src/providers/yahoo-finance.ts +339 -27
- package/src/routing/entity-extractor.ts +17 -0
- package/src/routing/route-manifest.ts +1 -1
- package/src/routing/router-llm-client.ts +31 -19
- package/src/routing/router.ts +340 -5
- package/src/routing/slot-resolver.ts +1 -1
- package/src/runtime/evidence.ts +1 -0
- package/src/runtime/numeric-claims.ts +108 -0
- package/src/runtime/planning-evidence.ts +6 -91
- package/src/runtime/prompt-step.ts +127 -2
- package/src/runtime/session-coordinator.ts +462 -57
- package/src/runtime/validation.ts +1 -1
- package/src/runtime/workflow-events.ts +2 -0
- package/src/runtime/workflow-types.ts +3 -0
- package/src/tools/fundamentals/comps.ts +25 -14
- package/src/tools/fundamentals/dcf.ts +204 -124
- package/src/tools/index.ts +3 -0
- package/src/tools/macro/event-probabilities.ts +141 -0
- package/src/tools/market/crypto-price.ts +14 -2
- package/src/tools/market/screen-stocks.ts +14 -3
- package/src/tools/market/stock-quote.ts +49 -43
- package/src/tools/options/option-chain.ts +16 -2
- package/src/tools/portfolio/risk-analysis.ts +1 -1
- package/src/tools/technical/indicators.ts +1 -1
- package/src/types/fundamentals.ts +1 -0
- package/src/types/index.ts +1 -0
- package/src/types/market.ts +2 -0
- package/src/types/options.ts +1 -0
- package/src/types/prediction-markets.ts +13 -0
- package/gui/server/chat-run-session.ts +0 -16
- package/gui/web/dist/assets/CatalogOverlay-ChRTKNlf.js +0 -1
- package/gui/web/dist/assets/index-BzyqyVnd.css +0 -2
- package/gui/web/dist/assets/index-DvMjkOP9.js +0 -65
package/src/routing/router.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
RouterSlot,
|
|
27
27
|
ToolBundleName,
|
|
28
28
|
} from "./router-types.js";
|
|
29
|
+
import { mapDteHintToTarget } from "./slot-resolver.js";
|
|
29
30
|
import { disambiguateSymbols } from "./symbol-disambiguator.js";
|
|
30
31
|
import type { ExtractedEntities, WorkflowType } from "./types.js";
|
|
31
32
|
|
|
@@ -57,7 +58,7 @@ export async function route(
|
|
|
57
58
|
let firstError: string | undefined;
|
|
58
59
|
try {
|
|
59
60
|
const raw = await client.complete(prompt);
|
|
60
|
-
return postProcessRouterOutput(input.text, validateRouterOutput(raw));
|
|
61
|
+
return postProcessRouterOutput(input.text, validateRouterOutput(raw), input);
|
|
61
62
|
} catch (err) {
|
|
62
63
|
firstError = err instanceof Error ? err.message : String(err);
|
|
63
64
|
}
|
|
@@ -66,10 +67,10 @@ export async function route(
|
|
|
66
67
|
try {
|
|
67
68
|
const retryPrompt = `${prompt}\n\n(Your previous response failed validation: ${firstError}. Return a valid JSON object conforming to RouterOutput. Nothing else.)`;
|
|
68
69
|
const raw = await client.complete(retryPrompt);
|
|
69
|
-
return postProcessRouterOutput(input.text, validateRouterOutput(raw));
|
|
70
|
+
return postProcessRouterOutput(input.text, validateRouterOutput(raw), input);
|
|
70
71
|
} catch {
|
|
71
72
|
// Persistent failure — return a minimal fallback with regex-extracted symbols.
|
|
72
|
-
return postProcessRouterOutput(input.text, minimalFallback(input.text));
|
|
73
|
+
return postProcessRouterOutput(input.text, minimalFallback(input.text), input);
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
|
|
@@ -192,15 +193,25 @@ function validateEntities(raw: unknown): ExtractedEntities {
|
|
|
192
193
|
return out;
|
|
193
194
|
}
|
|
194
195
|
|
|
195
|
-
export function postProcessRouterOutput(
|
|
196
|
+
export function postProcessRouterOutput(
|
|
197
|
+
text: string,
|
|
198
|
+
output: RouterOutput,
|
|
199
|
+
inputContext?: Pick<RouterInputContext, "priorTurns" | "profileSnapshot">,
|
|
200
|
+
): RouterOutput {
|
|
196
201
|
const extracted = extractEntities(text);
|
|
197
202
|
const deterministic = classifyWithLegacyRules(text);
|
|
198
203
|
let diagnostics: RouterDiagnostic[] = [...output.diagnostics];
|
|
204
|
+
const symbolsAfterAmbiguousFilter = output.entities.symbols.filter(
|
|
205
|
+
(symbol) => !isAmbiguousConceptUsage(text, symbol),
|
|
206
|
+
);
|
|
207
|
+
const droppedAmbiguousSymbols = output.entities.symbols.filter(
|
|
208
|
+
(symbol) => !symbolsAfterAmbiguousFilter.includes(symbol),
|
|
209
|
+
);
|
|
199
210
|
let next: RouterOutput = {
|
|
200
211
|
...output,
|
|
201
212
|
entities: {
|
|
202
213
|
...output.entities,
|
|
203
|
-
symbols:
|
|
214
|
+
symbols: symbolsAfterAmbiguousFilter,
|
|
204
215
|
budget: output.entities.budget ?? extracted.budget,
|
|
205
216
|
maxPremium: output.entities.maxPremium ?? extracted.maxPremium,
|
|
206
217
|
timeHorizon: output.entities.timeHorizon ?? extracted.timeHorizon,
|
|
@@ -220,6 +231,169 @@ export function postProcessRouterOutput(text: string, output: RouterOutput): Rou
|
|
|
220
231
|
diagnostics,
|
|
221
232
|
};
|
|
222
233
|
|
|
234
|
+
if (next.workflow === "compare_assets") {
|
|
235
|
+
const restorableExtractedSymbols = extracted.symbols.filter(
|
|
236
|
+
(symbol) => disambiguateSymbols([symbol], text).kept.length > 0,
|
|
237
|
+
);
|
|
238
|
+
const orderedSymbols = orderSymbolsByText(
|
|
239
|
+
text,
|
|
240
|
+
mergeSymbols(next.entities.symbols, restorableExtractedSymbols),
|
|
241
|
+
);
|
|
242
|
+
if (!sameStringArray(orderedSymbols, next.entities.symbols)) {
|
|
243
|
+
diagnostics.push({
|
|
244
|
+
code: "compare_symbols_canonicalized",
|
|
245
|
+
message: "compare symbols restored from deterministic extraction and ordered by user text",
|
|
246
|
+
});
|
|
247
|
+
next = {
|
|
248
|
+
...next,
|
|
249
|
+
entities: {
|
|
250
|
+
...next.entities,
|
|
251
|
+
symbols: orderedSymbols,
|
|
252
|
+
},
|
|
253
|
+
slots: syncSymbolListSlot(next.slots, orderedSymbols),
|
|
254
|
+
diagnostics,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Prior-turn symbols merged into the slot (by the model or by the sync
|
|
259
|
+
// above) must not claim user provenance: the user typed only what is in
|
|
260
|
+
// this turn's text, and the Assumptions block renders slot sources.
|
|
261
|
+
// preference/memory/default sources legitimately reference out-of-text
|
|
262
|
+
// symbols and are left alone.
|
|
263
|
+
if (symbolsSlotClaimsPriorTurnUserProvenance(next.slots, text, inputContext?.priorTurns)) {
|
|
264
|
+
diagnostics.push({
|
|
265
|
+
code: "symbols_slot_provenance_prior_context",
|
|
266
|
+
message:
|
|
267
|
+
"compare symbols slot includes symbols absent from the user text; source downgraded from user to prior_context",
|
|
268
|
+
});
|
|
269
|
+
next = {
|
|
270
|
+
...next,
|
|
271
|
+
slots: {
|
|
272
|
+
...next.slots,
|
|
273
|
+
symbols: {
|
|
274
|
+
...next.slots.symbols,
|
|
275
|
+
source: "prior_context",
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
diagnostics,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The DTE horizon is a named historical loss class; when the model drops
|
|
284
|
+
// the slot on an options dispatch, the deterministic extraction preserves
|
|
285
|
+
// the user's stated range (mirrors the profile risk-slot fill below).
|
|
286
|
+
const dteHint = next.entities.dteHint ?? extracted.dteHint;
|
|
287
|
+
if (next.workflow === "options_screener" && !next.slots.dte_target && dteHint) {
|
|
288
|
+
const dteTarget = mapDteHintToTarget(dteHint);
|
|
289
|
+
if (dteTarget) {
|
|
290
|
+
diagnostics.push({
|
|
291
|
+
code: "dte_slot_filled_from_extraction",
|
|
292
|
+
message: "dte_target slot filled from deterministic horizon extraction",
|
|
293
|
+
});
|
|
294
|
+
next = {
|
|
295
|
+
...next,
|
|
296
|
+
slots: {
|
|
297
|
+
...next.slots,
|
|
298
|
+
dte_target: {
|
|
299
|
+
value: dteTarget,
|
|
300
|
+
source: "user",
|
|
301
|
+
confidence: "high",
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
diagnostics,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Model-vocabulary synonyms for the canonical asset_scope value
|
|
310
|
+
// ("etf_only" for "etf_focused") drift saved preferences and slot
|
|
311
|
+
// consumers onto private vocabularies; canonicalize before the echo
|
|
312
|
+
// suppression below so restatements still match the profile.
|
|
313
|
+
next = canonicalizeAssetScopeVocabulary(next);
|
|
314
|
+
|
|
315
|
+
// A preference update that restates the saved profile value is an echo,
|
|
316
|
+
// not a change; writing it pollutes preference provenance (the
|
|
317
|
+
// preference-ECHO contract, fixture 029).
|
|
318
|
+
const echoedUpdates = next.preference_updates.filter(
|
|
319
|
+
(update) => readProfileString(inputContext?.profileSnapshot, update.key) === update.value,
|
|
320
|
+
);
|
|
321
|
+
if (echoedUpdates.length > 0) {
|
|
322
|
+
diagnostics.push({
|
|
323
|
+
code: "preference_echo_suppressed",
|
|
324
|
+
message: `dropped preference update(s) restating the saved profile: ${echoedUpdates
|
|
325
|
+
.map((update) => update.key)
|
|
326
|
+
.join(", ")}`,
|
|
327
|
+
});
|
|
328
|
+
next = {
|
|
329
|
+
...next,
|
|
330
|
+
preference_updates: next.preference_updates.filter(
|
|
331
|
+
(update) => !echoedUpdates.includes(update),
|
|
332
|
+
),
|
|
333
|
+
diagnostics,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const profileRiskProfile = readProfileString(inputContext?.profileSnapshot, "risk_profile");
|
|
338
|
+
if (
|
|
339
|
+
next.workflow === "portfolio_builder" &&
|
|
340
|
+
profileRiskProfile &&
|
|
341
|
+
!next.slots.risk_profile &&
|
|
342
|
+
!next.entities.riskProfile
|
|
343
|
+
) {
|
|
344
|
+
diagnostics.push({
|
|
345
|
+
code: "profile_risk_slot_filled",
|
|
346
|
+
message: "risk_profile slot filled from investor profile snapshot",
|
|
347
|
+
});
|
|
348
|
+
next = {
|
|
349
|
+
...next,
|
|
350
|
+
slots: {
|
|
351
|
+
...next.slots,
|
|
352
|
+
risk_profile: {
|
|
353
|
+
value: profileRiskProfile,
|
|
354
|
+
source: "preference",
|
|
355
|
+
confidence: "high",
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
diagnostics,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (
|
|
363
|
+
next.routeKind === "pass_through" &&
|
|
364
|
+
extracted.riskProfile &&
|
|
365
|
+
isConversationalRiskPreferenceUpdate(text, inputContext)
|
|
366
|
+
) {
|
|
367
|
+
diagnostics.push({
|
|
368
|
+
code: "conversational_risk_preference_recovered",
|
|
369
|
+
message: "risk preference update recovered from profile/prior-turn context",
|
|
370
|
+
});
|
|
371
|
+
next = {
|
|
372
|
+
...next,
|
|
373
|
+
routeKind: "agent_task",
|
|
374
|
+
route: "fallback",
|
|
375
|
+
entities: {
|
|
376
|
+
...next.entities,
|
|
377
|
+
riskProfile: extracted.riskProfile,
|
|
378
|
+
},
|
|
379
|
+
slots: {
|
|
380
|
+
...next.slots,
|
|
381
|
+
risk_profile: {
|
|
382
|
+
value: extracted.riskProfile,
|
|
383
|
+
source: "user",
|
|
384
|
+
confidence: "high",
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
preference_updates: ensurePreferenceUpdate(next.preference_updates, {
|
|
388
|
+
key: "risk_profile",
|
|
389
|
+
value: extracted.riskProfile,
|
|
390
|
+
confidence: "high",
|
|
391
|
+
source: "inferred",
|
|
392
|
+
}),
|
|
393
|
+
diagnostics,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
223
397
|
if (
|
|
224
398
|
next.workflow === "options_screener" &&
|
|
225
399
|
isExistingPositionOptionRequest(text, extracted) &&
|
|
@@ -544,6 +718,27 @@ export function postProcessRouterOutput(text: string, output: RouterOutput): Rou
|
|
|
544
718
|
};
|
|
545
719
|
}
|
|
546
720
|
|
|
721
|
+
if (
|
|
722
|
+
next.workflow === "compare_assets" &&
|
|
723
|
+
(disambiguated.dropped.length > 0 || droppedAmbiguousSymbols.length > 0) &&
|
|
724
|
+
next.entities.symbols.length < 2 &&
|
|
725
|
+
isExplicitMacroDataRequest(text)
|
|
726
|
+
) {
|
|
727
|
+
diagnostics.push({
|
|
728
|
+
code: "compare_route_corrected_to_macro_task",
|
|
729
|
+
message: "macro/source acronyms were not explicit tickers",
|
|
730
|
+
});
|
|
731
|
+
next = {
|
|
732
|
+
...next,
|
|
733
|
+
routeKind: "agent_task",
|
|
734
|
+
route: "fallback",
|
|
735
|
+
workflow: "general_finance_qa",
|
|
736
|
+
missing_required: [],
|
|
737
|
+
slots: removeSymbolSlots(next.slots),
|
|
738
|
+
diagnostics,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
547
742
|
const missingRequired = computeMissingRequiredSlots(
|
|
548
743
|
next.workflow,
|
|
549
744
|
next.entities,
|
|
@@ -861,6 +1056,146 @@ function canonicalizeSymbolSlots(
|
|
|
861
1056
|
return next;
|
|
862
1057
|
}
|
|
863
1058
|
|
|
1059
|
+
function syncSymbolListSlot(
|
|
1060
|
+
slots: Record<string, RouterSlot>,
|
|
1061
|
+
symbols: string[],
|
|
1062
|
+
): Record<string, RouterSlot> {
|
|
1063
|
+
if (!slots.symbols || symbols.length === 0) return slots;
|
|
1064
|
+
return {
|
|
1065
|
+
...slots,
|
|
1066
|
+
symbols: {
|
|
1067
|
+
...slots.symbols,
|
|
1068
|
+
value: symbols,
|
|
1069
|
+
},
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const ASSET_SCOPE_SYNONYMS: Record<string, string> = {
|
|
1074
|
+
etf_only: "etf_focused",
|
|
1075
|
+
etfs_only: "etf_focused",
|
|
1076
|
+
only_etfs: "etf_focused",
|
|
1077
|
+
etf: "etf_focused",
|
|
1078
|
+
etfs: "etf_focused",
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
function canonicalAssetScope(value: unknown): string | undefined {
|
|
1082
|
+
if (typeof value !== "string") return undefined;
|
|
1083
|
+
return ASSET_SCOPE_SYNONYMS[value.toLowerCase()];
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function canonicalizeAssetScopeVocabulary(output: RouterOutput): RouterOutput {
|
|
1087
|
+
let next = output;
|
|
1088
|
+
const slotCanonical = canonicalAssetScope(next.slots.asset_scope?.value);
|
|
1089
|
+
if (slotCanonical) {
|
|
1090
|
+
next = {
|
|
1091
|
+
...next,
|
|
1092
|
+
slots: {
|
|
1093
|
+
...next.slots,
|
|
1094
|
+
asset_scope: { ...next.slots.asset_scope, value: slotCanonical },
|
|
1095
|
+
},
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
const entityCanonical = canonicalAssetScope(next.entities.assetScope);
|
|
1099
|
+
if (entityCanonical) {
|
|
1100
|
+
next = {
|
|
1101
|
+
...next,
|
|
1102
|
+
entities: { ...next.entities, assetScope: entityCanonical },
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
if (
|
|
1106
|
+
next.preference_updates.some(
|
|
1107
|
+
(update) => update.key === "asset_scope" && canonicalAssetScope(update.value),
|
|
1108
|
+
)
|
|
1109
|
+
) {
|
|
1110
|
+
next = {
|
|
1111
|
+
...next,
|
|
1112
|
+
preference_updates: next.preference_updates.map((update) =>
|
|
1113
|
+
update.key === "asset_scope"
|
|
1114
|
+
? { ...update, value: canonicalAssetScope(update.value) ?? update.value }
|
|
1115
|
+
: update,
|
|
1116
|
+
),
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
return next;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function symbolsSlotClaimsPriorTurnUserProvenance(
|
|
1123
|
+
slots: Record<string, RouterSlot>,
|
|
1124
|
+
text: string,
|
|
1125
|
+
priorTurns: RouterInputContext["priorTurns"] | undefined,
|
|
1126
|
+
): boolean {
|
|
1127
|
+
const slot = slots.symbols;
|
|
1128
|
+
if (!slot || slot.source !== "user" || !Array.isArray(slot.value)) return false;
|
|
1129
|
+
if (!priorTurns || priorTurns.length === 0) return false;
|
|
1130
|
+
const priorText = priorTurns.map((turn) => turn.text).join("\n");
|
|
1131
|
+
// A symbol absent from this turn's text but present in prior turns is a
|
|
1132
|
+
// carryover, not a user-typed value. Symbols absent from both (e.g. tickers
|
|
1133
|
+
// resolved from company names in the current text) are left alone.
|
|
1134
|
+
return slot.value.some(
|
|
1135
|
+
(symbol) =>
|
|
1136
|
+
typeof symbol === "string" &&
|
|
1137
|
+
symbolPosition(text, symbol) === Number.MAX_SAFE_INTEGER &&
|
|
1138
|
+
symbolPosition(priorText, symbol) !== Number.MAX_SAFE_INTEGER,
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function removeSymbolSlots(slots: Record<string, RouterSlot>): Record<string, RouterSlot> {
|
|
1143
|
+
const next = { ...slots };
|
|
1144
|
+
delete next.symbol;
|
|
1145
|
+
delete next.symbols;
|
|
1146
|
+
return next;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function orderSymbolsByText(text: string, symbols: string[]): string[] {
|
|
1150
|
+
return [...symbols].sort((a, b) => symbolPosition(text, a) - symbolPosition(text, b));
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function symbolPosition(text: string, symbol: string): number {
|
|
1154
|
+
const escaped = escapeRegExp(symbol);
|
|
1155
|
+
const match = new RegExp(`\\$?${escaped}\\b`, "i").exec(text);
|
|
1156
|
+
return match?.index ?? Number.MAX_SAFE_INTEGER;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function sameStringArray(left: string[], right: string[]): boolean {
|
|
1160
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function readProfileString(
|
|
1164
|
+
profileSnapshot: RouterInputContext["profileSnapshot"] | undefined,
|
|
1165
|
+
key: string,
|
|
1166
|
+
): string | undefined {
|
|
1167
|
+
const value = profileSnapshot?.[key];
|
|
1168
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function isConversationalRiskPreferenceUpdate(
|
|
1172
|
+
text: string,
|
|
1173
|
+
inputContext: Pick<RouterInputContext, "priorTurns" | "profileSnapshot"> | undefined,
|
|
1174
|
+
): boolean {
|
|
1175
|
+
if (!/\b(?:now|lately|getting|becoming|thinking|feel|feeling|prefer|preference)\b/i.test(text)) {
|
|
1176
|
+
return false;
|
|
1177
|
+
}
|
|
1178
|
+
// Finance context must come from the conversation itself — a saved
|
|
1179
|
+
// risk_profile alone must not convert non-finance chat ("more aggressive
|
|
1180
|
+
// on the tennis court lately") into a routed preference write.
|
|
1181
|
+
const priorText = inputContext?.priorTurns.map((turn) => turn.text).join(" ") ?? "";
|
|
1182
|
+
return /\b(?:profile|risk|portfolio|position|invest|sizing)\b/i.test(`${text} ${priorText}`);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function ensurePreferenceUpdate(
|
|
1186
|
+
updates: RouterPreferenceUpdate[],
|
|
1187
|
+
update: RouterPreferenceUpdate,
|
|
1188
|
+
): RouterPreferenceUpdate[] {
|
|
1189
|
+
if (updates.some((item) => item.key === update.key && item.value === update.value)) {
|
|
1190
|
+
return updates;
|
|
1191
|
+
}
|
|
1192
|
+
return [...updates, update];
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function escapeRegExp(value: string): string {
|
|
1196
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1197
|
+
}
|
|
1198
|
+
|
|
864
1199
|
function validatePreferenceUpdates(raw: unknown): RouterPreferenceUpdate[] {
|
|
865
1200
|
if (raw === undefined || raw === null) return [];
|
|
866
1201
|
if (!Array.isArray(raw)) {
|
|
@@ -19,7 +19,7 @@ interface Preferences {
|
|
|
19
19
|
liquidityMinimum?: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
function mapDteHintToTarget(dteHint: string | undefined): string | undefined {
|
|
22
|
+
export function mapDteHintToTarget(dteHint: string | undefined): string | undefined {
|
|
23
23
|
const normalized = dteHint?.toLowerCase().trim();
|
|
24
24
|
if (!normalized) return undefined;
|
|
25
25
|
|
package/src/runtime/evidence.ts
CHANGED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { EvidenceRecord } from "./evidence.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Observe-only numeric-claim extraction for synthesis validation (I3).
|
|
5
|
+
*
|
|
6
|
+
* These heuristics bind numbers in analyst prose to tool-result metrics so
|
|
7
|
+
* checkNumberMatch has claims to verify. They are deliberately conservative:
|
|
8
|
+
* every false mismatch poisons the false-positive-rate data that gates the
|
|
9
|
+
* answer-receipts direction (I8 phase 1), so a missed claim is cheaper than
|
|
10
|
+
* a wrong one.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Flatten numeric values from a tool-evidence digest preview into labeled tool results. */
|
|
14
|
+
export function collectToolNumbers(record: EvidenceRecord, toolResults: Map<string, number>): void {
|
|
15
|
+
if (!isPlainObject(record.value)) return;
|
|
16
|
+
const tool = typeof record.value.tool === "string" ? record.value.tool : undefined;
|
|
17
|
+
const digest = isPlainObject(record.value.resultDigest) ? record.value.resultDigest : undefined;
|
|
18
|
+
const preview = typeof digest?.preview === "string" ? digest.preview : undefined;
|
|
19
|
+
if (!tool || !preview) return;
|
|
20
|
+
const parsed = parseMaybeJson(preview);
|
|
21
|
+
if (!parsed) return;
|
|
22
|
+
for (const [path, value] of flattenNumericValues(parsed)) {
|
|
23
|
+
toolResults.set(`${tool}.${path}`, value);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extract numeric claims from step text for metrics present in toolResults.
|
|
29
|
+
*
|
|
30
|
+
* Binding rules (each guards against a live-observed false-positive class):
|
|
31
|
+
* - the metric term must appear as a whole word ("opening" is not `open`)
|
|
32
|
+
* - the number must follow within a short window (no cross-sentence binding)
|
|
33
|
+
* - thousands separators parse as one number ("129,935,067" is not 129)
|
|
34
|
+
* - a prose value that rounds to the tool result is normalized onto it
|
|
35
|
+
* ("197.14" claims the tool's 197.13999938964844, not a mismatch)
|
|
36
|
+
*/
|
|
37
|
+
export function extractNumericClaims(
|
|
38
|
+
text: string,
|
|
39
|
+
toolResults: Map<string, number>,
|
|
40
|
+
): EvidenceRecord[] {
|
|
41
|
+
const records: EvidenceRecord[] = [];
|
|
42
|
+
for (const [label, expected] of toolResults) {
|
|
43
|
+
const metric = label.split(".").at(-1);
|
|
44
|
+
if (!metric) continue;
|
|
45
|
+
const pattern = new RegExp(
|
|
46
|
+
`\\b${escapeRegex(metric)}\\b[^\\d-]{0,30}(-?\\d{1,3}(?:,\\d{3})+(?:\\.\\d+)?|-?\\d+(?:\\.\\d+)?)`,
|
|
47
|
+
"i",
|
|
48
|
+
);
|
|
49
|
+
const match = text.match(pattern);
|
|
50
|
+
if (!match) continue;
|
|
51
|
+
const value = Number(match[1].replace(/,/g, ""));
|
|
52
|
+
if (!Number.isFinite(value)) continue;
|
|
53
|
+
records.push({
|
|
54
|
+
label,
|
|
55
|
+
value: isRoundingOf(value, expected) ? expected : value,
|
|
56
|
+
provenance: { source: "computed" },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return records;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** True when `claim` is `expected` rounded to the claim's own precision. */
|
|
63
|
+
function isRoundingOf(claim: number, expected: number): boolean {
|
|
64
|
+
if (claim === expected) return true;
|
|
65
|
+
const decimals = countDecimals(claim);
|
|
66
|
+
const factor = 10 ** decimals;
|
|
67
|
+
return Math.round(expected * factor) / factor === claim;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function countDecimals(value: number): number {
|
|
71
|
+
const text = String(value);
|
|
72
|
+
const dot = text.indexOf(".");
|
|
73
|
+
return dot === -1 ? 0 : text.length - dot - 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function flattenNumericValues(
|
|
77
|
+
value: Record<string, unknown>,
|
|
78
|
+
prefix = "",
|
|
79
|
+
): Array<[string, number]> {
|
|
80
|
+
const values: Array<[string, number]> = [];
|
|
81
|
+
for (const [key, item] of Object.entries(value)) {
|
|
82
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
83
|
+
if (typeof item === "number" && Number.isFinite(item)) {
|
|
84
|
+
values.push([path, item]);
|
|
85
|
+
} else if (isPlainObject(item)) {
|
|
86
|
+
values.push(...flattenNumericValues(item, path));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return values;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function escapeRegex(value: string): string {
|
|
93
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseMaybeJson(raw: unknown): Record<string, unknown> | undefined {
|
|
97
|
+
if (typeof raw !== "string") return undefined;
|
|
98
|
+
try {
|
|
99
|
+
const parsed: unknown = JSON.parse(raw);
|
|
100
|
+
return isPlainObject(parsed) ? parsed : undefined;
|
|
101
|
+
} catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
107
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
108
|
+
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
classifyMarketStatus,
|
|
3
|
+
KNOWN_US_MARKET_HOLIDAYS,
|
|
4
|
+
lastTradingDay,
|
|
5
|
+
localDateTimeParts,
|
|
6
|
+
} from "../infra/market-calendar.js";
|
|
1
7
|
import type { ParsedTag } from "../onboarding/tool-tags.js";
|
|
2
8
|
import { parseToolTag } from "../onboarding/tool-tags.js";
|
|
3
9
|
import type { CapabilityGapId, EvidencePlanId, TaskFamily } from "../routing/planning.js";
|
|
@@ -202,19 +208,6 @@ export const EVIDENCE_PLAN_REGISTRY: Record<EvidencePlanId, EvidencePlanDefiniti
|
|
|
202
208
|
),
|
|
203
209
|
};
|
|
204
210
|
|
|
205
|
-
const KNOWN_US_MARKET_HOLIDAYS: Record<string, string> = {
|
|
206
|
-
"2026-01-01": "New Year's Day",
|
|
207
|
-
"2026-01-19": "Martin Luther King Jr. Day",
|
|
208
|
-
"2026-02-16": "Washington's Birthday",
|
|
209
|
-
"2026-04-03": "Good Friday",
|
|
210
|
-
"2026-05-25": "Memorial Day",
|
|
211
|
-
"2026-06-19": "Juneteenth",
|
|
212
|
-
"2026-07-03": "Independence Day observed",
|
|
213
|
-
"2026-09-07": "Labor Day",
|
|
214
|
-
"2026-11-26": "Thanksgiving Day",
|
|
215
|
-
"2026-12-25": "Christmas Day",
|
|
216
|
-
};
|
|
217
|
-
|
|
218
211
|
function placeholderPlan(
|
|
219
212
|
id: EvidencePlanId,
|
|
220
213
|
taskFamilies: TaskFamily[],
|
|
@@ -591,20 +584,6 @@ function temporalReferencesFor(text: string): string[] {
|
|
|
591
584
|
return refs;
|
|
592
585
|
}
|
|
593
586
|
|
|
594
|
-
function classifyMarketStatus(
|
|
595
|
-
local: LocalDateTimeParts,
|
|
596
|
-
isWeekend: boolean,
|
|
597
|
-
isMarketHoliday: boolean,
|
|
598
|
-
temporalReferences: string[],
|
|
599
|
-
): string {
|
|
600
|
-
if (isWeekend) return "closed_weekend";
|
|
601
|
-
if (isMarketHoliday) return "closed_holiday";
|
|
602
|
-
if (local.minutesSinceMidnight < 9 * 60 + 30) return "pre_market";
|
|
603
|
-
if (local.minutesSinceMidnight < 16 * 60) return "open";
|
|
604
|
-
if (temporalReferences.includes("after_close")) return "after_close";
|
|
605
|
-
return "closed_after_hours";
|
|
606
|
-
}
|
|
607
|
-
|
|
608
587
|
function quoteAsOfCaveat(marketStatus: string): string {
|
|
609
588
|
if (marketStatus === "open")
|
|
610
589
|
return "Quote evidence may be intraday or delayed depending on provider.";
|
|
@@ -613,70 +592,6 @@ function quoteAsOfCaveat(marketStatus: string): string {
|
|
|
613
592
|
return "Quote evidence should distinguish the current calendar date from the most recent trading session.";
|
|
614
593
|
}
|
|
615
594
|
|
|
616
|
-
interface LocalDateTimeParts {
|
|
617
|
-
date: string;
|
|
618
|
-
time: string;
|
|
619
|
-
weekday: string;
|
|
620
|
-
minutesSinceMidnight: number;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
function localDateTimeParts(date: Date, timezone: string): LocalDateTimeParts {
|
|
624
|
-
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
625
|
-
timeZone: timezone,
|
|
626
|
-
year: "numeric",
|
|
627
|
-
month: "2-digit",
|
|
628
|
-
day: "2-digit",
|
|
629
|
-
hour: "2-digit",
|
|
630
|
-
minute: "2-digit",
|
|
631
|
-
hour12: false,
|
|
632
|
-
weekday: "short",
|
|
633
|
-
});
|
|
634
|
-
const parts = formatter.formatToParts(date).reduce<Record<string, string>>((acc, part) => {
|
|
635
|
-
acc[part.type] = part.value;
|
|
636
|
-
return acc;
|
|
637
|
-
}, {});
|
|
638
|
-
const hour = Number(parts.hour);
|
|
639
|
-
const minute = Number(parts.minute);
|
|
640
|
-
return {
|
|
641
|
-
date: `${parts.year}-${parts.month}-${parts.day}`,
|
|
642
|
-
time: `${parts.hour}:${parts.minute}`,
|
|
643
|
-
weekday: parts.weekday ?? "",
|
|
644
|
-
minutesSinceMidnight: hour * 60 + minute,
|
|
645
|
-
};
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
function lastTradingDay(localDate: string): string {
|
|
649
|
-
let cursor = dateFromKey(localDate);
|
|
650
|
-
do {
|
|
651
|
-
cursor = addDays(cursor, -1);
|
|
652
|
-
} while (isWeekendOrKnownHoliday(dateKey(cursor)));
|
|
653
|
-
return dateKey(cursor);
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
function isWeekendOrKnownHoliday(key: string): boolean {
|
|
657
|
-
const date = dateFromKey(key);
|
|
658
|
-
const day = date.getUTCDay();
|
|
659
|
-
return day === 0 || day === 6 || KNOWN_US_MARKET_HOLIDAYS[key] !== undefined;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
function dateFromKey(key: string): Date {
|
|
663
|
-
const [year, month, day] = key.split("-").map(Number);
|
|
664
|
-
return new Date(Date.UTC(year, month - 1, day));
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
function addDays(date: Date, days: number): Date {
|
|
668
|
-
const next = new Date(date);
|
|
669
|
-
next.setUTCDate(next.getUTCDate() + days);
|
|
670
|
-
return next;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
function dateKey(date: Date): string {
|
|
674
|
-
const year = date.getUTCFullYear();
|
|
675
|
-
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
676
|
-
const day = String(date.getUTCDate()).padStart(2, "0");
|
|
677
|
-
return `${year}-${month}-${day}`;
|
|
678
|
-
}
|
|
679
|
-
|
|
680
595
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
681
596
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
682
597
|
}
|