fodda-mcp 1.3.2 → 1.30.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/LICENSE +31 -0
- package/README.md +192 -20
- package/dist/a2aHandler.d.ts +20 -0
- package/dist/a2aHandler.d.ts.map +1 -0
- package/dist/a2aHandler.js +247 -0
- package/dist/a2aHandler.js.map +1 -0
- package/dist/agents/fodda-researcher/index.d.ts +35 -0
- package/dist/agents/fodda-researcher/index.d.ts.map +1 -0
- package/dist/agents/fodda-researcher/index.js +62 -0
- package/dist/agents/fodda-researcher/index.js.map +1 -0
- package/dist/agents/fodda-researcher/skills.d.ts +16 -0
- package/dist/agents/fodda-researcher/skills.d.ts.map +1 -0
- package/dist/agents/fodda-researcher/skills.js +163 -0
- package/dist/agents/fodda-researcher/skills.js.map +1 -0
- package/dist/brandTemplate.d.ts +14 -0
- package/dist/brandTemplate.d.ts.map +1 -0
- package/dist/brandTemplate.js +742 -0
- package/dist/brandTemplate.js.map +1 -0
- package/dist/catalogCache.d.ts +177 -0
- package/dist/catalogCache.d.ts.map +1 -0
- package/dist/catalogCache.js +708 -0
- package/dist/catalogCache.js.map +1 -0
- package/dist/editorialFill.d.ts +44 -0
- package/dist/editorialFill.d.ts.map +1 -0
- package/dist/editorialFill.js +377 -0
- package/dist/editorialFill.js.map +1 -0
- package/dist/enrichment.d.ts +74 -0
- package/dist/enrichment.d.ts.map +1 -0
- package/dist/enrichment.js +211 -0
- package/dist/enrichment.js.map +1 -0
- package/dist/errorHandling.d.ts +52 -0
- package/dist/errorHandling.d.ts.map +1 -0
- package/dist/errorHandling.js +327 -0
- package/dist/errorHandling.js.map +1 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +595 -410
- package/dist/index.js.map +1 -1
- package/dist/pricingCache.d.ts +99 -0
- package/dist/pricingCache.d.ts.map +1 -0
- package/dist/pricingCache.js +414 -0
- package/dist/pricingCache.js.map +1 -0
- package/dist/queryCache.d.ts +25 -0
- package/dist/queryCache.d.ts.map +1 -0
- package/dist/queryCache.js +194 -0
- package/dist/queryCache.js.map +1 -0
- package/dist/searchTemplate.d.ts +16 -0
- package/dist/searchTemplate.d.ts.map +1 -0
- package/dist/searchTemplate.js +318 -0
- package/dist/searchTemplate.js.map +1 -0
- package/dist/sessionTracker.d.ts +39 -0
- package/dist/sessionTracker.d.ts.map +1 -0
- package/dist/sessionTracker.js +187 -0
- package/dist/sessionTracker.js.map +1 -0
- package/dist/skillClient.d.ts +142 -0
- package/dist/skillClient.d.ts.map +1 -0
- package/dist/skillClient.js +256 -0
- package/dist/skillClient.js.map +1 -0
- package/dist/svgVisuals.d.ts +77 -0
- package/dist/svgVisuals.d.ts.map +1 -0
- package/dist/svgVisuals.js +323 -0
- package/dist/svgVisuals.js.map +1 -0
- package/dist/systemPrompt.d.ts +26 -0
- package/dist/systemPrompt.d.ts.map +1 -0
- package/dist/systemPrompt.js +546 -0
- package/dist/systemPrompt.js.map +1 -0
- package/dist/test-waverunner-response.d.ts +2 -0
- package/dist/test-waverunner-response.d.ts.map +1 -0
- package/dist/test-waverunner-response.js +20 -0
- package/dist/test-waverunner-response.js.map +1 -0
- package/dist/test_analyst.d.ts +2 -0
- package/dist/test_analyst.d.ts.map +1 -0
- package/dist/test_analyst.js +39 -0
- package/dist/test_analyst.js.map +1 -0
- package/dist/test_analyst_local.d.ts +2 -0
- package/dist/test_analyst_local.d.ts.map +1 -0
- package/dist/test_analyst_local.js +51 -0
- package/dist/test_analyst_local.js.map +1 -0
- package/dist/test_live_mcp.d.ts +2 -0
- package/dist/test_live_mcp.d.ts.map +1 -0
- package/dist/test_live_mcp.js +49 -0
- package/dist/test_live_mcp.js.map +1 -0
- package/dist/test_skills.d.ts +9 -0
- package/dist/test_skills.d.ts.map +1 -0
- package/dist/test_skills.js +267 -0
- package/dist/test_skills.js.map +1 -0
- package/dist/toolHandlers.d.ts +11 -0
- package/dist/toolHandlers.d.ts.map +1 -0
- package/dist/toolHandlers.js +2829 -0
- package/dist/toolHandlers.js.map +1 -0
- package/dist/tools.d.ts +80 -3
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +89 -186
- package/dist/tools.js.map +1 -1
- package/dist/trialTracker.d.ts +58 -0
- package/dist/trialTracker.d.ts.map +1 -0
- package/dist/trialTracker.js +179 -0
- package/dist/trialTracker.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/verify_hmac.js +1 -1
- package/dist/verify_hmac.js.map +1 -1
- package/dist/verify_tools_endpoint.js +5 -5
- package/dist/verify_tools_endpoint.js.map +1 -1
- package/dist/widgetShell.d.ts +15 -0
- package/dist/widgetShell.d.ts.map +1 -0
- package/dist/widgetShell.js +151 -0
- package/dist/widgetShell.js.map +1 -0
- package/package.json +10 -6
- package/server.json +12 -17
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph Catalog Cache — fetches /v1/graphs/catalog at startup and refreshes hourly.
|
|
3
|
+
* Provides typed access to the full graph registry and dynamic prompt generation.
|
|
4
|
+
*
|
|
5
|
+
* The catalog endpoint is public (no auth), already cached with 1h TTL on the API side,
|
|
6
|
+
* and is the same endpoint the website and app use.
|
|
7
|
+
*/
|
|
8
|
+
import axios from 'axios';
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Cache
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
const API_BASE_URL = process.env.FODDA_API_URL || 'https://api.fodda.ai';
|
|
13
|
+
const CATALOG_REFRESH_MS = 60 * 60 * 1000; // 1 hour
|
|
14
|
+
let cachedCatalog = null;
|
|
15
|
+
let cachedAnalysts = [];
|
|
16
|
+
let lastFetchedAt = 0;
|
|
17
|
+
let refreshInterval = null;
|
|
18
|
+
/**
|
|
19
|
+
* Fetch the catalog from the API. Public endpoint, no auth needed.
|
|
20
|
+
*/
|
|
21
|
+
async function fetchCatalog() {
|
|
22
|
+
const url = `${API_BASE_URL}/v1/graphs/catalog`;
|
|
23
|
+
try {
|
|
24
|
+
const response = await axios.get(url, { timeout: 10000 });
|
|
25
|
+
return response.data;
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
console.error(`[catalogCache] Failed to fetch catalog: ${err.message}`);
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Fetch the analysts from the API. Requires internal auth.
|
|
34
|
+
*/
|
|
35
|
+
async function fetchAnalysts() {
|
|
36
|
+
const url = `${API_BASE_URL}/v1/analysts`;
|
|
37
|
+
const internalKey = process.env.FODDA_INTERNAL_API_KEY;
|
|
38
|
+
const headers = internalKey ? { 'Authorization': `Bearer ${internalKey}` } : {};
|
|
39
|
+
try {
|
|
40
|
+
const response = await axios.get(url, { headers, timeout: 10000 });
|
|
41
|
+
return Array.isArray(response.data) ? response.data : (response.data.analysts || []);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
console.error(`[catalogCache] Failed to fetch analysts: ${err.message}`);
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Initialize the catalog cache. Call once at server startup.
|
|
50
|
+
* Fetches immediately, then sets up hourly refresh.
|
|
51
|
+
*/
|
|
52
|
+
export async function initCatalogCache() {
|
|
53
|
+
try {
|
|
54
|
+
cachedCatalog = await fetchCatalog();
|
|
55
|
+
cachedAnalysts = await fetchAnalysts();
|
|
56
|
+
lastFetchedAt = Date.now();
|
|
57
|
+
rebuildSearchIndex();
|
|
58
|
+
console.error(`[catalogCache] Loaded ${cachedCatalog.graph_count} graphs from catalog (search index: ${graphSearchTexts.size} entries)`);
|
|
59
|
+
console.error(`[catalogCache] Loaded ${cachedAnalysts.length} analysts`);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
console.error('[catalogCache] Initial fetch failed — dynamic prompt sections will use fallback text');
|
|
63
|
+
}
|
|
64
|
+
// Refresh hourly in the background
|
|
65
|
+
if (refreshInterval)
|
|
66
|
+
clearInterval(refreshInterval);
|
|
67
|
+
refreshInterval = setInterval(async () => {
|
|
68
|
+
try {
|
|
69
|
+
cachedCatalog = await fetchCatalog();
|
|
70
|
+
cachedAnalysts = await fetchAnalysts();
|
|
71
|
+
lastFetchedAt = Date.now();
|
|
72
|
+
rebuildSearchIndex();
|
|
73
|
+
console.error(`[catalogCache] Refreshed catalog — ${cachedCatalog.graph_count} graphs (search index rebuilt) and ${cachedAnalysts.length} analysts`);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
console.error('[catalogCache] Background refresh failed — keeping stale cache');
|
|
77
|
+
}
|
|
78
|
+
}, CATALOG_REFRESH_MS);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Get the cached catalog. Returns null if not yet fetched.
|
|
82
|
+
*/
|
|
83
|
+
export function getCatalog() {
|
|
84
|
+
return cachedCatalog;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Get the cached analysts.
|
|
88
|
+
*/
|
|
89
|
+
export function getAnalysts() {
|
|
90
|
+
return cachedAnalysts;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Get all graphs from the cached catalog.
|
|
94
|
+
*/
|
|
95
|
+
export function getGraphs() {
|
|
96
|
+
return cachedCatalog?.graphs ?? [];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Get only graphs with status === 'live'.
|
|
100
|
+
*/
|
|
101
|
+
export function getLiveGraphs() {
|
|
102
|
+
return getGraphs().filter(g => g.status === 'live');
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Get domain/curated graphs (graph_type === 'domain').
|
|
106
|
+
*/
|
|
107
|
+
export function getDomainGraphs() {
|
|
108
|
+
return getLiveGraphs().filter(g => g.graph_type === 'domain');
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Get expert graphs (graph_type === 'expert' or 'industry report').
|
|
112
|
+
* Industry report graphs follow the same EVIDENCE_FOR pattern and should
|
|
113
|
+
* be routed identically to expert graphs.
|
|
114
|
+
*/
|
|
115
|
+
export function getExpertGraphs() {
|
|
116
|
+
return getLiveGraphs().filter(g => g.graph_type === 'expert' || g.graph_type === 'industry report');
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Get skill graphs (graph_type === 'skill').
|
|
120
|
+
* Includes both 'live' and 'beta' status skills.
|
|
121
|
+
*/
|
|
122
|
+
export function getSkillGraphs() {
|
|
123
|
+
return getGraphs().filter(g => g.graph_type === 'skill' && (g.status === 'live' || g.status === 'beta'));
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Get enabled skill configs for a user, excluding their disabled graphs.
|
|
127
|
+
* Returns configs ready to pass to the skill client.
|
|
128
|
+
*
|
|
129
|
+
* Note: Skills route through the Core API now — mcp_url is no longer
|
|
130
|
+
* required. The API handles upstream MCP connections, auth, and billing.
|
|
131
|
+
*/
|
|
132
|
+
export function getEnabledSkillConfigs(disabledGraphIds) {
|
|
133
|
+
return getSkillGraphs()
|
|
134
|
+
.filter(g => !disabledGraphIds.has(g.graph_id))
|
|
135
|
+
.map(g => ({
|
|
136
|
+
id: g.graph_id,
|
|
137
|
+
name: g.name,
|
|
138
|
+
phase: (g.skill_phase === 'research' ? 'research' : 'output'),
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Get the set of domain graph IDs for theme coloring.
|
|
143
|
+
*/
|
|
144
|
+
export function getDomainGraphIds() {
|
|
145
|
+
return new Set(getDomainGraphs().map(g => g.graph_id));
|
|
146
|
+
}
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Dynamic Prompt Builders
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
/**
|
|
151
|
+
* Build the GRAPH NAMING block from catalog data.
|
|
152
|
+
* Generates: - graphId "retail" → "PSFK's Retail Graph"
|
|
153
|
+
*/
|
|
154
|
+
export function buildGraphNamingBlock() {
|
|
155
|
+
const graphs = getLiveGraphs();
|
|
156
|
+
if (graphs.length === 0)
|
|
157
|
+
return ''; // fallback — will use hardcoded
|
|
158
|
+
const lines = [];
|
|
159
|
+
lines.push('GRAPH NAMING: Never call results "the Fodda graph." Fodda is the platform — the knowledge graphs are created by named experts. Always attribute to the expert:');
|
|
160
|
+
for (const g of graphs) {
|
|
161
|
+
const displayName = buildDisplayName(g);
|
|
162
|
+
const domainHint = g.domain ? ` (${g.domain})` : '';
|
|
163
|
+
lines.push(`- graphId "${g.graph_id}" → "${displayName}"${domainHint}`);
|
|
164
|
+
}
|
|
165
|
+
lines.push('Example: "PSFK\'s Retail Graph identifies Retailer-Operated Value-Recovery Programs as a top signal (score: 100)" — NOT "the Fodda graph shows..."');
|
|
166
|
+
lines.push('Fodda is the delivery mechanism. The experts are the authority.');
|
|
167
|
+
lines.push('When presenting results from community Pattern Graphs, use the creator\'s name in attribution (e.g., "According to [creator]\'s [Graph Name] Pattern Graph on Fodda...").');
|
|
168
|
+
return lines.join('\n');
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Build a human-friendly display name for a graph.
|
|
172
|
+
*/
|
|
173
|
+
export function buildDisplayName(g) {
|
|
174
|
+
const possessive = (name) => name.endsWith('s') ? `${name}'` : `${name}'s`;
|
|
175
|
+
// Domain graphs curated by PSFK Editorial
|
|
176
|
+
if (g.curator === 'PSFK Editorial') {
|
|
177
|
+
// Extract the domain keyword from the name, e.g. "PSFK Retail Trends" → "Retail"
|
|
178
|
+
const keyword = g.name.replace('PSFK ', '').replace(' Trends', '').trim();
|
|
179
|
+
return `PSFK's ${keyword} Graph`;
|
|
180
|
+
}
|
|
181
|
+
// Graphs with a company name — use company + graph name, but avoid duplication
|
|
182
|
+
if (g.company && g.company !== g.curator) {
|
|
183
|
+
// If graph name already contains the company name, just use curator + name
|
|
184
|
+
if (g.name.toLowerCase().includes(g.company.toLowerCase())) {
|
|
185
|
+
return `${possessive(g.curator)} ${g.name}`;
|
|
186
|
+
}
|
|
187
|
+
return `${possessive(g.curator)} ${g.company} ${g.name}`;
|
|
188
|
+
}
|
|
189
|
+
// Default: curator's graph name — but avoid duplication
|
|
190
|
+
if (g.curator) {
|
|
191
|
+
// If graph name already starts with or contains the curator name, just use the name
|
|
192
|
+
if (g.name.toLowerCase().includes(g.curator.toLowerCase())) {
|
|
193
|
+
return g.name;
|
|
194
|
+
}
|
|
195
|
+
return `${possessive(g.curator)} ${g.name}`;
|
|
196
|
+
}
|
|
197
|
+
return g.name;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Build the GRAPH TYPES block from catalog data.
|
|
201
|
+
*/
|
|
202
|
+
export function buildGraphTypesBlock() {
|
|
203
|
+
const domainGraphs = getDomainGraphs();
|
|
204
|
+
const expertGraphs = getExpertGraphs();
|
|
205
|
+
const baselineGraphs = getLiveGraphs().filter(g => g.graph_type === 'baseline');
|
|
206
|
+
const lines = [];
|
|
207
|
+
lines.push('GRAPH TYPES: Fodda serves three types of knowledge graphs:');
|
|
208
|
+
// Domain
|
|
209
|
+
const domainNames = domainGraphs.map(g => g.name.replace('PSFK ', '').replace(' Trends', '').replace(' Graph', '')).join(', ');
|
|
210
|
+
lines.push(`- CURATED GRAPHS: Expert-curated by PSFK (${domainNames}) and partners. These use deep editorial curation and AI-powered embeddings.`);
|
|
211
|
+
// Expert
|
|
212
|
+
const expertDescs = expertGraphs.map(g => {
|
|
213
|
+
const who = g.company && g.company !== g.curator ? `${g.company}/${g.curator}` : g.curator;
|
|
214
|
+
const domain = g.domain ? ` (${g.domain})` : '';
|
|
215
|
+
return `${who}${domain}`;
|
|
216
|
+
}).join(', ');
|
|
217
|
+
lines.push(`- EXPERT GRAPHS: Domain-specific knowledge graphs built from expert reports and presentations. Each is curated by a named industry expert or organization: ${expertDescs}. These follow the EVIDENCE_FOR relationship pattern and use gemini-embedding-001 (768d) embeddings.`);
|
|
218
|
+
// Baseline / Community
|
|
219
|
+
if (baselineGraphs.length > 0) {
|
|
220
|
+
const baselineNames = baselineGraphs.map(g => g.name).join(', ');
|
|
221
|
+
lines.push(`- BASELINE GRAPHS: Structured reference data (${baselineNames}).`);
|
|
222
|
+
}
|
|
223
|
+
lines.push('- COMMUNITY PATTERN GRAPHS: Contributed by strategists via Google Sheets. These follow the Fodda Pattern Standard (Signals → Patterns → Entities).');
|
|
224
|
+
return lines.join('\n');
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Build the EXPERT GRAPH ROUTING block from catalog data.
|
|
228
|
+
* Uses the graph's domain field and topics to generate routing keywords.
|
|
229
|
+
*/
|
|
230
|
+
export function buildExpertRoutingBlock() {
|
|
231
|
+
const experts = getExpertGraphs();
|
|
232
|
+
if (experts.length === 0)
|
|
233
|
+
return '';
|
|
234
|
+
const lines = [];
|
|
235
|
+
lines.push('EXPERT GRAPH ROUTING: When a user\'s query matches one of these domains, route to the corresponding expert graph:');
|
|
236
|
+
for (const g of experts) {
|
|
237
|
+
const keywords = buildRoutingKeywords(g);
|
|
238
|
+
lines.push(`- ${keywords} → ${g.graph_id}`);
|
|
239
|
+
}
|
|
240
|
+
lines.push('Expert graphs provide specialist perspectives from named industry leaders. Living expert graphs (those with recurring updates) are primary research sources alongside PSFK domain graphs. Static expert graphs offer deep specialist analysis from a specific point in time. When a query matches an expert graph\'s domain, search it — expert analysis is often the most proprietary content in the system.');
|
|
241
|
+
return lines.join('\n');
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Generate routing keywords from a graph's domain, topics, and description.
|
|
245
|
+
*/
|
|
246
|
+
function buildRoutingKeywords(g) {
|
|
247
|
+
// If the catalog provides explicit routing_keywords, use them
|
|
248
|
+
if (g.routing_keywords && g.routing_keywords.length > 0) {
|
|
249
|
+
return g.routing_keywords.join(' / ');
|
|
250
|
+
}
|
|
251
|
+
// Otherwise, extract from domain + topics + description keywords
|
|
252
|
+
const keywords = [];
|
|
253
|
+
// Domain is the primary source (e.g. "Future of Work & Digital Transformation")
|
|
254
|
+
if (g.domain) {
|
|
255
|
+
// Split on & and , to get individual terms
|
|
256
|
+
const domainParts = g.domain.split(/[&,]/).map(s => s.trim()).filter(Boolean);
|
|
257
|
+
keywords.push(...domainParts);
|
|
258
|
+
}
|
|
259
|
+
// Add topics as supplementary keywords
|
|
260
|
+
if (g.topics && g.topics.length > 0) {
|
|
261
|
+
for (const t of g.topics) {
|
|
262
|
+
const capitalized = t.charAt(0).toUpperCase() + t.slice(1);
|
|
263
|
+
if (!keywords.some(k => k.toLowerCase().includes(t.toLowerCase()))) {
|
|
264
|
+
keywords.push(capitalized);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return keywords.join(' / ') || g.name;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Build the EXPERT GRAPH WORKFLOW block dynamically.
|
|
272
|
+
*/
|
|
273
|
+
export function buildExpertWorkflowBlock() {
|
|
274
|
+
const experts = getExpertGraphs();
|
|
275
|
+
if (experts.length === 0)
|
|
276
|
+
return '';
|
|
277
|
+
const expertIds = experts.map(g => g.graph_id).join(', ');
|
|
278
|
+
return `EXPERT GRAPH WORKFLOW: Expert graphs (${expertIds}) contain Trend nodes with rich categorized evidence — statistics (48%), case studies (27%), analysis (14%), and interviews (10%). When querying an expert graph: 1) Call search_graph to find trends. 2) Call get_evidence for supporting articles. 3) Call search_statistics for quantitative data points within the expert's domain. 4) Call search_insights for expert quotes and analytical framing. 5) Call supplemental tools for macro context. Expert graphs work with ALL evidence tools — treat them the same as PSFK curated graphs for evidence retrieval.`;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Build the SUPPLEMENTAL PAIRING STRATEGY block from catalog data.
|
|
282
|
+
* Uses domain knowledge to map graph domains to appropriate supplemental tools.
|
|
283
|
+
*/
|
|
284
|
+
export function buildSupplementalPairingBlock() {
|
|
285
|
+
const graphs = getLiveGraphs();
|
|
286
|
+
if (graphs.length === 0)
|
|
287
|
+
return '';
|
|
288
|
+
const lines = [];
|
|
289
|
+
lines.push('SUPPLEMENTAL PAIRING STRATEGY: After querying any knowledge graph, select supplemental tools based on the graph being queried. Each graph has different data needs:\n');
|
|
290
|
+
// Separate domain vs expert graphs
|
|
291
|
+
const domainGraphs = graphs.filter(g => g.graph_type === 'domain');
|
|
292
|
+
const expertGraphs = graphs.filter(g => g.graph_type === 'expert');
|
|
293
|
+
for (const g of domainGraphs) {
|
|
294
|
+
const pairing = inferSupplementalPairing(g);
|
|
295
|
+
lines.push(`── ${g.name} (graphId: ${g.graph_id}) ──`);
|
|
296
|
+
lines.push(`PRIMARY: ${pairing.primary.join(', ')}`);
|
|
297
|
+
lines.push(`SECONDARY: ${pairing.secondary.join(', ')}`);
|
|
298
|
+
lines.push(`USE WHEN: ${pairing.useWhen}`);
|
|
299
|
+
lines.push('');
|
|
300
|
+
}
|
|
301
|
+
// Expert graphs — simpler pairing
|
|
302
|
+
if (expertGraphs.length > 0) {
|
|
303
|
+
lines.push('── Expert Graphs — Supplemental Pairing ──');
|
|
304
|
+
lines.push('Expert graphs are domain-specific and narrower than PSFK curated graphs. Use the following pairings when querying expert graphs:');
|
|
305
|
+
for (const g of expertGraphs) {
|
|
306
|
+
const pairing = inferSupplementalPairing(g);
|
|
307
|
+
const domainHint = g.domain ? ` (${g.domain})` : '';
|
|
308
|
+
lines.push(`- ${g.graph_id}${domainHint}: ${pairing.primary.join(' + ')}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return lines.join('\n');
|
|
312
|
+
}
|
|
313
|
+
function inferSupplementalPairing(g) {
|
|
314
|
+
// If the catalog provides explicit tool lists, use them
|
|
315
|
+
if (g.supplemental_tools_primary && g.supplemental_tools_primary.length > 0) {
|
|
316
|
+
return {
|
|
317
|
+
primary: g.supplemental_tools_primary,
|
|
318
|
+
secondary: g.supplemental_tools_secondary || [],
|
|
319
|
+
useWhen: `Use for ${g.domain || g.name} queries.`
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
// Otherwise, infer from domain and topics
|
|
323
|
+
const domain = (g.domain || '').toLowerCase();
|
|
324
|
+
const topics = (g.topics || []).map(t => t.toLowerCase());
|
|
325
|
+
const name = g.name.toLowerCase();
|
|
326
|
+
const desc = (g.description || '').toLowerCase();
|
|
327
|
+
// Combined text for keyword matching
|
|
328
|
+
const text = `${domain} ${topics.join(' ')} ${name} ${desc}`;
|
|
329
|
+
const primary = [];
|
|
330
|
+
const secondary = [];
|
|
331
|
+
let useWhen = '';
|
|
332
|
+
// Retail / Commerce
|
|
333
|
+
if (text.match(/retail|commerce|shopping|store|e-commerce|omnichannel/)) {
|
|
334
|
+
primary.push('Economic Indicators', 'Market Data');
|
|
335
|
+
secondary.push('Demographic Context', 'Financial Reporting');
|
|
336
|
+
useWhen = 'Always. Retail trends need economic context — sales data, consumer spending, sentiment.';
|
|
337
|
+
}
|
|
338
|
+
// Beauty / Wellness / Health
|
|
339
|
+
else if (text.match(/beauty|wellness|cosmetic|ingredient|skincare|health|biotech/)) {
|
|
340
|
+
primary.push('Research Signals');
|
|
341
|
+
secondary.push('Economic Indicators', 'Demand Signals', 'Market Data');
|
|
342
|
+
useWhen = 'Always for ingredient-specific queries. Research Signals for publication velocity and safety data.';
|
|
343
|
+
}
|
|
344
|
+
// Sports / Fitness / Recreation
|
|
345
|
+
else if (text.match(/sport|fitness|fandom|athlete|recreation|trail|outdoor/)) {
|
|
346
|
+
primary.push('Demand Signals', 'Economic Indicators', 'Demographic Context');
|
|
347
|
+
secondary.push('Market Data');
|
|
348
|
+
useWhen = 'Demand Signals for attention tracking. Economic Indicators for recreation spending. Demographic Context for media consumption.';
|
|
349
|
+
}
|
|
350
|
+
// Fashion / Apparel
|
|
351
|
+
else if (text.match(/fashion|apparel|clothing|streetwear|luxury|textile/)) {
|
|
352
|
+
primary.push('Economic Indicators', 'Market Data');
|
|
353
|
+
secondary.push('Demand Signals');
|
|
354
|
+
useWhen = 'Economic Indicators for clothing PCE and apparel CPI. Market Data for e-commerce vs. brick-and-mortar split.';
|
|
355
|
+
}
|
|
356
|
+
// Work / HR / Organization
|
|
357
|
+
else if (text.match(/work|hr|talent|hybrid|employment|organization|workforce/)) {
|
|
358
|
+
primary.push('Economic Indicators');
|
|
359
|
+
secondary.push('Demographic Context');
|
|
360
|
+
useWhen = 'Economic Indicators for employment, wage data, and labor market indicators.';
|
|
361
|
+
}
|
|
362
|
+
// Consumer culture / Marketing / Advertising / Brand
|
|
363
|
+
else if (text.match(/culture|marketing|advertising|brand|media|consumer culture|cultural/)) {
|
|
364
|
+
primary.push('Demographic Context', 'Demand Signals');
|
|
365
|
+
secondary.push('Economic Indicators', 'Research Signals');
|
|
366
|
+
useWhen = 'Demographic Context for social media usage and attitudes. Demand Signals for cultural moment tracking. Economic Indicators for consumer spending.';
|
|
367
|
+
}
|
|
368
|
+
// Technology / Electronics / Digital
|
|
369
|
+
else if (text.match(/technology|electronics|digital|enterprise|platform|tech|ai|software/)) {
|
|
370
|
+
primary.push('Economic Indicators');
|
|
371
|
+
secondary.push('Demographic Context', 'Research Signals');
|
|
372
|
+
useWhen = 'Economic Indicators for business investment. Demographic Context for technology adoption attitudes. Research Signals for academic trends.';
|
|
373
|
+
}
|
|
374
|
+
// Travel / Tourism
|
|
375
|
+
else if (text.match(/travel|tourism|hospitality|destination/)) {
|
|
376
|
+
primary.push('Economic Indicators', 'Market Data');
|
|
377
|
+
secondary.push('Demand Signals');
|
|
378
|
+
useWhen = 'Economic Indicators for tourism GDP and services trade. Demand Signals for destination attention tracking.';
|
|
379
|
+
}
|
|
380
|
+
// Design / Lifestyle / Materials
|
|
381
|
+
else if (text.match(/design|lifestyle|interior|material|color|aesthetic/)) {
|
|
382
|
+
primary.push('Economic Indicators');
|
|
383
|
+
secondary.push('Demand Signals', 'Research Signals');
|
|
384
|
+
useWhen = 'Economic Indicators for housing and durable goods spending. Research Signals for materials science context.';
|
|
385
|
+
}
|
|
386
|
+
// Food / CPG / Snacking
|
|
387
|
+
else if (text.match(/food|snack|treat|indulgence|cpg|consumer goods/)) {
|
|
388
|
+
primary.push('Economic Indicators', 'Market Data');
|
|
389
|
+
secondary.push('Research Signals');
|
|
390
|
+
useWhen = 'Economic Indicators for food spending and CPI. Market Data for product composition and trade data.';
|
|
391
|
+
}
|
|
392
|
+
// Logistics / Shipping / Supply chain
|
|
393
|
+
else if (text.match(/logistics|shipping|warehouse|supply chain|last.mile/)) {
|
|
394
|
+
primary.push('Market Data', 'Economic Indicators');
|
|
395
|
+
secondary.push('Financial Reporting');
|
|
396
|
+
useWhen = 'Market Data for e-commerce vs retail and cross-border trade volumes. Economic Indicators for macro context.';
|
|
397
|
+
}
|
|
398
|
+
// Trust / Public opinion
|
|
399
|
+
else if (text.match(/trust|tipping|opinion|societal|backlash/)) {
|
|
400
|
+
primary.push('Economic Indicators', 'Demographic Context');
|
|
401
|
+
secondary.push('Research Signals');
|
|
402
|
+
useWhen = 'Economic Indicators for consumer confidence. Demographic Context for public trust and attitudes. Research Signals for academic framing.';
|
|
403
|
+
}
|
|
404
|
+
// Automotive
|
|
405
|
+
else if (text.match(/automotive|car|vehicle|color trend/)) {
|
|
406
|
+
primary.push('Economic Indicators', 'Market Data');
|
|
407
|
+
secondary.push('Financial Reporting');
|
|
408
|
+
useWhen = 'Economic Indicators for auto manufacturing data. Market Data for automotive trade flows. Financial Reporting for OEM earnings.';
|
|
409
|
+
}
|
|
410
|
+
// CRM / Engagement / Martech
|
|
411
|
+
else if (text.match(/crm|engagement|martech|retention|personalization/)) {
|
|
412
|
+
primary.push('Economic Indicators');
|
|
413
|
+
secondary.push('Demographic Context', 'Financial Reporting');
|
|
414
|
+
useWhen = 'Economic Indicators for services spending and consumer sentiment. Financial Reporting for corporate strategy context.';
|
|
415
|
+
}
|
|
416
|
+
// Sponsorship (MLB etc)
|
|
417
|
+
else if (text.match(/sponsorship|mlb|baseball|stadium/)) {
|
|
418
|
+
primary.push('Demand Signals', 'Economic Indicators');
|
|
419
|
+
secondary.push('Demographic Context', 'Financial Reporting');
|
|
420
|
+
useWhen = 'Demand Signals for team/league/brand attention tracking. Economic Indicators for recreation spending.';
|
|
421
|
+
}
|
|
422
|
+
// Public opinion data (Pew-type)
|
|
423
|
+
else if (text.match(/public opinion|survey|demographics|pew/)) {
|
|
424
|
+
primary.push('Demographic Context');
|
|
425
|
+
secondary.push('Economic Indicators');
|
|
426
|
+
useWhen = 'Demographic Context for demographic cross-referencing and public opinion. Economic Indicators for macro sentiment backdrop.';
|
|
427
|
+
}
|
|
428
|
+
// Fallback
|
|
429
|
+
else {
|
|
430
|
+
primary.push('Economic Indicators');
|
|
431
|
+
secondary.push('Demand Signals', 'Market Data');
|
|
432
|
+
useWhen = `Use Economic Indicators for macro context on ${g.domain || g.name} trends.`;
|
|
433
|
+
}
|
|
434
|
+
return { primary, secondary, useWhen };
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Build the SUPPLEMENTAL DEFAULT RULE — which graphs should default to including supplemental data.
|
|
438
|
+
*/
|
|
439
|
+
export function buildSupplementalDefaultBlock() {
|
|
440
|
+
const domainGraphs = getDomainGraphs();
|
|
441
|
+
const expertGraphs = getExpertGraphs();
|
|
442
|
+
const domainIds = domainGraphs.map(g => g.graph_id).join(', ');
|
|
443
|
+
// Expert graphs with economic dimensions — infer from domain
|
|
444
|
+
const econExpertIds = expertGraphs
|
|
445
|
+
.filter(g => {
|
|
446
|
+
const text = `${g.domain} ${g.topics?.join(' ')} ${g.description}`.toLowerCase();
|
|
447
|
+
return text.match(/commerce|retail|food|consumer|spending|economic|logistics|trade|automotive/);
|
|
448
|
+
})
|
|
449
|
+
.map(g => g.graph_id)
|
|
450
|
+
.join(', ');
|
|
451
|
+
let block = `SUPPLEMENTAL DEFAULT RULE: Supplemental data calls are NOT optional for substantive queries on consumer-facing graphs (${domainIds}). Default toward inclusion — the question is not "does this query need economic context?" but "would a reader benefit from knowing the macro conditions around this trend?"`;
|
|
452
|
+
if (econExpertIds) {
|
|
453
|
+
block += ` For expert graphs with economic dimensions (${econExpertIds}), also default to inclusion.`;
|
|
454
|
+
}
|
|
455
|
+
block += ` Escape valve: if the query is demonstrably about design language, physical formats, or brand tactics with no macro dependency, skip supplemental data. Do not ask the user. Make the judgment call and execute.`;
|
|
456
|
+
return block;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Build the evidence tools rule (search_statistics + search_insights).
|
|
460
|
+
* These now work on ALL graph types, not just curated.
|
|
461
|
+
*/
|
|
462
|
+
export function buildCuratedOnlyToolsBlock() {
|
|
463
|
+
return `- search_statistics → Works on ALL graphs (PSFK curated AND expert graphs). Search for quantitative data points, market sizes, and growth rates.\n- search_insights → Works on ALL graphs (PSFK curated AND expert graphs). Search for expert quotes, analysis, and qualitative evidence.`;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Master function: build all dynamic sections and return them as a single string
|
|
467
|
+
* to inject into the system prompt, replacing the hardcoded blocks.
|
|
468
|
+
*/
|
|
469
|
+
export function buildDynamicPromptSections() {
|
|
470
|
+
const catalog = getCatalog();
|
|
471
|
+
if (!catalog || catalog.graphs.length === 0) {
|
|
472
|
+
return null; // Return null to signal: use hardcoded fallback
|
|
473
|
+
}
|
|
474
|
+
const sections = [
|
|
475
|
+
buildGraphNamingBlock(),
|
|
476
|
+
'',
|
|
477
|
+
buildGraphTypesBlock(),
|
|
478
|
+
'',
|
|
479
|
+
buildExpertRoutingBlock(),
|
|
480
|
+
'',
|
|
481
|
+
buildSupplementalDefaultBlock(),
|
|
482
|
+
'',
|
|
483
|
+
buildSupplementalPairingBlock(),
|
|
484
|
+
'',
|
|
485
|
+
buildExpertWorkflowBlock(),
|
|
486
|
+
'',
|
|
487
|
+
buildCuratedOnlyToolsBlock(),
|
|
488
|
+
];
|
|
489
|
+
return sections.join('\n');
|
|
490
|
+
}
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
// Smart Graph Routing — 2-step query-to-graph relevance scoring
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
/**
|
|
495
|
+
* Build a single searchable text blob from all the metadata fields of a graph.
|
|
496
|
+
* Used for keyword matching against user queries.
|
|
497
|
+
*/
|
|
498
|
+
function buildSearchableText(g) {
|
|
499
|
+
const parts = [
|
|
500
|
+
g.name || '',
|
|
501
|
+
g.description || '',
|
|
502
|
+
g.domain || '',
|
|
503
|
+
g.headline || '',
|
|
504
|
+
g.subhead || '',
|
|
505
|
+
g.company || '',
|
|
506
|
+
g.curator || '',
|
|
507
|
+
...(g.topics || []),
|
|
508
|
+
...(g.routing_keywords || []),
|
|
509
|
+
...(g.example_queries || []),
|
|
510
|
+
];
|
|
511
|
+
return parts.join(' ').toLowerCase();
|
|
512
|
+
}
|
|
513
|
+
// Pre-computed searchable text cache (rebuilt on catalog refresh)
|
|
514
|
+
let graphSearchTexts = new Map();
|
|
515
|
+
/**
|
|
516
|
+
* Rebuild the searchable text index. Called after catalog refresh.
|
|
517
|
+
*/
|
|
518
|
+
function rebuildSearchIndex() {
|
|
519
|
+
graphSearchTexts.clear();
|
|
520
|
+
for (const g of getGraphs()) {
|
|
521
|
+
graphSearchTexts.set(g.graph_id, buildSearchableText(g));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Score how relevant a graph is to a query.
|
|
526
|
+
* Returns a 0-1 score based on keyword overlap between query terms and graph metadata.
|
|
527
|
+
*
|
|
528
|
+
* Scoring strategy:
|
|
529
|
+
* - Each query term that appears in the graph's searchable text adds to the score
|
|
530
|
+
* - Matches in topics/routing_keywords are weighted higher (2x) than description matches
|
|
531
|
+
* - Living graphs get a modest freshness boost (+0.05)
|
|
532
|
+
* - Brand-name queries (single capitalized word) always include domain graphs
|
|
533
|
+
*/
|
|
534
|
+
export function scoreGraphRelevance(query, g) {
|
|
535
|
+
const queryLower = query.toLowerCase();
|
|
536
|
+
// Stopwords that shouldn't contribute to routing relevance
|
|
537
|
+
const stopWords = new Set([
|
|
538
|
+
'trend', 'trends', 'consumer', 'consumers', 'report', 'reports',
|
|
539
|
+
'industry', 'data', 'future', 'insight', 'insights', 'analysis',
|
|
540
|
+
'what', 'how', 'why', 'who', 'when', 'where', 'are', 'the', 'and', 'for', 'with'
|
|
541
|
+
]);
|
|
542
|
+
const queryTerms = queryLower
|
|
543
|
+
.split(/\s+/)
|
|
544
|
+
.filter(t => t.length > 2) // Skip very short words (a, in, of, etc.)
|
|
545
|
+
.map(t => t.replace(/[^a-z0-9]/g, '')) // Strip punctuation
|
|
546
|
+
.filter(t => t.length > 2 && !stopWords.has(t));
|
|
547
|
+
if (queryTerms.length === 0)
|
|
548
|
+
return 0;
|
|
549
|
+
const searchText = graphSearchTexts.get(g.graph_id) || buildSearchableText(g);
|
|
550
|
+
const topicsText = [...(g.topics || []), ...(g.routing_keywords || [])].join(' ').toLowerCase();
|
|
551
|
+
const domainText = (g.domain || '').toLowerCase();
|
|
552
|
+
let matchedTerms = 0;
|
|
553
|
+
let highValueMatches = 0;
|
|
554
|
+
for (const term of queryTerms) {
|
|
555
|
+
if (searchText.includes(term)) {
|
|
556
|
+
matchedTerms++;
|
|
557
|
+
// High-value match: the term appears in topics, routing_keywords, or domain
|
|
558
|
+
if (topicsText.includes(term) || domainText.includes(term)) {
|
|
559
|
+
highValueMatches++;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (matchedTerms === 0)
|
|
564
|
+
return 0;
|
|
565
|
+
// Base score: fraction of query terms that match
|
|
566
|
+
let score = matchedTerms / queryTerms.length;
|
|
567
|
+
// Boost for high-value matches (topics/domain/routing)
|
|
568
|
+
score += (highValueMatches / queryTerms.length) * 0.3;
|
|
569
|
+
// Living graphs (any type with recurring updates) get a modest freshness boost
|
|
570
|
+
const isLiving = g.update_frequency && !['One-Off', 'Coming Soon'].includes(g.update_frequency);
|
|
571
|
+
if (isLiving) {
|
|
572
|
+
score += 0.05; // Freshness tiebreaker, not an inclusion floor
|
|
573
|
+
}
|
|
574
|
+
return Math.min(score, 1.0);
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Classify a graph into its operational tier based on type + update cadence.
|
|
578
|
+
* Living = actively maintained, freshest data. Static = frozen specialist snapshot.
|
|
579
|
+
* Report = industry analysis (may be ongoing or one-off).
|
|
580
|
+
*/
|
|
581
|
+
export function classifyGraphTier(g) {
|
|
582
|
+
if (g.graph_type === 'supplemental')
|
|
583
|
+
return 'supplemental';
|
|
584
|
+
if (g.graph_type === 'skill')
|
|
585
|
+
return 'skill';
|
|
586
|
+
const isRecurring = g.update_frequency &&
|
|
587
|
+
!['One-Off', 'Coming Soon'].includes(g.update_frequency);
|
|
588
|
+
// Domain graphs with recurring updates + experts with recurring updates = Living
|
|
589
|
+
if ((g.graph_type === 'domain' || g.graph_type === 'expert') && isRecurring) {
|
|
590
|
+
return 'living';
|
|
591
|
+
}
|
|
592
|
+
if (g.graph_type === 'expert')
|
|
593
|
+
return 'static_expert';
|
|
594
|
+
if (g.graph_type === 'industry report')
|
|
595
|
+
return 'report';
|
|
596
|
+
return 'report'; // fallback for any unclassified
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Get the graphs most relevant to a query, using 2-step routing:
|
|
600
|
+
* 1. Score every live graph's metadata against the query
|
|
601
|
+
* 2. Return only graphs above the relevance threshold
|
|
602
|
+
*
|
|
603
|
+
* Always returns at least `minGraphs` results to avoid blind spots.
|
|
604
|
+
* Maximum of `maxGraphs` to cap fan-out.
|
|
605
|
+
*
|
|
606
|
+
* @param query - The user's search query
|
|
607
|
+
* @param minGraphs - Minimum graphs to return (default 2)
|
|
608
|
+
* @param maxGraphs - Maximum graphs to return (default 6)
|
|
609
|
+
* @param threshold - Minimum relevance score (default 0.15)
|
|
610
|
+
*/
|
|
611
|
+
export function getRelevantGraphs(query, minGraphs = 4, maxGraphs = 15, threshold = 0.10) {
|
|
612
|
+
const DEPRECATED_GRAPH_IDS = new Set(['waldo', 'psfk']);
|
|
613
|
+
const allGraphs = getLiveGraphs().filter(g => (g.graph_type === 'domain' || g.graph_type === 'expert' || g.graph_type === 'industry report')
|
|
614
|
+
&& !DEPRECATED_GRAPH_IDS.has(g.graph_id));
|
|
615
|
+
if (allGraphs.length === 0)
|
|
616
|
+
return [];
|
|
617
|
+
// Runtime check: skip graphs that have no synced content in Neo4j.
|
|
618
|
+
// These are registry shells — the Airtable record exists as 'live' but the
|
|
619
|
+
// Neo4j sync step hasn't run yet (trend_count=0, last_synced=null).
|
|
620
|
+
// Once the CE pipeline syncs them, they'll automatically start routing.
|
|
621
|
+
const hasContent = (g) => (g.trend_count > 0) || (g.last_synced !== null && g.last_synced !== undefined);
|
|
622
|
+
const syncedGraphs = allGraphs.filter(hasContent);
|
|
623
|
+
const skippedShells = allGraphs.length - syncedGraphs.length;
|
|
624
|
+
if (skippedShells > 0) {
|
|
625
|
+
console.error(`[graphRouter] Skipped ${skippedShells} unsynced graph shell(s) (trend_count=0, last_synced=null)`);
|
|
626
|
+
}
|
|
627
|
+
// ── Phase 0: Direct name matching ──
|
|
628
|
+
// When users explicitly name a graph, curator, or company (e.g. "Pull from
|
|
629
|
+
// TBWA/Alyson Stevens Macro, SIC (Ben Dietz), Marieke Neleman..."), keyword
|
|
630
|
+
// scoring dilutes these names across 100+ query terms, dropping them below
|
|
631
|
+
// the threshold. Direct matches get automatic inclusion at score 1.0.
|
|
632
|
+
const queryLower = query.toLowerCase();
|
|
633
|
+
const directMatchIds = new Set();
|
|
634
|
+
for (const g of syncedGraphs) {
|
|
635
|
+
// Check graph_id (e.g., "alyson-stevens-macro", "sic")
|
|
636
|
+
if (queryLower.includes(g.graph_id)) {
|
|
637
|
+
directMatchIds.add(g.graph_id);
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
// Check curator full name (e.g., "Alyson Stevens", "Ben Dietz")
|
|
641
|
+
const curatorLower = (g.curator || '').toLowerCase();
|
|
642
|
+
if (curatorLower && curatorLower.length > 3 && queryLower.includes(curatorLower)) {
|
|
643
|
+
directMatchIds.add(g.graph_id);
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
// Check curator last name (>3 chars to avoid false positives like "AI", "NIQ")
|
|
647
|
+
const curatorParts = curatorLower.split(/\s+/);
|
|
648
|
+
const curatorLastName = curatorParts[curatorParts.length - 1];
|
|
649
|
+
if (curatorLastName && curatorLastName.length > 3 && queryLower.includes(curatorLastName)) {
|
|
650
|
+
directMatchIds.add(g.graph_id);
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
// Check company name (e.g., "TBWA", "Delta", "Havas", "Kantar")
|
|
654
|
+
const companyLower = (g.company || '').toLowerCase();
|
|
655
|
+
if (companyLower && companyLower.length > 2 && queryLower.includes(companyLower)) {
|
|
656
|
+
directMatchIds.add(g.graph_id);
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
// Check graph name (e.g., "SIC", "Connection Index")
|
|
660
|
+
const nameLower = (g.name || '').toLowerCase();
|
|
661
|
+
if (nameLower && nameLower.length > 3 && queryLower.includes(nameLower)) {
|
|
662
|
+
directMatchIds.add(g.graph_id);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (directMatchIds.size > 0) {
|
|
667
|
+
console.error(`[graphRouter] Phase 0 direct name matches: ${[...directMatchIds].join(', ')}`);
|
|
668
|
+
}
|
|
669
|
+
// Score ALL graph types together — domain, expert, industry report compete on merit
|
|
670
|
+
const scored = syncedGraphs.map(g => ({
|
|
671
|
+
graph: g,
|
|
672
|
+
score: directMatchIds.has(g.graph_id) ? 1.0 : scoreGraphRelevance(query, g),
|
|
673
|
+
graphTier: classifyGraphTier(g),
|
|
674
|
+
}));
|
|
675
|
+
// Sort by score descending
|
|
676
|
+
scored.sort((a, b) => b.score - a.score);
|
|
677
|
+
// Take all above threshold, respecting min/max bounds
|
|
678
|
+
// Direct matches always pass (score 1.0 >> threshold)
|
|
679
|
+
const aboveThreshold = scored.filter(s => s.score >= threshold);
|
|
680
|
+
const resultCount = Math.max(minGraphs, Math.min(aboveThreshold.length, maxGraphs));
|
|
681
|
+
// Always take the top N (even if some are below threshold, to meet minGraphs)
|
|
682
|
+
const results = scored.slice(0, resultCount);
|
|
683
|
+
// ── Tier diversity guarantee ──
|
|
684
|
+
// Domain graphs have keyword-rich metadata that naturally wins pure keyword scoring.
|
|
685
|
+
// Ensure at least 1 report and 1 expert appear when scored candidates exist,
|
|
686
|
+
// so the user always gets insight from multiple source types.
|
|
687
|
+
const ensureTierPresent = (tier) => {
|
|
688
|
+
if (results.some(r => r.graphTier === tier))
|
|
689
|
+
return; // already represented
|
|
690
|
+
const best = scored.find(s => s.graphTier === tier && s.score > 0);
|
|
691
|
+
if (!best)
|
|
692
|
+
return; // no scored candidate of this tier
|
|
693
|
+
// Replace the lowest-scoring result (if our candidate scores >= 50% of it)
|
|
694
|
+
const weakest = results[results.length - 1];
|
|
695
|
+
if (weakest && best.score >= weakest.score * 0.5) {
|
|
696
|
+
results[results.length - 1] = best;
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
// Or just append (we'll slightly exceed maxGraphs but gain diversity)
|
|
700
|
+
results.push(best);
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
ensureTierPresent('report');
|
|
704
|
+
ensureTierPresent('static_expert');
|
|
705
|
+
console.error(`[graphRouter] Query: "${query.substring(0, 60)}..." → ${results.length}/${syncedGraphs.length} synced graphs selected (${skippedShells} shells skipped): ${results.map(r => `${r.graph.graph_id}(${r.score.toFixed(2)},${r.graphTier})`).join(', ')}`);
|
|
706
|
+
return results;
|
|
707
|
+
}
|
|
708
|
+
//# sourceMappingURL=catalogCache.js.map
|