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,2829 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Handlers — MCP server factory with all tool registrations.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from index.ts to reduce monolith size.
|
|
5
|
+
* Contains createServer() which registers all 30+ MCP tools.
|
|
6
|
+
*/
|
|
7
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
8
|
+
import { ListResourcesRequestSchema, ListPromptsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { GoogleGenAI } from '@google/genai';
|
|
11
|
+
import axios from 'axios';
|
|
12
|
+
import crypto from 'crypto';
|
|
13
|
+
import { buildDynamicPromptSections, getDomainGraphIds, getGraphs, getLiveGraphs, buildDisplayName, getRelevantGraphs, getEnabledSkillConfigs, getSkillGraphs, getAnalysts } from './catalogCache.js';
|
|
14
|
+
import { renderBrandWidget } from './brandTemplate.js';
|
|
15
|
+
import { renderSearchWidget } from './searchTemplate.js';
|
|
16
|
+
import { FODDA_COMPONENT_GUIDE, getShellTemplate } from './widgetShell.js';
|
|
17
|
+
import { MCP_SERVER_VERSION } from './tools.js';
|
|
18
|
+
import { buildSystemPrompt, BRAND_INTELLIGENCE_RENDERING_SPEC, FODDA_WIDGET_DESIGN_BRIEF } from './systemPrompt.js';
|
|
19
|
+
import { computeLifecycle, computeMomentum, isFastMover, enrichEvidence, GRAPH_BADGES, getFoddaTheme, getSupplementalTheme } from './enrichment.js';
|
|
20
|
+
import { handleAccessError, handleTrialCreditExhaustion } from './errorHandling.js';
|
|
21
|
+
import { chargeQuery } from './pricingCache.js';
|
|
22
|
+
import { callOutputSkills, buildSkillInput, discoverSkillTools, executeSkillTool, mapSkillError } from './skillClient.js';
|
|
23
|
+
import { createSessionTracker, postToSlack } from './sessionTracker.js';
|
|
24
|
+
import { buildResearcherInstruction } from './agents/fodda-researcher/index.js';
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Render instructions — embedded in tool responses for LLM clients that
|
|
27
|
+
// don't receive MCP server-level `instructions` (e.g. Claude.ai).
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
const RENDER_SPEC_VERSION = '1.0';
|
|
30
|
+
function buildRenderInstructions(opts) {
|
|
31
|
+
const rules = [
|
|
32
|
+
'Attribute findings to their source graph by name using graphName. Never say "the Fodda graph." Fodda is the platform — the graphs are created by named experts.',
|
|
33
|
+
];
|
|
34
|
+
if (opts.hasEvidence) {
|
|
35
|
+
rules.push('Use formatted_citation from evidence items as inline markdown links. Never present a claim without its source link.');
|
|
36
|
+
}
|
|
37
|
+
if (opts.hasPrompts) {
|
|
38
|
+
rules.push('Surface suggested_next_prompts to the user as numbered follow-up suggestions.');
|
|
39
|
+
}
|
|
40
|
+
rules.push('Each trend result has a suggested_drill_down field containing a ready-to-use follow-up prompt. After presenting each trend, show its drill-down as a clickable suggestion (e.g. "→ Tell me more about X"). This lets the user explore individual trends without typing.');
|
|
41
|
+
if (opts.hasWidget) {
|
|
42
|
+
rules.push('A separate content block starting with "── WIDGET HTML ──" may follow this JSON payload. It contains a pre-rendered Fodda visualization. If your client supports HTML rendering (show_widget, visualize:show_widget, artifacts), pass that HTML verbatim. Do not rewrite or restyle.');
|
|
43
|
+
}
|
|
44
|
+
rules.push('Keep responses clean and structured. Lead with a sharp editorial claim, not a summary of data methodology.');
|
|
45
|
+
const instructions = {
|
|
46
|
+
_render_spec_version: RENDER_SPEC_VERSION,
|
|
47
|
+
rules,
|
|
48
|
+
};
|
|
49
|
+
// Graph-level link templates (populated when catalog has webpage_url)
|
|
50
|
+
if (opts.graphWebpageUrls && Object.keys(opts.graphWebpageUrls).length > 0) {
|
|
51
|
+
instructions._source_links = opts.graphWebpageUrls;
|
|
52
|
+
}
|
|
53
|
+
return instructions;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Append a low-credit warning to the response data if tokens are running low.
|
|
57
|
+
* Utilizes new dynamic Stripe links and upsell data provided by the Fodda API.
|
|
58
|
+
*
|
|
59
|
+
* Also surfaces upstream X-Usage-* header warnings (approaching-limit, overage-active)
|
|
60
|
+
* injected by foddaRequest() as _upstream_usage.
|
|
61
|
+
*/
|
|
62
|
+
function appendUsageWarning(data, isTrial, userEmail) {
|
|
63
|
+
// ── Upstream header-based warnings (X-Usage-Warning / X-Usage-Percent / X-Usage-Overage-Tokens) ──
|
|
64
|
+
if (data?._upstream_usage) {
|
|
65
|
+
const u = data._upstream_usage;
|
|
66
|
+
if (u.warning === 'approaching-limit' && u.percent) {
|
|
67
|
+
data._usage_status = `⚠️ You've used ${u.percent}% of your monthly API calls. Consider upgrading or adding a payment method to avoid interruption.`;
|
|
68
|
+
}
|
|
69
|
+
else if (u.warning === 'overage-active') {
|
|
70
|
+
data._usage_status = u.overage_tokens
|
|
71
|
+
? `📊 You're in overage — ${u.overage_tokens} additional token(s) used at $0.20/token this billing cycle.`
|
|
72
|
+
: `📊 Overage billing is active — additional queries are charged at $0.20/token.`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!data?.usage)
|
|
76
|
+
return;
|
|
77
|
+
const remaining = data.usage.remaining ?? data.usage.credits_remaining;
|
|
78
|
+
if (remaining === undefined || remaining < 0)
|
|
79
|
+
return;
|
|
80
|
+
const threshold = isTrial ? 10 : 15;
|
|
81
|
+
if (remaining >= threshold)
|
|
82
|
+
return;
|
|
83
|
+
const noun = remaining === 1 ? 'API call' : 'API calls';
|
|
84
|
+
const upsell = data.usage.upsell || data.upsell || (data._account?.upsell);
|
|
85
|
+
const stripeLink = data.usage.stripeLink || (data._account?.stripe_link);
|
|
86
|
+
const price = data.usage.monthlyPriceUSD || (data._account?.monthly_price_usd);
|
|
87
|
+
// Build portal upgrade URL with pre-filled email
|
|
88
|
+
const portalParams = new URLSearchParams({ action: 'upgrade' });
|
|
89
|
+
if (userEmail && userEmail.includes('@'))
|
|
90
|
+
portalParams.set('email', userEmail);
|
|
91
|
+
const portalUrl = `${API_BASE_URL.replace('api.', 'app.')}/portal?${portalParams.toString()}`;
|
|
92
|
+
if (isTrial) {
|
|
93
|
+
data._credit_warning = `\u26a0\ufe0f You have ${remaining} ${noun} remaining on your trial. Upgrade to the free Base plan (100 API calls/month) or a paid plan to continue.\n\n\ud83d\ude80 **[Upgrade Now \u2192](${portalUrl})**`;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
let msg = `\u26a0\ufe0f You have ${remaining} ${noun} remaining this month.`;
|
|
97
|
+
if (upsell) {
|
|
98
|
+
msg += ` You can get 100 more API calls for $${upsell.price || '50'} right now: ${upsell.link || portalUrl}`;
|
|
99
|
+
}
|
|
100
|
+
else if (stripeLink) {
|
|
101
|
+
msg += ` You can top up your API calls here: ${stripeLink}`;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
msg += ` You can add more at ${portalUrl}, or your balance resets next month.`;
|
|
105
|
+
}
|
|
106
|
+
data._credit_warning = msg;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Collect graph webpage URLs from catalog for graphs present in results */
|
|
110
|
+
function collectGraphWebpageUrls(graphIds) {
|
|
111
|
+
const urls = {};
|
|
112
|
+
const allGraphs = getGraphs();
|
|
113
|
+
for (const gid of graphIds) {
|
|
114
|
+
const g = allGraphs.find(cat => cat.graph_id === gid);
|
|
115
|
+
// webpage_url will become available once the API surfaces it from Airtable
|
|
116
|
+
if (g && g.webpage_url) {
|
|
117
|
+
urls[gid] = g.webpage_url;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return urls;
|
|
121
|
+
}
|
|
122
|
+
const API_BASE_URL = process.env.FODDA_API_URL || 'https://api.fodda.ai';
|
|
123
|
+
const GRAPH_ID_DESC = "The graph ID. Use list_graphs to see all options. Examples: 'retail', 'tech', 'food', 'travel', 'beauty', 'sports', 'sic', 'pew', 'ce-design', 'ezra-eeman-wayfinder', 'dhl-ecommerce-trends-2026', 'automotive-color-trends', 'alyson-stevens-macro', 'generative-realities', 'pwc/sxsw-2026-key-insights', 'green-house/thrive-report', 'michaels-2026-creativity-trend-report', 'delta/the-connection-index'";
|
|
124
|
+
// ── P0 Security: Allowlist serializer for list_graphs ──
|
|
125
|
+
const GRAPH_LIST_ALLOWLIST = new Set([
|
|
126
|
+
'graph_id', 'name', 'one_liner', 'description', 'curator',
|
|
127
|
+
'domain', 'graph_type', 'trend_count', 'evidence_count',
|
|
128
|
+
'status', 'last_updated',
|
|
129
|
+
]);
|
|
130
|
+
const SNAKE_TO_CAMEL = {
|
|
131
|
+
'graph_id': 'graphId', 'one_liner': 'oneLiner', 'graph_type': 'graphType',
|
|
132
|
+
'trend_count': 'trendCount', 'evidence_count': 'evidenceCount',
|
|
133
|
+
'last_updated': 'lastUpdated',
|
|
134
|
+
};
|
|
135
|
+
// Strip internal routing guidance that may be baked into a description — either
|
|
136
|
+
// injected by us below or already present in the API/Airtable description field.
|
|
137
|
+
function stripRoutingInstruction(text) {
|
|
138
|
+
return text.replace(/\n*\[ROUTING INSTRUCTION:[\s\S]*?\]\s*$/g, '').trimEnd();
|
|
139
|
+
}
|
|
140
|
+
function serializeGraphForList(g) {
|
|
141
|
+
const out = {};
|
|
142
|
+
for (const key of GRAPH_LIST_ALLOWLIST) {
|
|
143
|
+
let val = g[key] ?? g[SNAKE_TO_CAMEL[key] || key];
|
|
144
|
+
if (val !== undefined && val !== null) {
|
|
145
|
+
// Defensive: never surface internal routing text in the public description.
|
|
146
|
+
if (key === 'description' && typeof val === 'string') {
|
|
147
|
+
val = stripRoutingInstruction(val);
|
|
148
|
+
}
|
|
149
|
+
out[key] = val;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
const DEPRECATED_GRAPH_IDS = new Set(['waldo', 'psfk']);
|
|
155
|
+
/**
|
|
156
|
+
* Resolve the effective userId for API requests.
|
|
157
|
+
* Priority: session userId (from MCP URL, typically an email) > tool-provided userId.
|
|
158
|
+
* For authenticated users, the session email ALWAYS wins — the LLM-generated slug is ignored.
|
|
159
|
+
* For trial/anonymous users (session userId is 'anonymous' or empty), the tool-provided
|
|
160
|
+
* userId acts as a fingerprint for key-sharing detection.
|
|
161
|
+
*/
|
|
162
|
+
function resolveUserId(sessionUserId, toolProvidedUid) {
|
|
163
|
+
// If session has a real identifier (email), always use it
|
|
164
|
+
if (sessionUserId && sessionUserId !== 'anonymous') {
|
|
165
|
+
return sessionUserId;
|
|
166
|
+
}
|
|
167
|
+
// For trial/anonymous: use tool-provided uid as fingerprint, fall back to 'anonymous'
|
|
168
|
+
return toolProvidedUid || sessionUserId || 'anonymous';
|
|
169
|
+
}
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// createServer — builds and returns a fully-configured MCP server
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
const activeResearchJobs = new Map();
|
|
174
|
+
const activeSupplementalJobs = new Map();
|
|
175
|
+
export async function createServer(apiKey, userId, foddaRequest, waverunnerRequest, storeWidget, getServiceUrl, entryId = '') {
|
|
176
|
+
// Fetch account profile for persona-aware framing (best-effort)
|
|
177
|
+
let accountProfile;
|
|
178
|
+
let sessionDisabledGraphs = new Set();
|
|
179
|
+
let sessionSkills = [];
|
|
180
|
+
let discoveredSkills = [];
|
|
181
|
+
try {
|
|
182
|
+
// H2: Race the /v1/graphs call against a 5s timeout so a slow upstream
|
|
183
|
+
// never blocks the MCP initialize handshake. Degrades gracefully —
|
|
184
|
+
// missing account profile means no persona-aware framing, but tools work.
|
|
185
|
+
const INIT_TIMEOUT_MS = 5000;
|
|
186
|
+
const graphsData = await Promise.race([
|
|
187
|
+
foddaRequest('GET', '/v1/graphs', apiKey, userId),
|
|
188
|
+
new Promise((resolve) => setTimeout(() => resolve(null), INIT_TIMEOUT_MS)),
|
|
189
|
+
]);
|
|
190
|
+
if (graphsData?._account) {
|
|
191
|
+
accountProfile = graphsData._account;
|
|
192
|
+
console.error(`[persona] Account profile loaded: isProfessionalServices=${accountProfile.isProfessionalServices}, jobTitle=${accountProfile.jobTitle}, company=${accountProfile.companyName}`);
|
|
193
|
+
}
|
|
194
|
+
// Capture disabled graphs from the API response (added by API Agent)
|
|
195
|
+
if (Array.isArray(graphsData?.disabled_graphs)) {
|
|
196
|
+
sessionDisabledGraphs = new Set(graphsData.disabled_graphs);
|
|
197
|
+
console.error(`[skills] User has ${sessionDisabledGraphs.size} disabled graph(s)`);
|
|
198
|
+
}
|
|
199
|
+
// Resolve enabled skills for this session
|
|
200
|
+
// Strategy: extract skill configs from /v1/graphs response directly,
|
|
201
|
+
// then fall back to catalogCache. Skills route through the Core API
|
|
202
|
+
// now — no mcp_url or auth tokens needed.
|
|
203
|
+
const responseGraphs = graphsData?.graphs || [];
|
|
204
|
+
const responseSkills = responseGraphs
|
|
205
|
+
.filter((g) => g.graph_type === 'skill' && !g.disabled && (g.status === 'live' || g.status === 'beta'))
|
|
206
|
+
.map((g) => ({
|
|
207
|
+
id: g.graph_id,
|
|
208
|
+
name: g.name,
|
|
209
|
+
phase: (g.skill_phase === 'research' || g.skillPhase === 'research' ? 'research' : 'output'),
|
|
210
|
+
}));
|
|
211
|
+
// Use response-extracted skills if available, otherwise fall back to catalogCache
|
|
212
|
+
sessionSkills = responseSkills.length > 0
|
|
213
|
+
? responseSkills
|
|
214
|
+
: getEnabledSkillConfigs(sessionDisabledGraphs);
|
|
215
|
+
if (sessionSkills.length > 0) {
|
|
216
|
+
console.error(`[skills] ${sessionSkills.length} skill(s) enabled: ${sessionSkills.map(s => s.name).join(', ')}`);
|
|
217
|
+
}
|
|
218
|
+
// Discover interactive tools for each enabled skill via Core API
|
|
219
|
+
// This calls GET /v1/skills/{skill_id}/tools for each skill in parallel
|
|
220
|
+
if (sessionSkills.length > 0) {
|
|
221
|
+
const discoveryResults = await Promise.allSettled(sessionSkills.map(s => discoverSkillTools(s.id, apiKey)));
|
|
222
|
+
for (const r of discoveryResults) {
|
|
223
|
+
if (r.status === 'fulfilled' && r.value) {
|
|
224
|
+
discoveredSkills.push(r.value);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (discoveredSkills.length > 0) {
|
|
228
|
+
const totalTools = discoveredSkills.reduce((sum, s) => sum + s.tools.length, 0);
|
|
229
|
+
console.error(`[skills] Discovered ${totalTools} interactive tool(s) across ${discoveredSkills.length} skill(s): ${discoveredSkills.map(s => `${s.skill_name}(${s.tools.length})`).join(', ')}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
console.error('[persona] Failed to fetch account profile — proceeding without persona framing');
|
|
235
|
+
}
|
|
236
|
+
// sk_trial_ keys are retired. New individual trial accounts (planCode 13) use
|
|
237
|
+
// sk_live_ keys and are detected from API error responses via isIndividualTrial().
|
|
238
|
+
// isTrial is false at session init for all current account types.
|
|
239
|
+
const isTrial = false;
|
|
240
|
+
const sessionTracker = createSessionTracker();
|
|
241
|
+
// Fire-and-forget: log the user's query text to the Questions table.
|
|
242
|
+
// Called at tool entry — BEFORE cache-eligible foddaRequest calls —
|
|
243
|
+
// so the question is captured even when the MCP query cache serves a hit.
|
|
244
|
+
function logUserQuery(query, interactionType, graphId) {
|
|
245
|
+
foddaRequest('POST', '/v1/log/question', apiKey, userId, {
|
|
246
|
+
question: query,
|
|
247
|
+
graphId: graphId || 'all',
|
|
248
|
+
interactionType,
|
|
249
|
+
source: 'mcp',
|
|
250
|
+
}).catch(() => { }); // Never block on logging failures
|
|
251
|
+
}
|
|
252
|
+
// Build skill metadata for system prompt — includes both output-phase and interactive skills
|
|
253
|
+
const skillPromptMeta = sessionSkills.map(s => {
|
|
254
|
+
const discovered = discoveredSkills.find(d => d.skill_id === s.id);
|
|
255
|
+
return {
|
|
256
|
+
id: s.id,
|
|
257
|
+
name: s.name,
|
|
258
|
+
interactiveTools: discovered?.tools.map(t => `${s.id}_${t.name}`) || [],
|
|
259
|
+
costPerCall: discovered?.cost_per_call ?? 2,
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
const server = new McpServer({
|
|
263
|
+
name: 'fodda_mcp',
|
|
264
|
+
version: MCP_SERVER_VERSION,
|
|
265
|
+
}, {
|
|
266
|
+
instructions: buildSystemPrompt(accountProfile, skillPromptMeta, isTrial, entryId),
|
|
267
|
+
});
|
|
268
|
+
// Register empty capabilities and handlers to silence directory warnings
|
|
269
|
+
server.server.registerCapabilities({
|
|
270
|
+
resources: {},
|
|
271
|
+
prompts: {}
|
|
272
|
+
});
|
|
273
|
+
server.server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
274
|
+
return { resources: [] };
|
|
275
|
+
});
|
|
276
|
+
server.server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
277
|
+
return { prompts: [] };
|
|
278
|
+
});
|
|
279
|
+
// ── Register discovered interactive skill tools as MCP tools ──
|
|
280
|
+
for (const discovered of discoveredSkills) {
|
|
281
|
+
for (const tool of discovered.tools) {
|
|
282
|
+
const prefixedName = `${discovered.skill_id}_${tool.name}`;
|
|
283
|
+
const costNote = `(costs ${discovered.cost_per_call} API calls)`;
|
|
284
|
+
const description = `[${discovered.skill_name}] ${tool.description || tool.name} ${costNote}`;
|
|
285
|
+
// Build a Zod schema from the tool's inputSchema
|
|
286
|
+
// The inputSchema from the API is a JSON Schema object — we accept
|
|
287
|
+
// it as a free-form argument object and pass through to the API
|
|
288
|
+
server.tool(prefixedName, description, { arguments: z.record(z.string(), z.any()).optional().describe('Arguments for the skill tool. Check the tool description for expected parameters.') }, { title: `${discovered.skill_name}: ${tool.name}`, readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async ({ arguments: toolArgs }) => {
|
|
289
|
+
try {
|
|
290
|
+
const { output, durationMs } = await executeSkillTool(discovered.skill_id, tool.name, toolArgs || {}, apiKey, userId);
|
|
291
|
+
return {
|
|
292
|
+
content: [{
|
|
293
|
+
type: 'text',
|
|
294
|
+
text: output || '(No output returned from skill)',
|
|
295
|
+
}],
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
// Map known skill errors to user-friendly messages
|
|
300
|
+
const friendlyMsg = mapSkillError(err);
|
|
301
|
+
if (friendlyMsg) {
|
|
302
|
+
return {
|
|
303
|
+
isError: true,
|
|
304
|
+
content: [{ type: 'text', text: friendlyMsg }],
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
// Check for credit exhaustion
|
|
308
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
309
|
+
if (trialResult)
|
|
310
|
+
return trialResult;
|
|
311
|
+
// Generic error
|
|
312
|
+
const msg = err.response?.data?.error?.message || err.message;
|
|
313
|
+
return {
|
|
314
|
+
isError: true,
|
|
315
|
+
content: [{ type: 'text', text: JSON.stringify({ error: msg }) }],
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// --- get_my_account ---
|
|
322
|
+
server.tool('get_my_account', 'Check the current user\'s account status: API call balance, plan, enabled/disabled graphs, and profile info. Use when the user asks "how many API calls do I have?", "what plan am I on?", "what graphs can I access?", or similar account questions. Returns live data — not cached from session start.', {}, { title: 'Check Account Status', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async () => {
|
|
323
|
+
try {
|
|
324
|
+
// Fetch fresh account data from /v1/graphs (which returns _account)
|
|
325
|
+
const data = await foddaRequest('GET', '/v1/graphs', apiKey, userId);
|
|
326
|
+
const account = data?._account;
|
|
327
|
+
if (!account) {
|
|
328
|
+
if (isTrial) {
|
|
329
|
+
return {
|
|
330
|
+
content: [{
|
|
331
|
+
type: 'text',
|
|
332
|
+
text: JSON.stringify({
|
|
333
|
+
plan: 'Trial',
|
|
334
|
+
note: 'You\'re on a trial — limited API calls across all expert graphs. I can set you up with a Free Base account (100 API calls/month) instantly — just give me your email.',
|
|
335
|
+
manage_url: 'https://app.fodda.ai',
|
|
336
|
+
}, null, 2)
|
|
337
|
+
}]
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
content: [{
|
|
342
|
+
type: 'text',
|
|
343
|
+
text: JSON.stringify({ note: 'Account information is not available. Visit app.fodda.ai to manage your account.' })
|
|
344
|
+
}]
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
// Format a clean, user-friendly response
|
|
348
|
+
// Note: API still returns tokens_remaining etc. — we read those fields
|
|
349
|
+
// but present them as "api_calls" to the user.
|
|
350
|
+
const status = {
|
|
351
|
+
plan: account.plan || 'Unknown',
|
|
352
|
+
api_calls_remaining: account.tokens_remaining ?? account.credits ?? 'unknown',
|
|
353
|
+
api_calls_total: account.tokens_total ?? account.monthlyQueryLimit ?? 'unknown',
|
|
354
|
+
};
|
|
355
|
+
// Flag overage status when tokens_remaining is negative (overage billing active)
|
|
356
|
+
if (typeof status.api_calls_remaining === 'number' && status.api_calls_remaining < 0) {
|
|
357
|
+
status.overage_active = true;
|
|
358
|
+
status.overage_tokens = Math.abs(status.api_calls_remaining);
|
|
359
|
+
status.overage_note = `You're ${Math.abs(status.api_calls_remaining)} token(s) over your monthly limit. Overage charges apply at $0.20/token.`;
|
|
360
|
+
}
|
|
361
|
+
if (account.tokens_used !== undefined)
|
|
362
|
+
status.api_calls_used = account.tokens_used;
|
|
363
|
+
if (account.reset_date)
|
|
364
|
+
status.reset_date = account.reset_date;
|
|
365
|
+
if (account.graphs_enabled?.length) {
|
|
366
|
+
status.graphs_enabled_count = account.graphs_enabled.length;
|
|
367
|
+
status.graphs_enabled_note = 'Use list_graphs to see the full list';
|
|
368
|
+
}
|
|
369
|
+
if (account.graphs_disabled?.length)
|
|
370
|
+
status.graphs_disabled = account.graphs_disabled;
|
|
371
|
+
if (account.profile) {
|
|
372
|
+
status.profile = {};
|
|
373
|
+
if (account.profile.name && !/^rec[A-Za-z0-9]{14}$/.test(account.profile.name))
|
|
374
|
+
status.profile.name = account.profile.name;
|
|
375
|
+
if (account.profile.company)
|
|
376
|
+
status.profile.company = account.profile.company;
|
|
377
|
+
if (account.profile.jobTitle)
|
|
378
|
+
status.profile.job_title = account.profile.jobTitle;
|
|
379
|
+
}
|
|
380
|
+
status.manage_url = 'https://app.fodda.ai/account';
|
|
381
|
+
if (account.stripe_link)
|
|
382
|
+
status.stripe_link = account.stripe_link;
|
|
383
|
+
if (account.upsell && account.upsell.plan && account.upsell.price > 0) {
|
|
384
|
+
status.upgrade_offer = {
|
|
385
|
+
target: account.upsell.plan,
|
|
386
|
+
price: `$${account.upsell.price}`,
|
|
387
|
+
link: account.upsell.link,
|
|
388
|
+
action: `Upgrade to ${account.upsell.plan}`
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
status.graphs_url = 'https://app.fodda.ai/graphs';
|
|
392
|
+
return {
|
|
393
|
+
content: [{
|
|
394
|
+
type: 'text',
|
|
395
|
+
text: JSON.stringify(status, null, 2)
|
|
396
|
+
}]
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
401
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
// --- list_graphs ---
|
|
405
|
+
server.tool('list_graphs', 'List all knowledge graphs the user can access — IDs, descriptions, authors, sectors, signal counts. Use FIRST in any session to discover available sources before searching. Returns graph metadata needed for graphId parameters in other tools. Deprecated: waldo, psfk (use retail/tech/food/travel/fashion/beauty/sports instead).', { userId: z.string().optional().describe('Optional user identifier. Authenticated users are identified automatically via API key. For trial users, this helps track usage.') }, { title: 'List Knowledge Graphs', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ userId: uid }) => {
|
|
406
|
+
try {
|
|
407
|
+
const data = await foddaRequest('GET', '/v1/graphs', apiKey, resolveUserId(userId, uid));
|
|
408
|
+
// P0: Apply allowlist serializer — strips PII (owner_email) and CMS bloat fields,
|
|
409
|
+
// and sanitizes any routing text baked into description. Routing guidance is then
|
|
410
|
+
// exposed as a dedicated routing_hint field for LLM consumption, keeping the
|
|
411
|
+
// public-facing description clean (it must never contain internal routing text).
|
|
412
|
+
if (data && Array.isArray(data.graphs)) {
|
|
413
|
+
data.graphs = data.graphs.map((g) => {
|
|
414
|
+
const serialized = serializeGraphForList(g);
|
|
415
|
+
if (g.agent_prompt) {
|
|
416
|
+
serialized.routing_hint = g.agent_prompt;
|
|
417
|
+
}
|
|
418
|
+
return serialized;
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
// Profile nudge: if userContext is empty, append a nudge for Claude
|
|
422
|
+
const account = data?._account;
|
|
423
|
+
// Strip _account from list_graphs output (use get_my_account instead)
|
|
424
|
+
if (data)
|
|
425
|
+
delete data._account;
|
|
426
|
+
if (account && !account.userContext && !isTrial) {
|
|
427
|
+
const nudge = `\n\n---\n⚠️ NO RESEARCH PROFILE SET for this user.\nResponses will be generic until you capture their profile.\nThrough natural conversation, determine:\n- Their role and what they use Fodda for (pitches, ongoing research, client advisory)\n- What kind of evidence they value (commercial data vs. design inspiration)\n- Geographic focus (global, specific regions)\n- How results should be framed (executive brief vs. deep analysis)\nThen call update_user_profile. Write BEHAVIORAL INSTRUCTIONS, not a bio.\nFormat: one sentence of identity, then numbered directives that change how you respond.\nExample: "Agency strategist doing pitches. (1) Lead with landscape orientation. (2) Prioritize commercial evidence. (3) Time-scarce — strongest findings first."\n---`;
|
|
428
|
+
const jsonText = JSON.stringify(data, null, 2);
|
|
429
|
+
return { content: [{ type: 'text', text: jsonText + nudge }] };
|
|
430
|
+
}
|
|
431
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
435
|
+
if (trialResult)
|
|
436
|
+
return trialResult;
|
|
437
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
438
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
// --- list_analysts ---
|
|
442
|
+
server.tool('list_analysts', 'List available Synthetic Analysts — named expert personas grounded in specific knowledge graphs. Each analyst has a unique voice, methodology, and domain expertise that cannot be replicated by web search. Use when user asks to "talk to" or "consult" an expert, or when you need specialist depth on culture, strategy, or innovation topics.', { userId: z.string().optional().describe('Optional user identifier.') }, { title: 'List Synthetic Analysts', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ userId: uid }) => {
|
|
443
|
+
try {
|
|
444
|
+
const data = await foddaRequest('GET', '/v1/analysts', apiKey, resolveUserId(userId, uid));
|
|
445
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
446
|
+
}
|
|
447
|
+
catch (err) {
|
|
448
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
449
|
+
if (trialResult)
|
|
450
|
+
return trialResult;
|
|
451
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
452
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
// --- search_graph ---
|
|
456
|
+
server.tool('search_graph', 'Search expert-curated knowledge graphs for trend clusters, signals, and consumer behavior evidence across retail, beauty, luxury, fashion, sport, consumer electronics, F&B, travel, and 30+ specialist domains. Returns structured trend data with cited evidence chains, source attribution, lifecycle signals (emerging/building/mature/fading), and momentum indicators — not generic web summaries. If graphId is omitted, searches ALL accessible graphs in parallel (recommended default). Use when the query involves market trends, competitor analysis, innovation signals, consumer behavior, cultural shifts, or any topic where curated expert intelligence outperforms web search.', {
|
|
457
|
+
graphId: z.string().optional().describe("Optional graph ID. If omitted, searches ALL accessible graphs. Examples: 'retail', 'tech', 'food', 'travel', 'beauty', 'sports', 'sic', 'pew', 'ce-design', 'ezra-eeman-wayfinder', 'dhl-ecommerce-trends-2026', 'automotive-color-trends', 'alyson-stevens-macro', 'generative-realities', 'pwc/sxsw-2026-key-insights', 'green-house/thrive-report', 'delta/the-connection-index'"),
|
|
458
|
+
query: z.string().describe('The search query. Location terms are auto-detected and used to filter results geographically.'),
|
|
459
|
+
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
|
|
460
|
+
limit: z.number().optional().describe('Maximum number of results (default 10, max 50)'),
|
|
461
|
+
use_semantic: z.boolean().optional().describe('Whether to use semantic search (default true)'),
|
|
462
|
+
include_evidence: z.boolean().optional().describe('If true, batch-fetch supporting evidence articles inline with results. Default: true.'),
|
|
463
|
+
skip_skills: z.boolean().optional().describe('If true, skip applying any enabled skills (Paralogy, Igloo, etc.) for this query only. Use when the user says "without skills", "skip Paralogy", or "just the raw results". Default: false.')
|
|
464
|
+
}, { title: 'Search Knowledge Graph', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ graphId, query, userId: uid, limit, use_semantic, include_evidence, skip_skills }) => {
|
|
465
|
+
try {
|
|
466
|
+
// Log query to Questions table (fire-and-forget, before cache)
|
|
467
|
+
logUserQuery(query, 'search', graphId);
|
|
468
|
+
const effectiveLimit = Math.min(limit || 10, 50);
|
|
469
|
+
const body = {
|
|
470
|
+
query,
|
|
471
|
+
limit: effectiveLimit,
|
|
472
|
+
use_semantic: use_semantic !== false,
|
|
473
|
+
include_evidence: include_evidence ?? true,
|
|
474
|
+
};
|
|
475
|
+
// ── Supplemental data is deferred until we know results are relevant ──
|
|
476
|
+
let data;
|
|
477
|
+
// If no graphId or deprecated 'psfk', use smart 2-step routing
|
|
478
|
+
if (!graphId || graphId === 'psfk') {
|
|
479
|
+
// Step 1: Score query against graph metadata to find relevant graphs
|
|
480
|
+
const relevantGraphs = getRelevantGraphs(query);
|
|
481
|
+
const graphsToSearch = relevantGraphs.map(r => r.graph);
|
|
482
|
+
const perGraphLimit = Math.max(5, Math.ceil(effectiveLimit / Math.max(graphsToSearch.length, 1)));
|
|
483
|
+
const results = await Promise.allSettled(graphsToSearch.map(g => foddaRequest('POST', `/v1/graphs/${encodeURIComponent(g.graph_id)}/search`, apiKey, resolveUserId(userId, uid), { ...body, limit: perGraphLimit })));
|
|
484
|
+
// Merge rows, deduplicate by trendId + near-duplicate name detection
|
|
485
|
+
const allRows = [];
|
|
486
|
+
const seen = new Set();
|
|
487
|
+
const seenNames = []; // for near-duplicate check
|
|
488
|
+
const nameTokens = (name) => new Set(name.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(t => t.length > 2));
|
|
489
|
+
const isSemDuplicate = (nameA, nameB) => {
|
|
490
|
+
const tokA = nameTokens(nameA);
|
|
491
|
+
const tokB = nameTokens(nameB);
|
|
492
|
+
if (tokA.size === 0 || tokB.size === 0)
|
|
493
|
+
return false;
|
|
494
|
+
const overlap = [...tokA].filter(t => tokB.has(t)).length;
|
|
495
|
+
return overlap / Math.min(tokA.size, tokB.size) > 0.6;
|
|
496
|
+
};
|
|
497
|
+
for (let i = 0; i < results.length; i++) {
|
|
498
|
+
const r = results[i];
|
|
499
|
+
if (r.status !== 'fulfilled')
|
|
500
|
+
continue;
|
|
501
|
+
const fulfilled = r;
|
|
502
|
+
const rows = Array.isArray(fulfilled.value) ? fulfilled.value : (fulfilled.value?.rows || []);
|
|
503
|
+
const graphMeta = relevantGraphs[i];
|
|
504
|
+
for (const row of rows) {
|
|
505
|
+
const key = row.trendId || row.node_id || row.trendName || `${row.name}_${row.signal_score}`;
|
|
506
|
+
if (seen.has(String(key)))
|
|
507
|
+
continue;
|
|
508
|
+
// Near-duplicate name check
|
|
509
|
+
const rowName = row.trendName || row.name || '';
|
|
510
|
+
if (rowName && seenNames.some(n => isSemDuplicate(n, rowName)))
|
|
511
|
+
continue;
|
|
512
|
+
seen.add(String(key));
|
|
513
|
+
if (rowName)
|
|
514
|
+
seenNames.push(rowName);
|
|
515
|
+
// Tag with source tier and label for editorial composition
|
|
516
|
+
if (graphMeta) {
|
|
517
|
+
const g = graphMeta.graph;
|
|
518
|
+
row.source_tier = graphMeta.graphTier;
|
|
519
|
+
row.source_label = g.graph_type === 'domain'
|
|
520
|
+
? `${g.name} (PSFK Living)`
|
|
521
|
+
: g.graph_type === 'expert' && graphMeta.graphTier === 'living'
|
|
522
|
+
? `${g.name} (Living Expert)`
|
|
523
|
+
: g.graph_type === 'expert'
|
|
524
|
+
? `${g.name} (Expert)`
|
|
525
|
+
: `${g.name} (${g.company || 'Report'})`;
|
|
526
|
+
}
|
|
527
|
+
allRows.push(row);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
allRows.sort((a, b) => {
|
|
531
|
+
const relA = a.relevance_score || a.semantic_score || a._score || 0;
|
|
532
|
+
const relB = b.relevance_score || b.semantic_score || b._score || 0;
|
|
533
|
+
// Primary: relevance score (includes evidence + freshness from API)
|
|
534
|
+
if (Math.abs(relB - relA) > 0.05)
|
|
535
|
+
return relB - relA;
|
|
536
|
+
// Tiebreaker: prefer more recent content
|
|
537
|
+
const daysA = a.freshnessDays || 999;
|
|
538
|
+
const daysB = b.freshnessDays || 999;
|
|
539
|
+
if (daysA !== daysB)
|
|
540
|
+
return daysA - daysB;
|
|
541
|
+
return (b.signal_score || 0) - (a.signal_score || 0);
|
|
542
|
+
});
|
|
543
|
+
// Diagnostic: check freshnessDays flow
|
|
544
|
+
console.error(`[search_graph] Freshness check (top 3):`, allRows.slice(0, 3).map(r => ({
|
|
545
|
+
name: (r.trendName || r.label || '?').slice(0, 30),
|
|
546
|
+
freshnessDays: r.freshnessDays,
|
|
547
|
+
updated_at: r.updated_at,
|
|
548
|
+
freshnessDate: r.freshnessDate,
|
|
549
|
+
lastSeen: r.lastSeen,
|
|
550
|
+
rel: r.relevance_score,
|
|
551
|
+
})));
|
|
552
|
+
// ── Quality-gated diversity reranking ──
|
|
553
|
+
// When one graph dominates >50% of top results, promote competitive
|
|
554
|
+
// results from underrepresented graphs. Never forces thin/low-quality
|
|
555
|
+
// results — only swaps when the alternative is within 15% of the
|
|
556
|
+
// dominant graph's weakest included result.
|
|
557
|
+
const diversified = (() => {
|
|
558
|
+
const candidate = allRows.slice(0, effectiveLimit);
|
|
559
|
+
if (candidate.length < 4 || graphsToSearch.length < 3)
|
|
560
|
+
return candidate;
|
|
561
|
+
const getGraphId = (r) => r.graphId || r._use_this_graphId || 'unknown';
|
|
562
|
+
const getScore = (r) => r.relevance_score || r.semantic_score || r._score || 0;
|
|
563
|
+
// Count per-graph representation in the top N
|
|
564
|
+
const graphCounts = {};
|
|
565
|
+
for (const row of candidate) {
|
|
566
|
+
const gid = getGraphId(row);
|
|
567
|
+
graphCounts[gid] = (graphCounts[gid] || 0) + 1;
|
|
568
|
+
}
|
|
569
|
+
// Find the dominant graph (if any holds >50%)
|
|
570
|
+
const dominantEntry = Object.entries(graphCounts)
|
|
571
|
+
.sort((a, b) => b[1] - a[1])[0];
|
|
572
|
+
if (!dominantEntry || dominantEntry[1] <= candidate.length * 0.5)
|
|
573
|
+
return candidate;
|
|
574
|
+
const [dominantGraphId, dominantCount] = dominantEntry;
|
|
575
|
+
const dominantCap = Math.max(2, Math.ceil(candidate.length * 0.4)); // allow up to 40%
|
|
576
|
+
if (dominantCount <= dominantCap)
|
|
577
|
+
return candidate;
|
|
578
|
+
// Find the weakest score from the dominant graph in the current results
|
|
579
|
+
const dominantScores = candidate
|
|
580
|
+
.filter(r => getGraphId(r) === dominantGraphId)
|
|
581
|
+
.map(r => getScore(r));
|
|
582
|
+
const dominantFloor = Math.min(...dominantScores);
|
|
583
|
+
// Quality threshold: alternatives must score within 15% of the dominant floor
|
|
584
|
+
const qualityThreshold = dominantFloor * 0.85;
|
|
585
|
+
// Collect competitive alternatives from the overflow pool
|
|
586
|
+
const overflow = allRows.slice(effectiveLimit);
|
|
587
|
+
const alternatives = overflow
|
|
588
|
+
.filter(r => getGraphId(r) !== dominantGraphId && getScore(r) >= qualityThreshold)
|
|
589
|
+
.sort((a, b) => getScore(b) - getScore(a));
|
|
590
|
+
if (alternatives.length === 0)
|
|
591
|
+
return candidate;
|
|
592
|
+
// Build the diversified list: keep dominant up to cap, backfill with alternatives
|
|
593
|
+
const result = [];
|
|
594
|
+
let dominantUsed = 0;
|
|
595
|
+
const altQueue = [...alternatives];
|
|
596
|
+
for (const row of candidate) {
|
|
597
|
+
if (getGraphId(row) === dominantGraphId) {
|
|
598
|
+
if (dominantUsed < dominantCap) {
|
|
599
|
+
result.push(row);
|
|
600
|
+
dominantUsed++;
|
|
601
|
+
}
|
|
602
|
+
else if (altQueue.length > 0) {
|
|
603
|
+
result.push(altQueue.shift());
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
result.push(row); // no alternatives left, keep original
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
result.push(row);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const promotedCount = dominantCount - dominantCap;
|
|
614
|
+
const actualPromoted = Math.min(promotedCount, alternatives.length);
|
|
615
|
+
if (actualPromoted > 0) {
|
|
616
|
+
console.error(`[diversity] Reranked: ${dominantGraphId} capped from ${dominantCount}→${dominantCap} results, promoted ${actualPromoted} competitive alternative(s) (quality floor: ${qualityThreshold.toFixed(2)})`);
|
|
617
|
+
}
|
|
618
|
+
return result;
|
|
619
|
+
})();
|
|
620
|
+
const finalRows = diversified.slice(0, effectiveLimit);
|
|
621
|
+
const actualSourceGraphs = [...new Set(finalRows.map((r) => r.graphId || r._use_this_graphId).filter(Boolean))];
|
|
622
|
+
data = { rows: finalRows, dataStatus: allRows.length > 0 ? 'ok' : 'NO_MATCH', _routed_graphs: actualSourceGraphs };
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
data = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(graphId)}/search`, apiKey, resolveUserId(userId, uid), body);
|
|
626
|
+
}
|
|
627
|
+
// ── Track search for frustration detection ──
|
|
628
|
+
const effectiveTrackGraphId = graphId || (data?.rows?.[0]?._use_this_graphId) || 'all';
|
|
629
|
+
const trackResultCount = Array.isArray(data) ? data.length : (data?.rows?.length || 0);
|
|
630
|
+
sessionTracker.trackSearch(query, effectiveTrackGraphId, trackResultCount);
|
|
631
|
+
// Normalize: if API returns a bare array, wrap it into { rows: [...] }
|
|
632
|
+
if (Array.isArray(data)) {
|
|
633
|
+
data = { rows: data, dataStatus: 'ok' };
|
|
634
|
+
}
|
|
635
|
+
// Post-process results
|
|
636
|
+
if (data?.rows) {
|
|
637
|
+
// Build name lookup map once (not per-row)
|
|
638
|
+
const graphNameMap = new Map();
|
|
639
|
+
for (const g of getGraphs())
|
|
640
|
+
graphNameMap.set(g.graph_id, buildDisplayName(g));
|
|
641
|
+
const LEGACY_ALIASES = { 'psfk': 'retail' };
|
|
642
|
+
const enrichNow = Date.now(); // compute once for all rows
|
|
643
|
+
data.rows = data.rows.map((row) => {
|
|
644
|
+
const trimmed = { ...row };
|
|
645
|
+
trimmed._use_this_graphId = row.graphId || graphId;
|
|
646
|
+
if (!trimmed.node_id)
|
|
647
|
+
trimmed.node_id = trimmed.trendId || trimmed.id || trimmed.nodeId || trimmed._id || trimmed.uuid || null;
|
|
648
|
+
if (!trimmed.title)
|
|
649
|
+
trimmed.title = trimmed.trendName || trimmed.display || trimmed.name || null;
|
|
650
|
+
// P0 Item 3: Populate canonical summary from source fields (raw rows have no summary)
|
|
651
|
+
if (!trimmed.summary)
|
|
652
|
+
trimmed.summary = trimmed.description || trimmed.trendDescription || null;
|
|
653
|
+
if (!trimmed.relevance_score)
|
|
654
|
+
trimmed.relevance_score = trimmed.semantic_score || trimmed._score || trimmed.score || null;
|
|
655
|
+
const resolvedId = LEGACY_ALIASES[trimmed._use_this_graphId || ''] || trimmed._use_this_graphId || graphId || '';
|
|
656
|
+
trimmed.graphName = graphNameMap.get(resolvedId) || resolvedId;
|
|
657
|
+
// P0 Item 3: Convert brandNames from pipe-delimited string to capped array
|
|
658
|
+
const rawBrands = typeof trimmed.brandNames === 'string'
|
|
659
|
+
? trimmed.brandNames.split('|').map((s) => s.trim()).filter(Boolean)
|
|
660
|
+
: Array.isArray(trimmed.brandNames) ? trimmed.brandNames : [];
|
|
661
|
+
trimmed.brandNames = rawBrands.slice(0, 10);
|
|
662
|
+
trimmed.brand_count = rawBrands.length;
|
|
663
|
+
// P0 Item 3: Convert place from comma-delimited string to capped array
|
|
664
|
+
const rawPlaces = typeof trimmed.place === 'string'
|
|
665
|
+
? trimmed.place.split(',').map((s) => s.trim()).filter(Boolean)
|
|
666
|
+
: Array.isArray(trimmed.place) ? trimmed.place : [];
|
|
667
|
+
trimmed.place = rawPlaces.slice(0, 10);
|
|
668
|
+
trimmed.place_count = rawPlaces.length;
|
|
669
|
+
if (trimmed.whyNow?.length > 200)
|
|
670
|
+
trimmed.whyNow = trimmed.whyNow.substring(0, 200) + '...';
|
|
671
|
+
// P0 Item 4 + Round-2: cap at top-3 BY RELEVANCE (API order), not recency
|
|
672
|
+
if (trimmed.evidence?.length > 0) {
|
|
673
|
+
trimmed.evidence_count = trimmed.evidence.length; // total before cap
|
|
674
|
+
trimmed.evidence = enrichEvidence(trimmed.evidence.slice(0, 3));
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
trimmed.evidence_count = trimmed.evidence_count || trimmed.evidenceCount || 0;
|
|
678
|
+
if ((include_evidence ?? true) && trimmed.evidence_count > 0) {
|
|
679
|
+
trimmed.evidence_status = 'Evidence expected but not returned by API';
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
const drillTrendName = trimmed.title || trimmed.trendName || 'this trend';
|
|
683
|
+
const drillGraphId = trimmed._use_this_graphId || graphId || '';
|
|
684
|
+
trimmed.suggested_drill_down = `Tell me more about "${drillTrendName}" from the ${trimmed.graphName || drillGraphId} graph. What is driving this and what are the key signals?`;
|
|
685
|
+
trimmed.trendLifecycle = computeLifecycle(trimmed, enrichNow);
|
|
686
|
+
trimmed.momentum = computeMomentum(trimmed, enrichNow);
|
|
687
|
+
trimmed.fastMover = isFastMover(trimmed, enrichNow);
|
|
688
|
+
trimmed.graphBadge = GRAPH_BADGES[trimmed._use_this_graphId || graphId || ''] || '○';
|
|
689
|
+
return trimmed;
|
|
690
|
+
});
|
|
691
|
+
// P0 Item 6: Filter out deprecated graph rows
|
|
692
|
+
data.rows = data.rows.filter((r) => {
|
|
693
|
+
const gid = r._use_this_graphId || r.graphId || '';
|
|
694
|
+
return !DEPRECATED_GRAPH_IDS.has(gid);
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
if (data?.rows) {
|
|
698
|
+
data.results = data.rows;
|
|
699
|
+
if (data.total === undefined)
|
|
700
|
+
data.total = data.rows.length;
|
|
701
|
+
}
|
|
702
|
+
// When graphId was omitted (all-graph search), use first result's graph or 'retail'
|
|
703
|
+
const effectiveGraphId = graphId || (data?.rows?.[0]?._use_this_graphId) || (data?.rows?.[0]?.graphId) || 'retail';
|
|
704
|
+
data.theme = getFoddaTheme(effectiveGraphId);
|
|
705
|
+
const primaryCatalogEntry = getGraphs().find(g => g.graph_id === effectiveGraphId);
|
|
706
|
+
const primaryGraphName = primaryCatalogEntry ? buildDisplayName(primaryCatalogEntry) : effectiveGraphId;
|
|
707
|
+
// P0 Item 6: Attribution covers all source graphs, resolved via catalog
|
|
708
|
+
const attrGraphIds = [...new Set((data.rows || []).map((r) => r._use_this_graphId || r.graphId).filter(Boolean))];
|
|
709
|
+
if (attrGraphIds.length > 1) {
|
|
710
|
+
const attrNames = attrGraphIds.map(id => {
|
|
711
|
+
const resolved = id === 'psfk' ? 'retail' : id;
|
|
712
|
+
const entry = getGraphs().find((g) => g.graph_id === resolved);
|
|
713
|
+
return entry ? buildDisplayName(entry) : resolved;
|
|
714
|
+
});
|
|
715
|
+
data._attribution = `Data sourced from ${attrNames.join(', ')}`;
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
data._attribution = `Data sourced from ${primaryGraphName}`;
|
|
719
|
+
}
|
|
720
|
+
// Suggested next prompts
|
|
721
|
+
const rows = data.rows || [];
|
|
722
|
+
const prompts = [];
|
|
723
|
+
const topTrend = rows[0];
|
|
724
|
+
if (topTrend) {
|
|
725
|
+
const trendName = topTrend.trendName || topTrend.display || topTrend.name || 'this trend';
|
|
726
|
+
const nodeGraphId = topTrend._use_this_graphId || effectiveGraphId;
|
|
727
|
+
prompts.push({ label: 'Deep dive', prompt: `Show me the sources and evidence behind "${trendName}" from the ${nodeGraphId} graph`, type: 'deeper_dive' });
|
|
728
|
+
const econMap = { retail: 'Census retail sales and BEA consumer spending', fashion: 'BEA spending on clothing and BLS apparel CPI', beauty: 'FDA safety data and PubMed research trends', sports: 'BEA recreation spending and Wikipedia cultural attention', sic: 'FRED consumer sentiment and Pew survey data' };
|
|
729
|
+
prompts.push({ label: 'Economic context', prompt: `Pull supplemental data from ${econMap[effectiveGraphId] || 'FRED economic indicators and OECD data'} to validate these trends`, type: 'economic_lens' });
|
|
730
|
+
if (effectiveGraphId !== 'sic' && rows.length >= 2)
|
|
731
|
+
prompts.push({ label: 'Cultural lens', prompt: `How does "${trendName}" show up in the SIC cultural intelligence graph?`, type: 'adjacent_angle' });
|
|
732
|
+
const hasBrands = rows.some((r) => r.evidence?.some((e) => e.brandNames?.length > 0));
|
|
733
|
+
if (hasBrands && prompts.length < 4)
|
|
734
|
+
prompts.push({ label: 'Brand landscape', prompt: `Which brands are driving "${trendName}"? Show me the competitive landscape.`, type: 'brand_focus' });
|
|
735
|
+
}
|
|
736
|
+
data.suggested_next_prompts = prompts.slice(0, 3);
|
|
737
|
+
// Pre-formatted branded follow-up block for the LLM to render verbatim
|
|
738
|
+
if (prompts.length > 0) {
|
|
739
|
+
data._fodda_followup = `**Fodda →** ${prompts.map(p => p.label).join(' · ')}`;
|
|
740
|
+
}
|
|
741
|
+
// Inject _render_instructions for LLM clients that don't read server-level instructions
|
|
742
|
+
const resultGraphIds = [...new Set((data.rows || []).map((r) => r._use_this_graphId || r.graphId).filter(Boolean))];
|
|
743
|
+
data._render_instructions = buildRenderInstructions({
|
|
744
|
+
hasWidget: true, // will be determined later, but default to true
|
|
745
|
+
hasPrompts: prompts.length > 0,
|
|
746
|
+
hasEvidence: (data.rows || []).some((r) => r.evidence?.length > 0),
|
|
747
|
+
graphWebpageUrls: collectGraphWebpageUrls(resultGraphIds),
|
|
748
|
+
});
|
|
749
|
+
// Phase 2 envelope enrichment
|
|
750
|
+
const enrichedRows = data.rows || [];
|
|
751
|
+
const mainstream = enrichedRows.filter((r) => (r.evidence_count || r.evidenceCount || 0) >= 3);
|
|
752
|
+
const weakSignals = enrichedRows.filter((r) => (r.evidence_count || r.evidenceCount || 0) < 3 && (r.trendLifecycle === 'emerging' || r.trendLifecycle === 'unknown'));
|
|
753
|
+
if (weakSignals.length > 0) {
|
|
754
|
+
data.mainstream = mainstream;
|
|
755
|
+
data.weak_signals = weakSignals;
|
|
756
|
+
}
|
|
757
|
+
const allFirstSeen = enrichedRows.map((r) => r.firstSeen).filter(Boolean).sort();
|
|
758
|
+
const allLastSeen = enrichedRows.map((r) => r.lastSeen).filter(Boolean).sort().reverse();
|
|
759
|
+
if (allFirstSeen.length > 0 && allLastSeen.length > 0) {
|
|
760
|
+
data.queryTimeline = { earliest: allFirstSeen[0], latest: allLastSeen[0], span: `${new Date(allFirstSeen[0]).getFullYear()}–${new Date(allLastSeen[0]).getFullYear()}` };
|
|
761
|
+
}
|
|
762
|
+
const places = enrichedRows.map((r) => r.place || r.geographical_region).filter(Boolean);
|
|
763
|
+
if (places.length >= 3) {
|
|
764
|
+
const uniqueRegions = new Set(places.flatMap((p) => Array.isArray(p) ? p : p.split(',').map((s) => s.trim())));
|
|
765
|
+
if (uniqueRegions.size === 1)
|
|
766
|
+
data.geoBias = { concentrated: true, region: [...uniqueRegions][0], note: 'Results are geographically concentrated' };
|
|
767
|
+
}
|
|
768
|
+
if (enrichedRows.length < 3 || enrichedRows.every((r) => (r.evidence_count || r.evidenceCount || 0) < 3)) {
|
|
769
|
+
data.research_gaps = { thin_coverage: true, note: 'The graph has limited coverage on this topic. These are the closest matches.' };
|
|
770
|
+
}
|
|
771
|
+
// Confidence-gated fallback: auto-broaden thin results
|
|
772
|
+
if (enrichedRows.length < 3 && query.split(' ').length > 3 && effectiveGraphId) {
|
|
773
|
+
try {
|
|
774
|
+
const shorterQuery = query.split(' ').slice(0, 3).join(' ');
|
|
775
|
+
const fallback = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(effectiveGraphId)}/search`, apiKey, resolveUserId(userId, uid), { query: shorterQuery, limit: 10, use_semantic: true, include_evidence: false });
|
|
776
|
+
const fallbackRows = Array.isArray(fallback) ? fallback : fallback?.rows || [];
|
|
777
|
+
if (fallbackRows.length > 0) {
|
|
778
|
+
const existingIds = new Set(enrichedRows.map((r) => r.node_id || r.trendId));
|
|
779
|
+
const newRows = fallbackRows.filter((r) => !existingIds.has(r.node_id || r.trendId));
|
|
780
|
+
if (newRows.length > 0) {
|
|
781
|
+
const enrichedNew = newRows.map((row) => {
|
|
782
|
+
const t = { ...row };
|
|
783
|
+
t._use_this_graphId = row.graphId || effectiveGraphId;
|
|
784
|
+
t.trendLifecycle = computeLifecycle(t);
|
|
785
|
+
t.momentum = computeMomentum(t);
|
|
786
|
+
t.fastMover = isFastMover(t);
|
|
787
|
+
t._broadened = true;
|
|
788
|
+
return t;
|
|
789
|
+
});
|
|
790
|
+
data.rows.push(...enrichedNew);
|
|
791
|
+
data.results = data.rows;
|
|
792
|
+
data.total = data.rows.length;
|
|
793
|
+
data._broadened = true;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
catch { /* Broadening failed silently */ }
|
|
798
|
+
}
|
|
799
|
+
// ── Low-credit warning for all users — utilizes dynamic Stripe links from API ──
|
|
800
|
+
appendUsageWarning(data, isTrial, resolveUserId(userId));
|
|
801
|
+
// ── Supplemental data — macro context for all queries with results ──
|
|
802
|
+
let supplemental = { google_trends: null, census_retail: null };
|
|
803
|
+
const resultCount = (data?.rows || []).length;
|
|
804
|
+
if (resultCount >= 1) { // Was ≥3 — thin queries need macro context most
|
|
805
|
+
const [googleTrendsResult, censusResult] = await Promise.allSettled([
|
|
806
|
+
foddaRequest('GET', `/v1/supplemental/google-trends?query=${encodeURIComponent(query)}&geo=US&timeframe=today+12-m`, apiKey, resolveUserId(userId, uid)),
|
|
807
|
+
foddaRequest('GET', `/v1/supplemental/census/retail-snapshot`, apiKey, resolveUserId(userId, uid)),
|
|
808
|
+
]);
|
|
809
|
+
supplemental = {
|
|
810
|
+
google_trends: googleTrendsResult.status === 'fulfilled' ? googleTrendsResult.value : null,
|
|
811
|
+
census_retail: censusResult.status === 'fulfilled' ? censusResult.value : null,
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
console.error(`[search_graph] Skipping supplemental fetch — only ${resultCount} results (threshold: 3)`);
|
|
816
|
+
}
|
|
817
|
+
// ── Skill post-processing — call enabled output skills via Core API ──
|
|
818
|
+
let skillResults = [];
|
|
819
|
+
if (!skip_skills && sessionSkills.length > 0 && resultCount >= 1) {
|
|
820
|
+
const skillInput = buildSkillInput(query, data, effectiveGraphId, primaryGraphName, supplemental);
|
|
821
|
+
skillResults = await callOutputSkills(sessionSkills, skillInput, apiKey, resolveUserId(userId, uid));
|
|
822
|
+
const applied = skillResults.filter(r => r.success);
|
|
823
|
+
if (applied.length > 0) {
|
|
824
|
+
data._skills_applied = applied.map(r => ({ id: r.skillId, name: r.skillName, durationMs: r.durationMs }));
|
|
825
|
+
console.error(`[search_graph] ${applied.length} skill(s) applied: ${applied.map(r => r.skillName).join(', ')}`);
|
|
826
|
+
}
|
|
827
|
+
const failed = skillResults.filter(r => !r.success);
|
|
828
|
+
if (failed.length > 0) {
|
|
829
|
+
console.error(`[search_graph] ${failed.length} skill(s) failed: ${failed.map(r => `${r.skillName}: ${r.error}`).join(', ')}`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
else if (skip_skills && sessionSkills.length > 0) {
|
|
833
|
+
console.error(`[search_graph] Skills skipped (user requested skip_skills)`);
|
|
834
|
+
data._skills_skipped = sessionSkills.map(s => s.name);
|
|
835
|
+
}
|
|
836
|
+
// ── Server-side widget rendering ──
|
|
837
|
+
const searchWidget = await renderSearchWidget(data, query, primaryGraphName, supplemental);
|
|
838
|
+
if (searchWidget.widget_html) {
|
|
839
|
+
// Also cache for direct browser access via /widget/:id
|
|
840
|
+
storeWidget(searchWidget.widget_html);
|
|
841
|
+
// P0 Item 3+4: Dedupe aliased fields; keep capped evidence inline
|
|
842
|
+
const liteData = { ...data };
|
|
843
|
+
delete liteData.results; // exact copy of rows
|
|
844
|
+
delete liteData.weak_signals; // subset of rows
|
|
845
|
+
liteData.rows = (data.rows || []).map((r) => {
|
|
846
|
+
const out = { ...r };
|
|
847
|
+
// ID aliases → keep node_id
|
|
848
|
+
delete out.trendId;
|
|
849
|
+
delete out.nodeId;
|
|
850
|
+
delete out.uuid;
|
|
851
|
+
if (out.node_id) {
|
|
852
|
+
delete out.id;
|
|
853
|
+
delete out._id;
|
|
854
|
+
}
|
|
855
|
+
// Score aliases → keep relevance_score
|
|
856
|
+
if (!out.relevance_score)
|
|
857
|
+
out.relevance_score = out.semantic_score || out._score || out.score || null;
|
|
858
|
+
delete out._score;
|
|
859
|
+
delete out.semantic_score;
|
|
860
|
+
delete out.score;
|
|
861
|
+
// Description aliases → keep summary
|
|
862
|
+
delete out.description;
|
|
863
|
+
delete out.trendDescription;
|
|
864
|
+
// Drop adjacentPossibilities and evidenceCount (canonical is evidence_count)
|
|
865
|
+
delete out.adjacentPossibilities;
|
|
866
|
+
delete out.evidenceCount;
|
|
867
|
+
return out;
|
|
868
|
+
});
|
|
869
|
+
// Size check: if total payload exceeds ~30KB, skip widget to avoid context overflow
|
|
870
|
+
const jsonPayload = JSON.stringify(liteData, null, 2);
|
|
871
|
+
const totalSize = searchWidget.widget_html.length + jsonPayload.length;
|
|
872
|
+
if (totalSize > 30000) {
|
|
873
|
+
console.error(`[search_graph] Payload too large (${(totalSize / 1024).toFixed(1)}KB) — skipping widget HTML, sending liteData JSON + design brief`);
|
|
874
|
+
// ── Query-level billing (large payload path) ──
|
|
875
|
+
chargeQuery({ queryTypeCode: 'topic_research', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest })
|
|
876
|
+
.catch(e => console.error('[search_graph] chargeQuery failed:', e.message));
|
|
877
|
+
return { content: [
|
|
878
|
+
{ type: 'text', text: jsonPayload },
|
|
879
|
+
{ type: 'text', text: FODDA_WIDGET_DESIGN_BRIEF },
|
|
880
|
+
] };
|
|
881
|
+
}
|
|
882
|
+
const widgetResponse = { content: [
|
|
883
|
+
{ type: 'text', text: '── RAW DATA (for follow-up reasoning) ──\n' + jsonPayload },
|
|
884
|
+
{ type: 'text', text: searchWidget.editorial_instruction },
|
|
885
|
+
{ type: 'text', text: '── WIDGET HTML ──\nIf your client supports HTML visualization (show_widget, visualize:show_widget, or artifacts), pass this HTML verbatim. Do not rewrite or restyle.\n\n' + searchWidget.widget_html },
|
|
886
|
+
// Append skill outputs as additional content blocks
|
|
887
|
+
...skillResults
|
|
888
|
+
.filter(r => r.success && r.output)
|
|
889
|
+
.map(r => ({
|
|
890
|
+
type: 'text',
|
|
891
|
+
text: `── SKILL: ${r.skillName} ──\n${r.output}\n── END SKILL: ${r.skillName} ──`,
|
|
892
|
+
})),
|
|
893
|
+
] };
|
|
894
|
+
// ── Query-level billing (rich widget path) ──
|
|
895
|
+
chargeQuery({ queryTypeCode: 'topic_research', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest })
|
|
896
|
+
.catch(e => console.error('[search_graph] chargeQuery failed:', e.message));
|
|
897
|
+
return widgetResponse;
|
|
898
|
+
}
|
|
899
|
+
// Fallback: <3 results or no widget — give Claude the shell + stripped data
|
|
900
|
+
const shellSources = [...new Set((data?.rows || []).map((r) => r.graphName).filter(Boolean))];
|
|
901
|
+
const shellHtml = getShellTemplate(`Search: ${query}`, shellSources.length ? shellSources : [primaryGraphName]);
|
|
902
|
+
// P0 Item 3+4: Dedupe aliased fields in fallback path; keep capped evidence inline
|
|
903
|
+
const fallbackData = { ...data };
|
|
904
|
+
delete fallbackData.results;
|
|
905
|
+
delete fallbackData.weak_signals;
|
|
906
|
+
fallbackData.rows = (data.rows || []).map((r) => {
|
|
907
|
+
const out = { ...r };
|
|
908
|
+
delete out.trendId;
|
|
909
|
+
delete out.nodeId;
|
|
910
|
+
delete out.uuid;
|
|
911
|
+
if (out.node_id) {
|
|
912
|
+
delete out.id;
|
|
913
|
+
delete out._id;
|
|
914
|
+
}
|
|
915
|
+
if (!out.relevance_score)
|
|
916
|
+
out.relevance_score = out.semantic_score || out._score || out.score || null;
|
|
917
|
+
delete out._score;
|
|
918
|
+
delete out.semantic_score;
|
|
919
|
+
delete out.score;
|
|
920
|
+
delete out.description;
|
|
921
|
+
delete out.trendDescription;
|
|
922
|
+
delete out.adjacentPossibilities;
|
|
923
|
+
delete out.evidenceCount;
|
|
924
|
+
return out;
|
|
925
|
+
});
|
|
926
|
+
// ── Query-level billing (fallback path) ──
|
|
927
|
+
chargeQuery({ queryTypeCode: 'topic_research', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest })
|
|
928
|
+
.catch(e => console.error('[search_graph] chargeQuery failed:', e.message));
|
|
929
|
+
return { content: [
|
|
930
|
+
{ type: 'text', text: JSON.stringify(fallbackData, null, 2) },
|
|
931
|
+
{ type: 'text', text: '── FODDA SHELL TEMPLATE ──\nUse this shell to wrap your widget response. Replace {{CONTENT}} with your HTML and {{EXTRA_CSS}} with any additional styles.\n\n' + shellHtml },
|
|
932
|
+
{ type: 'text', text: FODDA_COMPONENT_GUIDE },
|
|
933
|
+
// Append skill outputs (if any ran despite thin results)
|
|
934
|
+
...skillResults
|
|
935
|
+
.filter(r => r.success && r.output)
|
|
936
|
+
.map(r => ({
|
|
937
|
+
type: 'text',
|
|
938
|
+
text: `── SKILL: ${r.skillName} ──\n${r.output}\n── END SKILL: ${r.skillName} ──`,
|
|
939
|
+
})),
|
|
940
|
+
// ── Frustration hint (if detected) ──
|
|
941
|
+
...((() => {
|
|
942
|
+
const hint = sessionTracker.detectFrustration();
|
|
943
|
+
if (hint) {
|
|
944
|
+
const frustrationUser = userId !== 'anonymous' ? userId : (apiKey ? `key:${apiKey.substring(0, 12)}…` : 'anonymous');
|
|
945
|
+
sessionTracker.postFrustrationToSlack(frustrationUser);
|
|
946
|
+
}
|
|
947
|
+
return hint ? [{ type: 'text', text: `\n---\n${hint}\n---` }] : [];
|
|
948
|
+
})()),
|
|
949
|
+
] };
|
|
950
|
+
}
|
|
951
|
+
catch (err) {
|
|
952
|
+
// Trial-aware credit exhaustion handling
|
|
953
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
954
|
+
if (trialResult)
|
|
955
|
+
return trialResult;
|
|
956
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
957
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
// --- get_neighbors ---
|
|
961
|
+
server.tool('get_neighbors', 'Traverse graph relationships from a specific trend node to discover connected signals, brands, technologies, and locations. Returns structured relationship data that web search cannot provide — the curated editorial connections between trends. Use after search_graph to map the territory around a specific trend, find which brands are connected, or understand cross-domain links. Requires node_id from a prior search_graph result.', {
|
|
962
|
+
graphId: z.string().describe(GRAPH_ID_DESC),
|
|
963
|
+
seed_node_ids: z.array(z.string()).describe('Array of node IDs to start traversal from. MUST be actual node_id values from a prior search_graph result (e.g. ["2507.0"]). Node IDs are NOT sequential integers — do NOT guess or invent IDs like "1", "2", "3". Always call search_graph first to obtain valid IDs.'),
|
|
964
|
+
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
|
|
965
|
+
relationship_types: z.array(z.string()).optional().describe("Filter by relationship types: 'EVIDENCED_BY', 'RELATED_TO', 'SEMANTICALLY_SIMILAR', 'ASSOCIATED_BRAND', 'MENTIONS_BRAND', 'IN_LOCATION'"),
|
|
966
|
+
direction: z.enum(['in', 'out']).optional().describe("Traversal direction: 'out' (default) follows outgoing edges, 'in' follows incoming edges"),
|
|
967
|
+
depth: z.number().optional().describe('Traversal depth (default 1, max 2)'),
|
|
968
|
+
limit: z.number().optional().describe('Maximum results (default 50)')
|
|
969
|
+
}, { title: 'Explore Graph Neighbors', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ graphId, seed_node_ids, userId: uid, relationship_types, direction, depth, limit }) => {
|
|
970
|
+
try {
|
|
971
|
+
const body = {
|
|
972
|
+
seed_node_ids,
|
|
973
|
+
depth: Math.min(depth || 1, 2),
|
|
974
|
+
limit: Math.min(limit || 50, 50),
|
|
975
|
+
};
|
|
976
|
+
if (relationship_types)
|
|
977
|
+
body.relationship_types = relationship_types;
|
|
978
|
+
if (direction)
|
|
979
|
+
body.direction = direction;
|
|
980
|
+
let data = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(graphId)}/neighbors`, apiKey, resolveUserId(userId, uid), body);
|
|
981
|
+
appendUsageWarning(data, isTrial, resolveUserId(userId));
|
|
982
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
983
|
+
}
|
|
984
|
+
catch (err) {
|
|
985
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
986
|
+
if (trialResult)
|
|
987
|
+
return trialResult;
|
|
988
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
989
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
// --- get_evidence ---
|
|
993
|
+
server.tool('get_evidence', 'Retrieve curated source articles and structured evidence for a specific trend node — case studies, statistics, expert quotes, and analysis with full source attribution. Returns evidence that has been editorially selected and categorized, not raw web results. Each item includes sourceUrl, place, brandNames, publishedAt, category, and formatted_citation. Use after search_graph when you need the supporting proof behind a specific trend. This is a node lookup — not a text search tool.', {
|
|
994
|
+
graphId: z.string().describe(GRAPH_ID_DESC),
|
|
995
|
+
for_node_id: z.string().describe("The node_id from a prior search_graph result (e.g. '2507.0'). MUST come from the search result's node_id field. Node IDs are NOT sequential integers — do NOT guess or invent IDs like '1', '2', '3'. Do NOT pass the trend name."),
|
|
996
|
+
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
|
|
997
|
+
top_k: z.number().optional().describe('Number of evidence items to return (default 5)')
|
|
998
|
+
}, { title: 'Get Supporting Evidence', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ graphId, for_node_id, userId: uid, top_k }) => {
|
|
999
|
+
try {
|
|
1000
|
+
if (graphId === 'psfk')
|
|
1001
|
+
graphId = 'retail';
|
|
1002
|
+
const body = { for_node_id, top_k: Math.min(top_k || 5, 10) };
|
|
1003
|
+
let data;
|
|
1004
|
+
data = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(graphId)}/evidence`, apiKey, resolveUserId(userId, uid), body);
|
|
1005
|
+
// Enrich evidence with pre-formatted citations
|
|
1006
|
+
if (data?.evidence)
|
|
1007
|
+
data.evidence = enrichEvidence(data.evidence);
|
|
1008
|
+
appendUsageWarning(data, isTrial, resolveUserId(userId));
|
|
1009
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1010
|
+
}
|
|
1011
|
+
catch (err) {
|
|
1012
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1013
|
+
if (trialResult)
|
|
1014
|
+
return trialResult;
|
|
1015
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
1016
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
// --- get_node ---
|
|
1020
|
+
server.tool('get_node', 'Retrieve complete metadata for a specific trend node — full description, signal score, lifecycle, geographic scope, adjacent possibilities, and all properties. Use when you need the full detail on a single trend after search_graph returned a summary. Requires node_id from a prior search_graph result.', {
|
|
1021
|
+
graphId: z.string().describe(GRAPH_ID_DESC),
|
|
1022
|
+
nodeId: z.string().describe("The node_id from a prior search_graph result (e.g. '2507.0'). MUST come from the search result's node_id field. Node IDs are NOT sequential integers — do NOT guess or invent IDs like '1', '2', '3'. Do NOT pass the trend name."),
|
|
1023
|
+
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.')
|
|
1024
|
+
}, { title: 'Get Node Details', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ graphId, nodeId, userId: uid }) => {
|
|
1025
|
+
try {
|
|
1026
|
+
if (graphId === 'psfk')
|
|
1027
|
+
graphId = 'retail';
|
|
1028
|
+
const data = await foddaRequest('GET', `/v1/graphs/${encodeURIComponent(graphId)}/nodes/${nodeId}`, apiKey, resolveUserId(userId, uid));
|
|
1029
|
+
// Inject theme block for visualization branding
|
|
1030
|
+
if (data && typeof data === 'object') {
|
|
1031
|
+
data.theme = getFoddaTheme(graphId);
|
|
1032
|
+
}
|
|
1033
|
+
appendUsageWarning(data, isTrial, resolveUserId(userId));
|
|
1034
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1035
|
+
}
|
|
1036
|
+
catch (err) {
|
|
1037
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1038
|
+
if (trialResult)
|
|
1039
|
+
return trialResult;
|
|
1040
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
1041
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
// --- get_label_values ---
|
|
1045
|
+
server.tool('get_label_values', 'List all values for a structured category within a graph — Brand names, Locations, Technologies, Audiences, RetailerTypes, or Trend names. Use when you need to enumerate what entities exist in a graph before filtering, or when the user asks "what brands are in the retail graph?" or "what locations does the fashion graph cover?". To enumerate every trend in a graph (especially industry-report graphs, where semantic search/search_insights may return only the top match or nothing if evidence is unlinked), call with label="Trend" — this returns the complete, deterministic list of trend names.', {
|
|
1046
|
+
graphId: z.string().describe(GRAPH_ID_DESC),
|
|
1047
|
+
label: z.string().describe("The label to fetch values for (e.g., 'Brand', 'Location', 'Technology', 'Audience', 'RetailerType', 'Trend')"),
|
|
1048
|
+
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
|
|
1049
|
+
property: z.string().optional().describe('Optional property to return values for. Defaults vary by label.')
|
|
1050
|
+
}, { title: 'Get Category Values', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ graphId, label, userId: uid, property }) => {
|
|
1051
|
+
try {
|
|
1052
|
+
if (graphId === 'psfk')
|
|
1053
|
+
graphId = 'retail';
|
|
1054
|
+
const propParam = property ? `?property=${encodeURIComponent(property)}` : '';
|
|
1055
|
+
const data = await foddaRequest('GET', `/v1/graphs/${encodeURIComponent(graphId)}/labels/${label}/values${propParam}`, apiKey, resolveUserId(userId, uid));
|
|
1056
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1057
|
+
}
|
|
1058
|
+
catch (err) {
|
|
1059
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1060
|
+
if (trialResult)
|
|
1061
|
+
return trialResult;
|
|
1062
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
1063
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
// --- discover_adjacent_trends ---
|
|
1067
|
+
server.tool('discover_adjacent_trends', 'Find trends semantically similar to a given trend using pre-computed embeddings — surfaces connections that keyword search would miss. Returns scored similarity matches and optionally editorial links across graphs. Use to expand research briefs, discover unexpected cross-domain connections, or map the territory around a strong signal. Web search cannot replicate this — it uses Fodda\'s internal embedding space.', {
|
|
1068
|
+
graphId: z.string().describe(GRAPH_ID_DESC),
|
|
1069
|
+
trend_id: z.string().describe("The node_id from a prior search_graph result (e.g. '2507.0'). MUST come from the search result's node_id field. Node IDs are NOT sequential integers — do NOT guess or invent IDs like '1', '2', '3'. Do NOT pass the trend name."),
|
|
1070
|
+
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
|
|
1071
|
+
min_score: z.number().optional().describe('Minimum similarity score threshold (0-1). Default: 0.80'),
|
|
1072
|
+
limit: z.number().optional().describe('Maximum number of adjacent trends to return. Default: 10'),
|
|
1073
|
+
include_editorial: z.boolean().optional().describe('If true, also include editorially linked trends. Default: false')
|
|
1074
|
+
}, { title: 'Discover Adjacent Trends', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ graphId, trend_id, userId: uid, min_score, limit, include_editorial }) => {
|
|
1075
|
+
try {
|
|
1076
|
+
if (graphId === 'psfk')
|
|
1077
|
+
graphId = 'retail';
|
|
1078
|
+
const params = new URLSearchParams({ node_id: trend_id });
|
|
1079
|
+
if (min_score !== undefined)
|
|
1080
|
+
params.set('min_score', String(min_score));
|
|
1081
|
+
params.set('limit', String(Math.min(limit || 10, 20)));
|
|
1082
|
+
if (include_editorial !== undefined)
|
|
1083
|
+
params.set('include_editorial', String(include_editorial));
|
|
1084
|
+
let data = await foddaRequest('GET', `/v1/graphs/${encodeURIComponent(graphId)}/adjacent?${params.toString()}`, apiKey, resolveUserId(userId, uid));
|
|
1085
|
+
appendUsageWarning(data, isTrial, resolveUserId(userId));
|
|
1086
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1087
|
+
}
|
|
1088
|
+
catch (err) {
|
|
1089
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1090
|
+
if (trialResult)
|
|
1091
|
+
return trialResult;
|
|
1092
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
1093
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
// --- brand_tracker ---
|
|
1097
|
+
const executeBrandTracker = async (brand_name, uid, graph_ids, include_evidence, max_evidence) => {
|
|
1098
|
+
const brandName = brand_name.trim();
|
|
1099
|
+
const includeEvidence = include_evidence !== false;
|
|
1100
|
+
const maxEv = Math.min(max_evidence || 10, 25);
|
|
1101
|
+
const brandLower = brandName.toLowerCase();
|
|
1102
|
+
// ── Fire static/brand-only supplemental queries in parallel ──
|
|
1103
|
+
const amazonPromise = foddaRequest('GET', `/v1/supplemental/amazon?query=${encodeURIComponent(brandName)}&limit=8`, apiKey, resolveUserId(userId, uid));
|
|
1104
|
+
const censusPromise = foddaRequest('GET', `/v1/supplemental/census/retail-snapshot`, apiKey, resolveUserId(userId, uid));
|
|
1105
|
+
// Build graph lookup map
|
|
1106
|
+
const graphLookup = new Map();
|
|
1107
|
+
for (const g of getGraphs())
|
|
1108
|
+
graphLookup.set(g.graph_id, g);
|
|
1109
|
+
// ── Data collection arrays ──
|
|
1110
|
+
let allTrends = [];
|
|
1111
|
+
let allEvidence = [];
|
|
1112
|
+
const graphPresence = {};
|
|
1113
|
+
const competitorCounts = {};
|
|
1114
|
+
const competitorGraphs = {}; // track which graphs each competitor appears in
|
|
1115
|
+
let usedCypherEndpoint = false;
|
|
1116
|
+
// ── Strategy 1: Single Cypher endpoint (fast path) ──
|
|
1117
|
+
try {
|
|
1118
|
+
const params = new URLSearchParams();
|
|
1119
|
+
params.set('maxEvidence', String(maxEv));
|
|
1120
|
+
params.set('limit', '50');
|
|
1121
|
+
const cypherData = await foddaRequest('POST', `/v1/brand-intelligence/${encodeURIComponent(brandName)}?${params.toString()}`, apiKey, resolveUserId(userId, uid));
|
|
1122
|
+
if (cypherData?.ok && cypherData.trend_footprint) {
|
|
1123
|
+
usedCypherEndpoint = true;
|
|
1124
|
+
// Map Cypher response → MCP profile shape
|
|
1125
|
+
for (const t of cypherData.trend_footprint) {
|
|
1126
|
+
const catalogEntry = graphLookup.get(t.graphId);
|
|
1127
|
+
const graphName = catalogEntry ? buildDisplayName(catalogEntry) : t.graphId;
|
|
1128
|
+
allTrends.push({
|
|
1129
|
+
trend_name: (t.trendName || '').replace(/^\[(?:REVIW|REVIEW|DRAFT|WIP)\]\s*/i, '').trim(),
|
|
1130
|
+
trend_description: t.trendDescription || '',
|
|
1131
|
+
graphId: t.graphId,
|
|
1132
|
+
graphName,
|
|
1133
|
+
signal_score: t.signalScore || null,
|
|
1134
|
+
lifecycle: computeLifecycle({ firstSeen: t.firstSeen, lastSeen: t.lastSeen, evidenceCount: t.evidenceCount, signal_score: t.signalScore }),
|
|
1135
|
+
momentum: computeMomentum({ firstSeen: t.firstSeen, lastSeen: t.lastSeen, evidenceCount: t.evidenceCount }),
|
|
1136
|
+
evidence_count: t.evidenceCount || 0,
|
|
1137
|
+
node_id: t.nodeId || t.trendId,
|
|
1138
|
+
_use_this_graphId: t.graphId,
|
|
1139
|
+
freshnessDays: t.freshnessDays ?? (() => {
|
|
1140
|
+
// Fallback: compute from catalog graph published_date or last_updated
|
|
1141
|
+
const dateStr = catalogEntry?.published_date || catalogEntry?.last_updated;
|
|
1142
|
+
if (dateStr) {
|
|
1143
|
+
const d = new Date(dateStr);
|
|
1144
|
+
if (!isNaN(d.getTime()))
|
|
1145
|
+
return Math.floor((Date.now() - d.getTime()) / 86400000);
|
|
1146
|
+
}
|
|
1147
|
+
return null;
|
|
1148
|
+
})(),
|
|
1149
|
+
});
|
|
1150
|
+
// Recompute lifecycle with freshnessDays now available
|
|
1151
|
+
const lastTrend = allTrends[allTrends.length - 1];
|
|
1152
|
+
lastTrend.lifecycle = computeLifecycle({ ...lastTrend, firstSeen: t.firstSeen, lastSeen: t.lastSeen, evidenceCount: t.evidenceCount, signal_score: t.signalScore, freshnessDays: lastTrend.freshnessDays });
|
|
1153
|
+
// Collect evidence — filter to items that actually mention the brand
|
|
1154
|
+
if (includeEvidence && t.evidence) {
|
|
1155
|
+
for (const ev of t.evidence) {
|
|
1156
|
+
const evBrands = typeof ev.brandNames === 'string' ? ev.brandNames.split('|').map((s) => s.trim()).filter(Boolean) : (Array.isArray(ev.brandNames) ? ev.brandNames : []);
|
|
1157
|
+
const evText = `${ev.title || ''} ${ev.summary || ''}`.toLowerCase();
|
|
1158
|
+
const evMentionsBrand = evBrands.some((b) => b.toLowerCase().includes(brandLower)) || evText.includes(brandLower);
|
|
1159
|
+
if (!evMentionsBrand)
|
|
1160
|
+
continue;
|
|
1161
|
+
allEvidence.push({
|
|
1162
|
+
title: ev.title,
|
|
1163
|
+
excerpt: ev.summary || '',
|
|
1164
|
+
source_url: ev.sourceUrl,
|
|
1165
|
+
image_url: ev.imageUrl || null,
|
|
1166
|
+
published_at: ev.publishedAt || null,
|
|
1167
|
+
category: ev.category || 'Case Study',
|
|
1168
|
+
place: ev.place || null,
|
|
1169
|
+
graphId: t.graphId,
|
|
1170
|
+
graphName,
|
|
1171
|
+
brands_mentioned: evBrands,
|
|
1172
|
+
linked_trend: t.trendName,
|
|
1173
|
+
formatted_citation: ev.formatted_citation || (ev.title && ev.sourceUrl ? `[${ev.title}](${ev.sourceUrl})` : ev.title || ''),
|
|
1174
|
+
speaker_name: ev.speakerName || null,
|
|
1175
|
+
speaker_title: ev.speakerTitle || null,
|
|
1176
|
+
});
|
|
1177
|
+
// Track which graphs each co-occurring brand appears in (for sector-aware competitor labels)
|
|
1178
|
+
// Skip earnings/finance graphs — analyst transcripts co-mention unrelated brands
|
|
1179
|
+
if (!t.graphId.includes('earnings') && !t.graphId.includes('finance')) {
|
|
1180
|
+
for (const b of evBrands) {
|
|
1181
|
+
const bLower = (b || '').toLowerCase();
|
|
1182
|
+
if (bLower && bLower !== brandLower && !bLower.includes(brandLower) && !brandLower.includes(bLower)) {
|
|
1183
|
+
if (!competitorGraphs[b])
|
|
1184
|
+
competitorGraphs[b] = new Set();
|
|
1185
|
+
competitorGraphs[b].add(t.graphId);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
// Map cross-graph presence (must happen BEFORE competitor mapping so graphPresence is populated)
|
|
1193
|
+
for (const g of (cypherData.cross_graph_presence || [])) {
|
|
1194
|
+
const catalogEntry = graphLookup.get(g.graphId);
|
|
1195
|
+
const graphName = catalogEntry ? buildDisplayName(catalogEntry) : g.graphId;
|
|
1196
|
+
graphPresence[g.graphId] = {
|
|
1197
|
+
graphId: g.graphId,
|
|
1198
|
+
graphName,
|
|
1199
|
+
graphType: catalogEntry?.graph_type || 'expert',
|
|
1200
|
+
trendCount: g.trendCount || 0,
|
|
1201
|
+
evidenceCount: g.evidenceCount || 0,
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
// Map co-occurring brands — use graphIds from API response + evidence extraction
|
|
1205
|
+
for (const c of (cypherData.competitive_context?.co_occurring || [])) {
|
|
1206
|
+
competitorCounts[c.brand] = c.co_occurrences || c.coOccurrences || 1;
|
|
1207
|
+
// API now returns graphIds per competitor — merge with any evidence-derived ones
|
|
1208
|
+
if (c.graphIds && Array.isArray(c.graphIds)) {
|
|
1209
|
+
if (!competitorGraphs[c.brand])
|
|
1210
|
+
competitorGraphs[c.brand] = new Set();
|
|
1211
|
+
for (const gId of c.graphIds)
|
|
1212
|
+
competitorGraphs[c.brand].add(gId);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
// Filter stale trends — drop trends older than 365 days to focus on current footprint
|
|
1216
|
+
const STALE_THRESHOLD_DAYS = 365;
|
|
1217
|
+
const freshTrends = allTrends.filter((t) => {
|
|
1218
|
+
if (t.freshnessDays === null || t.freshnessDays === undefined)
|
|
1219
|
+
return true; // keep if no data
|
|
1220
|
+
return t.freshnessDays <= STALE_THRESHOLD_DAYS;
|
|
1221
|
+
});
|
|
1222
|
+
if (freshTrends.length > 0 && freshTrends.length < allTrends.length) {
|
|
1223
|
+
console.error(`[brand_tracker] Filtered ${allTrends.length - freshTrends.length} stale trend(s) (>${STALE_THRESHOLD_DAYS} days old)`);
|
|
1224
|
+
allTrends.length = 0;
|
|
1225
|
+
allTrends.push(...freshTrends);
|
|
1226
|
+
}
|
|
1227
|
+
// ── Evidence backfill: Cypher may return thin or no evidence arrays ──
|
|
1228
|
+
const MIN_EVIDENCE_THRESHOLD = 5;
|
|
1229
|
+
const trendsWithMissingEvidence = includeEvidence && allEvidence.length < MIN_EVIDENCE_THRESHOLD
|
|
1230
|
+
? allTrends.filter(t => (t.evidence_count || 0) > 0)
|
|
1231
|
+
: [];
|
|
1232
|
+
if (trendsWithMissingEvidence.length > 0) {
|
|
1233
|
+
console.error(`[brand_tracker] Cypher returned ${trendsWithMissingEvidence.length} trend(s) with evidenceCount but no evidence array — backfilling via /evidence`);
|
|
1234
|
+
const evBackfillResults = await Promise.allSettled(trendsWithMissingEvidence.slice(0, 5).map(async (t) => {
|
|
1235
|
+
try {
|
|
1236
|
+
const evData = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(t.graphId)}/evidence`, apiKey, resolveUserId(userId, uid), { for_node_id: t.node_id, top_k: maxEv });
|
|
1237
|
+
return { trend: t, evidence: evData?.evidence || [] };
|
|
1238
|
+
}
|
|
1239
|
+
catch {
|
|
1240
|
+
return { trend: t, evidence: [] };
|
|
1241
|
+
}
|
|
1242
|
+
}));
|
|
1243
|
+
for (const r of evBackfillResults) {
|
|
1244
|
+
if (r.status !== 'fulfilled')
|
|
1245
|
+
continue;
|
|
1246
|
+
const { trend, evidence: evItems } = r.value;
|
|
1247
|
+
for (const ev of evItems) {
|
|
1248
|
+
// Filter to evidence that actually mentions the brand
|
|
1249
|
+
const evBrandsRaw = typeof ev.brandNames === 'string' ? ev.brandNames.split('|').map((s) => s.trim()).filter(Boolean) : (Array.isArray(ev.brandNames) ? ev.brandNames : []);
|
|
1250
|
+
const evText = `${ev.title || ''} ${ev.snippet || ev.summary || ''}`.toLowerCase();
|
|
1251
|
+
const evMentionsBrand = evBrandsRaw.some((b) => b.toLowerCase().includes(brandLower)) || evText.includes(brandLower);
|
|
1252
|
+
if (!evMentionsBrand)
|
|
1253
|
+
continue;
|
|
1254
|
+
allEvidence.push({
|
|
1255
|
+
title: ev.title,
|
|
1256
|
+
excerpt: ev.snippet || ev.summary || '',
|
|
1257
|
+
source_url: ev.sourceUrl,
|
|
1258
|
+
image_url: ev.imageUrl || null,
|
|
1259
|
+
published_at: ev.publishedAt || null,
|
|
1260
|
+
category: ev.category || 'Case Study',
|
|
1261
|
+
place: ev.place || null,
|
|
1262
|
+
graphId: trend.graphId,
|
|
1263
|
+
graphName: trend.graphName,
|
|
1264
|
+
brands_mentioned: evBrandsRaw,
|
|
1265
|
+
linked_trend: trend.trend_name,
|
|
1266
|
+
formatted_citation: ev.formatted_citation || (ev.title && ev.sourceUrl ? `[${ev.title}](${ev.sourceUrl})` : ev.title || ''),
|
|
1267
|
+
speaker_name: ev.speakerName || null,
|
|
1268
|
+
speaker_title: ev.speakerTitle || null,
|
|
1269
|
+
});
|
|
1270
|
+
// Collect competitor brands from backfilled evidence
|
|
1271
|
+
for (const b of evBrandsRaw) {
|
|
1272
|
+
const bLower = b.toLowerCase();
|
|
1273
|
+
if (bLower !== brandLower && !bLower.includes(brandLower) && !brandLower.includes(bLower)) {
|
|
1274
|
+
competitorCounts[b] = (competitorCounts[b] || 0) + 1;
|
|
1275
|
+
if (!competitorGraphs[b])
|
|
1276
|
+
competitorGraphs[b] = new Set();
|
|
1277
|
+
competitorGraphs[b].add(trend.graphId);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
if (allEvidence.length > 0) {
|
|
1283
|
+
console.error(`[brand_tracker] Evidence backfill recovered ${allEvidence.length} evidence item(s)`);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
catch (cypherErr) {
|
|
1289
|
+
console.error(`[brand_tracker] Cypher endpoint failed (${cypherErr.message}) — falling back to multi-search`);
|
|
1290
|
+
}
|
|
1291
|
+
// ── Strategy 2: Fallback/supplement — parallel per-graph search ──
|
|
1292
|
+
const needsSupplement = !usedCypherEndpoint || allTrends.length < 3;
|
|
1293
|
+
if (needsSupplement) {
|
|
1294
|
+
const cypherGraphIds = new Set(Object.keys(graphPresence));
|
|
1295
|
+
const NO_BRAND_GRAPHS = new Set([
|
|
1296
|
+
'braze-2026-trends',
|
|
1297
|
+
'ezra-eeman-wayfinder',
|
|
1298
|
+
'havas-media-trends',
|
|
1299
|
+
'publicis-sapient-next-graph',
|
|
1300
|
+
]);
|
|
1301
|
+
const allGraphs = getLiveGraphs();
|
|
1302
|
+
const MAX_BRAND_FALLBACK_GRAPHS = 8; // Cap fan-out to prevent flooding API
|
|
1303
|
+
const graphsToSearch = graph_ids?.length
|
|
1304
|
+
? allGraphs.filter(g => graph_ids.includes(g.graph_id))
|
|
1305
|
+
: allGraphs.filter(g => (g.graph_type === 'domain' || g.graph_type === 'expert') && !NO_BRAND_GRAPHS.has(g.graph_id) && !cypherGraphIds.has(g.graph_id)).slice(0, MAX_BRAND_FALLBACK_GRAPHS);
|
|
1306
|
+
if (graphsToSearch.length > 0) {
|
|
1307
|
+
const searchResults = await Promise.allSettled(graphsToSearch.map(async (graph) => {
|
|
1308
|
+
try {
|
|
1309
|
+
const body = {
|
|
1310
|
+
query: brandName,
|
|
1311
|
+
limit: 10,
|
|
1312
|
+
use_semantic: true,
|
|
1313
|
+
include_evidence: includeEvidence,
|
|
1314
|
+
};
|
|
1315
|
+
const searchPromise = foddaRequest('POST', `/v1/graphs/${encodeURIComponent(graph.graph_id)}/search`, apiKey, resolveUserId(userId, uid), body);
|
|
1316
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000));
|
|
1317
|
+
const data = await Promise.race([searchPromise, timeoutPromise]);
|
|
1318
|
+
const rows = Array.isArray(data) ? data : data?.rows || [];
|
|
1319
|
+
return { graphId: graph.graph_id, graph, rows };
|
|
1320
|
+
}
|
|
1321
|
+
catch {
|
|
1322
|
+
return { graphId: graph.graph_id, graph, rows: [] };
|
|
1323
|
+
}
|
|
1324
|
+
}));
|
|
1325
|
+
for (const result of searchResults) {
|
|
1326
|
+
if (result.status !== 'fulfilled')
|
|
1327
|
+
continue;
|
|
1328
|
+
const { graphId, graph, rows } = result.value;
|
|
1329
|
+
if (rows.length === 0)
|
|
1330
|
+
continue;
|
|
1331
|
+
const graphName = buildDisplayName(graph);
|
|
1332
|
+
const brandRows = rows.filter((row) => {
|
|
1333
|
+
const nameMatch = (row.trendName || row.label || row.title || '').toLowerCase().includes(brandLower);
|
|
1334
|
+
const descMatch = (row.trendDescription || row.description || row.summary || '').toLowerCase().includes(brandLower);
|
|
1335
|
+
const brandNamesArr = typeof row.brandNames === 'string' ? row.brandNames.split('|').map((s) => s.trim()).filter(Boolean) : (Array.isArray(row.brandNames) ? row.brandNames : []);
|
|
1336
|
+
const brandNamesMatch = brandNamesArr.some((b) => b.toLowerCase().includes(brandLower));
|
|
1337
|
+
const evidenceBrandMatch = (row.evidence || []).some((e) => (() => { const ebn = typeof e.brandNames === 'string' ? e.brandNames.split('|').map((s) => s.trim()).filter(Boolean) : (Array.isArray(e.brandNames) ? e.brandNames : []); return ebn.some((b) => b.toLowerCase().includes(brandLower)); })() ||
|
|
1338
|
+
(e.title || '').toLowerCase().includes(brandLower) ||
|
|
1339
|
+
(e.snippet || e.summary || '').toLowerCase().includes(brandLower));
|
|
1340
|
+
// Tier 1: Direct brand mention — high confidence
|
|
1341
|
+
const directMatch = nameMatch || descMatch || brandNamesMatch || evidenceBrandMatch;
|
|
1342
|
+
// Tier 2: Semantic relevance — the search used use_semantic:true,
|
|
1343
|
+
// so high-scoring results are topically relevant even without a literal brand mention.
|
|
1344
|
+
// This prevents discarding trends like "Closed-Loop Textiles" when searching for "Patagonia".
|
|
1345
|
+
const semanticMatch = (row.signal_score || row.score || 0) >= 60;
|
|
1346
|
+
if (!directMatch && !semanticMatch)
|
|
1347
|
+
return false;
|
|
1348
|
+
// Tag row so competitor extraction only runs on direct matches
|
|
1349
|
+
row._directBrandMatch = directMatch;
|
|
1350
|
+
return true;
|
|
1351
|
+
});
|
|
1352
|
+
if (brandRows.length === 0)
|
|
1353
|
+
continue;
|
|
1354
|
+
let graphEvCount = 0;
|
|
1355
|
+
for (const row of brandRows) {
|
|
1356
|
+
allTrends.push({
|
|
1357
|
+
trend_name: row.trendName || row.label || row.title,
|
|
1358
|
+
trend_description: row.trendDescription || row.description || row.summary || '',
|
|
1359
|
+
graphId,
|
|
1360
|
+
graphName,
|
|
1361
|
+
signal_score: row.signal_score || null,
|
|
1362
|
+
lifecycle: computeLifecycle(row),
|
|
1363
|
+
momentum: computeMomentum(row),
|
|
1364
|
+
evidence_count: row.evidence_count || row.evidenceCount || 0,
|
|
1365
|
+
node_id: row.node_id || row.trendId,
|
|
1366
|
+
_use_this_graphId: row._use_this_graphId || graphId,
|
|
1367
|
+
});
|
|
1368
|
+
if (includeEvidence && row.evidence) {
|
|
1369
|
+
for (const ev of row.evidence) {
|
|
1370
|
+
const evBrandsRaw = typeof ev.brandNames === 'string' ? ev.brandNames.split('|').map((s) => s.trim()).filter(Boolean) : (Array.isArray(ev.brandNames) ? ev.brandNames : []);
|
|
1371
|
+
const evText = `${ev.title || ''} ${ev.snippet || ev.summary || ''}`.toLowerCase();
|
|
1372
|
+
const evMentionsBrand = evBrandsRaw.some((b) => b.toLowerCase().includes(brandLower)) || evText.includes(brandLower);
|
|
1373
|
+
if (!evMentionsBrand)
|
|
1374
|
+
continue;
|
|
1375
|
+
allEvidence.push({
|
|
1376
|
+
title: ev.title,
|
|
1377
|
+
excerpt: ev.snippet || ev.summary || '',
|
|
1378
|
+
source_url: ev.sourceUrl,
|
|
1379
|
+
image_url: ev.imageUrl || null,
|
|
1380
|
+
published_at: ev.publishedAt || null,
|
|
1381
|
+
category: ev.evidenceType || ev.contentType || ev.category || 'Case Study',
|
|
1382
|
+
place: ev.place || null,
|
|
1383
|
+
graphId,
|
|
1384
|
+
graphName,
|
|
1385
|
+
brands_mentioned: evBrandsRaw,
|
|
1386
|
+
linked_trend: row.trendName || row.label || row.title,
|
|
1387
|
+
formatted_citation: ev.formatted_citation || (ev.title && ev.sourceUrl ? `[${ev.title}](${ev.sourceUrl})` : ev.title || ''),
|
|
1388
|
+
speaker_name: ev.speakerName || ev.speaker_name || null,
|
|
1389
|
+
speaker_title: ev.speakerTitle || ev.speaker_title || null,
|
|
1390
|
+
});
|
|
1391
|
+
graphEvCount++;
|
|
1392
|
+
// Only count competitors from DIRECT brand matches, not semantic matches
|
|
1393
|
+
if (row._directBrandMatch) {
|
|
1394
|
+
for (const b of evBrandsRaw) {
|
|
1395
|
+
const bLower = b.toLowerCase();
|
|
1396
|
+
if (bLower !== brandLower && !bLower.includes(brandLower) && !brandLower.includes(bLower)) {
|
|
1397
|
+
if (graphId.includes('earnings') || graphId.includes('finance'))
|
|
1398
|
+
continue;
|
|
1399
|
+
competitorCounts[b] = (competitorCounts[b] || 0) + 1;
|
|
1400
|
+
if (!competitorGraphs[b])
|
|
1401
|
+
competitorGraphs[b] = new Set();
|
|
1402
|
+
competitorGraphs[b].add(graphId);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
// Only count trend-level brands as competitors for DIRECT brand matches
|
|
1409
|
+
if (row._directBrandMatch) {
|
|
1410
|
+
const rowBrands = typeof row.brandNames === 'string' ? row.brandNames.split('|').map((s) => s.trim()).filter(Boolean) : (Array.isArray(row.brandNames) ? row.brandNames : []);
|
|
1411
|
+
for (const b of rowBrands) {
|
|
1412
|
+
const bLower = b.toLowerCase();
|
|
1413
|
+
if (bLower !== brandLower && !bLower.includes(brandLower) && !brandLower.includes(bLower)) {
|
|
1414
|
+
competitorCounts[b] = (competitorCounts[b] || 0) + 1;
|
|
1415
|
+
if (!competitorGraphs[b])
|
|
1416
|
+
competitorGraphs[b] = new Set();
|
|
1417
|
+
competitorGraphs[b].add(graphId);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
graphPresence[graphId] = {
|
|
1423
|
+
graphId,
|
|
1424
|
+
graphName,
|
|
1425
|
+
graphType: graph.graph_type,
|
|
1426
|
+
trendCount: brandRows.length,
|
|
1427
|
+
evidenceCount: graphEvCount,
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
// Deduplicate trends
|
|
1433
|
+
const seenTrends = new Set();
|
|
1434
|
+
const uniqueTrends = allTrends.filter(t => {
|
|
1435
|
+
const key = `${t.graphId}:${t.trend_name}`;
|
|
1436
|
+
if (seenTrends.has(key))
|
|
1437
|
+
return false;
|
|
1438
|
+
seenTrends.add(key);
|
|
1439
|
+
return true;
|
|
1440
|
+
});
|
|
1441
|
+
uniqueTrends.sort((a, b) => (b.signal_score || 0) - (a.signal_score || 0));
|
|
1442
|
+
// Deduplicate, filter stale, and limit evidence
|
|
1443
|
+
const EVIDENCE_STALE_MONTHS = 18;
|
|
1444
|
+
const evidenceCutoff = new Date();
|
|
1445
|
+
evidenceCutoff.setMonth(evidenceCutoff.getMonth() - EVIDENCE_STALE_MONTHS);
|
|
1446
|
+
const seenEvidence = new Set();
|
|
1447
|
+
const uniqueEvidence = allEvidence
|
|
1448
|
+
.filter(e => {
|
|
1449
|
+
const key = e.source_url || e.title;
|
|
1450
|
+
if (!key || seenEvidence.has(key))
|
|
1451
|
+
return false;
|
|
1452
|
+
seenEvidence.add(key);
|
|
1453
|
+
// Drop evidence older than 18 months
|
|
1454
|
+
if (e.published_at) {
|
|
1455
|
+
const d = new Date(e.published_at);
|
|
1456
|
+
if (!isNaN(d.getTime()) && d < evidenceCutoff)
|
|
1457
|
+
return false;
|
|
1458
|
+
}
|
|
1459
|
+
return true;
|
|
1460
|
+
})
|
|
1461
|
+
.sort((a, b) => {
|
|
1462
|
+
const aDate = a.published_at ? new Date(a.published_at).getTime() : 0;
|
|
1463
|
+
const bDate = b.published_at ? new Date(b.published_at).getTime() : 0;
|
|
1464
|
+
return bDate - aDate;
|
|
1465
|
+
})
|
|
1466
|
+
.slice(0, maxEv * Math.max(Object.keys(graphPresence).length, 1));
|
|
1467
|
+
// Filter out platforms/marketplaces — these appear in evidence as channels, not competitors
|
|
1468
|
+
const PLATFORM_BLOCKLIST = new Set([
|
|
1469
|
+
'Meituan', 'Taobao', 'Alibaba', 'JD.com', 'Tmall', 'Pinduoduo', 'Shopee',
|
|
1470
|
+
'Amazon', 'eBay', 'Etsy', 'Shopify', 'Walmart', 'Target',
|
|
1471
|
+
'Google', 'Apple', 'Meta', 'Microsoft', 'OpenAI',
|
|
1472
|
+
'Instagram', 'TikTok', 'YouTube', 'Snapchat', 'Pinterest', 'X', 'Twitter', 'Reddit', 'Substack',
|
|
1473
|
+
'Spotify', 'Netflix', 'Disney+', 'Hulu',
|
|
1474
|
+
'Uber', 'Lyft', 'DoorDash', 'Instacart',
|
|
1475
|
+
'WeChat', 'WhatsApp', 'Telegram', 'LINE',
|
|
1476
|
+
'Stripe', 'PayPal', 'Square', 'Klarna',
|
|
1477
|
+
]);
|
|
1478
|
+
const filteredCompetitorCounts = Object.fromEntries(Object.entries(competitorCounts).filter(([name]) => !PLATFORM_BLOCKLIST.has(name)));
|
|
1479
|
+
// Build competitive context — sort by shared DOMAIN graph overlap
|
|
1480
|
+
const brandGraphSet = new Set(Object.keys(graphPresence));
|
|
1481
|
+
const domainGraphIds = new Set(getDomainGraphIds());
|
|
1482
|
+
const competitors = Object.entries(filteredCompetitorCounts)
|
|
1483
|
+
.sort(([nameA, countA], [nameB, countB]) => {
|
|
1484
|
+
const aGraphs = competitorGraphs[nameA] ? [...competitorGraphs[nameA]] : [];
|
|
1485
|
+
const bGraphs = competitorGraphs[nameB] ? [...competitorGraphs[nameB]] : [];
|
|
1486
|
+
// Weight: domain graph overlap = 2 points, expert graph overlap = 1 point
|
|
1487
|
+
const scoreA = aGraphs.filter(g => brandGraphSet.has(g)).reduce((s, g) => s + (domainGraphIds.has(g) ? 2 : 1), 0);
|
|
1488
|
+
const scoreB = bGraphs.filter(g => brandGraphSet.has(g)).reduce((s, g) => s + (domainGraphIds.has(g) ? 2 : 1), 0);
|
|
1489
|
+
// Primary: weighted graph overlap score
|
|
1490
|
+
if (scoreB !== scoreA)
|
|
1491
|
+
return scoreB - scoreA;
|
|
1492
|
+
// Secondary: co-occurrence count
|
|
1493
|
+
return countB - countA;
|
|
1494
|
+
})
|
|
1495
|
+
.slice(0, 15)
|
|
1496
|
+
.map(([name, count]) => ({
|
|
1497
|
+
brand: name,
|
|
1498
|
+
co_occurrences: count,
|
|
1499
|
+
graphIds: competitorGraphs[name] ? [...competitorGraphs[name]] : [],
|
|
1500
|
+
}));
|
|
1501
|
+
const crossGraphPresence = Object.values(graphPresence)
|
|
1502
|
+
.sort((a, b) => b.evidenceCount - a.evidenceCount);
|
|
1503
|
+
// Activity timeline
|
|
1504
|
+
const quarterCounts = {};
|
|
1505
|
+
for (const ev of uniqueEvidence) {
|
|
1506
|
+
if (ev.published_at) {
|
|
1507
|
+
const d = new Date(ev.published_at);
|
|
1508
|
+
const q = `${d.getFullYear()}-Q${Math.ceil((d.getMonth() + 1) / 3)}`;
|
|
1509
|
+
quarterCounts[q] = (quarterCounts[q] || 0) + 1;
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
const activityTimeline = Object.entries(quarterCounts)
|
|
1513
|
+
.sort(([a], [b]) => b.localeCompare(a))
|
|
1514
|
+
.map(([period, count]) => ({ period, count }));
|
|
1515
|
+
// Evidence by type
|
|
1516
|
+
const evidenceByType = {};
|
|
1517
|
+
for (const ev of uniqueEvidence) {
|
|
1518
|
+
const cat = ev.category || 'Case Study';
|
|
1519
|
+
evidenceByType[cat] = (evidenceByType[cat] || 0) + 1;
|
|
1520
|
+
}
|
|
1521
|
+
// Lifecycle distribution
|
|
1522
|
+
const lifecycleDist = {};
|
|
1523
|
+
for (const t of uniqueTrends) {
|
|
1524
|
+
lifecycleDist[t.lifecycle] = (lifecycleDist[t.lifecycle] || 0) + 1;
|
|
1525
|
+
}
|
|
1526
|
+
// Evidence velocity
|
|
1527
|
+
const sortedTimeline = [...activityTimeline].sort((a, b) => b.period.localeCompare(a.period));
|
|
1528
|
+
const currentQ = sortedTimeline[0]?.count || 0;
|
|
1529
|
+
const prevQ = sortedTimeline[1]?.count || 0;
|
|
1530
|
+
const velocityTrend = currentQ > prevQ ? 'accelerating' : currentQ === prevQ ? 'stable' : 'decelerating';
|
|
1531
|
+
// Build profile
|
|
1532
|
+
const profile = {
|
|
1533
|
+
brand: brandName,
|
|
1534
|
+
_data_source: usedCypherEndpoint ? 'cypher-single-query' : 'multi-graph-search',
|
|
1535
|
+
summary: {
|
|
1536
|
+
total_evidence_items: uniqueEvidence.length,
|
|
1537
|
+
total_trends_connected: uniqueTrends.length,
|
|
1538
|
+
graphs_present_in: crossGraphPresence.map(g => g.graphName),
|
|
1539
|
+
evidence_velocity: {
|
|
1540
|
+
current_quarter: currentQ,
|
|
1541
|
+
previous_quarter: prevQ,
|
|
1542
|
+
trend: velocityTrend,
|
|
1543
|
+
},
|
|
1544
|
+
evidence_by_type: evidenceByType,
|
|
1545
|
+
lifecycle_distribution: lifecycleDist,
|
|
1546
|
+
},
|
|
1547
|
+
trend_footprint: uniqueTrends,
|
|
1548
|
+
evidence_items: includeEvidence ? uniqueEvidence : undefined,
|
|
1549
|
+
competitive_context: {
|
|
1550
|
+
co_occurring_brands: competitors,
|
|
1551
|
+
note: 'Brands that appear in the same evidence articles or trend contexts as ' + brandName,
|
|
1552
|
+
},
|
|
1553
|
+
cross_graph_presence: crossGraphPresence,
|
|
1554
|
+
activity_timeline: activityTimeline,
|
|
1555
|
+
_attribution: `Brand Intelligence Profile for ${brandName} — aggregated from ${crossGraphPresence.length} knowledge graph${crossGraphPresence.length !== 1 ? 's' : ''} on Fodda`,
|
|
1556
|
+
supplemental_signals: null,
|
|
1557
|
+
suggested_next_prompts: [
|
|
1558
|
+
{
|
|
1559
|
+
label: `vs ${competitors[0]?.brand || 'competitors'}`,
|
|
1560
|
+
prompt: `Compare ${brandName}'s innovation activity with ${competitors[0]?.brand || 'its top competitor'}. Which trends are they both pursuing and where do they diverge?`,
|
|
1561
|
+
type: 'competitive_comparison',
|
|
1562
|
+
},
|
|
1563
|
+
{
|
|
1564
|
+
label: 'Trend deep dive',
|
|
1565
|
+
prompt: `Show me the evidence behind ${brandName}'s strongest trend: "${uniqueTrends[0]?.trend_name || 'top trend'}"`,
|
|
1566
|
+
type: 'deeper_dive',
|
|
1567
|
+
},
|
|
1568
|
+
{
|
|
1569
|
+
label: 'Market signals',
|
|
1570
|
+
prompt: `Pull Google Trends and Amazon data for ${brandName} to validate the innovation signals from the knowledge graphs`,
|
|
1571
|
+
type: 'supplemental_validation',
|
|
1572
|
+
},
|
|
1573
|
+
].filter(p => uniqueTrends.length > 0),
|
|
1574
|
+
_render_instructions: buildRenderInstructions({
|
|
1575
|
+
hasWidget: true,
|
|
1576
|
+
hasPrompts: true,
|
|
1577
|
+
hasEvidence: uniqueEvidence.length > 0,
|
|
1578
|
+
graphWebpageUrls: collectGraphWebpageUrls(Object.keys(graphPresence)),
|
|
1579
|
+
}),
|
|
1580
|
+
};
|
|
1581
|
+
const topCompetitors = competitors.slice(0, 2).map(c => c.brand);
|
|
1582
|
+
const comparisonQuery = [brandName, ...topCompetitors].join(',');
|
|
1583
|
+
// Wikipedia disambiguation: map brand names to canonical article titles
|
|
1584
|
+
const wikiDisambig = { 'Nike': 'Nike, Inc.', 'Apple': 'Apple Inc.', 'Amazon': 'Amazon (company)', 'Meta': 'Meta Platforms', 'Target': 'Target Corporation' };
|
|
1585
|
+
const wikiArticles = [brandName, ...topCompetitors].map(b => wikiDisambig[b] || b).join(',');
|
|
1586
|
+
const [googleTrendsResult, wikipediaResult, amazonResult, beaResult, earningsResult] = await Promise.allSettled([
|
|
1587
|
+
foddaRequest('GET', `/v1/supplemental/google-trends?query=${encodeURIComponent(comparisonQuery)}&geo=US&timeframe=today+12-m`, apiKey, resolveUserId(userId, uid)),
|
|
1588
|
+
foddaRequest('GET', `/v1/supplemental/wikipedia/pageviews?articles=${encodeURIComponent(wikiArticles)}&period=monthly`, apiKey, resolveUserId(userId, uid)),
|
|
1589
|
+
amazonPromise,
|
|
1590
|
+
censusPromise,
|
|
1591
|
+
foddaRequest('GET', `/v1/supplemental/earnings/snapshot?brand=${encodeURIComponent(brandName)}&limit=5`, apiKey, resolveUserId(userId, uid)),
|
|
1592
|
+
]);
|
|
1593
|
+
// Unwrap .snapshot nesting — supplemental API wraps actual data inside .snapshot alongside metadata
|
|
1594
|
+
const unwrapSnapshot = (raw) => raw?.snapshot || raw;
|
|
1595
|
+
profile.supplemental_signals = {
|
|
1596
|
+
google_trends: googleTrendsResult.status === 'fulfilled' ? unwrapSnapshot(googleTrendsResult.value) : null,
|
|
1597
|
+
wikipedia: wikipediaResult.status === 'fulfilled' ? unwrapSnapshot(wikipediaResult.value) : null,
|
|
1598
|
+
amazon: amazonResult.status === 'fulfilled' ? unwrapSnapshot(amazonResult.value) : null,
|
|
1599
|
+
census_retail: beaResult.status === 'fulfilled' ? unwrapSnapshot(beaResult.value) : null,
|
|
1600
|
+
};
|
|
1601
|
+
// ── Earnings Intelligence (for publicly traded brands) ──
|
|
1602
|
+
const earningsRaw = earningsResult.status === 'fulfilled' ? earningsResult.value : null;
|
|
1603
|
+
const earningsSnapshot = earningsRaw?.snapshot || earningsRaw; // unwrap .snapshot if present
|
|
1604
|
+
const earningsItems = earningsSnapshot?.results || earningsSnapshot?.earnings || (Array.isArray(earningsSnapshot) ? earningsSnapshot : []);
|
|
1605
|
+
profile.earningsIntelligence = earningsItems.length > 0 ? earningsItems : undefined;
|
|
1606
|
+
const widget = await renderBrandWidget(profile);
|
|
1607
|
+
const EDITORIAL_INSTRUCTION = widget.open_slots.length === 0
|
|
1608
|
+
? `── BRAND WIDGET: READY ──\nThe widget is fully populated. Call show_widget with the widget_html as-is. Do NOT modify the HTML.`
|
|
1609
|
+
: `── BRAND WIDGET: EDITORIAL SLOTS ──\nThe widget_html is mostly complete. Fill these remaining slot(s): ${widget.open_slots.join(', ')}\n\n${widget.open_slots.includes('ANALYSIS_HTML') ? '{{ANALYSIS_HTML}} — Write 3-5 paragraphs of strategic analysis using <p> tags. Cover: strongest signal and why, gaps or emerging opportunities, competitive positioning, and what to watch next. Use <strong> for key terms.' : ''}\n\nEDITORIAL CONTEXT:\n${JSON.stringify(widget.editorial_context, null, 2)}\n\nCRITICAL: ALL output must go INSIDE the widget slots. Do NOT redesign, restyle, or add new elements.\nAfter filling slots, pass the complete HTML to show_widget.\n`;
|
|
1610
|
+
storeWidget(widget.widget_html);
|
|
1611
|
+
return { profile, widget, EDITORIAL_INSTRUCTION };
|
|
1612
|
+
};
|
|
1613
|
+
server.tool('brand_tracker', 'Build a complete Brand Intelligence Profile by searching ALL knowledge graphs for a specific brand. Returns trend footprint (which trends the brand appears in), competitive landscape (co-occurring brands ranked by overlap), cross-graph presence, evidence timeline, lifecycle distribution, and bundled supplemental signals (Google Trends, Wikipedia, Amazon, earnings). Use when the query is about a specific company or brand — "What is Nike doing?", "Patagonia\'s innovation strategy", "How is Apple positioned?". This aggregates intelligence that would require dozens of separate web searches to assemble.', {
|
|
1614
|
+
brand_name: z.string().describe("The brand name to look up (e.g. 'Nike', 'Adidas', 'Apple'). Case-insensitive."),
|
|
1615
|
+
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
|
|
1616
|
+
graph_ids: z.array(z.string()).optional().describe('Optional: specific graph IDs to search. If omitted, searches ALL accessible graphs.'),
|
|
1617
|
+
include_evidence: z.boolean().optional().describe('If true (default), include individual evidence items. Set to false for summary-only.'),
|
|
1618
|
+
max_evidence: z.number().optional().describe('Maximum evidence items per graph. Default: 10. Max: 25.'),
|
|
1619
|
+
}, { title: 'Brand Intelligence Profile', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ brand_name, userId: uid, graph_ids, include_evidence, max_evidence }) => {
|
|
1620
|
+
try {
|
|
1621
|
+
// Log query to Questions table (fire-and-forget, before cache)
|
|
1622
|
+
logUserQuery(brand_name, 'brand_tracker');
|
|
1623
|
+
const { widget, EDITORIAL_INSTRUCTION } = await executeBrandTracker(brand_name, uid, graph_ids, include_evidence, max_evidence);
|
|
1624
|
+
// ── Query-level billing ──
|
|
1625
|
+
chargeQuery({ queryTypeCode: 'brand_intelligence', apiKey, userId: resolveUserId(userId, uid), query: brand_name, foddaRequest })
|
|
1626
|
+
.catch(e => console.error('[brand_tracker] chargeQuery failed:', e.message));
|
|
1627
|
+
return { content: [
|
|
1628
|
+
{ type: 'text', text: '── WIDGET HTML ──\nIf your client supports HTML visualization (show_widget, visualize:show_widget, or artifacts), pass this HTML verbatim. Do not rewrite or restyle.\n\n' + widget.widget_html },
|
|
1629
|
+
{ type: 'text', text: EDITORIAL_INSTRUCTION },
|
|
1630
|
+
] };
|
|
1631
|
+
}
|
|
1632
|
+
catch (err) {
|
|
1633
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1634
|
+
if (trialResult)
|
|
1635
|
+
return trialResult;
|
|
1636
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
1637
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
// --- get_supplemental_context (Unified Supplemental Endpoint) ---
|
|
1641
|
+
// Replaces 21 individual supplemental tools with a single call.
|
|
1642
|
+
// The API routes to 6-10 relevant sources based on query + domain,
|
|
1643
|
+
// queries them in parallel, and returns a consolidated response.
|
|
1644
|
+
server.tool('get_supplemental_context', 'Get institutional market data from up to 10 sources in a single call. The server automatically selects the most relevant sources based on your query and domain — economic indicators, trade data, product landscape, food economics, agricultural production, nutritional composition, commodity pricing, research signals, demographic surveys, and more. Use this AFTER graph searches to add macro context, or standalone for quantitative market intelligence. Returns categorized data blocks (demand_signals, economic_context, market_data, research_signals, demographic_context) with source attribution for citations. 5 API calls per standalone use.', {
|
|
1645
|
+
query: z.string().describe("The topic or query to get supplemental data for (e.g., 'sustainable packaging', 'tequila spirits market', 'Gen Z beauty')"),
|
|
1646
|
+
domain: z.string().optional().describe("Domain hint to improve source routing: 'retail', 'beauty', 'fashion', 'sports', 'food', 'technology', 'culture', 'travel', 'design'. If omitted, inferred from query."),
|
|
1647
|
+
brands: z.array(z.string()).optional().describe("Brand names to include in demand/product lookups (e.g., ['Nike', 'Adidas']). Triggers Google Trends comparison and Amazon product search."),
|
|
1648
|
+
graph_ids: z.array(z.string()).optional().describe("Graph IDs from prior search results — helps refine domain inference."),
|
|
1649
|
+
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
|
|
1650
|
+
}, { title: 'Get Market Context Data', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ query, domain, brands, graph_ids, userId: uid }) => {
|
|
1651
|
+
try {
|
|
1652
|
+
const body = { query };
|
|
1653
|
+
if (domain)
|
|
1654
|
+
body.domain = domain;
|
|
1655
|
+
if (brands?.length)
|
|
1656
|
+
body.brands = brands;
|
|
1657
|
+
if (graph_ids?.length)
|
|
1658
|
+
body.graph_ids = graph_ids;
|
|
1659
|
+
const jobId = crypto.randomUUID();
|
|
1660
|
+
activeSupplementalJobs.set(jobId, { status: 'RUNNING', result: null, error: null });
|
|
1661
|
+
// Run fetch in the background
|
|
1662
|
+
(async () => {
|
|
1663
|
+
try {
|
|
1664
|
+
const data = await foddaRequest('POST', '/v1/supplemental/context', apiKey, resolveUserId(userId, uid), body);
|
|
1665
|
+
// ── Query-level billing ──
|
|
1666
|
+
chargeQuery({ queryTypeCode: 'standalone_supplemental', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest })
|
|
1667
|
+
.catch(e => console.error('[supplemental] chargeQuery failed:', e.message));
|
|
1668
|
+
activeSupplementalJobs.set(jobId, { status: 'COMPLETE', result: JSON.stringify(data, null, 2) });
|
|
1669
|
+
}
|
|
1670
|
+
catch (err) {
|
|
1671
|
+
const errMsg = err.response?.data?.error?.message || err.response?.data?.message || err.message || 'Unknown error';
|
|
1672
|
+
activeSupplementalJobs.set(jobId, { status: 'FAILED', error: errMsg });
|
|
1673
|
+
}
|
|
1674
|
+
})();
|
|
1675
|
+
return {
|
|
1676
|
+
content: [{
|
|
1677
|
+
type: 'text',
|
|
1678
|
+
text: `Supplemental data gathering started! The server is collecting context from up to 15 external sources in parallel. Job ID: ${jobId}\n\nIMPORTANT: You must use the check_supplemental_status tool with this Job ID to poll the status of the job and retrieve the data. Wait about 5-10 seconds before your first poll.`
|
|
1679
|
+
}]
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
catch (err) {
|
|
1683
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1684
|
+
if (trialResult)
|
|
1685
|
+
return trialResult;
|
|
1686
|
+
return await handleAccessError(err, 'supplemental');
|
|
1687
|
+
}
|
|
1688
|
+
});
|
|
1689
|
+
// --- check_supplemental_status ---
|
|
1690
|
+
server.tool('check_supplemental_status', 'Check the status of a long-running supplemental data gathering job. If complete, this tool returns the full JSON data payload. You MUST poll this periodically until the status is COMPLETE or FAILED.', {
|
|
1691
|
+
job_id: z.string().describe('The Job ID returned by get_supplemental_context'),
|
|
1692
|
+
}, { title: 'Check Supplemental Status', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ job_id }) => {
|
|
1693
|
+
const job = activeSupplementalJobs.get(job_id);
|
|
1694
|
+
if (!job) {
|
|
1695
|
+
return { isError: true, content: [{ type: 'text', text: `Job ID ${job_id} not found. It may have expired or never existed.` }] };
|
|
1696
|
+
}
|
|
1697
|
+
if (job.status === 'RUNNING') {
|
|
1698
|
+
return { content: [{ type: 'text', text: `Job ${job_id} is still RUNNING. The server is waiting on external APIs. Please poll again in 5 seconds.` }] };
|
|
1699
|
+
}
|
|
1700
|
+
if (job.status === 'FAILED') {
|
|
1701
|
+
activeSupplementalJobs.delete(job_id); // cleanup
|
|
1702
|
+
return { isError: true, content: [{ type: 'text', text: `Job ${job_id} FAILED: ${job.error}` }] };
|
|
1703
|
+
}
|
|
1704
|
+
if (job.status === 'COMPLETE') {
|
|
1705
|
+
activeSupplementalJobs.delete(job_id); // cleanup
|
|
1706
|
+
return { content: [{ type: 'text', text: job.result }] };
|
|
1707
|
+
}
|
|
1708
|
+
return { isError: true, content: [{ type: 'text', text: `Unknown status for job ${job_id}` }] };
|
|
1709
|
+
});
|
|
1710
|
+
// --- get_domain_intelligence ---
|
|
1711
|
+
// Searches ALL PSFK curated domain graphs in parallel. Returns trends + bundled evidence.
|
|
1712
|
+
server.tool('get_domain_intelligence', "Search PSFK-curated domain graphs (retail, beauty, fashion, sports, consumer electronics, F&B) for trend intelligence with bundled evidence. No graph ID needed — searches all relevant domain graphs in parallel. Returns expert-curated trends with categorized evidence (statistics, case studies, analysis, interviews) and source attribution. Use for broad industry trend research, sector analysis, or when the query spans multiple consumer categories. Preferred over web search for trend-level intelligence because results are editorially structured, not algorithmically ranked.", {
|
|
1713
|
+
query: z.string().describe("Natural language search query (e.g., 'sustainable packaging trends', 'Gen Z beauty habits')"),
|
|
1714
|
+
limit: z.number().optional().describe('Max trends to return (default: 10, max: 50)'),
|
|
1715
|
+
include_evidence: z.boolean().optional().describe('Bundle evidence for each trend (default: true)'),
|
|
1716
|
+
max_evidence_per_trend: z.number().optional().describe('Evidence items per trend (default: 5, max: 20)'),
|
|
1717
|
+
min_score: z.number().optional().describe('Minimum relevance threshold (default: 0.6)'),
|
|
1718
|
+
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
|
|
1719
|
+
}, { title: 'Search Domain Intelligence', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ query, limit, include_evidence, max_evidence_per_trend, min_score, userId: uid }) => {
|
|
1720
|
+
try {
|
|
1721
|
+
const body = { query };
|
|
1722
|
+
if (limit !== undefined)
|
|
1723
|
+
body.limit = limit;
|
|
1724
|
+
if (include_evidence !== undefined)
|
|
1725
|
+
body.include_evidence = include_evidence;
|
|
1726
|
+
if (max_evidence_per_trend !== undefined)
|
|
1727
|
+
body.max_evidence_per_trend = max_evidence_per_trend;
|
|
1728
|
+
if (min_score !== undefined)
|
|
1729
|
+
body.min_score = min_score;
|
|
1730
|
+
const data = await foddaRequest('POST', '/v1/search/domain', apiKey, resolveUserId(userId, uid), body);
|
|
1731
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1732
|
+
}
|
|
1733
|
+
catch (err) {
|
|
1734
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1735
|
+
if (trialResult)
|
|
1736
|
+
return trialResult;
|
|
1737
|
+
return await handleAccessError(err, 'supplemental');
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1740
|
+
// --- get_expert_intelligence ---
|
|
1741
|
+
// Searches ALL expert specialist graphs in parallel.
|
|
1742
|
+
server.tool('get_expert_intelligence', "Search specialist knowledge graphs built by named strategists and industry leaders — contains proprietary analysis, expert interviews, and high-density statistics not available via web search. No graph ID needed — searches all expert graphs in parallel. Use when the query requires specialist depth, named-expert perspectives, or strategic frameworks beyond mainstream coverage. Expert graphs cover domains like macro strategy, wayfinding, design innovation, SXSW insights, and sector-specific research reports.", {
|
|
1743
|
+
query: z.string().describe("Natural language search query (e.g., 'tequila spirits market', 'future of work')"),
|
|
1744
|
+
limit: z.number().optional().describe('Max trends to return (default: 10, max: 50)'),
|
|
1745
|
+
include_evidence: z.boolean().optional().describe('Bundle evidence for each trend (default: true)'),
|
|
1746
|
+
max_evidence_per_trend: z.number().optional().describe('Evidence items per trend (default: 5, max: 20)'),
|
|
1747
|
+
min_score: z.number().optional().describe('Minimum relevance threshold (default: 0.6)'),
|
|
1748
|
+
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
|
|
1749
|
+
}, { title: 'Search Expert Intelligence', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ query, limit, include_evidence, max_evidence_per_trend, min_score, userId: uid }) => {
|
|
1750
|
+
try {
|
|
1751
|
+
const body = { query };
|
|
1752
|
+
if (limit !== undefined)
|
|
1753
|
+
body.limit = limit;
|
|
1754
|
+
if (include_evidence !== undefined)
|
|
1755
|
+
body.include_evidence = include_evidence;
|
|
1756
|
+
if (max_evidence_per_trend !== undefined)
|
|
1757
|
+
body.max_evidence_per_trend = max_evidence_per_trend;
|
|
1758
|
+
if (min_score !== undefined)
|
|
1759
|
+
body.min_score = min_score;
|
|
1760
|
+
const data = await foddaRequest('POST', '/v1/search/expert', apiKey, resolveUserId(userId, uid), body);
|
|
1761
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1762
|
+
}
|
|
1763
|
+
catch (err) {
|
|
1764
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1765
|
+
if (trialResult)
|
|
1766
|
+
return trialResult;
|
|
1767
|
+
return await handleAccessError(err, 'supplemental');
|
|
1768
|
+
}
|
|
1769
|
+
});
|
|
1770
|
+
// --- get_report_intelligence ---
|
|
1771
|
+
// Searches ALL industry report graphs in parallel.
|
|
1772
|
+
server.tool('get_report_intelligence', "Search industry report knowledge graphs for published research findings, market forecasts, and quantitative projections from organizations like DHL, PwC, Delta, and specialist research firms. Returns structured findings with bundled evidence — not raw PDFs or summaries, but editorially extracted trend data with source attribution. No graph ID needed. Use for market sizing, competitive landscape analysis, and data-heavy research where published report intelligence is more authoritative than web search results.", {
|
|
1773
|
+
query: z.string().describe("Natural language search query (e.g., 'luxury resale market size', 'electric vehicle adoption rates')"),
|
|
1774
|
+
limit: z.number().optional().describe('Max trends to return (default: 10, max: 50)'),
|
|
1775
|
+
include_evidence: z.boolean().optional().describe('Bundle evidence for each trend (default: true)'),
|
|
1776
|
+
max_evidence_per_trend: z.number().optional().describe('Evidence items per trend (default: 5, max: 20)'),
|
|
1777
|
+
min_score: z.number().optional().describe('Minimum relevance threshold (default: 0.6)'),
|
|
1778
|
+
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
|
|
1779
|
+
}, { title: 'Search Report Intelligence', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ query, limit, include_evidence, max_evidence_per_trend, min_score, userId: uid }) => {
|
|
1780
|
+
try {
|
|
1781
|
+
const body = { query };
|
|
1782
|
+
if (limit !== undefined)
|
|
1783
|
+
body.limit = limit;
|
|
1784
|
+
if (include_evidence !== undefined)
|
|
1785
|
+
body.include_evidence = include_evidence;
|
|
1786
|
+
if (max_evidence_per_trend !== undefined)
|
|
1787
|
+
body.max_evidence_per_trend = max_evidence_per_trend;
|
|
1788
|
+
if (min_score !== undefined)
|
|
1789
|
+
body.min_score = min_score;
|
|
1790
|
+
const data = await foddaRequest('POST', '/v1/search/report', apiKey, resolveUserId(userId, uid), body);
|
|
1791
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1792
|
+
}
|
|
1793
|
+
catch (err) {
|
|
1794
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1795
|
+
if (trialResult)
|
|
1796
|
+
return trialResult;
|
|
1797
|
+
return await handleAccessError(err, 'supplemental');
|
|
1798
|
+
}
|
|
1799
|
+
});
|
|
1800
|
+
// --- search_statistics ---
|
|
1801
|
+
server.tool('search_statistics', "Search for specific quantitative data points — market sizes, growth rates, expert quotes, and brand case studies — directly in Fodda's knowledge graphs. Each result includes its parent trend context, enabling reverse lookup: data point → expert trend. Use this BEFORE supplemental tools when a query asks for specific numbers or statistics that Fodda's experts may have covered. Works on ALL graphs — PSFK curated graphs AND expert graphs. Try multiple graphs for coverage.", {
|
|
1802
|
+
graph_id: z.string().describe("Graph ID to search. Works on ALL graphs — PSFK curated ('retail', 'fashion', 'beauty', 'sports', 'sic', 'ce-design', 'pew') AND expert graphs. Search across multiple graphs for best coverage."),
|
|
1803
|
+
query: z.string().describe("What data to search for (e.g., 'luxury resale market size', 'secondhand clothing sales volume', 'Gen Z spending behavior')"),
|
|
1804
|
+
limit: z.number().optional().describe('Max results to return (default: 10, max: 50)'),
|
|
1805
|
+
min_score: z.number().optional().describe('Minimum relevance threshold, 0-1 (default: 0.60). Use 0.60 for broad queries, 0.70+ only for precise data lookups.'),
|
|
1806
|
+
include_signals: z.boolean().optional().describe('Also include Signal nodes (case studies, brand examples). Default: false'),
|
|
1807
|
+
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.')
|
|
1808
|
+
}, { title: 'Search Statistics & Data Points', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ graph_id, query, limit, min_score, include_signals, userId: uid }) => {
|
|
1809
|
+
try {
|
|
1810
|
+
const params = new URLSearchParams();
|
|
1811
|
+
params.set('query', query);
|
|
1812
|
+
if (limit !== undefined)
|
|
1813
|
+
params.set('limit', String(limit));
|
|
1814
|
+
if (min_score !== undefined)
|
|
1815
|
+
params.set('min_score', String(min_score));
|
|
1816
|
+
if (include_signals)
|
|
1817
|
+
params.set('include_signals', 'true');
|
|
1818
|
+
const path = `/v1/graphs/${graph_id}/statistics?${params.toString()}`;
|
|
1819
|
+
const data = await foddaRequest('GET', path, apiKey, resolveUserId(userId, uid));
|
|
1820
|
+
// Inject theme block for visualization branding
|
|
1821
|
+
if (data && typeof data === 'object') {
|
|
1822
|
+
data.theme = getFoddaTheme(graph_id);
|
|
1823
|
+
}
|
|
1824
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1825
|
+
}
|
|
1826
|
+
catch (err) {
|
|
1827
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1828
|
+
if (trialResult)
|
|
1829
|
+
return trialResult;
|
|
1830
|
+
return await handleAccessError(err, 'supplemental');
|
|
1831
|
+
}
|
|
1832
|
+
});
|
|
1833
|
+
// --- search_insights ---
|
|
1834
|
+
server.tool('search_insights', 'Search for expert quotes, editorial interpretations, and qualitative evidence across knowledge graphs. Returns categorized evidence (metric, quote, interpretation, signal) with source attribution and parent trend context. Works on ALL graphs. Use when you need named-expert voices, strategic framing, or analytical perspectives on a topic — the kind of curated qualitative intelligence that web search cannot surface because it lives inside structured knowledge graphs, not on public web pages.', {
|
|
1835
|
+
graph_id: z.string().describe("Graph ID to search. Works on ALL graphs — PSFK curated ('retail', 'sic', 'beauty', 'sports', 'fashion', 'ce-design', 'pew') AND expert graphs. Search across multiple graphs for best coverage."),
|
|
1836
|
+
query: z.string().describe("Natural language search query. E.g. 'expert views on Gen Z luxury' or 'resale market statistics'"),
|
|
1837
|
+
types: z.string().optional().describe("Comma-separated evidence types to search: metric, quote, interpretation, signal, or 'all' (default: 'metric,quote,interpretation')"),
|
|
1838
|
+
limit: z.number().optional().describe('Max results to return (default: 10, max: 50)'),
|
|
1839
|
+
min_score: z.number().optional().describe('Minimum relevance threshold 0-1 (default: 0.60). Use 0.60 for broad queries, 0.70+ for precise lookups.'),
|
|
1840
|
+
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.')
|
|
1841
|
+
}, { title: 'Search Expert Insights', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ graph_id, query, types, limit, min_score, userId: uid }) => {
|
|
1842
|
+
try {
|
|
1843
|
+
const searchTypes = types || 'metric,quote,interpretation';
|
|
1844
|
+
const params = new URLSearchParams();
|
|
1845
|
+
params.set('query', query);
|
|
1846
|
+
params.set('types', searchTypes);
|
|
1847
|
+
if (limit !== undefined)
|
|
1848
|
+
params.set('limit', String(limit));
|
|
1849
|
+
if (min_score !== undefined)
|
|
1850
|
+
params.set('min_score', String(min_score));
|
|
1851
|
+
const path = `/v1/graphs/${graph_id}/statistics?${params.toString()}`;
|
|
1852
|
+
const data = await foddaRequest('GET', path, apiKey, resolveUserId(userId, uid));
|
|
1853
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1854
|
+
}
|
|
1855
|
+
catch (err) {
|
|
1856
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1857
|
+
if (trialResult)
|
|
1858
|
+
return trialResult;
|
|
1859
|
+
return await handleAccessError(err, 'supplemental');
|
|
1860
|
+
}
|
|
1861
|
+
});
|
|
1862
|
+
// --- get_earnings_intelligence ---
|
|
1863
|
+
// Cross-company and industry-level earnings call intelligence.
|
|
1864
|
+
// For single-brand earnings, brand_tracker already includes earningsIntelligence.
|
|
1865
|
+
// This tool is for: multi-company comparisons, industry/sector filters, and explicit earnings queries.
|
|
1866
|
+
server.tool('get_earnings_intelligence', 'Query earnings call intelligence across companies, industries, or sectors. Returns structured evidence from public company earnings calls — management commentary, guidance, key topics, and analyst Q&A. Use for cross-company comparisons ("what are hotel companies saying about labor costs?"), industry-level queries ("earnings intelligence for consumer electronics"), or explicit earnings requests. For single-brand earnings, use brand_tracker instead — it includes earningsIntelligence automatically. Results include a source field: "knowledge_graph" (high confidence, structured Neo4j data) or "web_supplemental" (backfilled via web search). 5 API calls per use.', {
|
|
1867
|
+
ticker: z.string().optional().describe("Company stock ticker (e.g., 'NKE', 'LVMUY', 'HLT'). At least one filter required."),
|
|
1868
|
+
brand: z.string().optional().describe("Brand name for fuzzy matching (e.g., 'Nike', 'Marriott')"),
|
|
1869
|
+
industry: z.string().optional().describe("Industry filter (e.g., 'hotels', 'sportswear', 'consumer electronics')"),
|
|
1870
|
+
sector: z.string().optional().describe("Sector filter (e.g., 'retail', 'technology', 'travel')"),
|
|
1871
|
+
search: z.string().optional().describe("Free text search in earnings summaries (e.g., 'labor costs', 'tariff guidance', 'AI investment')"),
|
|
1872
|
+
dateFrom: z.string().optional().describe("ISO date range start (e.g., '2025-01-01')"),
|
|
1873
|
+
dateTo: z.string().optional().describe("ISO date range end (e.g., '2026-06-01')"),
|
|
1874
|
+
limit: z.number().int().optional().describe('Max results to return (default 20, max 50)'),
|
|
1875
|
+
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
|
|
1876
|
+
}, { title: 'Query Earnings Call Intelligence', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ ticker, brand, industry, sector, search, dateFrom, dateTo, limit, userId: uid }) => {
|
|
1877
|
+
try {
|
|
1878
|
+
const params = new URLSearchParams();
|
|
1879
|
+
if (ticker)
|
|
1880
|
+
params.set('ticker', ticker);
|
|
1881
|
+
if (brand)
|
|
1882
|
+
params.set('brand', brand);
|
|
1883
|
+
if (industry)
|
|
1884
|
+
params.set('industry', industry);
|
|
1885
|
+
if (sector)
|
|
1886
|
+
params.set('sector', sector);
|
|
1887
|
+
if (search)
|
|
1888
|
+
params.set('search', search);
|
|
1889
|
+
if (dateFrom)
|
|
1890
|
+
params.set('dateFrom', dateFrom);
|
|
1891
|
+
if (dateTo)
|
|
1892
|
+
params.set('dateTo', dateTo);
|
|
1893
|
+
if (limit !== undefined)
|
|
1894
|
+
params.set('limit', String(Math.min(limit, 50)));
|
|
1895
|
+
const qs = params.toString();
|
|
1896
|
+
const data = await foddaRequest('GET', `/v1/supplemental/earnings/snapshot${qs ? '?' + qs : ''}`, apiKey, resolveUserId(userId, uid));
|
|
1897
|
+
// ── Query-level billing ──
|
|
1898
|
+
chargeQuery({ queryTypeCode: 'earnings_intelligence', apiKey, userId: resolveUserId(userId, uid), query: search || brand || ticker || sector || '', foddaRequest })
|
|
1899
|
+
.catch(e => console.error('[get_earnings_intelligence] chargeQuery failed:', e.message));
|
|
1900
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1901
|
+
}
|
|
1902
|
+
catch (err) {
|
|
1903
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1904
|
+
if (trialResult)
|
|
1905
|
+
return trialResult;
|
|
1906
|
+
return await handleAccessError(err, 'supplemental');
|
|
1907
|
+
}
|
|
1908
|
+
});
|
|
1909
|
+
// --- get_earnings_divergence ---
|
|
1910
|
+
// Gaps between what analysts are concerned about and how management responds.
|
|
1911
|
+
// This is premium intelligence — surfaces deflection and narrative mismatches.
|
|
1912
|
+
server.tool('get_earnings_divergence', 'Detect divergence between analyst concerns and management responses in earnings calls. Surfaces where executives are deflecting, reframing, or avoiding specific topics. Premium intelligence — shows the gap between what Wall Street is worried about and what companies are saying. Results include deflected topics, concern-vs-response framing, and connections to Fodda trends via :VALIDATES edges. Use for "where are executives deflecting?" or "divergence in [sector] earnings." 5 API calls per use.', {
|
|
1913
|
+
sector: z.string().optional().describe("Sector filter (e.g., 'retail', 'technology', 'travel')"),
|
|
1914
|
+
industry: z.string().optional().describe("Industry filter (e.g., 'hotels', 'sportswear', 'luxury')"),
|
|
1915
|
+
search: z.string().optional().describe("Free text search (e.g., 'tariffs', 'AI capex', 'margin erosion')"),
|
|
1916
|
+
dateFrom: z.string().optional().describe("ISO date range start"),
|
|
1917
|
+
dateTo: z.string().optional().describe("ISO date range end"),
|
|
1918
|
+
limit: z.number().int().optional().describe('Max results to return (default 10, max 25)'),
|
|
1919
|
+
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
|
|
1920
|
+
}, { title: 'Detect Earnings Call Divergence', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ sector, industry, search, dateFrom, dateTo, limit, userId: uid }) => {
|
|
1921
|
+
try {
|
|
1922
|
+
const params = new URLSearchParams();
|
|
1923
|
+
if (sector)
|
|
1924
|
+
params.set('sector', sector);
|
|
1925
|
+
if (industry)
|
|
1926
|
+
params.set('industry', industry);
|
|
1927
|
+
if (search)
|
|
1928
|
+
params.set('search', search);
|
|
1929
|
+
if (dateFrom)
|
|
1930
|
+
params.set('dateFrom', dateFrom);
|
|
1931
|
+
if (dateTo)
|
|
1932
|
+
params.set('dateTo', dateTo);
|
|
1933
|
+
if (limit !== undefined)
|
|
1934
|
+
params.set('limit', String(Math.min(limit, 25)));
|
|
1935
|
+
const qs = params.toString();
|
|
1936
|
+
const data = await foddaRequest('GET', `/v1/supplemental/earnings/divergence${qs ? '?' + qs : ''}`, apiKey, resolveUserId(userId, uid));
|
|
1937
|
+
// ── Query-level billing ──
|
|
1938
|
+
chargeQuery({ queryTypeCode: 'earnings_intelligence', apiKey, userId: resolveUserId(userId, uid), query: search || sector || industry || 'divergence', foddaRequest })
|
|
1939
|
+
.catch(e => console.error('[get_earnings_divergence] chargeQuery failed:', e.message));
|
|
1940
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1941
|
+
}
|
|
1942
|
+
catch (err) {
|
|
1943
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1944
|
+
if (trialResult)
|
|
1945
|
+
return trialResult;
|
|
1946
|
+
return await handleAccessError(err, 'supplemental');
|
|
1947
|
+
}
|
|
1948
|
+
});
|
|
1949
|
+
// --- update_user_profile ---
|
|
1950
|
+
server.tool('update_user_profile', 'Save the user\'s research profile to improve the relevance of future responses. Call this after you understand the user\'s role, industry, and research needs. The profile persists across sessions — you only need to set it once, then update if their focus changes. Write BEHAVIORAL INSTRUCTIONS, not a bio. Format: one sentence of identity (who they are and how they use Fodda), then numbered directives that change how you synthesize and frame responses. Include: what evidence to prioritize, how to frame conclusions, geographic needs, and output structure preferences. Max 2000 chars per field.', {
|
|
1951
|
+
userContext: z.string().describe('Behavioral framing instructions for this person. Format: one sentence of identity, then numbered FRAMING INSTRUCTIONS. Example: "Agency strategist doing time-pressured pitches. (1) Lead with landscape orientation — top 3-5 macro forces. (2) Prioritize commercially validated signals over design concepts. (3) ALWAYS differentiate by geography. (4) Executive-ready framing — concise, pitch-deck-ready. (5) Strongest findings first, not exhaustive lists." Max 2000 chars.'),
|
|
1952
|
+
accountContext: z.string().optional().describe('Description of their company: industry, size, key markets, competitive position, mission. Shared across all users on this account. Max 2000 chars.'),
|
|
1953
|
+
}, { title: 'Update Research Profile', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ userContext, accountContext }) => {
|
|
1954
|
+
try {
|
|
1955
|
+
const body = {};
|
|
1956
|
+
if (userContext)
|
|
1957
|
+
body.userContext = String(userContext).slice(0, 2000);
|
|
1958
|
+
if (accountContext)
|
|
1959
|
+
body.accountContext = String(accountContext).slice(0, 2000);
|
|
1960
|
+
await foddaRequest('POST', '/v1/user/context', apiKey, userId, body);
|
|
1961
|
+
console.error(`[update_user_profile] Profile saved for ${userId}: userContext=${(userContext || '').length} chars, accountContext=${(accountContext || '').length} chars`);
|
|
1962
|
+
return {
|
|
1963
|
+
content: [{
|
|
1964
|
+
type: 'text',
|
|
1965
|
+
text: JSON.stringify({
|
|
1966
|
+
status: 'SAVED',
|
|
1967
|
+
message: 'Research profile updated. Future sessions will use this context to personalize responses.',
|
|
1968
|
+
}, null, 2)
|
|
1969
|
+
}]
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
catch (err) {
|
|
1973
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
1974
|
+
console.error(`[update_user_profile] Error: ${msg}`);
|
|
1975
|
+
// Fail gracefully — don't break the conversation
|
|
1976
|
+
if (isTrial) {
|
|
1977
|
+
return {
|
|
1978
|
+
content: [{
|
|
1979
|
+
type: 'text',
|
|
1980
|
+
text: JSON.stringify({
|
|
1981
|
+
status: 'SKIPPED',
|
|
1982
|
+
message: 'Profile saving is available for Base accounts and above. I\'ll use this context for the current session. Sign up at app.fodda.ai to persist your preferences.',
|
|
1983
|
+
}, null, 2)
|
|
1984
|
+
}]
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
return {
|
|
1988
|
+
content: [{
|
|
1989
|
+
type: 'text',
|
|
1990
|
+
text: JSON.stringify({
|
|
1991
|
+
status: 'ERROR',
|
|
1992
|
+
message: 'Could not save profile right now. I\'ll use this context for the current session.',
|
|
1993
|
+
error: msg,
|
|
1994
|
+
}, null, 2)
|
|
1995
|
+
}]
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1998
|
+
});
|
|
1999
|
+
// --- toggle_graph_preference ---
|
|
2000
|
+
server.tool('toggle_graph_preference', 'Enable or disable any knowledge graph, supplemental data source, or skill for the user. Use this when the user says "Turn off Paralogy", "Enable igloo", "Disable the economics data", or similar. The change is permanent until toggled again.', {
|
|
2001
|
+
target_id: z.string().describe('The ID of the graph, skill, or data source to toggle (e.g., "paralogy", "igloo", "retail", "get_bea_spending_snapshot"). Use the exact ID from list_graphs.'),
|
|
2002
|
+
enabled: z.boolean().describe('true to enable (turn on), false to disable (turn off).'),
|
|
2003
|
+
user_email: z.string().optional().describe('Optional. Use ONLY when operating as an Admin on behalf of another user to specify their email.')
|
|
2004
|
+
}, { title: 'Toggle Graph or Skill', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ target_id, enabled, user_email }) => {
|
|
2005
|
+
try {
|
|
2006
|
+
const body = { target_id, enabled };
|
|
2007
|
+
if (user_email)
|
|
2008
|
+
body.user_email = user_email;
|
|
2009
|
+
const result = await foddaRequest('POST', '/v1/user/preferences/toggle', apiKey, userId, body);
|
|
2010
|
+
return {
|
|
2011
|
+
content: [{
|
|
2012
|
+
type: 'text',
|
|
2013
|
+
text: JSON.stringify({
|
|
2014
|
+
status: 'SUCCESS',
|
|
2015
|
+
message: `Successfully ${enabled ? 'enabled' : 'disabled'} ${target_id}.`,
|
|
2016
|
+
disabled_graphs: result.disabled_graphs || [],
|
|
2017
|
+
}, null, 2)
|
|
2018
|
+
}]
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
catch (err) {
|
|
2022
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
2023
|
+
return {
|
|
2024
|
+
content: [{
|
|
2025
|
+
type: 'text',
|
|
2026
|
+
text: JSON.stringify({
|
|
2027
|
+
status: 'ERROR',
|
|
2028
|
+
message: `Failed to ${enabled ? 'enable' : 'disable'} ${target_id}.`,
|
|
2029
|
+
error: msg,
|
|
2030
|
+
}, null, 2)
|
|
2031
|
+
}]
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
});
|
|
2035
|
+
// --- send_feedback ---
|
|
2036
|
+
const FEEDBACK_CATEGORY_EMOJI = {
|
|
2037
|
+
feedback: '💬',
|
|
2038
|
+
bug: '🐛',
|
|
2039
|
+
feature_request: '✨',
|
|
2040
|
+
exit_reason: '🚪',
|
|
2041
|
+
complaint: '😤',
|
|
2042
|
+
};
|
|
2043
|
+
server.tool('send_feedback', 'Forward user feedback, feature requests, complaints, or exit reasons to the Fodda team via email and Slack. Call this whenever a user shares feedback — including when they want to leave, report a problem, or suggest an improvement.', {
|
|
2044
|
+
feedback: z.string().describe('The user\'s feedback, complaint, suggestion, or exit reason'),
|
|
2045
|
+
user_email: z.string().optional().describe('User\'s email if known (for follow-up)'),
|
|
2046
|
+
category: z.string().optional().describe("Category: 'feedback', 'bug', 'feature_request', 'exit_reason', 'complaint'"),
|
|
2047
|
+
}, { title: 'Send Feedback', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ feedback, user_email, category }) => {
|
|
2048
|
+
try {
|
|
2049
|
+
const userLabel = user_email || (userId !== 'anonymous' ? userId : 'anonymous trial user');
|
|
2050
|
+
const entryLabel = entryId ? ` (entry: ${entryId})` : '';
|
|
2051
|
+
const catLabel = category || 'general';
|
|
2052
|
+
const emoji = FEEDBACK_CATEGORY_EMOJI[catLabel] || '💬';
|
|
2053
|
+
// ── Slack alert (fire-and-forget) ──
|
|
2054
|
+
const slackText = [
|
|
2055
|
+
`<@U0AU49JG7AS> ${emoji} *User Feedback*`,
|
|
2056
|
+
`👤 ${userLabel}`,
|
|
2057
|
+
`📁 Category: ${catLabel}`,
|
|
2058
|
+
`📝 ${feedback}`,
|
|
2059
|
+
`→ Check if this needs a response or product action.`,
|
|
2060
|
+
].join('\n');
|
|
2061
|
+
postToSlack(slackText).catch(() => { });
|
|
2062
|
+
// ── Resend email ──
|
|
2063
|
+
const resendKey = process.env.RESEND_API_KEY;
|
|
2064
|
+
if (!resendKey) {
|
|
2065
|
+
console.error('[send_feedback] RESEND_API_KEY not set — logging feedback locally');
|
|
2066
|
+
console.error(`[FEEDBACK] category=${catLabel} email=${userLabel} feedback=${feedback}`);
|
|
2067
|
+
return {
|
|
2068
|
+
content: [{
|
|
2069
|
+
type: 'text',
|
|
2070
|
+
text: JSON.stringify({ status: 'LOGGED', message: 'Thank you — your feedback has been recorded.' })
|
|
2071
|
+
}]
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
const { Resend } = await import('resend');
|
|
2075
|
+
const resend = new Resend(resendKey);
|
|
2076
|
+
const subject = `[Fodda MCP Feedback] ${catLabel} from ${userLabel}`;
|
|
2077
|
+
await resend.emails.send({
|
|
2078
|
+
from: 'Fodda MCP <feedback@fodda.ai>',
|
|
2079
|
+
to: ['piers@fodda.ai'],
|
|
2080
|
+
subject,
|
|
2081
|
+
text: [
|
|
2082
|
+
`Category: ${catLabel}`,
|
|
2083
|
+
`User: ${userLabel}${entryLabel}`,
|
|
2084
|
+
`API Key: ${apiKey.substring(0, 15)}...`,
|
|
2085
|
+
`Date: ${new Date().toISOString()}`,
|
|
2086
|
+
'',
|
|
2087
|
+
'Feedback:',
|
|
2088
|
+
feedback,
|
|
2089
|
+
].join('\n'),
|
|
2090
|
+
});
|
|
2091
|
+
return {
|
|
2092
|
+
content: [{
|
|
2093
|
+
type: 'text',
|
|
2094
|
+
text: JSON.stringify({ status: 'SENT', message: 'Your feedback has been sent to the Fodda team. Thank you.' })
|
|
2095
|
+
}]
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
catch (err) {
|
|
2099
|
+
console.error('[send_feedback] Error:', err.message);
|
|
2100
|
+
// Still log it even if sending fails
|
|
2101
|
+
console.error(`[FEEDBACK-FALLBACK] category=${category || 'general'} feedback=${feedback}`);
|
|
2102
|
+
return {
|
|
2103
|
+
content: [{
|
|
2104
|
+
type: 'text',
|
|
2105
|
+
text: JSON.stringify({ status: 'LOGGED', message: 'Thank you — your feedback has been recorded.' })
|
|
2106
|
+
}]
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
});
|
|
2110
|
+
// --- sign_up_free_account ---
|
|
2111
|
+
const APP_BASE_URL = process.env.FODDA_APP_URL || 'https://app.fodda.ai';
|
|
2112
|
+
server.tool('sign_up_free_account', 'Create a Fodda Base account for a trial user who has run out of credits. Call this when a trial user provides their email after being prompted. Creates an account with 100 API calls/month across ALL knowledge graphs and sends a confirmation email. Can also be called with additional profile fields (name, job_title, company).', {
|
|
2113
|
+
email: z.string().describe('User\'s email address (required)'),
|
|
2114
|
+
name: z.string().optional().describe('User\'s full name (optional — collect conversationally after signup)'),
|
|
2115
|
+
job_title: z.string().optional().describe('User\'s job title (optional)'),
|
|
2116
|
+
company: z.string().optional().describe('User\'s company name (optional)'),
|
|
2117
|
+
}, { title: 'Create Base Account', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ email, name, job_title, company }) => {
|
|
2118
|
+
try {
|
|
2119
|
+
// Derive firstName from name or email prefix
|
|
2120
|
+
const firstName = name
|
|
2121
|
+
? (name.split(' ')[0] || name)
|
|
2122
|
+
: (email.split('@')[0] || 'User').replace(/[._-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
2123
|
+
const convertBody = {
|
|
2124
|
+
email,
|
|
2125
|
+
trialKey: apiKey, // the trial key that brought them in
|
|
2126
|
+
firstName,
|
|
2127
|
+
};
|
|
2128
|
+
const response = await axios.post(`${APP_BASE_URL}/api/account/trial-convert`, convertBody, { headers: { 'Content-Type': 'application/json' }, timeout: 10000 });
|
|
2129
|
+
if (response.data?.ok && !response.data?.alreadyExists) {
|
|
2130
|
+
return {
|
|
2131
|
+
content: [{
|
|
2132
|
+
type: 'text',
|
|
2133
|
+
text: JSON.stringify({
|
|
2134
|
+
status: 'UPGRADED',
|
|
2135
|
+
message: `I've created your Base account — you'll get ${response.data.monthlyTokens || 100} tokens/month. Check your email to confirm and then log in at https://app.fodda.ai to grab your MCP URL and update your connector.`,
|
|
2136
|
+
plan: response.data.plan || 'Base',
|
|
2137
|
+
monthly_token_limit: response.data.monthlyTokens || 100,
|
|
2138
|
+
graphId: response.data.graphId || null,
|
|
2139
|
+
accountId: response.data.accountId || null,
|
|
2140
|
+
}, null, 2)
|
|
2141
|
+
}]
|
|
2142
|
+
};
|
|
2143
|
+
}
|
|
2144
|
+
if (response.data?.alreadyExists) {
|
|
2145
|
+
return {
|
|
2146
|
+
content: [{
|
|
2147
|
+
type: 'text',
|
|
2148
|
+
text: JSON.stringify({
|
|
2149
|
+
status: 'EXISTING_ACCOUNT',
|
|
2150
|
+
message: `An account with ${email} already exists. Log in at https://app.fodda.ai to grab your MCP URL and update your connector.`,
|
|
2151
|
+
}, null, 2)
|
|
2152
|
+
}]
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
// Unexpected response
|
|
2156
|
+
return {
|
|
2157
|
+
isError: true,
|
|
2158
|
+
content: [{
|
|
2159
|
+
type: 'text',
|
|
2160
|
+
text: JSON.stringify({ error: response.data?.message || 'Account creation failed. Please try again or visit https://app.fodda.ai' })
|
|
2161
|
+
}]
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
catch (err) {
|
|
2165
|
+
const msg = err.response?.data?.message || err.message || 'Account creation failed.';
|
|
2166
|
+
console.error('[sign_up_free_account] Error:', msg);
|
|
2167
|
+
return {
|
|
2168
|
+
isError: true,
|
|
2169
|
+
content: [{
|
|
2170
|
+
type: 'text',
|
|
2171
|
+
text: JSON.stringify({
|
|
2172
|
+
error: msg,
|
|
2173
|
+
fallback: 'You can also sign up at https://app.fodda.ai to continue using Fodda.'
|
|
2174
|
+
})
|
|
2175
|
+
}]
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
});
|
|
2179
|
+
// --- brainstorm_topic ---
|
|
2180
|
+
// Fourth MCP orchestration flow: Graph-native ideation via neighbor traversal.
|
|
2181
|
+
// Uses get_neighbors as the core mechanism to discover unexpected connections,
|
|
2182
|
+
// adjacent territories, and cross-domain links that text search wouldn't surface.
|
|
2183
|
+
server.tool('brainstorm_topic', 'Explore and brainstorm around a topic using knowledge graph connections. Unlike search (which finds what matches), this tool discovers what CONNECTS — adjacent trends, unexpected cross-domain links, key brands, and geographic hotspots. Use when the user wants to brainstorm, explore adjacencies, find inspiration, or understand the landscape around a topic. Returns a structured brainstorm map with territories to explore.', {
|
|
2184
|
+
query: z.string().describe("The topic or theme to brainstorm around (e.g., 'tequila', 'sustainable packaging', 'Gen Z beauty')"),
|
|
2185
|
+
depth: z.number().optional().describe('Traversal depth: 1 (immediate connections) or 2 (connections of connections). Default: 2. Use 1 for focused brainstorms, 2 for wider exploration.'),
|
|
2186
|
+
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
|
|
2187
|
+
}, { title: 'Brainstorm & Explore Topic', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ query, depth, userId: uid }) => {
|
|
2188
|
+
try {
|
|
2189
|
+
const resolvedUserId = resolveUserId(userId, uid);
|
|
2190
|
+
const traversalDepth = Math.min(depth || 2, 2);
|
|
2191
|
+
// ── Step 1: Find seed trends across all relevant graphs ──
|
|
2192
|
+
const relevantGraphs = getRelevantGraphs(query);
|
|
2193
|
+
const graphIdsToSearch = relevantGraphs.slice(0, 4).map(g => g.graph.graph_id);
|
|
2194
|
+
// Search up to 4 graphs in parallel for seed trends
|
|
2195
|
+
const searchPromises = graphIdsToSearch.map(async (gid) => {
|
|
2196
|
+
try {
|
|
2197
|
+
const body = { query, limit: 5, use_semantic: true, include_evidence: false };
|
|
2198
|
+
const res = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(gid)}/search`, apiKey, resolvedUserId, body);
|
|
2199
|
+
return (res?.rows || []).map((r) => ({ ...r, _source_graph: gid }));
|
|
2200
|
+
}
|
|
2201
|
+
catch {
|
|
2202
|
+
return [];
|
|
2203
|
+
}
|
|
2204
|
+
});
|
|
2205
|
+
const allResults = (await Promise.all(searchPromises)).flat();
|
|
2206
|
+
if (allResults.length === 0) {
|
|
2207
|
+
return {
|
|
2208
|
+
content: [{
|
|
2209
|
+
type: 'text',
|
|
2210
|
+
text: JSON.stringify({
|
|
2211
|
+
query,
|
|
2212
|
+
status: 'no_seeds',
|
|
2213
|
+
message: `Fodda's knowledge graphs don't have strong coverage on "${query}" yet. Try a broader topic or different angle.`,
|
|
2214
|
+
suggested_alternatives: [
|
|
2215
|
+
`Try broader: "${query.split(' ')[0]} trends"`,
|
|
2216
|
+
`Try adjacent: "innovation in ${query}"`,
|
|
2217
|
+
]
|
|
2218
|
+
}, null, 2)
|
|
2219
|
+
}]
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
// Take top 5 unique trends as seeds (deduplicated by title)
|
|
2223
|
+
const seen = new Set();
|
|
2224
|
+
const seeds = [];
|
|
2225
|
+
for (const r of allResults.sort((a, b) => (b.score || 0) - (a.score || 0))) {
|
|
2226
|
+
const key = (r.title || r.trendName || '').toLowerCase();
|
|
2227
|
+
if (!seen.has(key) && seeds.length < 5) {
|
|
2228
|
+
seen.add(key);
|
|
2229
|
+
seeds.push(r);
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
// ── Step 2: Discover adjacent trends via semantic similarity ──
|
|
2233
|
+
// Uses /adjacent (pre-computed SEMANTICALLY_SIMILAR edges) instead of
|
|
2234
|
+
// /neighbors (relationship traversal) because expert graphs have flat
|
|
2235
|
+
// structures with no inter-node relationships.
|
|
2236
|
+
const adjacentPromises = seeds.map(async (seed) => {
|
|
2237
|
+
const graphId = seed._use_this_graphId || seed._source_graph;
|
|
2238
|
+
const nodeId = seed.node_id || seed.trendId;
|
|
2239
|
+
if (!graphId || !nodeId)
|
|
2240
|
+
return { seed, adjacent: [] };
|
|
2241
|
+
try {
|
|
2242
|
+
const params = new URLSearchParams({
|
|
2243
|
+
node_id: String(nodeId),
|
|
2244
|
+
min_score: '0.70',
|
|
2245
|
+
limit: '15',
|
|
2246
|
+
include_editorial: 'true',
|
|
2247
|
+
cross_graph: 'true', // Option B: discover trends from ANY graph
|
|
2248
|
+
});
|
|
2249
|
+
const res = await foddaRequest('GET', `/v1/graphs/${encodeURIComponent(graphId)}/adjacent?${params.toString()}`, apiKey, resolvedUserId);
|
|
2250
|
+
return { seed, graphId, adjacent: res?.adjacent || [] };
|
|
2251
|
+
}
|
|
2252
|
+
catch {
|
|
2253
|
+
return { seed, graphId, adjacent: [] };
|
|
2254
|
+
}
|
|
2255
|
+
});
|
|
2256
|
+
const adjacentResults = await Promise.all(adjacentPromises);
|
|
2257
|
+
// ── Step 3: Cluster discoveries ──
|
|
2258
|
+
const adjacentTrends = new Map();
|
|
2259
|
+
const brands = new Map();
|
|
2260
|
+
const locations = new Map();
|
|
2261
|
+
const seedTitles = new Set(seeds.map(s => (s.title || s.trendName || '').toLowerCase()));
|
|
2262
|
+
for (const { seed, adjacent } of adjacentResults) {
|
|
2263
|
+
const seedTitle = seed.title || seed.trendName || 'Unknown';
|
|
2264
|
+
for (const node of adjacent) {
|
|
2265
|
+
const name = node.trendName || node.name || node.title || '';
|
|
2266
|
+
const nameKey = name.toLowerCase();
|
|
2267
|
+
if (!name || seedTitles.has(nameKey))
|
|
2268
|
+
continue;
|
|
2269
|
+
if (!adjacentTrends.has(nameKey)) {
|
|
2270
|
+
adjacentTrends.set(nameKey, {
|
|
2271
|
+
name,
|
|
2272
|
+
node_id: node.node_id || node.trendId,
|
|
2273
|
+
graph_id: node.vertical || node.graph_id,
|
|
2274
|
+
score: node.similarity || node.score,
|
|
2275
|
+
relationship: node.editoriallyLinked ? 'EDITORIALLY_LINKED' : 'SEMANTICALLY_SIMILAR',
|
|
2276
|
+
connected_to: seedTitle,
|
|
2277
|
+
description: node.description || node.summary || '',
|
|
2278
|
+
cross_graph: node.vertical !== (seed._use_this_graphId || seed._source_graph),
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
// ── Step 4: Build suggested next prompts from graph connections ──
|
|
2284
|
+
const topAdjacent = [...adjacentTrends.values()].slice(0, 10);
|
|
2285
|
+
const topBrands = [...brands.values()]
|
|
2286
|
+
.sort((a, b) => b.connections.length - a.connections.length)
|
|
2287
|
+
.slice(0, 8);
|
|
2288
|
+
const topLocations = [...locations.values()]
|
|
2289
|
+
.sort((a, b) => b.connections.length - a.connections.length)
|
|
2290
|
+
.slice(0, 5);
|
|
2291
|
+
// Generate graph-powered follow-up prompts
|
|
2292
|
+
const suggestedPrompts = [];
|
|
2293
|
+
if (topAdjacent.length > 0) {
|
|
2294
|
+
const surprise = topAdjacent.find(t => t.relationship === 'SEMANTICALLY_SIMILAR') || topAdjacent[0];
|
|
2295
|
+
suggestedPrompts.push(`How does "${query}" connect to "${surprise.name}"?`);
|
|
2296
|
+
}
|
|
2297
|
+
if (topBrands.length >= 2) {
|
|
2298
|
+
suggestedPrompts.push(`What are ${topBrands[0].name} and ${topBrands[1].name} doing in ${query}?`);
|
|
2299
|
+
}
|
|
2300
|
+
if (topAdjacent.length > 2) {
|
|
2301
|
+
suggestedPrompts.push(`Deep research: ${query} and ${topAdjacent[1].name}`);
|
|
2302
|
+
}
|
|
2303
|
+
if (topLocations.length > 0) {
|
|
2304
|
+
suggestedPrompts.push(`What's happening with ${query} in ${topLocations[0].name}?`);
|
|
2305
|
+
}
|
|
2306
|
+
if (topAdjacent.length > 4) {
|
|
2307
|
+
suggestedPrompts.push(`Brainstorm: ${topAdjacent[3].name}`);
|
|
2308
|
+
}
|
|
2309
|
+
// ── Step 5: Assemble brainstorm map ──
|
|
2310
|
+
const brainstormMap = {
|
|
2311
|
+
query,
|
|
2312
|
+
status: 'brainstorm_complete',
|
|
2313
|
+
_generated_by: 'brainstorm_topic',
|
|
2314
|
+
traversal_depth: traversalDepth,
|
|
2315
|
+
seed_trends: seeds.map(s => ({
|
|
2316
|
+
name: s.title || s.trendName,
|
|
2317
|
+
graph: s._use_this_graphId || s._source_graph,
|
|
2318
|
+
signal_score: s.signal_score || s.score,
|
|
2319
|
+
node_id: s.node_id || s.trendId,
|
|
2320
|
+
lifecycle: s.trendLifecycle || s.lifecycle,
|
|
2321
|
+
})),
|
|
2322
|
+
adjacent_territories: topAdjacent.map(t => ({
|
|
2323
|
+
name: t.name,
|
|
2324
|
+
relationship: t.relationship,
|
|
2325
|
+
connected_to: t.connected_to,
|
|
2326
|
+
description: t.description,
|
|
2327
|
+
graph_id: t.graph_id,
|
|
2328
|
+
node_id: t.node_id,
|
|
2329
|
+
})),
|
|
2330
|
+
key_brands: topBrands.map(b => ({
|
|
2331
|
+
name: b.name,
|
|
2332
|
+
appears_in: b.connections,
|
|
2333
|
+
cross_trend: b.connections.length > 1,
|
|
2334
|
+
})),
|
|
2335
|
+
geographic_hotspots: topLocations.map(l => ({
|
|
2336
|
+
location: l.name,
|
|
2337
|
+
connected_to: l.connections,
|
|
2338
|
+
})),
|
|
2339
|
+
brainstorm_stats: {
|
|
2340
|
+
seeds_found: seeds.length,
|
|
2341
|
+
graphs_searched: graphIdsToSearch.length,
|
|
2342
|
+
adjacent_trends_discovered: adjacentTrends.size,
|
|
2343
|
+
brands_identified: brands.size,
|
|
2344
|
+
locations_identified: locations.size,
|
|
2345
|
+
},
|
|
2346
|
+
suggested_next_prompts: suggestedPrompts,
|
|
2347
|
+
_presentation_hint: 'Present as a brainstorm map. Center: the query. First ring: seed trends. Second ring: adjacent territories. Highlight cross-trend brands and unexpected connections. Suggest follow-up explorations.',
|
|
2348
|
+
};
|
|
2349
|
+
// ── Query-level billing ──
|
|
2350
|
+
chargeQuery({ queryTypeCode: 'brainstorm', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest })
|
|
2351
|
+
.catch(e => console.error('[brainstorm] chargeQuery failed:', e.message));
|
|
2352
|
+
return { content: [{ type: 'text', text: JSON.stringify(brainstormMap, null, 2) }] };
|
|
2353
|
+
}
|
|
2354
|
+
catch (err) {
|
|
2355
|
+
const msg = err.message || 'Brainstorm execution failed.';
|
|
2356
|
+
console.error('[brainstorm_topic] Error:', msg);
|
|
2357
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
2358
|
+
}
|
|
2359
|
+
});
|
|
2360
|
+
// --- generate_visual ---
|
|
2361
|
+
server.tool('generate_visual', 'Generate a branded SVG data visualization from structured insight data. Use after research to create presentation-ready visuals. Available chart types: "cultural_shifts" (From→To transitions), "competitive_compass" (brands on 2 axes), "trend_constellation" (network of related trends), "implication_ladder" (Signal→Trend→So What→Do What), "innovation_pathway" (Now→Near-Term→Future), "opportunity_map" (2×2 white space analysis). Returns inline SVG that renders directly in the chat.', {
|
|
2362
|
+
chart_type: z.enum(['cultural_shifts', 'competitive_compass', 'trend_constellation', 'implication_ladder', 'innovation_pathway', 'opportunity_map']).describe('The type of visualization to generate'),
|
|
2363
|
+
data: z.string().describe('JSON string containing the chart data. Structure depends on chart_type. cultural_shifts: {shifts:[{from,to}]}. competitive_compass: {brands:[{name,x,y}], axes:{left,right,top,bottom}}. trend_constellation: {trends:[{name,x,y}], connections:[{from,to,strength}]}. implication_ladder: {signal,trend,so_what,do_what}. innovation_pathway: {now,near_term,future}. opportunity_map: {items:[{name,consumer_desire,market_activity}]}'),
|
|
2364
|
+
}, { title: 'Generate Visual', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ chart_type, data }) => {
|
|
2365
|
+
try {
|
|
2366
|
+
const { renderCulturalShifts, renderCompetitiveCompass, renderTrendConstellation, renderImplicationLadder, renderInnovationPathway, renderWhiteSpaceMap } = await import('./svgVisuals.js');
|
|
2367
|
+
const parsed = JSON.parse(data);
|
|
2368
|
+
let svg = '';
|
|
2369
|
+
switch (chart_type) {
|
|
2370
|
+
case 'cultural_shifts':
|
|
2371
|
+
svg = renderCulturalShifts(parsed.shifts || parsed);
|
|
2372
|
+
break;
|
|
2373
|
+
case 'competitive_compass':
|
|
2374
|
+
svg = renderCompetitiveCompass(parsed.brands || parsed, parsed.axes || { left: 'Traditional', right: 'Innovative', top: 'Premium', bottom: 'Mass' });
|
|
2375
|
+
break;
|
|
2376
|
+
case 'trend_constellation':
|
|
2377
|
+
svg = renderTrendConstellation(parsed.trends || parsed, parsed.connections || []);
|
|
2378
|
+
break;
|
|
2379
|
+
case 'implication_ladder':
|
|
2380
|
+
svg = renderImplicationLadder(parsed);
|
|
2381
|
+
break;
|
|
2382
|
+
case 'innovation_pathway':
|
|
2383
|
+
svg = renderInnovationPathway(parsed);
|
|
2384
|
+
break;
|
|
2385
|
+
case 'opportunity_map':
|
|
2386
|
+
svg = renderWhiteSpaceMap(parsed.items || parsed, parsed.x_label, parsed.y_label);
|
|
2387
|
+
break;
|
|
2388
|
+
}
|
|
2389
|
+
if (!svg) {
|
|
2390
|
+
return { isError: true, content: [{ type: 'text', text: 'Failed to generate visual. Check data format.' }] };
|
|
2391
|
+
}
|
|
2392
|
+
return {
|
|
2393
|
+
content: [{
|
|
2394
|
+
type: 'text',
|
|
2395
|
+
text: svg
|
|
2396
|
+
}]
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
catch (err) {
|
|
2400
|
+
console.error('[generate_visual] Error:', err.message);
|
|
2401
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }] };
|
|
2402
|
+
}
|
|
2403
|
+
});
|
|
2404
|
+
// --- manage_scheduled_reports ---
|
|
2405
|
+
server.tool('manage_scheduled_reports', 'Create, list, cancel, update, pause, or resume scheduled intelligence briefings. Users can set up autonomous research that runs weekly (Mondays) or daily (Mon-Fri) at 9am in their timezone, delivered via email or Slack. Costs 20 API calls per run. Supports topic research or brand intelligence report types.', {
|
|
2406
|
+
action: z.enum(['create', 'list', 'cancel', 'update', 'pause', 'resume']),
|
|
2407
|
+
query: z.string().optional().describe('For "create": the research query to run'),
|
|
2408
|
+
email: z.string().optional().describe('Email address to deliver reports to'),
|
|
2409
|
+
slack_webhook: z.string().optional().describe('Optional Slack webhook URL for delivery'),
|
|
2410
|
+
graphs: z.array(z.string()).optional().describe('Specific graph IDs to search. Default: all accessible'),
|
|
2411
|
+
schedule_id: z.string().optional().describe('For cancel/update/pause/resume: the schedule ID'),
|
|
2412
|
+
cadence: z.enum(['weekly', 'daily']).optional()
|
|
2413
|
+
.describe('weekly or daily (Mon-Fri). Default: weekly'),
|
|
2414
|
+
timezone: z.enum(['london', 'new_york', 'san_francisco', 'sydney']).optional()
|
|
2415
|
+
.describe('Delivery timezone for 9am delivery. Default: new_york'),
|
|
2416
|
+
report_type: z.enum(['topic_research', 'brand_intelligence']).optional()
|
|
2417
|
+
.describe('topic_research for sector trends, brand_intelligence for competitive tracking'),
|
|
2418
|
+
brands: z.array(z.string()).optional()
|
|
2419
|
+
.describe('For brand_intelligence: brand names to track (e.g., ["Nike", "Patagonia"])'),
|
|
2420
|
+
}, { title: 'Manage Scheduled Reports', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async ({ action, query, email, slack_webhook, graphs, schedule_id, cadence, timezone, report_type, brands }) => {
|
|
2421
|
+
try {
|
|
2422
|
+
if (action === 'create') {
|
|
2423
|
+
if (!query)
|
|
2424
|
+
return { isError: true, content: [{ type: 'text', text: 'A research query is required to create a schedule.' }] };
|
|
2425
|
+
if (!email)
|
|
2426
|
+
return { isError: true, content: [{ type: 'text', text: 'An email address is required for report delivery.' }] };
|
|
2427
|
+
const day_of_week = cadence === 'daily' ? 'weekdays' : 'monday';
|
|
2428
|
+
const body = {
|
|
2429
|
+
query,
|
|
2430
|
+
cadence: cadence || 'weekly',
|
|
2431
|
+
day_of_week,
|
|
2432
|
+
hour_utc: 9, // Always 9am local
|
|
2433
|
+
timezone: timezone || 'new_york',
|
|
2434
|
+
report_type: report_type || 'topic_research',
|
|
2435
|
+
brands: brands || [],
|
|
2436
|
+
graphs: graphs || [],
|
|
2437
|
+
delivery: { email, slack_webhook, format: 'markdown' },
|
|
2438
|
+
name: query.substring(0, 80),
|
|
2439
|
+
};
|
|
2440
|
+
const result = await foddaRequest('POST', '/v1/research/schedules', apiKey, resolveUserId('', ''), body);
|
|
2441
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) + '\n\n✅ Briefing created! Your first report will arrive within 24 hours as a preview.' }] };
|
|
2442
|
+
}
|
|
2443
|
+
else if (action === 'list') {
|
|
2444
|
+
const result = await foddaRequest('GET', '/v1/research/schedules', apiKey, resolveUserId('', ''));
|
|
2445
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
2446
|
+
}
|
|
2447
|
+
else if (action === 'cancel') {
|
|
2448
|
+
if (!schedule_id)
|
|
2449
|
+
return { isError: true, content: [{ type: 'text', text: 'A schedule_id is required to cancel.' }] };
|
|
2450
|
+
const result = await foddaRequest('POST', `/v1/research/schedules/${encodeURIComponent(schedule_id)}/cancel`, apiKey, resolveUserId('', ''), {});
|
|
2451
|
+
return { content: [{ type: 'text', text: '✅ Schedule cancelled. Already-consumed API calls are not refunded.' }] };
|
|
2452
|
+
}
|
|
2453
|
+
else if (action === 'pause') {
|
|
2454
|
+
if (!schedule_id)
|
|
2455
|
+
return { isError: true, content: [{ type: 'text', text: 'A schedule_id is required to pause.' }] };
|
|
2456
|
+
const result = await foddaRequest('PATCH', `/v1/research/schedules/${encodeURIComponent(schedule_id)}`, apiKey, resolveUserId('', ''), { status: 'paused' });
|
|
2457
|
+
return { content: [{ type: 'text', text: '⏸️ Briefing paused. Say "resume my briefing" to restart.' }] };
|
|
2458
|
+
}
|
|
2459
|
+
else if (action === 'resume') {
|
|
2460
|
+
if (!schedule_id)
|
|
2461
|
+
return { isError: true, content: [{ type: 'text', text: 'A schedule_id is required to resume.' }] };
|
|
2462
|
+
const result = await foddaRequest('PATCH', `/v1/research/schedules/${encodeURIComponent(schedule_id)}`, apiKey, resolveUserId('', ''), { status: 'active' });
|
|
2463
|
+
return { content: [{ type: 'text', text: `✅ Briefing resumed! Next delivery: ${result.next_run}` }] };
|
|
2464
|
+
}
|
|
2465
|
+
else if (action === 'update') {
|
|
2466
|
+
if (!schedule_id)
|
|
2467
|
+
return { isError: true, content: [{ type: 'text', text: 'A schedule_id is required to update.' }] };
|
|
2468
|
+
const body = {};
|
|
2469
|
+
if (cadence)
|
|
2470
|
+
body.cadence = cadence;
|
|
2471
|
+
if (timezone)
|
|
2472
|
+
body.timezone = timezone;
|
|
2473
|
+
if (email)
|
|
2474
|
+
body.delivery = { email };
|
|
2475
|
+
if (brands)
|
|
2476
|
+
body.brands = brands;
|
|
2477
|
+
if (report_type)
|
|
2478
|
+
body.report_type = report_type;
|
|
2479
|
+
const result = await foddaRequest('PATCH', `/v1/research/schedules/${encodeURIComponent(schedule_id)}`, apiKey, resolveUserId('', ''), body);
|
|
2480
|
+
return { content: [{ type: 'text', text: `✅ Briefing updated: ${result.changes.join(', ')}. Next delivery: ${result.next_run}` }] };
|
|
2481
|
+
}
|
|
2482
|
+
return { isError: true, content: [{ type: 'text', text: 'Unknown action.' }] };
|
|
2483
|
+
}
|
|
2484
|
+
catch (err) {
|
|
2485
|
+
console.error('[manage_scheduled_reports] Error:', err.message);
|
|
2486
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }] };
|
|
2487
|
+
}
|
|
2488
|
+
});
|
|
2489
|
+
// --- read_url ---
|
|
2490
|
+
server.tool('read_url', 'Extract clean text content from any URL. Use this when a user shares a link (competitor site, news article, client brief, trend report) and wants to cross-reference it against Fodda knowledge graphs. Returns structured text ready for analysis. Costs 15 API calls.', {
|
|
2491
|
+
url: z.string().describe('The URL to read and extract content from'),
|
|
2492
|
+
userId: z.string().optional().describe('Optional user identifier for usage tracking.')
|
|
2493
|
+
}, { title: 'Read URL Content', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, async ({ url, userId: uid }) => {
|
|
2494
|
+
try {
|
|
2495
|
+
const result = await waverunnerRequest('search', // Uses standard search pool
|
|
2496
|
+
1, apiKey, resolveUserId(userId, uid), {
|
|
2497
|
+
model: 'gemini-2.5-flash',
|
|
2498
|
+
input: [
|
|
2499
|
+
{ type: 'text', text: `Extract and return the full text content from this URL. Return ONLY the extracted text, preserving headings and structure. Do not add commentary or analysis. URL: ${url}` }
|
|
2500
|
+
],
|
|
2501
|
+
tools: [{ type: 'url_context' }]
|
|
2502
|
+
});
|
|
2503
|
+
const outputs = result.outputs || [];
|
|
2504
|
+
const textParts = outputs.filter((o) => o.type === 'text').map((o) => o.text);
|
|
2505
|
+
const extractedText = textParts.join('\n');
|
|
2506
|
+
if (!extractedText) {
|
|
2507
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'Could not extract content from this URL. It may be behind a login or paywall.' }) }] };
|
|
2508
|
+
}
|
|
2509
|
+
// ── Query-level billing ──
|
|
2510
|
+
chargeQuery({ queryTypeCode: 'url_as_prompt', apiKey, userId: resolveUserId(userId, uid), query: url, foddaRequest })
|
|
2511
|
+
.catch(e => console.error('[read_url] chargeQuery failed:', e.message));
|
|
2512
|
+
return {
|
|
2513
|
+
content: [{
|
|
2514
|
+
type: 'text',
|
|
2515
|
+
text: JSON.stringify({
|
|
2516
|
+
url,
|
|
2517
|
+
content: extractedText,
|
|
2518
|
+
content_length: extractedText.length,
|
|
2519
|
+
api_calls_charged: 15,
|
|
2520
|
+
hint: 'You now have the full text from this URL. Cross-reference it against Fodda knowledge graphs using search_graph or deep_research_topic to find relevant trends and insights.'
|
|
2521
|
+
}, null, 2)
|
|
2522
|
+
}]
|
|
2523
|
+
};
|
|
2524
|
+
}
|
|
2525
|
+
catch (err) {
|
|
2526
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
2527
|
+
if (trialResult)
|
|
2528
|
+
return trialResult;
|
|
2529
|
+
const msg = err.message || 'URL extraction failed.';
|
|
2530
|
+
console.error('[read_url] Error:', msg);
|
|
2531
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
2532
|
+
}
|
|
2533
|
+
});
|
|
2534
|
+
// --- deep_research_topic (Skills-Based Agent via Waverunner) ---
|
|
2535
|
+
// Uses the fodda-researcher agent with 5 skill instruction files.
|
|
2536
|
+
// Flow: Pre-fetch graph data → Build skill-loaded system instruction →
|
|
2537
|
+
// Call Gemini directly via waverunnerRequest → Stream progress via sendLoggingMessage.
|
|
2538
|
+
server.tool('deep_research_topic', 'Launch an autonomous Deep Research session that combines Fodda knowledge graph intelligence with live web research to produce a comprehensive editorial-quality report. The Research Agent plans its own strategy, searches multiple graphs, validates with institutional data, and synthesizes into a narrative brief with inline source citations. Use for complex, multi-faceted questions that need both curated expert intelligence AND current web context — e.g., strategic briefings, market landscape reports, competitive deep dives. Depth: "light" (20 API calls, faster single-pass) or "heavy" (30 API calls, comprehensive multi-pass with validation).', {
|
|
2539
|
+
query: z.string().describe('The research query/topic'),
|
|
2540
|
+
graphId: z.string().optional().describe('Optional specific graph ID to limit the research to'),
|
|
2541
|
+
depth: z.enum(['light', 'heavy']).optional().describe('Research depth: "light" for faster single-pass (20 API calls), "heavy" for comprehensive multi-pass (30 API calls). Defaults to "light".'),
|
|
2542
|
+
userId: z.string().optional().describe('Optional user identifier.')
|
|
2543
|
+
}, { title: 'Deep Research Topic', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async ({ query, graphId, depth, userId: uid }) => {
|
|
2544
|
+
const resolvedUserId = resolveUserId(userId, uid);
|
|
2545
|
+
const isHeavy = depth === 'heavy';
|
|
2546
|
+
const tokenCost = isHeavy ? 3 : 2; // Waverunner trial pool tokens
|
|
2547
|
+
const queryTypeCode = isHeavy ? 'deep_research_heavy' : 'deep_research_light';
|
|
2548
|
+
const maxGraphs = isHeavy ? 15 : 8;
|
|
2549
|
+
const startTime = Date.now();
|
|
2550
|
+
try {
|
|
2551
|
+
// Log query to Questions table (fire-and-forget, before cache)
|
|
2552
|
+
logUserQuery(query, 'deep_research');
|
|
2553
|
+
// ── Phase 1: Planning ──
|
|
2554
|
+
await server.sendLoggingMessage({
|
|
2555
|
+
level: 'info',
|
|
2556
|
+
data: `📋 Phase 1/5: Planning research approach for "${query.slice(0, 80)}"...`,
|
|
2557
|
+
});
|
|
2558
|
+
console.error(`[deep_research_topic] Starting ${isHeavy ? 'heavy' : 'light'} research: "${query}"`);
|
|
2559
|
+
// Determine relevant graphs
|
|
2560
|
+
const relevantGraphs = graphId
|
|
2561
|
+
? [{ graph: { graph_id: graphId } }]
|
|
2562
|
+
: getRelevantGraphs(query).slice(0, maxGraphs);
|
|
2563
|
+
const graphIds = relevantGraphs.map(g => g.graph.graph_id);
|
|
2564
|
+
// ── Phase 2: Searching Fodda Knowledge Graphs ──
|
|
2565
|
+
await server.sendLoggingMessage({
|
|
2566
|
+
level: 'info',
|
|
2567
|
+
data: `🔍 Phase 2/5: Searching ${graphIds.length} knowledge graph${graphIds.length !== 1 ? 's' : ''}...`,
|
|
2568
|
+
});
|
|
2569
|
+
// Pre-fetch graph data in parallel
|
|
2570
|
+
const graphSearchPromises = graphIds.map(async (gid) => {
|
|
2571
|
+
try {
|
|
2572
|
+
const searchBody = { query, limit: isHeavy ? 10 : 5, use_semantic: true, include_evidence: true };
|
|
2573
|
+
const res = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(gid)}/search`, apiKey, resolvedUserId, searchBody);
|
|
2574
|
+
const rows = res?.rows || [];
|
|
2575
|
+
const evidence = rows.flatMap((r) => r.evidence || []);
|
|
2576
|
+
return { graphId: gid, rows, evidence };
|
|
2577
|
+
}
|
|
2578
|
+
catch {
|
|
2579
|
+
return { graphId: gid, rows: [], evidence: [] };
|
|
2580
|
+
}
|
|
2581
|
+
});
|
|
2582
|
+
const graphResults = await Promise.all(graphSearchPromises);
|
|
2583
|
+
const totalTrends = graphResults.reduce((sum, g) => sum + g.rows.length, 0);
|
|
2584
|
+
const totalEvidence = graphResults.reduce((sum, g) => sum + g.evidence.length, 0);
|
|
2585
|
+
const activeGraphs = graphResults.filter(g => g.rows.length > 0);
|
|
2586
|
+
// Graceful degradation: if all graph searches failed, the agent
|
|
2587
|
+
// proceeds using ONLY Google Search + URL Context. The report will
|
|
2588
|
+
// be web-only but still useful — no hard failure.
|
|
2589
|
+
if (activeGraphs.length === 0) {
|
|
2590
|
+
console.error(`[deep_research_topic] All ${graphIds.length} graph searches failed — proceeding with web-only research`);
|
|
2591
|
+
await server.sendLoggingMessage({
|
|
2592
|
+
level: 'warning',
|
|
2593
|
+
data: `⚠️ Knowledge graph search returned no results — proceeding with web-only research...`,
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2596
|
+
else {
|
|
2597
|
+
await server.sendLoggingMessage({
|
|
2598
|
+
level: 'info',
|
|
2599
|
+
data: `📊 Found ${totalTrends} trend${totalTrends !== 1 ? 's' : ''} and ${totalEvidence} evidence pieces across ${activeGraphs.length} graph${activeGraphs.length !== 1 ? 's' : ''}. Launching deep analysis...`,
|
|
2600
|
+
});
|
|
2601
|
+
}
|
|
2602
|
+
// ── Phase 3: Deep Analysis via Gemini ──
|
|
2603
|
+
await server.sendLoggingMessage({
|
|
2604
|
+
level: 'info',
|
|
2605
|
+
data: `🧠 Phase 3/5: Deep analysis with web research — this takes 1-3 minutes...`,
|
|
2606
|
+
});
|
|
2607
|
+
// Build graph context for the agent
|
|
2608
|
+
const graphContext = {
|
|
2609
|
+
graphResults: JSON.stringify(graphResults.map(g => ({
|
|
2610
|
+
graph_id: g.graphId,
|
|
2611
|
+
trends: g.rows.map((r) => ({
|
|
2612
|
+
name: String(r.title || r.trendName || '').substring(0, 150),
|
|
2613
|
+
summary: String(r.summary || r.description || '').substring(0, 600),
|
|
2614
|
+
signal_score: r.signal_score || r.score,
|
|
2615
|
+
lifecycle: r.trendLifecycle || r.lifecycle,
|
|
2616
|
+
evidence: (r.evidence || []).slice(0, 3).map((e) => ({
|
|
2617
|
+
title: String(e.title || '').substring(0, 150),
|
|
2618
|
+
snippet: String(e.snippet || e.summary || '').substring(0, 400),
|
|
2619
|
+
source_url: e.sourceUrl || e.url,
|
|
2620
|
+
category: e.category || e.type,
|
|
2621
|
+
}))
|
|
2622
|
+
})),
|
|
2623
|
+
// Top-level evidence sample for cross-trend validation
|
|
2624
|
+
evidence: g.evidence.slice(0, isHeavy ? 10 : 5).map((e) => ({
|
|
2625
|
+
title: String(e.title || '').substring(0, 150),
|
|
2626
|
+
snippet: String(e.snippet || e.summary || '').substring(0, 400),
|
|
2627
|
+
source_url: e.sourceUrl || e.url,
|
|
2628
|
+
category: e.category || e.type,
|
|
2629
|
+
brand: e.brandNames?.[0] || e.brand,
|
|
2630
|
+
})),
|
|
2631
|
+
})), null, 2),
|
|
2632
|
+
graphsSearched: activeGraphs.map(g => g.graphId),
|
|
2633
|
+
totalTrends,
|
|
2634
|
+
totalEvidence,
|
|
2635
|
+
focusGraphId: graphId,
|
|
2636
|
+
};
|
|
2637
|
+
// Build skill-loaded system instruction
|
|
2638
|
+
const systemInstruction = buildResearcherInstruction(query, graphContext);
|
|
2639
|
+
// Call Gemini directly via Waverunner with a timeout guard.
|
|
2640
|
+
// Without a timeout, a stuck Gemini call hangs the MCP tool response
|
|
2641
|
+
// indefinitely — the user sees silence until their client times out.
|
|
2642
|
+
const geminiModel = isHeavy ? 'gemini-2.5-pro' : 'gemini-2.5-flash';
|
|
2643
|
+
// Build the Interactions API payload.
|
|
2644
|
+
// SDK type: BaseCreateModelInteractionParams supports system_instruction
|
|
2645
|
+
// (snake_case) as a top-level parameter alongside model, input, tools.
|
|
2646
|
+
const interactionPayload = {
|
|
2647
|
+
model: geminiModel,
|
|
2648
|
+
system_instruction: systemInstruction,
|
|
2649
|
+
input: [
|
|
2650
|
+
{
|
|
2651
|
+
type: 'text',
|
|
2652
|
+
text: `Research query: ${query}\n\nProduce a comprehensive research report following the skills in your system instruction. Write in editorial narrative style — like a senior strategist briefing a CMO. IMPORTANT: At the end of the report, you MUST include a "## Sources" section listing all the source URLs you used from the provided context.`,
|
|
2653
|
+
},
|
|
2654
|
+
],
|
|
2655
|
+
tools: [
|
|
2656
|
+
{ type: 'google_search' },
|
|
2657
|
+
{ type: 'url_context' },
|
|
2658
|
+
],
|
|
2659
|
+
};
|
|
2660
|
+
const jobId = crypto.randomUUID();
|
|
2661
|
+
activeResearchJobs.set(jobId, { status: 'RUNNING', result: null, error: null });
|
|
2662
|
+
// Run research in the background to avoid Claude Web timeout
|
|
2663
|
+
(async () => {
|
|
2664
|
+
try {
|
|
2665
|
+
let geminiPromise = waverunnerRequest('deep_dive', tokenCost, apiKey, resolvedUserId, interactionPayload);
|
|
2666
|
+
let result;
|
|
2667
|
+
try {
|
|
2668
|
+
result = await geminiPromise;
|
|
2669
|
+
}
|
|
2670
|
+
catch (primaryErr) {
|
|
2671
|
+
const errMsg = primaryErr?.response?.data?.error?.message || primaryErr?.message || '';
|
|
2672
|
+
const isCapacity = errMsg.includes('high demand') || errMsg.includes('overloaded') || errMsg.includes('503');
|
|
2673
|
+
if (isCapacity && geminiModel !== 'gemini-2.5-flash') {
|
|
2674
|
+
console.error(`[deep_research_topic] ${geminiModel} capacity error — retrying with gemini-2.5-flash`);
|
|
2675
|
+
interactionPayload.model = 'gemini-2.5-flash';
|
|
2676
|
+
geminiPromise = waverunnerRequest('deep_dive', tokenCost, apiKey, resolvedUserId, interactionPayload);
|
|
2677
|
+
result = await geminiPromise;
|
|
2678
|
+
}
|
|
2679
|
+
else {
|
|
2680
|
+
throw primaryErr;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
// Extract text from Gemini response
|
|
2684
|
+
const outputs = result?.outputs || [];
|
|
2685
|
+
const textParts = outputs
|
|
2686
|
+
.filter((o) => o.type === 'text')
|
|
2687
|
+
.map((o) => o.text);
|
|
2688
|
+
let reportText = textParts.join('\n\n');
|
|
2689
|
+
// Extract URLs
|
|
2690
|
+
const seenUrls = new Set();
|
|
2691
|
+
const sourceUrls = [];
|
|
2692
|
+
for (const output of outputs) {
|
|
2693
|
+
if (output.type === 'text' && Array.isArray(output.annotations)) {
|
|
2694
|
+
for (const ann of output.annotations) {
|
|
2695
|
+
if (ann.type === 'url_citation' && ann.url && !seenUrls.has(ann.url)) {
|
|
2696
|
+
if (ann.url.includes('vertexaisearch.cloud.google.com'))
|
|
2697
|
+
continue;
|
|
2698
|
+
seenUrls.add(ann.url);
|
|
2699
|
+
sourceUrls.push({ title: ann.title || '', url: ann.url });
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
if (output.type === 'url_context_result' && Array.isArray(output.result)) {
|
|
2704
|
+
for (const ctx of output.result) {
|
|
2705
|
+
if (ctx.url && ctx.status === 'success' && !seenUrls.has(ctx.url)) {
|
|
2706
|
+
seenUrls.add(ctx.url);
|
|
2707
|
+
sourceUrls.push({ title: '', url: ctx.url });
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
const groundingChunks = result?.groundingMetadata?.groundingChunks || [];
|
|
2713
|
+
for (const chunk of groundingChunks) {
|
|
2714
|
+
if (chunk?.web?.uri && !seenUrls.has(chunk.web.uri)) {
|
|
2715
|
+
seenUrls.add(chunk.web.uri);
|
|
2716
|
+
sourceUrls.push({ title: chunk.web.title || '', url: chunk.web.uri });
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
if (sourceUrls.length > 0) {
|
|
2720
|
+
reportText += '\n\n## Sources\n' + sourceUrls.map(s => s.title ? `- [${s.title}](${s.url})` : `- ${s.url}`).join('\n');
|
|
2721
|
+
}
|
|
2722
|
+
if (!reportText) {
|
|
2723
|
+
activeResearchJobs.set(jobId, { status: 'FAILED', error: 'Research agent returned no output.' });
|
|
2724
|
+
return;
|
|
2725
|
+
}
|
|
2726
|
+
const durationSec = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
2727
|
+
chargeQuery({
|
|
2728
|
+
queryTypeCode,
|
|
2729
|
+
apiKey,
|
|
2730
|
+
userId: resolvedUserId,
|
|
2731
|
+
query,
|
|
2732
|
+
graphsSearched: graphIds,
|
|
2733
|
+
foddaRequest,
|
|
2734
|
+
}).catch(e => console.error('[deep_research_topic] chargeQuery failed:', e.message));
|
|
2735
|
+
const header = [
|
|
2736
|
+
`_Research by Fodda Research Agent • ${activeGraphs.length} graph${activeGraphs.length !== 1 ? 's' : ''} searched • ${totalTrends} trends analyzed • ${durationSec}s_`,
|
|
2737
|
+
'',
|
|
2738
|
+
].join('\n');
|
|
2739
|
+
activeResearchJobs.set(jobId, { status: 'COMPLETE', result: header + reportText });
|
|
2740
|
+
}
|
|
2741
|
+
catch (err) {
|
|
2742
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
2743
|
+
activeResearchJobs.set(jobId, { status: 'FAILED', error: msg });
|
|
2744
|
+
}
|
|
2745
|
+
})();
|
|
2746
|
+
return {
|
|
2747
|
+
content: [{
|
|
2748
|
+
type: 'text',
|
|
2749
|
+
text: `Deep research job started! The agent is searching the graph and the live web. Job ID: ${jobId}\n\nIMPORTANT: You must use the check_research_status tool with this Job ID to poll the status of the job and retrieve the report.`
|
|
2750
|
+
}]
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2753
|
+
catch (err) {
|
|
2754
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
2755
|
+
if (trialResult)
|
|
2756
|
+
return trialResult;
|
|
2757
|
+
const msg = err.message;
|
|
2758
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
2759
|
+
}
|
|
2760
|
+
});
|
|
2761
|
+
// --- check_research_status ---
|
|
2762
|
+
server.tool('check_research_status', 'Check the status of a long-running deep research job. If complete, this tool returns the final report. You MUST poll this periodically until the status is COMPLETE or FAILED.', {
|
|
2763
|
+
job_id: z.string().describe('The Job ID returned by deep_research_topic'),
|
|
2764
|
+
}, { title: 'Check Research Status', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ job_id }) => {
|
|
2765
|
+
const job = activeResearchJobs.get(job_id);
|
|
2766
|
+
if (!job) {
|
|
2767
|
+
return { isError: true, content: [{ type: 'text', text: `Job ID ${job_id} not found. It may have expired or never existed.` }] };
|
|
2768
|
+
}
|
|
2769
|
+
if (job.status === 'RUNNING') {
|
|
2770
|
+
return { content: [{ type: 'text', text: `Job ${job_id} is still RUNNING. The agent is gathering and synthesizing data. Please poll again in 10 seconds.` }] };
|
|
2771
|
+
}
|
|
2772
|
+
if (job.status === 'FAILED') {
|
|
2773
|
+
activeResearchJobs.delete(job_id); // cleanup
|
|
2774
|
+
return { isError: true, content: [{ type: 'text', text: `Job ${job_id} FAILED: ${job.error}` }] };
|
|
2775
|
+
}
|
|
2776
|
+
if (job.status === 'COMPLETE') {
|
|
2777
|
+
activeResearchJobs.delete(job_id); // cleanup
|
|
2778
|
+
return { content: [{ type: 'text', text: job.result }] };
|
|
2779
|
+
}
|
|
2780
|
+
return { isError: true, content: [{ type: 'text', text: `Unknown status for job ${job_id}` }] };
|
|
2781
|
+
});
|
|
2782
|
+
// --- consult_analyst ---
|
|
2783
|
+
server.tool('consult_analyst', 'Consult a named Synthetic Analyst who answers in their expert voice using their curated knowledge graph. Each analyst has a unique methodology, domain expertise, and analytical lens that produces insights distinct from generic search or standard graph queries. Use when the user asks to talk to or consult a specific expert, or when you need a specialist perspective on culture, strategy, or innovation topics. Call list_analysts first to discover available analyst_id values. Responses may include a coverage status (in/adjacent/out), source attribution, and referrals to other expert graphs. Referrals MUST be presented in third-person platform voice (not the expert\'s voice) with an offer to query the referred graph.', {
|
|
2784
|
+
analyst_id: z.string().describe("The analyst ID (e.g., 'ben-dietz-sic')"),
|
|
2785
|
+
query: z.string().describe("The question or topic to discuss with the analyst"),
|
|
2786
|
+
company: z.string().optional().describe("Optional company name or stock ticker (e.g., 'Tesla' or 'TSLA') to bind the analyst to a specific brand context."),
|
|
2787
|
+
userId: z.string().optional().describe('Optional user identifier.')
|
|
2788
|
+
}, { title: 'Consult Synthetic Analyst', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ analyst_id, query, company, userId: uid }) => {
|
|
2789
|
+
try {
|
|
2790
|
+
// Log query to Questions table (fire-and-forget, before cache)
|
|
2791
|
+
logUserQuery(query, 'consult_analyst');
|
|
2792
|
+
const result = await foddaRequest('POST', `/v1/analysts/consult`, apiKey, resolveUserId(userId, uid), {
|
|
2793
|
+
analyst_id,
|
|
2794
|
+
query,
|
|
2795
|
+
company
|
|
2796
|
+
});
|
|
2797
|
+
// Extract the expert's answer text (legacy-compatible)
|
|
2798
|
+
const reportText = typeof result.result === 'string'
|
|
2799
|
+
? result.result
|
|
2800
|
+
: (typeof result.report === 'string' ? result.report : JSON.stringify(result, null, 2));
|
|
2801
|
+
const parts = [reportText];
|
|
2802
|
+
// --- Structured envelope fields (Phase 2 Digital Twin) ---
|
|
2803
|
+
if (result.coverage) {
|
|
2804
|
+
parts.push(`\n--- COVERAGE: ${result.coverage} ---`);
|
|
2805
|
+
}
|
|
2806
|
+
if (result.sources_used && Array.isArray(result.sources_used) && result.sources_used.length > 0) {
|
|
2807
|
+
const sourceLines = result.sources_used.map((s) => s.url ? `- ${s.label || s.name || 'Source'}: ${s.url}` : `- ${s.label || s.name || 'Source'}`);
|
|
2808
|
+
parts.push(`--- SOURCES USED ---\n${sourceLines.join('\n')}`);
|
|
2809
|
+
}
|
|
2810
|
+
if (result.referrals && Array.isArray(result.referrals) && result.referrals.length > 0) {
|
|
2811
|
+
const refLines = result.referrals.map((r, i) => `${i + 1}. ${r.name} by ${r.curator || 'unknown'} — ${r.reason || 'related expertise'}`);
|
|
2812
|
+
parts.push(`--- REFERRALS (deliver these in 3rd person as the platform, NOT in the expert's voice) ---\n${refLines.join('\n')}`);
|
|
2813
|
+
}
|
|
2814
|
+
if (result.speaker_note) {
|
|
2815
|
+
parts.push(`--- SPEAKER NOTE: ${result.speaker_note} ---`);
|
|
2816
|
+
}
|
|
2817
|
+
return { content: [{ type: 'text', text: parts.join('\n') }] };
|
|
2818
|
+
}
|
|
2819
|
+
catch (err) {
|
|
2820
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
2821
|
+
if (trialResult)
|
|
2822
|
+
return trialResult;
|
|
2823
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
2824
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
2825
|
+
}
|
|
2826
|
+
});
|
|
2827
|
+
return server;
|
|
2828
|
+
}
|
|
2829
|
+
//# sourceMappingURL=toolHandlers.js.map
|