opencandle 0.3.0 → 0.4.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/assets/logo.svg +187 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +38 -2
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +9 -0
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -1
- package/dist/infra/browser.d.ts +10 -0
- package/dist/infra/browser.js +1 -0
- package/dist/infra/browser.js.map +1 -1
- package/dist/infra/native-dependencies.d.ts +1 -0
- package/dist/infra/native-dependencies.js +10 -0
- package/dist/infra/native-dependencies.js.map +1 -0
- package/dist/infra/node-version.d.ts +2 -0
- package/dist/infra/node-version.js +23 -0
- package/dist/infra/node-version.js.map +1 -0
- package/dist/memory/index.d.ts +2 -0
- package/dist/memory/index.js +1 -0
- package/dist/memory/index.js.map +1 -1
- package/dist/memory/sqlite.js +42 -4
- package/dist/memory/sqlite.js.map +1 -1
- package/dist/memory/storage.d.ts +6 -0
- package/dist/memory/storage.js +3 -3
- package/dist/memory/storage.js.map +1 -1
- package/dist/memory/tool-defaults.d.ts +8 -0
- package/dist/memory/tool-defaults.js +59 -0
- package/dist/memory/tool-defaults.js.map +1 -0
- package/dist/onboarding/connect.d.ts +13 -1
- package/dist/onboarding/connect.js +21 -10
- package/dist/onboarding/connect.js.map +1 -1
- package/dist/onboarding/prompt-user.d.ts +1 -1
- package/dist/onboarding/providers.d.ts +7 -0
- package/dist/onboarding/providers.js +6 -3
- package/dist/onboarding/providers.js.map +1 -1
- package/dist/onboarding/tool-helpers.d.ts +1 -1
- package/dist/pi/opencandle-extension.d.ts +7 -1
- package/dist/pi/opencandle-extension.js +186 -10
- package/dist/pi/opencandle-extension.js.map +1 -1
- package/dist/pi/session-storage.d.ts +2 -0
- package/dist/pi/session-storage.js +5 -0
- package/dist/pi/session-storage.js.map +1 -0
- package/dist/pi/session.d.ts +4 -1
- package/dist/pi/session.js +25 -3
- package/dist/pi/session.js.map +1 -1
- package/dist/pi/setup.d.ts +1 -1
- package/dist/pi/setup.js +1 -1
- package/dist/pi/setup.js.map +1 -1
- package/dist/pi/tool-adapter.d.ts +2 -2
- package/dist/pi/tool-adapter.js +14 -1
- package/dist/pi/tool-adapter.js.map +1 -1
- package/dist/prompts/context-builder.d.ts +22 -0
- package/dist/prompts/context-builder.js +45 -10
- package/dist/prompts/context-builder.js.map +1 -1
- package/dist/prompts/disclaimer.d.ts +6 -0
- package/dist/prompts/disclaimer.js +9 -0
- package/dist/prompts/disclaimer.js.map +1 -0
- package/dist/prompts/workflow-prompts.d.ts +8 -0
- package/dist/prompts/workflow-prompts.js +39 -5
- package/dist/prompts/workflow-prompts.js.map +1 -1
- package/dist/providers/yahoo-finance.js +70 -33
- package/dist/providers/yahoo-finance.js.map +1 -1
- package/dist/routing/defaults.js +1 -1
- package/dist/routing/defaults.js.map +1 -1
- package/dist/routing/index.d.ts +4 -0
- package/dist/routing/index.js +3 -0
- package/dist/routing/index.js.map +1 -1
- package/dist/routing/router-llm-client.d.ts +11 -0
- package/dist/routing/router-llm-client.js +42 -0
- package/dist/routing/router-llm-client.js.map +1 -0
- package/dist/routing/router-prompt.d.ts +2 -0
- package/dist/routing/router-prompt.js +138 -0
- package/dist/routing/router-prompt.js.map +1 -0
- package/dist/routing/router-types.d.ts +62 -0
- package/dist/routing/router-types.js +2 -0
- package/dist/routing/router-types.js.map +1 -0
- package/dist/routing/router.d.ts +10 -0
- package/dist/routing/router.js +194 -0
- package/dist/routing/router.js.map +1 -0
- package/dist/runtime/session-coordinator.d.ts +63 -3
- package/dist/runtime/session-coordinator.js +155 -4
- package/dist/runtime/session-coordinator.js.map +1 -1
- package/dist/runtime/tool-defaults-wrapper.d.ts +3 -0
- package/dist/runtime/tool-defaults-wrapper.js +25 -0
- package/dist/runtime/tool-defaults-wrapper.js.map +1 -0
- package/dist/sentiment/store.js +5 -0
- package/dist/sentiment/store.js.map +1 -1
- package/dist/system-prompt.js +20 -12
- package/dist/system-prompt.js.map +1 -1
- package/dist/tool-kit.d.ts +4 -4
- package/dist/tools/fundamentals/company-overview.d.ts +1 -1
- package/dist/tools/fundamentals/comps.d.ts +1 -1
- package/dist/tools/fundamentals/dcf.d.ts +1 -1
- package/dist/tools/fundamentals/earnings.d.ts +1 -1
- package/dist/tools/fundamentals/financials.d.ts +1 -1
- package/dist/tools/fundamentals/sec-filings.d.ts +1 -1
- package/dist/tools/index.d.ts +28 -1
- package/dist/tools/index.js +27 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/interaction/ask-user.d.ts +1 -1
- package/dist/tools/interaction/twitter-login.d.ts +1 -1
- package/dist/tools/macro/fear-greed.d.ts +1 -1
- package/dist/tools/macro/fred-data.d.ts +1 -1
- package/dist/tools/market/crypto-history.d.ts +1 -1
- package/dist/tools/market/crypto-price.d.ts +1 -1
- package/dist/tools/market/search-ticker.d.ts +1 -1
- package/dist/tools/market/stock-history.d.ts +1 -1
- package/dist/tools/market/stock-quote.d.ts +1 -1
- package/dist/tools/options/option-chain.d.ts +1 -1
- package/dist/tools/options/option-chain.js +4 -1
- package/dist/tools/options/option-chain.js.map +1 -1
- package/dist/tools/portfolio/correlation.d.ts +1 -1
- package/dist/tools/portfolio/predictions.d.ts +1 -1
- package/dist/tools/portfolio/risk-analysis.d.ts +1 -1
- package/dist/tools/portfolio/tracker.d.ts +1 -1
- package/dist/tools/portfolio/watchlist.d.ts +1 -1
- package/dist/tools/sentiment/reddit-sentiment.d.ts +1 -1
- package/dist/tools/sentiment/sentiment-summary.d.ts +1 -1
- package/dist/tools/sentiment/sentiment-trend.d.ts +1 -1
- package/dist/tools/sentiment/twitter-sentiment.d.ts +1 -1
- package/dist/tools/sentiment/web-search.d.ts +1 -1
- package/dist/tools/sentiment/web-sentiment.d.ts +1 -1
- package/dist/tools/technical/backtest.d.ts +1 -1
- package/dist/tools/technical/indicators.d.ts +1 -1
- package/dist/tools/technical/indicators.js +7 -1
- package/dist/tools/technical/indicators.js.map +1 -1
- package/dist/workflows/options-screener.js +7 -2
- package/dist/workflows/options-screener.js.map +1 -1
- package/dist/workflows/portfolio-builder.js +3 -3
- package/dist/workflows/portfolio-builder.js.map +1 -1
- package/gui/server/background-quotes.ts +31 -0
- package/gui/server/chat-event-adapter.ts +142 -0
- package/gui/server/invoke-tool.ts +89 -0
- package/gui/server/live-chat-event-adapter.ts +181 -0
- package/gui/server/model-setup.ts +100 -0
- package/gui/server/package.json +5 -0
- package/gui/server/projector.ts +212 -0
- package/gui/server/server.ts +592 -0
- package/gui/server/session-actions.ts +31 -0
- package/gui/server/tool-metadata.ts +88 -0
- package/gui/server/websocket.ts +128 -0
- package/gui/server/writer-lock.ts +118 -0
- package/gui/shared/chat-events.ts +118 -0
- package/gui/shared/event-reducer.ts +186 -0
- package/gui/web/dist/assets/CatalogOverlay-D1ImSJTe.js +1 -0
- package/gui/web/dist/assets/index-DBrWq43L.css +1 -0
- package/gui/web/dist/assets/index-RflHaj0y.js +67 -0
- package/gui/web/dist/assets/logo-CWpt6Y2a.svg +187 -0
- package/gui/web/dist/index.html +17 -0
- package/package.json +44 -18
- package/src/analysts/contracts.ts +189 -0
- package/src/analysts/orchestrator.ts +300 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +161 -0
- package/src/index.ts +5 -0
- package/src/infra/browser.ts +111 -0
- package/src/infra/cache.ts +103 -0
- package/src/infra/http-client.ts +68 -0
- package/src/infra/index.ts +18 -0
- package/src/infra/native-dependencies.ts +12 -0
- package/src/infra/node-version.ts +24 -0
- package/src/infra/open-url.ts +28 -0
- package/src/infra/opencandle-paths.ts +64 -0
- package/src/infra/rate-limiter.ts +64 -0
- package/src/memory/index.ts +10 -0
- package/src/memory/manager.ts +159 -0
- package/src/memory/preference-extractor.ts +106 -0
- package/src/memory/retrieval.ts +70 -0
- package/src/memory/sqlite.ts +172 -0
- package/src/memory/storage.ts +204 -0
- package/src/memory/tool-defaults.ts +87 -0
- package/src/memory/types.ts +67 -0
- package/src/onboarding/connect.ts +184 -0
- package/src/onboarding/credential-interceptor.ts +134 -0
- package/src/onboarding/degradation-accumulator.ts +79 -0
- package/src/onboarding/prompt-user.ts +85 -0
- package/src/onboarding/providers.ts +315 -0
- package/src/onboarding/state.ts +218 -0
- package/src/onboarding/tool-helpers.ts +111 -0
- package/src/onboarding/tool-tags.ts +201 -0
- package/src/onboarding/validation.ts +158 -0
- package/src/pi/opencandle-extension.ts +724 -0
- package/src/pi/session-storage.ts +5 -0
- package/src/pi/session.ts +81 -0
- package/src/pi/setup.ts +371 -0
- package/src/pi/tool-adapter.ts +36 -0
- package/src/prompts/context-builder.ts +204 -0
- package/src/prompts/disclaimer.ts +9 -0
- package/src/prompts/sections.ts +46 -0
- package/src/prompts/workflow-prompts.ts +279 -0
- package/src/providers/alpha-vantage.ts +292 -0
- package/src/providers/coingecko.ts +96 -0
- package/src/providers/exa-search.ts +373 -0
- package/src/providers/fear-greed.ts +45 -0
- package/src/providers/finnhub.ts +124 -0
- package/src/providers/fred.ts +83 -0
- package/src/providers/index.ts +9 -0
- package/src/providers/provider-credential-error.ts +23 -0
- package/src/providers/reddit.ts +151 -0
- package/src/providers/sec-edgar.ts +96 -0
- package/src/providers/twitter.ts +173 -0
- package/src/providers/web-search.ts +293 -0
- package/src/providers/with-fallback.ts +41 -0
- package/src/providers/wrap-provider.ts +64 -0
- package/src/providers/yahoo-finance.ts +367 -0
- package/src/routing/classify-intent.ts +194 -0
- package/src/routing/defaults.ts +29 -0
- package/src/routing/entity-extractor.ts +140 -0
- package/src/routing/index.ts +26 -0
- package/src/routing/router-llm-client.ts +51 -0
- package/src/routing/router-prompt.ts +159 -0
- package/src/routing/router-types.ts +66 -0
- package/src/routing/router.ts +213 -0
- package/src/routing/slot-resolver.ts +152 -0
- package/src/routing/types.ts +63 -0
- package/src/runtime/evidence.ts +77 -0
- package/src/runtime/index.ts +55 -0
- package/src/runtime/prompt-step.ts +75 -0
- package/src/runtime/provider-ids.ts +15 -0
- package/src/runtime/provider-tracker.ts +40 -0
- package/src/runtime/run-context.ts +22 -0
- package/src/runtime/session-coordinator.ts +406 -0
- package/src/runtime/tool-defaults-wrapper.ts +35 -0
- package/src/runtime/validation.ts +214 -0
- package/src/runtime/workflow-events.ts +75 -0
- package/src/runtime/workflow-runner.ts +188 -0
- package/src/runtime/workflow-types.ts +102 -0
- package/src/sentiment/adapters/finnhub.ts +44 -0
- package/src/sentiment/adapters/reddit.ts +65 -0
- package/src/sentiment/adapters/twitter.ts +36 -0
- package/src/sentiment/adapters/web.ts +44 -0
- package/src/sentiment/index.ts +58 -0
- package/src/sentiment/keywords.ts +9 -0
- package/src/sentiment/pipeline.ts +68 -0
- package/src/sentiment/scorer.ts +78 -0
- package/src/sentiment/store.ts +260 -0
- package/src/sentiment/trends.ts +90 -0
- package/src/sentiment/types.ts +108 -0
- package/src/system-prompt.ts +115 -0
- package/src/tool-kit.ts +68 -0
- package/src/tools/AGENTS.md +36 -0
- package/src/tools/fundamentals/company-overview.ts +54 -0
- package/src/tools/fundamentals/comps.ts +156 -0
- package/src/tools/fundamentals/dcf.ts +267 -0
- package/src/tools/fundamentals/earnings.ts +47 -0
- package/src/tools/fundamentals/financials.ts +54 -0
- package/src/tools/fundamentals/sec-filings.ts +61 -0
- package/src/tools/index.ts +88 -0
- package/src/tools/interaction/ask-user.ts +81 -0
- package/src/tools/interaction/twitter-login.ts +93 -0
- package/src/tools/macro/fear-greed.ts +41 -0
- package/src/tools/macro/fred-data.ts +54 -0
- package/src/tools/market/crypto-history.ts +51 -0
- package/src/tools/market/crypto-price.ts +53 -0
- package/src/tools/market/search-ticker.ts +53 -0
- package/src/tools/market/stock-history.ts +79 -0
- package/src/tools/market/stock-quote.ts +64 -0
- package/src/tools/options/greeks.ts +82 -0
- package/src/tools/options/option-chain.ts +91 -0
- package/src/tools/portfolio/correlation.ts +162 -0
- package/src/tools/portfolio/predictions.ts +253 -0
- package/src/tools/portfolio/risk-analysis.ts +134 -0
- package/src/tools/portfolio/tracker.ts +147 -0
- package/src/tools/portfolio/watchlist.ts +153 -0
- package/src/tools/sentiment/reddit-sentiment.ts +164 -0
- package/src/tools/sentiment/sentiment-summary.ts +256 -0
- package/src/tools/sentiment/sentiment-trend.ts +58 -0
- package/src/tools/sentiment/twitter-sentiment.ts +96 -0
- package/src/tools/sentiment/web-search.ts +150 -0
- package/src/tools/sentiment/web-sentiment.ts +76 -0
- package/src/tools/technical/backtest.ts +246 -0
- package/src/tools/technical/indicators.ts +258 -0
- package/src/types/fundamentals.ts +46 -0
- package/src/types/index.ts +20 -0
- package/src/types/macro.ts +27 -0
- package/src/types/market.ts +43 -0
- package/src/types/options.ts +35 -0
- package/src/types/portfolio.ts +41 -0
- package/src/types/sentiment.ts +70 -0
- package/src/workflows/compare-assets.ts +39 -0
- package/src/workflows/index.ts +4 -0
- package/src/workflows/options-screener.ts +49 -0
- package/src/workflows/portfolio-builder.ts +52 -0
- package/src/workflows/types.ts +4 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { ExtractedEntities } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const COMMON_WORDS = new Set([
|
|
4
|
+
"I", "A", "AN", "AM", "AS", "AT", "BE", "BY", "DO", "GO", "IF", "IN", "IS",
|
|
5
|
+
"IT", "ME", "MY", "NO", "OF", "ON", "OR", "SO", "TO", "UP", "US", "WE",
|
|
6
|
+
"THE", "AND", "BUT", "FOR", "NOT", "ALL", "ARE", "CAN", "HAD", "HAS", "HER",
|
|
7
|
+
"HIM", "HIS", "HOW", "ITS", "LET", "MAY", "NEW", "NOW", "OLD", "OUR", "OWN",
|
|
8
|
+
"SAY", "SHE", "TOO", "USE", "WAY", "WHO", "BOY", "DID", "GET", "HAS", "HIM",
|
|
9
|
+
"OUT", "PUT", "RUN", "SET", "TOP", "WHY", "BIG", "END", "FAR", "FEW",
|
|
10
|
+
"GOT", "LOW", "MAN", "OFF", "PAY", "TRY", "TWO", "BUY", "ETF", "ETFS",
|
|
11
|
+
// Technical analysis acronyms
|
|
12
|
+
"SMA", "EMA", "RSI", "MACD", "OBV", "ATR", "ADX", "VWAP",
|
|
13
|
+
// Fundamental analysis acronyms
|
|
14
|
+
"DCF", "FCF", "ROE", "ROA", "ROI", "EPS", "NAV", "WACC", "EBIT",
|
|
15
|
+
"BEST", "WHAT", "WITH", "THAT", "THIS", "FROM", "HAVE", "BEEN", "SOME",
|
|
16
|
+
"THEM", "THAN", "LIKE", "JUST", "OVER", "ALSO", "BACK", "MUCH", "MOST",
|
|
17
|
+
"ONLY", "VERY", "WHEN", "COME", "MAKE", "FIND", "HERE", "KNOW", "TAKE",
|
|
18
|
+
"WANT", "GIVE", "GOOD", "CALL", "PUTS", "SAFE", "RISK", "LONG", "TERM",
|
|
19
|
+
"NEXT", "SHOW", "LAST",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export function extractEntities(input: string): ExtractedEntities {
|
|
23
|
+
return {
|
|
24
|
+
symbols: extractSymbols(input),
|
|
25
|
+
budget: extractBudget(input),
|
|
26
|
+
maxPremium: extractMaxPremium(input),
|
|
27
|
+
direction: extractDirection(input),
|
|
28
|
+
riskProfile: extractRiskProfile(input),
|
|
29
|
+
dteHint: extractDteHint(input),
|
|
30
|
+
timeHorizon: extractTimeHorizon(input),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function extractBudget(input: string): number | undefined {
|
|
35
|
+
// Match $10,000 or $10000 or $10k
|
|
36
|
+
const dollarSign = input.match(/\$\s*([\d,]+(?:\.\d+)?)\s*([kK])?\b/);
|
|
37
|
+
if (dollarSign) {
|
|
38
|
+
const base = parseFloat(dollarSign[1].replace(/,/g, ""));
|
|
39
|
+
return dollarSign[2] ? base * 1000 : base;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Match "10k" or "10K" standalone
|
|
43
|
+
const kNotation = input.match(/\b(\d+(?:\.\d+)?)\s*[kK]\b/);
|
|
44
|
+
if (kNotation) {
|
|
45
|
+
return parseFloat(kNotation[1]) * 1000;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Match "10000 dollars" or "10,000 dollars"
|
|
49
|
+
const dollarWord = input.match(/\b([\d,]+(?:\.\d+)?)\s+dollars?\b/i);
|
|
50
|
+
if (dollarWord) {
|
|
51
|
+
return parseFloat(dollarWord[1].replace(/,/g, ""));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function extractSymbols(input: string): string[] {
|
|
58
|
+
const symbols: string[] = [];
|
|
59
|
+
|
|
60
|
+
// Match $TICKER patterns
|
|
61
|
+
const dollarTickers = input.matchAll(/\$([A-Za-z]{1,5})\b/g);
|
|
62
|
+
for (const match of dollarTickers) {
|
|
63
|
+
symbols.push(match[1].toUpperCase());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Match standalone uppercase tickers (1-5 chars, all caps)
|
|
67
|
+
const words = input.split(/[\s,]+/);
|
|
68
|
+
for (const word of words) {
|
|
69
|
+
const cleaned = word.replace(/[^A-Za-z]/g, "");
|
|
70
|
+
if (
|
|
71
|
+
cleaned.length >= 1 &&
|
|
72
|
+
cleaned.length <= 5 &&
|
|
73
|
+
cleaned === cleaned.toUpperCase() &&
|
|
74
|
+
/^[A-Z]+$/.test(cleaned) &&
|
|
75
|
+
!COMMON_WORDS.has(cleaned) &&
|
|
76
|
+
!symbols.includes(cleaned)
|
|
77
|
+
) {
|
|
78
|
+
symbols.push(cleaned);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return symbols;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function extractMaxPremium(input: string): number | undefined {
|
|
86
|
+
const lower = input.toLowerCase();
|
|
87
|
+
if (!/\bpremium\b/.test(lower)) return undefined;
|
|
88
|
+
|
|
89
|
+
const under = input.match(/\b(?:under|below|less\s+than|max(?:imum)?|up\s+to)\s+\$?\s*([\d,]+(?:\.\d+)?)\s*([kK])?\b/i);
|
|
90
|
+
if (under) {
|
|
91
|
+
const base = parseFloat(under[1].replace(/,/g, ""));
|
|
92
|
+
if (isNaN(base)) return undefined;
|
|
93
|
+
return under[2] ? base * 1000 : base;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const trailing = input.match(/\$\s*([\d,]+(?:\.\d+)?)\s*([kK])?\s*(?:premium|max\s*premium)\b/i);
|
|
97
|
+
if (trailing) {
|
|
98
|
+
const base = parseFloat(trailing[1].replace(/,/g, ""));
|
|
99
|
+
if (isNaN(base)) return undefined;
|
|
100
|
+
return trailing[2] ? base * 1000 : base;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function extractDirection(input: string): "bullish" | "bearish" | undefined {
|
|
107
|
+
const lower = input.toLowerCase();
|
|
108
|
+
if (/\bcalls?\b/.test(lower) || /\bbullish\b/.test(lower)) return "bullish";
|
|
109
|
+
if (/\bputs?\b/.test(lower) || /\bbearish\b/.test(lower)) return "bearish";
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function extractRiskProfile(input: string): string | undefined {
|
|
114
|
+
const lower = input.toLowerCase();
|
|
115
|
+
if (/\bconservative\b/.test(lower) || /\brisk\s*averse\b/.test(lower) || /\bsafe[r]?\b/.test(lower)) {
|
|
116
|
+
return "conservative";
|
|
117
|
+
}
|
|
118
|
+
if (/\baggressive\b/.test(lower) || /\bhigh\s*risk\b/.test(lower)) {
|
|
119
|
+
return "aggressive";
|
|
120
|
+
}
|
|
121
|
+
if (/\bbalanced\b/.test(lower) || /\bmoderate\b/.test(lower)) {
|
|
122
|
+
return "balanced";
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function extractDteHint(input: string): string | undefined {
|
|
128
|
+
const lower = input.toLowerCase();
|
|
129
|
+
if (/\bleaps?\b/i.test(lower) || /\blong[\s-]*dated\b/.test(lower)) return "leaps";
|
|
130
|
+
if (/\bmonth\b/.test(lower)) return "month";
|
|
131
|
+
if (/\bweek(?:ly|s?)?\b/.test(lower)) return "week";
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function extractTimeHorizon(input: string): string | undefined {
|
|
136
|
+
const lower = input.toLowerCase();
|
|
137
|
+
if (/\bshort[\s-]*term\b/.test(lower) || /\bday[\s-]*trad/i.test(lower)) return "short";
|
|
138
|
+
if (/\blong[\s-]*term\b/.test(lower) || /\bbuy[\s-]*and[\s-]*hold\b/.test(lower)) return "long";
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export { classifyIntent } from "./classify-intent.js";
|
|
2
|
+
export { extractEntities, extractBudget } from "./entity-extractor.js";
|
|
3
|
+
export { resolvePortfolioSlots, resolveOptionsScreenerSlots } from "./slot-resolver.js";
|
|
4
|
+
export { PORTFOLIO_DEFAULTS, OPTIONS_SCREENER_DEFAULTS, parseDteTarget } from "./defaults.js";
|
|
5
|
+
export { route, validateRouterOutput } from "./router.js";
|
|
6
|
+
export { createPiAiRouterClient } from "./router-llm-client.js";
|
|
7
|
+
export { buildRouterPrompt } from "./router-prompt.js";
|
|
8
|
+
export type {
|
|
9
|
+
WorkflowType,
|
|
10
|
+
ClassificationResult,
|
|
11
|
+
ExtractedEntities,
|
|
12
|
+
PortfolioSlots,
|
|
13
|
+
OptionsScreenerSlots,
|
|
14
|
+
CompareAssetsSlots,
|
|
15
|
+
SlotResolution,
|
|
16
|
+
SlotSource,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
export type {
|
|
19
|
+
RouterOutput,
|
|
20
|
+
RouterRoute,
|
|
21
|
+
RouterSlot,
|
|
22
|
+
RouterConfidence,
|
|
23
|
+
RouterPreferenceUpdate,
|
|
24
|
+
RouterInputContext,
|
|
25
|
+
RouterLlmClient,
|
|
26
|
+
} from "./router-types.js";
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { completeSimple, type Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { RouterLlmClient } from "./router-types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build a router LLM client backed by pi-ai's `completeSimple`. The client
|
|
6
|
+
* is intentionally thin: prompt in, raw text out. Schema validation and
|
|
7
|
+
* retry logic live in `router.ts`.
|
|
8
|
+
*
|
|
9
|
+
* Zero tools are passed — the router operates on text alone. Temperature
|
|
10
|
+
* is pinned low for structured-output stability.
|
|
11
|
+
*/
|
|
12
|
+
export function createPiAiRouterClient(model: Model<"anthropic-messages"> | Model<any>): RouterLlmClient {
|
|
13
|
+
return {
|
|
14
|
+
async complete(prompt: string): Promise<string> {
|
|
15
|
+
const response = await completeSimple(
|
|
16
|
+
model,
|
|
17
|
+
{
|
|
18
|
+
messages: [
|
|
19
|
+
{
|
|
20
|
+
role: "user",
|
|
21
|
+
content: prompt,
|
|
22
|
+
timestamp: Date.now(),
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
// Explicitly no tools — spec requirement.
|
|
26
|
+
tools: [],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
temperature: 0,
|
|
30
|
+
maxTokens: 2000,
|
|
31
|
+
reasoning: "minimal",
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
if (response.stopReason === "error" || response.stopReason === "aborted") {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`router LLM call failed: ${response.errorMessage ?? response.stopReason}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const text = response.content
|
|
42
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
43
|
+
.map((c) => c.text)
|
|
44
|
+
.join("");
|
|
45
|
+
if (!text) {
|
|
46
|
+
throw new Error("router LLM call returned no text content");
|
|
47
|
+
}
|
|
48
|
+
return text;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { RouterInputContext } from "./router-types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Privacy note — priorTurns rendering:
|
|
5
|
+
* Conversational text rendered into the router prompt via `priorTurns` is NOT
|
|
6
|
+
* filtered by `src/memory/types.ts::NEVER_TRUST_FROM_MEMORY` (which governs
|
|
7
|
+
* structured market-sensitive memory keys such as `stock_price` and
|
|
8
|
+
* `target_price`). A future `/forget` command is the designated scrubbing
|
|
9
|
+
* primitive for removing or masking matching entries from the session branch
|
|
10
|
+
* so they no longer reach the router. See
|
|
11
|
+
* `openspec/changes/router-context-and-observability/` for the follow-up.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* List of workflows the router may emit. Keep this in sync with
|
|
16
|
+
* `WorkflowType` in `src/routing/types.ts` minus the `unclassified` sentinel.
|
|
17
|
+
*/
|
|
18
|
+
const WORKFLOW_CATALOG = [
|
|
19
|
+
{
|
|
20
|
+
name: "portfolio_builder",
|
|
21
|
+
when: "user asks to build/allocate a portfolio, invest a budget across positions",
|
|
22
|
+
required: ["budget"],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "options_screener",
|
|
26
|
+
when: "user asks for options trades / calls / puts on a specific ticker",
|
|
27
|
+
required: ["symbol"],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "compare_assets",
|
|
31
|
+
when: "user asks to compare two or more symbols (vs / versus / which is better)",
|
|
32
|
+
required: ["symbols (>=2)"],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "single_asset_analysis",
|
|
36
|
+
when: "user asks for a full analysis / deep dive / 'is X attractive' on ONE symbol",
|
|
37
|
+
required: ["symbol"],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "watchlist_or_tracking",
|
|
41
|
+
when: "user manages or asks about their saved watchlist / prediction history",
|
|
42
|
+
required: [],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "general_finance_qa",
|
|
46
|
+
when: "definitional / conceptual 'what is X', 'explain Y' questions",
|
|
47
|
+
required: [],
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function renderCatalog(): string {
|
|
52
|
+
return WORKFLOW_CATALOG.map(
|
|
53
|
+
(w) =>
|
|
54
|
+
`- "${w.name}": ${w.when}${w.required.length > 0 ? ` [required: ${w.required.join(", ")}]` : ""}`,
|
|
55
|
+
).join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderProfile(profile: Record<string, unknown>): string {
|
|
59
|
+
const entries = Object.entries(profile);
|
|
60
|
+
if (entries.length === 0) return "(empty)";
|
|
61
|
+
return entries.map(([k, v]) => `- ${k}: ${JSON.stringify(v)}`).join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderPriorTurns(
|
|
65
|
+
turns: Array<{ role: "user" | "assistant"; text: string }>,
|
|
66
|
+
): string {
|
|
67
|
+
if (turns.length === 0) return "(none)";
|
|
68
|
+
return turns
|
|
69
|
+
.map((t) => `[${t.role}] ${t.text.replace(/\n+/g, " ").slice(0, 400)}`)
|
|
70
|
+
.join("\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderRecentRuns(
|
|
74
|
+
runs: Array<{
|
|
75
|
+
workflowType: string;
|
|
76
|
+
turnType: string;
|
|
77
|
+
resolvedSlots?: Record<string, unknown>;
|
|
78
|
+
createdAt: string;
|
|
79
|
+
}>,
|
|
80
|
+
): string {
|
|
81
|
+
if (runs.length === 0) return "(none)";
|
|
82
|
+
return runs
|
|
83
|
+
.map(
|
|
84
|
+
(r) =>
|
|
85
|
+
`- ${r.createdAt} ${r.turnType}/${r.workflowType} ${r.resolvedSlots ? JSON.stringify(r.resolvedSlots) : ""}`,
|
|
86
|
+
)
|
|
87
|
+
.join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const SCHEMA_SPEC = `You MUST respond with a SINGLE JSON object and nothing else (no markdown fences, no prose outside the JSON). The object MUST conform to this TypeScript interface exactly:
|
|
91
|
+
|
|
92
|
+
interface RouterOutput {
|
|
93
|
+
route: "workflow" | "fallback";
|
|
94
|
+
workflow?: "portfolio_builder" | "options_screener" | "compare_assets" | "single_asset_analysis" | "watchlist_or_tracking" | "general_finance_qa";
|
|
95
|
+
entities: {
|
|
96
|
+
symbols: string[]; // UPPERCASE tickers the user mentioned or implied
|
|
97
|
+
budget?: number; // dollar amount if user stated one
|
|
98
|
+
maxPremium?: number;
|
|
99
|
+
timeHorizon?: string; // e.g. "6mo", "1y_plus", "short", "long"
|
|
100
|
+
riskProfile?: string; // "conservative" | "balanced" | "aggressive"
|
|
101
|
+
direction?: "bullish" | "bearish";
|
|
102
|
+
dteHint?: string;
|
|
103
|
+
};
|
|
104
|
+
slots: Record<string, {
|
|
105
|
+
value: unknown;
|
|
106
|
+
source: "user" | "preference" | "default"; // user = stated this turn; preference = from profileSnapshot; default = workflow fallback
|
|
107
|
+
confidence: "high" | "medium" | "low";
|
|
108
|
+
}>;
|
|
109
|
+
preference_updates: Array<{
|
|
110
|
+
key: string; // e.g. "risk_profile", "time_horizon", "asset_scope", "options_liquidity"
|
|
111
|
+
value: string;
|
|
112
|
+
confidence: "high" | "medium" | "low";
|
|
113
|
+
source: "inferred";
|
|
114
|
+
}>;
|
|
115
|
+
missing_required: string[]; // required slot names the turn/profile/defaults did not fill
|
|
116
|
+
reasoning: string; // one or two short sentences; used for debugging only
|
|
117
|
+
}`;
|
|
118
|
+
|
|
119
|
+
const ROUTING_RULES = `Routing rules:
|
|
120
|
+
- Choose route = "workflow" ONLY when the turn clearly matches one of the workflows below AND required slots are filled (from the turn OR the profile snapshot).
|
|
121
|
+
- Choose route = "fallback" for anything else — including simple data fetches like "AAPL quote", open-ended questions like "entry levels on ASTS for 6 months", or cases where required slots are missing.
|
|
122
|
+
- DO NOT invent a "direct_tool" or "needs_clarification" route. Only "workflow" or "fallback" are valid.
|
|
123
|
+
- If required slots are missing (e.g. options workflow needs a symbol, portfolio needs a budget), still pick the closest route but list the missing slot names in missing_required. The main agent will use ask_user to collect them.
|
|
124
|
+
- Source attribution rules (per-slot source field):
|
|
125
|
+
- source = "user": the value came from THIS turn's text.
|
|
126
|
+
- source = "preference": the value came from profileSnapshot (not this turn).
|
|
127
|
+
- source = "default": a sensible default was applied (workflow fallback).
|
|
128
|
+
- Preference updates:
|
|
129
|
+
- Emit preference_updates ONLY for stable user-dispositions stated (or very strongly implied) in the current turn. E.g. "I'm aggressive" → risk_profile=aggressive, high.
|
|
130
|
+
- Do NOT emit preference_updates that merely echo profileSnapshot.
|
|
131
|
+
- Only confidence="high" updates will be persisted; medium/low are logged but dropped.
|
|
132
|
+
- You have NO tools. Do not request tool execution. Classify on text alone.`;
|
|
133
|
+
|
|
134
|
+
export function buildRouterPrompt(input: RouterInputContext): string {
|
|
135
|
+
return `You are OpenCandle's routing agent. Your job is to classify the user's turn into one of the known workflows (or fallback), extract entities + per-slot provenance, and surface any stable preferences the user expressed. Your output feeds the main analyst agent — it does NOT go to the user.
|
|
136
|
+
|
|
137
|
+
WORKFLOW CATALOG:
|
|
138
|
+
${renderCatalog()}
|
|
139
|
+
|
|
140
|
+
${SCHEMA_SPEC}
|
|
141
|
+
|
|
142
|
+
${ROUTING_RULES}
|
|
143
|
+
|
|
144
|
+
--- CONTEXT ---
|
|
145
|
+
|
|
146
|
+
Profile snapshot (persisted preferences from prior sessions):
|
|
147
|
+
${renderProfile(input.profileSnapshot)}
|
|
148
|
+
|
|
149
|
+
Recent workflow runs (most recent last):
|
|
150
|
+
${renderRecentRuns(input.recentWorkflowRuns)}
|
|
151
|
+
|
|
152
|
+
Prior conversation turns (most recent last):
|
|
153
|
+
${renderPriorTurns(input.priorTurns)}
|
|
154
|
+
|
|
155
|
+
--- CURRENT TURN ---
|
|
156
|
+
${input.text}
|
|
157
|
+
|
|
158
|
+
Respond with the JSON object. Nothing else.`;
|
|
159
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ExtractedEntities, SlotSource, WorkflowType } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export type RouterRoute = "workflow" | "fallback";
|
|
4
|
+
|
|
5
|
+
export type RouterConfidence = "high" | "medium" | "low";
|
|
6
|
+
|
|
7
|
+
export interface RouterSlot {
|
|
8
|
+
value: unknown;
|
|
9
|
+
source: SlotSource;
|
|
10
|
+
confidence: RouterConfidence;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RouterPreferenceUpdate {
|
|
14
|
+
key: string;
|
|
15
|
+
value: string;
|
|
16
|
+
confidence: RouterConfidence;
|
|
17
|
+
source: "inferred";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Structured output from the LLM router. Mirrors existing types
|
|
22
|
+
* (`ClassificationResult`, `ExtractedEntities`, `SlotSource`) so downstream
|
|
23
|
+
* consumers can branch without a new vocabulary.
|
|
24
|
+
*
|
|
25
|
+
* `workflow` is only meaningful when `route === "workflow"`. For `fallback`
|
|
26
|
+
* routes, `workflow_type` at the storage layer is the sentinel `"fallback"`.
|
|
27
|
+
*/
|
|
28
|
+
export interface RouterOutput {
|
|
29
|
+
route: RouterRoute;
|
|
30
|
+
workflow?: Exclude<WorkflowType, "unclassified">;
|
|
31
|
+
entities: ExtractedEntities;
|
|
32
|
+
slots: Record<string, RouterSlot>;
|
|
33
|
+
preference_updates: RouterPreferenceUpdate[];
|
|
34
|
+
missing_required: string[];
|
|
35
|
+
reasoning: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Context passed into the router on each turn. */
|
|
39
|
+
export interface RouterInputContext {
|
|
40
|
+
/** Raw user text from `pi.on("input")`. */
|
|
41
|
+
text: string;
|
|
42
|
+
/** Last 5 user/assistant turns (most recent last). */
|
|
43
|
+
priorTurns: Array<{ role: "user" | "assistant"; text: string }>;
|
|
44
|
+
/** Current investor_profile snapshot retrieved from preferences storage. */
|
|
45
|
+
profileSnapshot: Record<string, unknown>;
|
|
46
|
+
/** Last 3 workflow_runs, compact summaries. */
|
|
47
|
+
recentWorkflowRuns: Array<{
|
|
48
|
+
workflowType: string;
|
|
49
|
+
turnType: string;
|
|
50
|
+
resolvedSlots?: Record<string, unknown>;
|
|
51
|
+
createdAt: string;
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Abstract LLM client used by the router. Injected by callers so unit tests
|
|
57
|
+
* can supply a deterministic mock. The real implementation (see
|
|
58
|
+
* `src/routing/router-llm-client.ts`) wraps pi-ai's `completeSimple`.
|
|
59
|
+
*/
|
|
60
|
+
export interface RouterLlmClient {
|
|
61
|
+
/**
|
|
62
|
+
* Run a single prompt → text completion. Router parses the returned text
|
|
63
|
+
* as JSON; the client is not responsible for structured-output parsing.
|
|
64
|
+
*/
|
|
65
|
+
complete(prompt: string): Promise<string>;
|
|
66
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { extractEntities } from "./entity-extractor.js";
|
|
2
|
+
import { buildRouterPrompt } from "./router-prompt.js";
|
|
3
|
+
import type {
|
|
4
|
+
RouterInputContext,
|
|
5
|
+
RouterLlmClient,
|
|
6
|
+
RouterOutput,
|
|
7
|
+
RouterPreferenceUpdate,
|
|
8
|
+
RouterRoute,
|
|
9
|
+
RouterSlot,
|
|
10
|
+
} from "./router-types.js";
|
|
11
|
+
import type { ExtractedEntities, WorkflowType } from "./types.js";
|
|
12
|
+
|
|
13
|
+
const VALID_ROUTES: readonly RouterRoute[] = ["workflow", "fallback"];
|
|
14
|
+
const VALID_WORKFLOWS: ReadonlyArray<Exclude<WorkflowType, "unclassified">> = [
|
|
15
|
+
"portfolio_builder",
|
|
16
|
+
"options_screener",
|
|
17
|
+
"compare_assets",
|
|
18
|
+
"single_asset_analysis",
|
|
19
|
+
"watchlist_or_tracking",
|
|
20
|
+
"general_finance_qa",
|
|
21
|
+
];
|
|
22
|
+
const VALID_SOURCES = new Set(["user", "preference", "default"]);
|
|
23
|
+
const VALID_CONFIDENCE = new Set(["high", "medium", "low"]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Run the LLM router against the given input context. Retries once on
|
|
27
|
+
* validation failure with a corrective message. Falls back to a minimal
|
|
28
|
+
* `route: "fallback"` output on persistent failure.
|
|
29
|
+
*
|
|
30
|
+
* The LLM client is injected so unit tests can supply deterministic responses.
|
|
31
|
+
*/
|
|
32
|
+
export async function route(
|
|
33
|
+
input: RouterInputContext,
|
|
34
|
+
client: RouterLlmClient,
|
|
35
|
+
): Promise<RouterOutput> {
|
|
36
|
+
const prompt = buildRouterPrompt(input);
|
|
37
|
+
|
|
38
|
+
let firstError: string | undefined;
|
|
39
|
+
try {
|
|
40
|
+
const raw = await client.complete(prompt);
|
|
41
|
+
return validateRouterOutput(raw);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
firstError = err instanceof Error ? err.message : String(err);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Retry once with error feedback.
|
|
47
|
+
try {
|
|
48
|
+
const retryPrompt = `${prompt}\n\n(Your previous response failed validation: ${firstError}. Return a valid JSON object conforming to RouterOutput. Nothing else.)`;
|
|
49
|
+
const raw = await client.complete(retryPrompt);
|
|
50
|
+
return validateRouterOutput(raw);
|
|
51
|
+
} catch {
|
|
52
|
+
// Persistent failure — return a minimal fallback with regex-extracted symbols.
|
|
53
|
+
return minimalFallback(input.text);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function validateRouterOutput(raw: string): RouterOutput {
|
|
58
|
+
const parsed = parseJsonPayload(raw);
|
|
59
|
+
|
|
60
|
+
if (!parsed || typeof parsed !== "object") {
|
|
61
|
+
throw new Error("router output was not a JSON object");
|
|
62
|
+
}
|
|
63
|
+
const obj = parsed as Record<string, unknown>;
|
|
64
|
+
|
|
65
|
+
const route = obj.route;
|
|
66
|
+
if (typeof route !== "string" || !VALID_ROUTES.includes(route as RouterRoute)) {
|
|
67
|
+
throw new Error(`invalid route: ${JSON.stringify(route)}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let workflow: RouterOutput["workflow"];
|
|
71
|
+
if (route === "workflow") {
|
|
72
|
+
if (typeof obj.workflow !== "string" || !VALID_WORKFLOWS.includes(obj.workflow as Exclude<WorkflowType, "unclassified">)) {
|
|
73
|
+
throw new Error(`workflow route requires a valid workflow; got ${JSON.stringify(obj.workflow)}`);
|
|
74
|
+
}
|
|
75
|
+
workflow = obj.workflow as Exclude<WorkflowType, "unclassified">;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const entities = validateEntities(obj.entities);
|
|
79
|
+
const slots = validateSlots(obj.slots);
|
|
80
|
+
const preference_updates = validatePreferenceUpdates(obj.preference_updates);
|
|
81
|
+
const missing_required = validateStringArray(obj.missing_required, "missing_required");
|
|
82
|
+
const reasoning =
|
|
83
|
+
typeof obj.reasoning === "string" ? obj.reasoning : "";
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
route: route as RouterRoute,
|
|
87
|
+
workflow,
|
|
88
|
+
entities,
|
|
89
|
+
slots,
|
|
90
|
+
preference_updates,
|
|
91
|
+
missing_required,
|
|
92
|
+
reasoning,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseJsonPayload(raw: string): unknown {
|
|
97
|
+
const trimmed = raw.trim();
|
|
98
|
+
// Tolerate ```json ... ``` fences even though the prompt forbids them.
|
|
99
|
+
const stripped = trimmed
|
|
100
|
+
.replace(/^```(?:json)?\s*/i, "")
|
|
101
|
+
.replace(/\s*```$/i, "")
|
|
102
|
+
.trim();
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(stripped);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
107
|
+
throw new Error(`router output was not valid JSON: ${msg}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function validateEntities(raw: unknown): ExtractedEntities {
|
|
112
|
+
if (!raw || typeof raw !== "object") {
|
|
113
|
+
throw new Error("entities must be an object");
|
|
114
|
+
}
|
|
115
|
+
const e = raw as Record<string, unknown>;
|
|
116
|
+
const symbols = validateStringArray(e.symbols, "entities.symbols").map((s) =>
|
|
117
|
+
s.toUpperCase(),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const out: ExtractedEntities = { symbols };
|
|
121
|
+
if (typeof e.budget === "number") out.budget = e.budget;
|
|
122
|
+
if (typeof e.maxPremium === "number") out.maxPremium = e.maxPremium;
|
|
123
|
+
if (typeof e.timeHorizon === "string") out.timeHorizon = e.timeHorizon;
|
|
124
|
+
if (typeof e.riskProfile === "string") out.riskProfile = e.riskProfile;
|
|
125
|
+
if (e.direction === "bullish" || e.direction === "bearish") out.direction = e.direction;
|
|
126
|
+
if (typeof e.dteHint === "string") out.dteHint = e.dteHint;
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function validateSlots(raw: unknown): Record<string, RouterSlot> {
|
|
131
|
+
if (raw === undefined || raw === null) return {};
|
|
132
|
+
if (typeof raw !== "object") {
|
|
133
|
+
throw new Error("slots must be an object");
|
|
134
|
+
}
|
|
135
|
+
const out: Record<string, RouterSlot> = {};
|
|
136
|
+
for (const [key, val] of Object.entries(raw as Record<string, unknown>)) {
|
|
137
|
+
if (!val || typeof val !== "object") {
|
|
138
|
+
throw new Error(`slot ${key} must be an object`);
|
|
139
|
+
}
|
|
140
|
+
const s = val as Record<string, unknown>;
|
|
141
|
+
if (!VALID_SOURCES.has(s.source as string)) {
|
|
142
|
+
throw new Error(`slot ${key} has invalid source: ${JSON.stringify(s.source)}`);
|
|
143
|
+
}
|
|
144
|
+
if (!VALID_CONFIDENCE.has(s.confidence as string)) {
|
|
145
|
+
throw new Error(`slot ${key} has invalid confidence: ${JSON.stringify(s.confidence)}`);
|
|
146
|
+
}
|
|
147
|
+
out[key] = {
|
|
148
|
+
value: s.value,
|
|
149
|
+
source: s.source as RouterSlot["source"],
|
|
150
|
+
confidence: s.confidence as RouterSlot["confidence"],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function validatePreferenceUpdates(raw: unknown): RouterPreferenceUpdate[] {
|
|
157
|
+
if (raw === undefined || raw === null) return [];
|
|
158
|
+
if (!Array.isArray(raw)) {
|
|
159
|
+
throw new Error("preference_updates must be an array");
|
|
160
|
+
}
|
|
161
|
+
return raw.map((item, idx) => {
|
|
162
|
+
if (!item || typeof item !== "object") {
|
|
163
|
+
throw new Error(`preference_updates[${idx}] must be an object`);
|
|
164
|
+
}
|
|
165
|
+
const p = item as Record<string, unknown>;
|
|
166
|
+
if (typeof p.key !== "string" || p.key.length === 0) {
|
|
167
|
+
throw new Error(`preference_updates[${idx}].key must be a non-empty string`);
|
|
168
|
+
}
|
|
169
|
+
if (typeof p.value !== "string") {
|
|
170
|
+
throw new Error(`preference_updates[${idx}].value must be a string`);
|
|
171
|
+
}
|
|
172
|
+
if (!VALID_CONFIDENCE.has(p.confidence as string)) {
|
|
173
|
+
throw new Error(`preference_updates[${idx}].confidence is invalid`);
|
|
174
|
+
}
|
|
175
|
+
// Router-emitted preferences are always inferred — absent is accepted
|
|
176
|
+
// (normalized), but any explicit non-"inferred" value is an invariant
|
|
177
|
+
// violation the caller should see rather than silently lose.
|
|
178
|
+
if (p.source !== undefined && p.source !== "inferred") {
|
|
179
|
+
throw new Error(`preference_updates[${idx}].source must be "inferred" (got ${JSON.stringify(p.source)})`);
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
key: p.key,
|
|
183
|
+
value: p.value,
|
|
184
|
+
confidence: p.confidence as RouterPreferenceUpdate["confidence"],
|
|
185
|
+
source: "inferred",
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function validateStringArray(raw: unknown, field: string): string[] {
|
|
191
|
+
if (raw === undefined || raw === null) return [];
|
|
192
|
+
if (!Array.isArray(raw)) {
|
|
193
|
+
throw new Error(`${field} must be an array`);
|
|
194
|
+
}
|
|
195
|
+
return raw.map((item, idx) => {
|
|
196
|
+
if (typeof item !== "string") {
|
|
197
|
+
throw new Error(`${field}[${idx}] must be a string`);
|
|
198
|
+
}
|
|
199
|
+
return item;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function minimalFallback(text: string): RouterOutput {
|
|
204
|
+
const entities = extractEntities(text);
|
|
205
|
+
return {
|
|
206
|
+
route: "fallback",
|
|
207
|
+
entities: { symbols: entities.symbols },
|
|
208
|
+
slots: {},
|
|
209
|
+
preference_updates: [],
|
|
210
|
+
missing_required: [],
|
|
211
|
+
reasoning: "router validation failed; emitted minimal fallback",
|
|
212
|
+
};
|
|
213
|
+
}
|