fodda-mcp 1.46.31 → 1.46.97

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.
Files changed (289) hide show
  1. package/README.md +14 -94
  2. package/dist/agents/fodda-researcher/skills.d.ts +1 -1
  3. package/dist/agents/fodda-researcher/skills.d.ts.map +1 -1
  4. package/dist/agents/fodda-researcher/skills.js +1 -1
  5. package/dist/brandTemplate.d.ts +1 -1
  6. package/dist/brandTemplate.d.ts.map +1 -1
  7. package/dist/brandTemplate.js +162 -74
  8. package/dist/brandTemplate.js.map +1 -1
  9. package/dist/catalogCache.d.ts +29 -1
  10. package/dist/catalogCache.d.ts.map +1 -1
  11. package/dist/catalogCache.js +267 -16
  12. package/dist/catalogCache.js.map +1 -1
  13. package/dist/coverageRelevance.d.ts +97 -2
  14. package/dist/coverageRelevance.d.ts.map +1 -1
  15. package/dist/coverageRelevance.js +959 -164
  16. package/dist/coverageRelevance.js.map +1 -1
  17. package/dist/editorialFill.d.ts +5 -0
  18. package/dist/editorialFill.d.ts.map +1 -1
  19. package/dist/editorialFill.js +1 -1
  20. package/dist/editorialFill.js.map +1 -1
  21. package/dist/enrichment.d.ts +26 -0
  22. package/dist/enrichment.d.ts.map +1 -1
  23. package/dist/enrichment.js +394 -31
  24. package/dist/enrichment.js.map +1 -1
  25. package/dist/errorHandling.d.ts +35 -7
  26. package/dist/errorHandling.d.ts.map +1 -1
  27. package/dist/errorHandling.js +225 -155
  28. package/dist/errorHandling.js.map +1 -1
  29. package/dist/index.d.ts +25 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +351 -143
  32. package/dist/index.js.map +1 -1
  33. package/dist/jobs/jlens_quarterly_sweep.d.ts +62 -0
  34. package/dist/jobs/jlens_quarterly_sweep.d.ts.map +1 -0
  35. package/dist/jobs/jlens_quarterly_sweep.js +160 -0
  36. package/dist/jobs/jlens_quarterly_sweep.js.map +1 -0
  37. package/dist/pricingCache.d.ts.map +1 -1
  38. package/dist/pricingCache.js +41 -1
  39. package/dist/pricingCache.js.map +1 -1
  40. package/dist/queryCache.d.ts.map +1 -1
  41. package/dist/queryCache.js +2 -0
  42. package/dist/queryCache.js.map +1 -1
  43. package/dist/reportBriefing.d.ts +89 -0
  44. package/dist/reportBriefing.d.ts.map +1 -0
  45. package/dist/reportBriefing.js +662 -0
  46. package/dist/reportBriefing.js.map +1 -0
  47. package/dist/searchTemplate.d.ts.map +1 -1
  48. package/dist/searchTemplate.js +65 -53
  49. package/dist/searchTemplate.js.map +1 -1
  50. package/dist/services/graphRouter.d.ts +7 -0
  51. package/dist/services/graphRouter.d.ts.map +1 -0
  52. package/dist/services/graphRouter.js +7 -0
  53. package/dist/services/graphRouter.js.map +1 -0
  54. package/dist/services/notifications.d.ts +49 -0
  55. package/dist/services/notifications.d.ts.map +1 -0
  56. package/dist/services/notifications.js +206 -0
  57. package/dist/services/notifications.js.map +1 -0
  58. package/dist/sessionTracker.d.ts +19 -2
  59. package/dist/sessionTracker.d.ts.map +1 -1
  60. package/dist/sessionTracker.js +69 -16
  61. package/dist/sessionTracker.js.map +1 -1
  62. package/dist/skillClient.d.ts.map +1 -1
  63. package/dist/skillClient.js +11 -6
  64. package/dist/skillClient.js.map +1 -1
  65. package/dist/svgVisuals.d.ts +17 -19
  66. package/dist/svgVisuals.d.ts.map +1 -1
  67. package/dist/svgVisuals.js +189 -233
  68. package/dist/svgVisuals.js.map +1 -1
  69. package/dist/systemPrompt.d.ts +7 -2
  70. package/dist/systemPrompt.d.ts.map +1 -1
  71. package/dist/systemPrompt.js +110 -68
  72. package/dist/systemPrompt.js.map +1 -1
  73. package/dist/telemetry.d.ts.map +1 -1
  74. package/dist/telemetry.js +15 -0
  75. package/dist/telemetry.js.map +1 -1
  76. package/dist/test_adjacent_trends_polymorphic.d.ts +2 -0
  77. package/dist/test_adjacent_trends_polymorphic.d.ts.map +1 -0
  78. package/dist/test_adjacent_trends_polymorphic.js +154 -0
  79. package/dist/test_adjacent_trends_polymorphic.js.map +1 -0
  80. package/dist/test_book_a_call_live.js +18 -5
  81. package/dist/test_book_a_call_live.js.map +1 -1
  82. package/dist/test_byo_mcp_submission_clarity.d.ts +2 -0
  83. package/dist/test_byo_mcp_submission_clarity.d.ts.map +1 -0
  84. package/dist/test_byo_mcp_submission_clarity.js +142 -0
  85. package/dist/test_byo_mcp_submission_clarity.js.map +1 -0
  86. package/dist/test_capabilities_guidance.d.ts +2 -0
  87. package/dist/test_capabilities_guidance.d.ts.map +1 -0
  88. package/dist/test_capabilities_guidance.js +372 -0
  89. package/dist/test_capabilities_guidance.js.map +1 -0
  90. package/dist/test_client_provenance.d.ts +2 -0
  91. package/dist/test_client_provenance.d.ts.map +1 -0
  92. package/dist/test_client_provenance.js +77 -0
  93. package/dist/test_client_provenance.js.map +1 -0
  94. package/dist/test_connection_surface_cleanup.d.ts +2 -0
  95. package/dist/test_connection_surface_cleanup.d.ts.map +1 -0
  96. package/dist/test_connection_surface_cleanup.js +203 -0
  97. package/dist/test_connection_surface_cleanup.js.map +1 -0
  98. package/dist/test_consult_routing.d.ts +2 -0
  99. package/dist/test_consult_routing.d.ts.map +1 -0
  100. package/dist/test_consult_routing.js +86 -0
  101. package/dist/test_consult_routing.js.map +1 -0
  102. package/dist/test_consult_split.d.ts +2 -0
  103. package/dist/test_consult_split.d.ts.map +1 -0
  104. package/dist/test_consult_split.js +30 -0
  105. package/dist/test_consult_split.js.map +1 -0
  106. package/dist/test_coverage_relevance.js +111 -70
  107. package/dist/test_coverage_relevance.js.map +1 -1
  108. package/dist/test_coverage_replay.d.ts +2 -0
  109. package/dist/test_coverage_replay.d.ts.map +1 -0
  110. package/dist/test_coverage_replay.js +170 -0
  111. package/dist/test_coverage_replay.js.map +1 -0
  112. package/dist/test_dcr_and_legacy_deprecation.js +45 -50
  113. package/dist/test_dcr_and_legacy_deprecation.js.map +1 -1
  114. package/dist/test_demand_signal_logging.d.ts +2 -0
  115. package/dist/test_demand_signal_logging.d.ts.map +1 -0
  116. package/dist/test_demand_signal_logging.js +92 -0
  117. package/dist/test_demand_signal_logging.js.map +1 -0
  118. package/dist/test_earnings_activity_scope.d.ts +5 -0
  119. package/dist/test_earnings_activity_scope.d.ts.map +1 -0
  120. package/dist/test_earnings_activity_scope.js +101 -0
  121. package/dist/test_earnings_activity_scope.js.map +1 -0
  122. package/dist/test_earnings_divergence_live.d.ts +5 -0
  123. package/dist/test_earnings_divergence_live.d.ts.map +1 -0
  124. package/dist/test_earnings_divergence_live.js +135 -0
  125. package/dist/test_earnings_divergence_live.js.map +1 -0
  126. package/dist/test_earnings_hygiene_and_binding.d.ts +5 -0
  127. package/dist/test_earnings_hygiene_and_binding.d.ts.map +1 -0
  128. package/dist/test_earnings_hygiene_and_binding.js +104 -0
  129. package/dist/test_earnings_hygiene_and_binding.js.map +1 -0
  130. package/dist/test_exec_quotes_render.d.ts +2 -0
  131. package/dist/test_exec_quotes_render.d.ts.map +1 -0
  132. package/dist/test_exec_quotes_render.js +86 -0
  133. package/dist/test_exec_quotes_render.js.map +1 -0
  134. package/dist/test_expert_intro.d.ts +2 -0
  135. package/dist/test_expert_intro.d.ts.map +1 -0
  136. package/dist/test_expert_intro.js +57 -0
  137. package/dist/test_expert_intro.js.map +1 -0
  138. package/dist/test_expert_layer.d.ts +11 -0
  139. package/dist/test_expert_layer.d.ts.map +1 -0
  140. package/dist/test_expert_layer.js +356 -0
  141. package/dist/test_expert_layer.js.map +1 -0
  142. package/dist/test_gap_alert.js +8 -0
  143. package/dist/test_gap_alert.js.map +1 -1
  144. package/dist/test_grok_offering_surfaces.d.ts +2 -0
  145. package/dist/test_grok_offering_surfaces.d.ts.map +1 -0
  146. package/dist/test_grok_offering_surfaces.js +155 -0
  147. package/dist/test_grok_offering_surfaces.js.map +1 -0
  148. package/dist/test_human_agent_live.d.ts +2 -0
  149. package/dist/test_human_agent_live.d.ts.map +1 -0
  150. package/dist/test_human_agent_live.js +48 -0
  151. package/dist/test_human_agent_live.js.map +1 -0
  152. package/dist/test_identity_gap.d.ts +2 -0
  153. package/dist/test_identity_gap.d.ts.map +1 -0
  154. package/dist/test_identity_gap.js +171 -0
  155. package/dist/test_identity_gap.js.map +1 -0
  156. package/dist/test_intelligence_and_verifier.d.ts +17 -0
  157. package/dist/test_intelligence_and_verifier.d.ts.map +1 -0
  158. package/dist/test_intelligence_and_verifier.js +162 -0
  159. package/dist/test_intelligence_and_verifier.js.map +1 -0
  160. package/dist/test_latency_mcp_brief5.d.ts +12 -0
  161. package/dist/test_latency_mcp_brief5.d.ts.map +1 -0
  162. package/dist/test_latency_mcp_brief5.js +200 -0
  163. package/dist/test_latency_mcp_brief5.js.map +1 -0
  164. package/dist/test_limit_and_credit_card_capture.d.ts +21 -0
  165. package/dist/test_limit_and_credit_card_capture.d.ts.map +1 -0
  166. package/dist/test_limit_and_credit_card_capture.js +216 -0
  167. package/dist/test_limit_and_credit_card_capture.js.map +1 -0
  168. package/dist/test_list_graphs_topics.js +1 -1
  169. package/dist/test_list_graphs_topics.js.map +1 -1
  170. package/dist/test_live_deployed_mcp_search.d.ts +2 -0
  171. package/dist/test_live_deployed_mcp_search.d.ts.map +1 -0
  172. package/dist/test_live_deployed_mcp_search.js +85 -0
  173. package/dist/test_live_deployed_mcp_search.js.map +1 -0
  174. package/dist/test_live_deployed_split.d.ts +2 -0
  175. package/dist/test_live_deployed_split.d.ts.map +1 -0
  176. package/dist/test_live_deployed_split.js +72 -0
  177. package/dist/test_live_deployed_split.js.map +1 -0
  178. package/dist/test_live_endpoint_runner.js +2 -2
  179. package/dist/test_live_endpoint_runner.js.map +1 -1
  180. package/dist/test_live_mcp.js +1 -1
  181. package/dist/test_live_mcp.js.map +1 -1
  182. package/dist/test_live_neil_widget.d.ts +2 -0
  183. package/dist/test_live_neil_widget.d.ts.map +1 -0
  184. package/dist/test_live_neil_widget.js +84 -0
  185. package/dist/test_live_neil_widget.js.map +1 -0
  186. package/dist/test_longpoll_status.d.ts +2 -0
  187. package/dist/test_longpoll_status.d.ts.map +1 -0
  188. package/dist/test_longpoll_status.js +117 -0
  189. package/dist/test_longpoll_status.js.map +1 -0
  190. package/dist/test_mcp_find_expert_and_vocabulary_routing.d.ts +2 -0
  191. package/dist/test_mcp_find_expert_and_vocabulary_routing.d.ts.map +1 -0
  192. package/dist/test_mcp_find_expert_and_vocabulary_routing.js +130 -0
  193. package/dist/test_mcp_find_expert_and_vocabulary_routing.js.map +1 -0
  194. package/dist/test_next_moves.js +644 -279
  195. package/dist/test_next_moves.js.map +1 -1
  196. package/dist/test_next_moves_transcripts.js +122 -40
  197. package/dist/test_next_moves_transcripts.js.map +1 -1
  198. package/dist/test_on_request_caller_identity.d.ts +13 -0
  199. package/dist/test_on_request_caller_identity.d.ts.map +1 -0
  200. package/dist/test_on_request_caller_identity.js +131 -0
  201. package/dist/test_on_request_caller_identity.js.map +1 -0
  202. package/dist/test_on_request_intercept.d.ts +10 -0
  203. package/dist/test_on_request_intercept.d.ts.map +1 -0
  204. package/dist/test_on_request_intercept.js +95 -0
  205. package/dist/test_on_request_intercept.js.map +1 -0
  206. package/dist/test_onboarding_consent_and_errors.d.ts +2 -0
  207. package/dist/test_onboarding_consent_and_errors.d.ts.map +1 -0
  208. package/dist/test_onboarding_consent_and_errors.js +110 -0
  209. package/dist/test_onboarding_consent_and_errors.js.map +1 -0
  210. package/dist/test_onboarding_live_scenarios.d.ts +2 -0
  211. package/dist/test_onboarding_live_scenarios.d.ts.map +1 -0
  212. package/dist/test_onboarding_live_scenarios.js +189 -0
  213. package/dist/test_onboarding_live_scenarios.js.map +1 -0
  214. package/dist/test_referral_live.d.ts +2 -0
  215. package/dist/test_referral_live.d.ts.map +1 -0
  216. package/dist/test_referral_live.js +33 -0
  217. package/dist/test_referral_live.js.map +1 -0
  218. package/dist/test_referral_piers.d.ts +2 -0
  219. package/dist/test_referral_piers.d.ts.map +1 -0
  220. package/dist/test_referral_piers.js +33 -0
  221. package/dist/test_referral_piers.js.map +1 -0
  222. package/dist/test_report_editorial_briefing.d.ts +21 -0
  223. package/dist/test_report_editorial_briefing.d.ts.map +1 -0
  224. package/dist/test_report_editorial_briefing.js +356 -0
  225. package/dist/test_report_editorial_briefing.js.map +1 -0
  226. package/dist/test_search_graph_quality.d.ts +2 -0
  227. package/dist/test_search_graph_quality.d.ts.map +1 -0
  228. package/dist/test_search_graph_quality.js +493 -0
  229. package/dist/test_search_graph_quality.js.map +1 -0
  230. package/dist/test_service_to_service_auth.d.ts +2 -0
  231. package/dist/test_service_to_service_auth.d.ts.map +1 -0
  232. package/dist/test_service_to_service_auth.js +199 -0
  233. package/dist/test_service_to_service_auth.js.map +1 -0
  234. package/dist/test_sources_live.d.ts +2 -0
  235. package/dist/test_sources_live.d.ts.map +1 -0
  236. package/dist/test_sources_live.js +30 -0
  237. package/dist/test_sources_live.js.map +1 -0
  238. package/dist/test_specialist_and_analyst_tiers.d.ts +2 -0
  239. package/dist/test_specialist_and_analyst_tiers.d.ts.map +1 -0
  240. package/dist/test_specialist_and_analyst_tiers.js +155 -0
  241. package/dist/test_specialist_and_analyst_tiers.js.map +1 -0
  242. package/dist/test_v2_2_visual_hardening.d.ts +5 -0
  243. package/dist/test_v2_2_visual_hardening.d.ts.map +1 -0
  244. package/dist/test_v2_2_visual_hardening.js +333 -0
  245. package/dist/test_v2_2_visual_hardening.js.map +1 -0
  246. package/dist/test_verify_deprecate_render_spec.d.ts +2 -0
  247. package/dist/test_verify_deprecate_render_spec.d.ts.map +1 -0
  248. package/dist/test_verify_deprecate_render_spec.js +217 -0
  249. package/dist/test_verify_deprecate_render_spec.js.map +1 -0
  250. package/dist/test_verify_neil_demo.d.ts +2 -0
  251. package/dist/test_verify_neil_demo.d.ts.map +1 -0
  252. package/dist/test_verify_neil_demo.js +149 -0
  253. package/dist/test_verify_neil_demo.js.map +1 -0
  254. package/dist/test_waitable_research_and_grounded_links.d.ts +2 -0
  255. package/dist/test_waitable_research_and_grounded_links.d.ts.map +1 -0
  256. package/dist/test_waitable_research_and_grounded_links.js +129 -0
  257. package/dist/test_waitable_research_and_grounded_links.js.map +1 -0
  258. package/dist/test_widget_guards.d.ts +2 -0
  259. package/dist/test_widget_guards.d.ts.map +1 -0
  260. package/dist/test_widget_guards.js +229 -0
  261. package/dist/test_widget_guards.js.map +1 -0
  262. package/dist/test_zero_retention_alerts.d.ts +14 -0
  263. package/dist/test_zero_retention_alerts.d.ts.map +1 -0
  264. package/dist/test_zero_retention_alerts.js +76 -0
  265. package/dist/test_zero_retention_alerts.js.map +1 -0
  266. package/dist/toolHandlers.d.ts +38 -1
  267. package/dist/toolHandlers.d.ts.map +1 -1
  268. package/dist/toolHandlers.js +3856 -938
  269. package/dist/toolHandlers.js.map +1 -1
  270. package/dist/tools.d.ts +20 -0
  271. package/dist/tools.d.ts.map +1 -1
  272. package/dist/tools.js +25 -4
  273. package/dist/tools.js.map +1 -1
  274. package/dist/verify_discoverability.d.ts +2 -0
  275. package/dist/verify_discoverability.d.ts.map +1 -0
  276. package/dist/verify_discoverability.js +53 -0
  277. package/dist/verify_discoverability.js.map +1 -0
  278. package/dist/verify_tools_endpoint.js +16 -3
  279. package/dist/verify_tools_endpoint.js.map +1 -1
  280. package/dist/widgetShell.d.ts +2 -2
  281. package/dist/widgetShell.d.ts.map +1 -1
  282. package/dist/widgetShell.js +1 -1
  283. package/dist/widgetShell.js.map +1 -1
  284. package/package.json +1 -1
  285. package/server.json +4 -32
  286. package/dist/oauthRegisterShim.d.ts +0 -13
  287. package/dist/oauthRegisterShim.d.ts.map +0 -1
  288. package/dist/oauthRegisterShim.js +0 -83
  289. package/dist/oauthRegisterShim.js.map +0 -1
@@ -31,7 +31,8 @@
31
31
  * emerging trends"), relevance can't be judged and `evaluated: false` is
32
32
  * returned — callers keep the legacy count-only behavior.
33
33
  */
34
- import { getGraphs, getLiveGraphs, buildDisplayName, getRelevantGraphs, getAnalysts } from './catalogCache.js';
34
+ import { getGraphs, getLiveGraphs, buildDisplayName, cleanDisplayName, getRelevantGraphs, getAnalysts } from './catalogCache.js';
35
+ import { LUXURY_KEYWORDS, LUXURY_BRANDS, MASS_BUDGET_KEYWORDS, MASS_BUDGET_BRANDS } from './enrichment.js';
35
36
  /** Nominal on-topic relevance-score scale per graph tier (observed in QA:
36
37
  * domain composites ~2.0, report vector scores ~0.8; expert sits between). */
37
38
  export const TIER_NOMINAL_SCORE = {
@@ -72,13 +73,13 @@ export function specificQueryTokens(query) {
72
73
  return [...new Set(query.toLowerCase().split(/[^a-z0-9]+/)
73
74
  .filter(t => t.length >= 2 && !GENERIC_QUERY_TOKENS.has(t)))];
74
75
  }
75
- function rowScore(row) {
76
+ export function rowScore(row) {
76
77
  return row.relevance_score || row.semantic_score || row._score || row.score || 0;
77
78
  }
78
- function rowGraphId(row) {
79
+ export function rowGraphId(row) {
79
80
  return row._use_this_graphId || row.graphId || row.graph_id || row.psfk_graph_slug || undefined;
80
81
  }
81
- function resolveRowTier(row, searchedGraphs, catalog) {
82
+ export function resolveRowTier(row, searchedGraphs, catalog) {
82
83
  const gid = rowGraphId(row);
83
84
  let type;
84
85
  if (gid) {
@@ -125,6 +126,247 @@ export function rowMatchesQueryTokens(row, tokens, catalog) {
125
126
  }
126
127
  return false;
127
128
  }
129
+ export function stemToken(token) {
130
+ let s = token.toLowerCase().trim();
131
+ if (s.endsWith('ies') && s.length > 4) {
132
+ s = s.slice(0, -3) + 'y';
133
+ }
134
+ else if (s.endsWith('es') && s.length > 4) {
135
+ s = s.slice(0, -2);
136
+ }
137
+ else if (s.endsWith('s') && !s.endsWith('ss') && s.length > 3) {
138
+ s = s.slice(0, -1);
139
+ }
140
+ return s;
141
+ }
142
+ /**
143
+ * Direct token match tier: checks if specific query tokens match directly
144
+ * against the row's core trend name, sectors, or trend slug/categories.
145
+ * Applies singular/plural folding and stem matching (e.g. "collectible" matches "Collectibles", "trading cards" matches "cards").
146
+ * (Used to rank direct niche matches above semantically adjacent mega-trends).
147
+ */
148
+ export function rowHasDirectTokenMatch(row, tokens) {
149
+ if (!tokens || tokens.length === 0 || !row)
150
+ return false;
151
+ const parts = [
152
+ row.trendName, row.name, row.label, row.title,
153
+ row.trendSlug, row.slug,
154
+ row.sectorNames, row.sectors, row.sector,
155
+ row.categoryNames, row.categories, row.category,
156
+ ];
157
+ for (const arr of [row.sectors, row.sectorNames, row.categories, row.categoryNames, row.topics]) {
158
+ if (Array.isArray(arr))
159
+ parts.push(...arr);
160
+ }
161
+ const words = new Set(parts.filter((x) => typeof x === 'string').join(' ').toLowerCase().split(/[^a-z0-9]+/));
162
+ const stemmedWords = new Set();
163
+ for (const w of words) {
164
+ stemmedWords.add(stemToken(w));
165
+ }
166
+ for (const t of tokens) {
167
+ if (words.has(t))
168
+ return true;
169
+ const stemT = stemToken(t);
170
+ if (stemmedWords.has(stemT))
171
+ return true;
172
+ if (t.length >= 4) {
173
+ for (const w of words) {
174
+ if (w.startsWith(t) || (w.length >= 4 && t.startsWith(w)))
175
+ return true;
176
+ const stemW = stemToken(w);
177
+ if (stemW.length >= 4 && stemT.length >= 4) {
178
+ if (stemW.startsWith(stemT) || stemT.startsWith(stemW))
179
+ return true;
180
+ }
181
+ }
182
+ }
183
+ }
184
+ return false;
185
+ }
186
+ /**
187
+ * Row-level brand guard: returns true if the row is NOT a mega-trend brand roster.
188
+ * Excludes rows with brand_count > 30 (or brandNames.length >= 10 when brand_count is absent).
189
+ */
190
+ export function isRowBrandEligible(row) {
191
+ if (!row)
192
+ return false;
193
+ const brandCount = typeof row.brand_count === 'number'
194
+ ? row.brand_count
195
+ : (typeof row.brandCount === 'number' ? row.brandCount : undefined);
196
+ if (brandCount !== undefined) {
197
+ if (brandCount > 30)
198
+ return false;
199
+ }
200
+ else {
201
+ const rawNames = Array.isArray(row.brandNames)
202
+ ? row.brandNames
203
+ : (Array.isArray(row.brands) ? row.brands : undefined);
204
+ if (rawNames && rawNames.length >= 10) {
205
+ return false;
206
+ }
207
+ }
208
+ return true;
209
+ }
210
+ /**
211
+ * Evaluates whether a result row matches category/market tier qualifiers in the query
212
+ * (e.g. "luxury" vs "budget"). Returns a positive score boost for matching tiers,
213
+ * or a penalty for conflicting tiers.
214
+ */
215
+ export function computeTierFit(row, query) {
216
+ if (!query || !row)
217
+ return 0;
218
+ const qLower = query.toLowerCase();
219
+ const isLuxuryQuery = [...LUXURY_KEYWORDS].some(k => qLower.includes(k));
220
+ const isBudgetQuery = [...MASS_BUDGET_KEYWORDS].some(k => qLower.includes(k));
221
+ const title = (row.trendName || row.title || row.label || row.name || '').toLowerCase();
222
+ const summary = (row.summary || row.description || row.trendDescription || '').toLowerCase();
223
+ let boost = 0;
224
+ // 1. Format alignment with query (e.g. pop-up / activation / experiential)
225
+ if (/\bpop-?ups?\b/i.test(qLower)) {
226
+ if (/\bpop-?ups?\b/i.test(title))
227
+ boost += 0.40;
228
+ else if (/\bpop-?ups?\b/i.test(summary))
229
+ boost += 0.15;
230
+ }
231
+ if (/\b(experiential|experiences?)\b/i.test(qLower)) {
232
+ if (/\b(experiential|experiences?)\b/i.test(title))
233
+ boost += 0.20;
234
+ }
235
+ if (/\bactivations?\b/i.test(qLower)) {
236
+ if (/\bactivations?\b/i.test(title))
237
+ boost += 0.15;
238
+ }
239
+ // 2. Luxury / Market Tier matching
240
+ if (isLuxuryQuery) {
241
+ // Title-level luxury match gets highest priority
242
+ const titleHasLuxWord = [...LUXURY_KEYWORDS].some(k => title.includes(k));
243
+ const titleHasLuxBrand = [...LUXURY_BRANDS].some(b => title.includes(b));
244
+ if (titleHasLuxWord || titleHasLuxBrand)
245
+ boost += 0.40;
246
+ // Summary-level luxury match
247
+ const summaryHasLux = [...LUXURY_KEYWORDS].some(k => summary.includes(k)) ||
248
+ [...LUXURY_BRANDS].some(b => summary.includes(b));
249
+ if (summaryHasLux)
250
+ boost += 0.25;
251
+ // Evidence-level luxury match
252
+ if (Array.isArray(row.evidence) && row.evidence.length > 0) {
253
+ const hasLuxEvidence = row.evidence.some((e) => {
254
+ const txt = `${e.title || ''} ${e.summary || ''}`.toLowerCase();
255
+ return [...LUXURY_BRANDS].some(b => txt.includes(b)) || [...LUXURY_KEYWORDS].some(k => txt.includes(k));
256
+ });
257
+ if (hasLuxEvidence)
258
+ boost += 0.35;
259
+ boost += 0.20; // Having verified on-topic evidence is a positive signal
260
+ }
261
+ else {
262
+ boost -= 0.20; // Having 0 valid evidence items penalizes the trend
263
+ }
264
+ // Mass-market penalty
265
+ const titleHasMass = [...MASS_BUDGET_BRANDS].some(b => title.includes(b));
266
+ const summaryHasMass = [...MASS_BUDGET_BRANDS].some(b => summary.includes(b));
267
+ if (titleHasMass || summaryHasMass)
268
+ boost -= 0.40;
269
+ }
270
+ else if (isBudgetQuery) {
271
+ const titleHasMass = [...MASS_BUDGET_BRANDS].some(b => title.includes(b)) ||
272
+ [...MASS_BUDGET_KEYWORDS].some(k => title.includes(k));
273
+ if (titleHasMass)
274
+ boost += 0.40;
275
+ }
276
+ return boost;
277
+ }
278
+ /** Normalize brand string for deduplication / comparison. */
279
+ export function normalizeBrandKey(name) {
280
+ return name.toLowerCase().replace(/[^\w\s]/g, '').trim().replace(/\s+/g, ' ');
281
+ }
282
+ /** Build standard publisher / curator exclusion set. */
283
+ export function buildPublisherExclusionSet(searchedGraphs = [], catalog = [], rows = []) {
284
+ const publisherTokens = new Set([
285
+ 'dentsu', 'dentsu creative', 'havas', 'psfk', 'nielseniq', 'niq',
286
+ 'wpp', 'omnicom', 'ipg', 'publicis', 'accenture', 'deloitte',
287
+ 'mckinsey', 'bcg', 'bain', 'gartner', 'forrester', 'kantar', 'jwt',
288
+ 'ogilvy', 'edelman', 'mccann', 'bbdo', 'tbwa', 'vml', 'leo burnett'
289
+ ]);
290
+ for (const sg of searchedGraphs) {
291
+ const gMeta = typeof sg === 'string' ? catalog.find(x => x.graph_id === sg) : sg;
292
+ if (gMeta) {
293
+ if (gMeta.curator)
294
+ publisherTokens.add(gMeta.curator.toLowerCase().trim());
295
+ if (gMeta.company)
296
+ publisherTokens.add(gMeta.company.toLowerCase().trim());
297
+ if (gMeta.name)
298
+ publisherTokens.add(gMeta.name.toLowerCase().trim());
299
+ for (const word of `${gMeta.curator || ''} ${gMeta.company || ''} ${gMeta.name || ''}`.toLowerCase().split(/[\s,.-]+/)) {
300
+ if (word.length > 2)
301
+ publisherTokens.add(word);
302
+ }
303
+ }
304
+ }
305
+ for (const r of rows) {
306
+ if (r && r.source_label) {
307
+ const match = String(r.source_label).match(/\(([^)]+)\)/);
308
+ if (match && match[1]) {
309
+ const org = match[1].toLowerCase().trim();
310
+ publisherTokens.add(org);
311
+ for (const w of org.split(/[\s,.-]+/)) {
312
+ if (w.length > 2)
313
+ publisherTokens.add(w);
314
+ }
315
+ }
316
+ }
317
+ }
318
+ return publisherTokens;
319
+ }
320
+ /** Test whether a brand name matches publisher / curator tokens. */
321
+ export function isPublisherBrand(brand, publisherTokens) {
322
+ const clean = brand.trim();
323
+ const norm = normalizeBrandKey(clean);
324
+ if (!norm)
325
+ return true;
326
+ if (publisherTokens.has(norm) || publisherTokens.has(clean.toLowerCase())) {
327
+ return true;
328
+ }
329
+ return [...publisherTokens].some(pt => norm === pt || norm.startsWith(`${pt} `) || norm.endsWith(` ${pt}`));
330
+ }
331
+ /** Extract clean, guarded brand names for a single row up to limit. */
332
+ export function extractCleanRowBrands(row, publisherTokens, limit = 4) {
333
+ if (!isRowBrandEligible(row))
334
+ return [];
335
+ const rawList = [];
336
+ if (Array.isArray(row.brandNames))
337
+ rawList.push(...row.brandNames);
338
+ else if (typeof row.brandNames === 'string')
339
+ rawList.push(...row.brandNames.split('|'));
340
+ if (Array.isArray(row.brands))
341
+ rawList.push(...row.brands);
342
+ if (typeof row.brand === 'string' && row.brand)
343
+ rawList.push(row.brand);
344
+ if (typeof row.Brand === 'string' && row.Brand)
345
+ rawList.push(...row.Brand.split('|'));
346
+ else if (Array.isArray(row.Brand))
347
+ rawList.push(...row.Brand);
348
+ if (typeof row.company === 'string' && row.company)
349
+ rawList.push(row.company);
350
+ if (Array.isArray(row.entities?.brands))
351
+ rawList.push(...row.entities.brands);
352
+ const seen = new Set();
353
+ const result = [];
354
+ for (const b of rawList) {
355
+ if (typeof b === 'string' && b.trim().length > 1) {
356
+ const clean = b.trim();
357
+ const norm = normalizeBrandKey(clean);
358
+ if (!norm || seen.has(norm))
359
+ continue;
360
+ if (publisherTokens && isPublisherBrand(clean, publisherTokens))
361
+ continue;
362
+ seen.add(norm);
363
+ result.push(clean);
364
+ if (result.length >= limit)
365
+ break;
366
+ }
367
+ }
368
+ return result;
369
+ }
128
370
  /**
129
371
  * Count rows that are on-topic for the query. `catalog` is injectable for
130
372
  * tests; defaults to the live graph catalog.
@@ -177,7 +419,7 @@ export function isDemandShaped(query) {
177
419
  ];
178
420
  return demandKeywords.some(pat => pat.test(q));
179
421
  }
180
- export function addCoverageAnnotation(data, query, searchedGraphs, limit, skipEvidenceCheck = false, catalog = getGraphs(), options) {
422
+ export async function addCoverageAnnotation(data, query, searchedGraphs, limit, skipEvidenceCheck = false, catalog = getGraphs(), options) {
181
423
  if (!data || typeof data !== 'object')
182
424
  return data;
183
425
  let normalizedData = data;
@@ -245,8 +487,17 @@ export function addCoverageAnnotation(data, query, searchedGraphs, limit, skipEv
245
487
  if (limit !== undefined && limit < 3 && resultCount === limit) {
246
488
  isThinCount = false;
247
489
  }
490
+ // Evidence check guard: only evaluate evidence thinness if rows actually contain
491
+ // evidence arrays AND evidence was not skipped or opted out.
492
+ // If resultCount >= 3 and query did not request inline evidence (or evidence was stripped),
493
+ // missing inline evidence must NOT flag healthy results as 'thin'.
494
+ const hasEvidenceArrays = rows.some((r) => (Array.isArray(r.evidence) && r.evidence.length > 0) ||
495
+ (Array.isArray(r.evidence_items) && r.evidence_items.length > 0) ||
496
+ (Array.isArray(r.evidenceItems) && r.evidenceItems.length > 0) ||
497
+ (Array.isArray(r.trendEvidence) && r.trendEvidence.length > 0));
248
498
  let isThinEvidence = false;
249
- if (!skipEvidenceCheck) {
499
+ const skipEvidence = skipEvidenceCheck || options?.include_evidence === false || !hasEvidenceArrays;
500
+ if (!skipEvidence && resultCount >= 3) {
250
501
  isThinEvidence = rows.every((r) => {
251
502
  const count = Array.isArray(r.evidence)
252
503
  ? r.evidence.length
@@ -276,6 +527,20 @@ export function addCoverageAnnotation(data, query, searchedGraphs, limit, skipEv
276
527
  if (isThinCount || isThinEvidence || isThinRelevance) {
277
528
  status = 'thin';
278
529
  }
530
+ // ── Respect Backend dataStatus / on_topic_total ──
531
+ // Fodda API evaluates vector similarity and relevance scores directly in the graph layer.
532
+ // When normalizedData.dataStatus === 'TREND_MATCH' or (on_topic_total >= 3),
533
+ // the backend has already confirmed that the returned trends are genuine on-topic matches.
534
+ // MCP's secondary heuristics should not downgrade a confirmed TREND_MATCH to 'thin'.
535
+ const backendOnTopicTotal = normalizedData.on_topic_total ?? options?.onTopicTotal;
536
+ const isBackendTrendMatch = normalizedData.dataStatus === 'TREND_MATCH' ||
537
+ (typeof backendOnTopicTotal === 'number' && backendOnTopicTotal >= 3);
538
+ if (isBackendTrendMatch && resultCount >= 3) {
539
+ status = 'ok';
540
+ if (onTopicCount !== undefined && onTopicCount < 3) {
541
+ onTopicCount = Math.min(resultCount, Math.max(3, typeof backendOnTopicTotal === 'number' ? backendOnTopicTotal : 3));
542
+ }
543
+ }
279
544
  }
280
545
  const isDemand = isDemandShaped(query);
281
546
  let suggestedAction = undefined;
@@ -339,10 +604,79 @@ export function addCoverageAnnotation(data, query, searchedGraphs, limit, skipEv
339
604
  ],
340
605
  };
341
606
  }
342
- normalizedData.next_moves = generateNextMoves(rows, query, searchedGraphs, status, onTopicCount, suggestedAction, catalog, options?.analysts || getAnalysts(), options);
607
+ normalizedData.next_moves = await generateNextMoves(rows, query, searchedGraphs, status, onTopicCount, suggestedAction, catalog, options?.analysts || getAnalysts(), options);
343
608
  console.error(`[coverage] status: ${status}, results: ${resultCount}/${limit || 'default'}${onTopicCount !== undefined ? `, on-topic: ${onTopicCount}` : ''}, query: "${query}"`);
344
609
  return normalizedData;
345
610
  }
611
+ const suggestCache = new Map();
612
+ const SUGGEST_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
613
+ const MAX_SUGGEST_CACHE_SIZE = 500;
614
+ export function clearSuggestCacheForTesting() {
615
+ suggestCache.clear();
616
+ }
617
+ function setSuggestCacheEntry(key, sources) {
618
+ const now = Date.now();
619
+ if (suggestCache.size >= MAX_SUGGEST_CACHE_SIZE) {
620
+ for (const [k, v] of suggestCache.entries()) {
621
+ if (now - v.timestamp >= SUGGEST_CACHE_TTL_MS) {
622
+ suggestCache.delete(k);
623
+ }
624
+ }
625
+ }
626
+ if (suggestCache.size >= MAX_SUGGEST_CACHE_SIZE) {
627
+ const oldestKey = suggestCache.keys().next().value;
628
+ if (oldestKey)
629
+ suggestCache.delete(oldestKey);
630
+ }
631
+ suggestCache.set(key, { timestamp: now, sources });
632
+ }
633
+ export function truncateAtWordBoundary(str, maxLength) {
634
+ if (!str || str.length <= maxLength)
635
+ return str;
636
+ const sub = str.slice(0, maxLength);
637
+ const lastSpace = sub.lastIndexOf(' ');
638
+ if (lastSpace > 10) {
639
+ return sub.slice(0, lastSpace).trim();
640
+ }
641
+ return sub.trim();
642
+ }
643
+ export async function fetchSupplementalSuggest(query, options) {
644
+ const normQuery = query.toLowerCase().trim();
645
+ const sessionId = options?.sessionId || options?.userId || 'default';
646
+ const cacheKey = `${sessionId}:${normQuery}`;
647
+ const cached = suggestCache.get(cacheKey);
648
+ if (cached && (Date.now() - cached.timestamp) < SUGGEST_CACHE_TTL_MS) {
649
+ return cached.sources;
650
+ }
651
+ try {
652
+ let suggestPromise = null;
653
+ if (options?.suggestPromise) {
654
+ suggestPromise = options.suggestPromise;
655
+ }
656
+ else if (options?.suggestFn) {
657
+ suggestPromise = options.suggestFn(query);
658
+ }
659
+ else if (options?.foddaRequest && options?.apiKey) {
660
+ suggestPromise = options.foddaRequest('GET', `/v1/supplemental/suggest?query=${encodeURIComponent(query)}`, options.apiKey, options.userId || '');
661
+ }
662
+ if (!suggestPromise) {
663
+ return null;
664
+ }
665
+ let timer;
666
+ const timeoutPromise = new Promise((_, reject) => {
667
+ timer = setTimeout(() => reject(new Error('suggest timeout (3000ms)')), 3000);
668
+ });
669
+ const data = await Promise.race([suggestPromise, timeoutPromise]);
670
+ clearTimeout(timer);
671
+ const sources = Array.isArray(data) ? data : (Array.isArray(data?.sources) ? data.sources : []);
672
+ setSuggestCacheEntry(cacheKey, sources);
673
+ return sources;
674
+ }
675
+ catch (err) {
676
+ console.error(`[next_moves] suggest fetch failed: ${err.message}`);
677
+ return null;
678
+ }
679
+ }
346
680
  /**
347
681
  * Clean and truncate a single theme candidate to <= 3 words, lowercase.
348
682
  */
@@ -394,7 +728,288 @@ export function formatThemePhrase(rawCandidates, fallback = 'emerging signals')
394
728
  }
395
729
  return `${cleanedThemes[0]} and ${cleanedThemes[1]}`;
396
730
  }
397
- export function generateNextMoves(rows, query, searchedGraphs, status, onTopicCount, suggestedAction, catalog = getGraphs(), analysts = getAnalysts(), options) {
731
+ /**
732
+ * Cross-cutting or generic tech/strategy terms that appear across multiple domains.
733
+ * When evaluating domain fit, matching solely on these words does NOT constitute
734
+ * genuine domain overlap if the query contains a specific vertical or domain term.
735
+ */
736
+ export const CROSS_CUTTING_MODIFIERS = new Set([
737
+ 'ai', 'artificial intelligence', 'genai', 'tech', 'technology', 'trends', 'trend',
738
+ 'insights', 'insight', 'strategy', 'strategic', 'systems', 'system',
739
+ 'innovation', 'innovations', 'future', 'futures', 'management', 'data',
740
+ 'analytics', 'research', 'market', 'markets', 'consumer', 'consumers',
741
+ 'culture', 'experience', 'experiences', 'overview', 'summary', 'report',
742
+ 'industry', 'growth', 'landscape', 'benchmarks', 'benchmark'
743
+ ]);
744
+ /**
745
+ * Score and rank candidate experts for a query.
746
+ * Reuses the shared scoring spine: domain overlap, blind-spot exclusion,
747
+ * self-recommendation suppression, and honest failure.
748
+ */
749
+ export function findCandidateExperts(query, options) {
750
+ const rawAnalysts = options?.analysts || getAnalysts();
751
+ const activeAnalysts = rawAnalysts.filter(a => {
752
+ const st = (a.status || a.Status || '').toLowerCase().trim();
753
+ return !st || st === 'active' || st === 'unclaimed' || st === 'on_request' || st === 'on request';
754
+ });
755
+ const isCurrentAnalyst = (a) => {
756
+ const excludeId = options?.currentAnalystId || options?.excludeAnalystId;
757
+ if (!excludeId)
758
+ return false;
759
+ const cur = excludeId.toLowerCase().trim();
760
+ return (a.analyst_id && a.analyst_id.toLowerCase().trim() === cur) ||
761
+ (a.name && a.name.toLowerCase().trim() === cur);
762
+ };
763
+ const qLower = (query || '').toLowerCase().trim();
764
+ const queryTokens = specificQueryTokens(query);
765
+ const allQueryWords = qLower.split(/[^a-z0-9]+/).filter((w) => w.length >= 3 && !GENERIC_QUERY_TOKENS.has(w));
766
+ const queryDomainWords = allQueryWords.filter(w => !CROSS_CUTTING_MODIFIERS.has(w));
767
+ const isBrandOrCorporateQuery = Boolean(options?.isBrandTracker ||
768
+ /brand|financial|earnings|corporate|cmo|ceo|cfo|executive|revenue|margin|quarterly|investor|q[1-4]|balance sheet/i.test(qLower));
769
+ const isPhilosophicalQuery = /art\b|aesthetic|philosophy|philosophical|criticism|craftsmanship|harlem renaissance|aesthetic theory|ethics of art|ruskin|locke\b/i.test(qLower);
770
+ function getCategory(a) {
771
+ if (a.category)
772
+ return a.category;
773
+ if (a.is_human_agent || a.is_verified_real_person)
774
+ return 'human_agent';
775
+ if (a.is_c_suite_agent || /brand-(cmo|ceo|cfo)/i.test(a.analyst_id || ''))
776
+ return 'c_suite_agent';
777
+ if (a.is_classic_agent)
778
+ return 'classic_agent';
779
+ const sub = (a.graphSubType || a.graph_sub_type || a.type || a.kind || '').toString().toLowerCase();
780
+ if (sub.includes('classic'))
781
+ return 'classic_agent';
782
+ if (sub.includes('digital twin') || sub.includes('human'))
783
+ return 'human_agent';
784
+ if (sub.includes('c-suite') || sub.includes('executive'))
785
+ return 'c_suite_agent';
786
+ return 'synthetic_agent';
787
+ }
788
+ const searchedGraphs = options?.searchedGraphs || (options?.graphs ? options.graphs : getRelevantGraphs(query, 4, 15, 0.10));
789
+ const rowsByGraph = options?.rowsByGraph || new Map();
790
+ const scoredAnalysts = [];
791
+ for (const a of activeAnalysts) {
792
+ if (isCurrentAnalyst(a))
793
+ continue;
794
+ let baseScore = 0;
795
+ let directMatch = false;
796
+ let hasExplicitMatch = false;
797
+ const category = getCategory(a);
798
+ // Extract expert name and slug parts for direct identification and blindspot immunity
799
+ const aName = (a.name || '').toLowerCase().trim();
800
+ const aSlug = (a.analyst_id || '').toLowerCase().trim();
801
+ const nameParts = new Set([
802
+ ...aName.split(/\s+/).filter((p) => p.length >= 3),
803
+ ...aSlug.split(/[-_]+/).filter((p) => p.length >= 3)
804
+ ]);
805
+ // 1. Negative signal: Blind spot / outside_their_lane
806
+ const rawBlindSpots = [
807
+ ...(Array.isArray(a.outside_their_lane) ? a.outside_their_lane : (typeof a.outside_their_lane === 'string' ? [a.outside_their_lane] : [])),
808
+ ...(Array.isArray(a.blind_spots) ? a.blind_spots : (typeof a.blind_spots === 'string' ? [a.blind_spots] : [])),
809
+ ].join(' ').toLowerCase();
810
+ let hasBlindSpot = false;
811
+ if (rawBlindSpots.length > 0) {
812
+ for (const t of queryTokens) {
813
+ if (nameParts.has(t))
814
+ continue; // Never disqualify an expert for matching their own name or slug!
815
+ if (t.length >= 3 && rawBlindSpots.includes(t)) {
816
+ hasBlindSpot = true;
817
+ baseScore -= 20;
818
+ }
819
+ }
820
+ for (const w of allQueryWords) {
821
+ if (nameParts.has(w))
822
+ continue; // Never disqualify an expert for matching their own name or slug!
823
+ if (w.length >= 4 && rawBlindSpots.includes(w)) {
824
+ hasBlindSpot = true;
825
+ baseScore -= 10;
826
+ }
827
+ }
828
+ }
829
+ // 2. Positive signal: expert_in & what_they_offer
830
+ const expertIn = [
831
+ ...(Array.isArray(a.expert_in) ? a.expert_in : (typeof a.expert_in === 'string' ? [a.expert_in] : [])),
832
+ ...(Array.isArray(a.what_they_offer) ? a.what_they_offer : (typeof a.what_they_offer === 'string' ? [a.what_they_offer] : [])),
833
+ ].join(' ').toLowerCase();
834
+ if (expertIn.length > 0) {
835
+ if (qLower.length > 5 && expertIn.includes(qLower)) {
836
+ baseScore += 8;
837
+ hasExplicitMatch = true;
838
+ }
839
+ for (const t of queryTokens) {
840
+ if (expertIn.includes(t)) {
841
+ const isDomainToken = queryDomainWords.length === 0 || queryDomainWords.includes(t) || !CROSS_CUTTING_MODIFIERS.has(t);
842
+ baseScore += isDomainToken ? 4 : 1.5;
843
+ if (t.length >= 4 && isDomainToken)
844
+ hasExplicitMatch = true;
845
+ }
846
+ }
847
+ for (const w of allQueryWords) {
848
+ if (expertIn.includes(w)) {
849
+ const isDomainWord = queryDomainWords.length === 0 || queryDomainWords.includes(w) || !CROSS_CUTTING_MODIFIERS.has(w);
850
+ baseScore += isDomainWord ? 1.5 : 0.5;
851
+ if (w.length >= 4 && isDomainWord)
852
+ hasExplicitMatch = true;
853
+ }
854
+ }
855
+ }
856
+ // 2b. Positive signal: direct name or analyst_id match (for lookup/booking/hiring intent)
857
+ if (qLower.includes(aName) && aName.length >= 4) {
858
+ baseScore += 15;
859
+ hasExplicitMatch = true;
860
+ directMatch = true;
861
+ }
862
+ else if (aSlug && (qLower.includes(aSlug) || aSlug.includes(qLower))) {
863
+ baseScore += 15;
864
+ hasExplicitMatch = true;
865
+ directMatch = true;
866
+ }
867
+ else {
868
+ for (const part of nameParts) {
869
+ if (queryTokens.includes(part) || allQueryWords.includes(part)) {
870
+ baseScore += 10;
871
+ hasExplicitMatch = true;
872
+ directMatch = true;
873
+ break;
874
+ }
875
+ }
876
+ }
877
+ // 3. Positive signal: topics
878
+ const aTopics = (Array.isArray(a.topics) ? a.topics : []).map((t) => (typeof t === 'string' ? t.toLowerCase() : '')).filter(Boolean);
879
+ for (const tp of aTopics) {
880
+ if (qLower.includes(tp) || tp.includes(qLower)) {
881
+ baseScore += 5;
882
+ hasExplicitMatch = true;
883
+ }
884
+ for (const t of queryTokens) {
885
+ if (tp.includes(t) || t.includes(tp)) {
886
+ const isDomainToken = queryDomainWords.length === 0 || queryDomainWords.includes(t) || !CROSS_CUTTING_MODIFIERS.has(t);
887
+ baseScore += isDomainToken ? 3 : 1;
888
+ if (t.length >= 3 && tp.length >= 3 && isDomainToken)
889
+ hasExplicitMatch = true;
890
+ }
891
+ }
892
+ for (const w of allQueryWords) {
893
+ if (tp.includes(w) || w.includes(tp)) {
894
+ const isDomainWord = queryDomainWords.length === 0 || queryDomainWords.includes(w) || !CROSS_CUTTING_MODIFIERS.has(w);
895
+ baseScore += isDomainWord ? 1.5 : 0.5;
896
+ if (w.length >= 3 && tp.length >= 3 && isDomainWord)
897
+ hasExplicitMatch = true;
898
+ }
899
+ }
900
+ }
901
+ // 4. Positive signal: description (supporting context)
902
+ const desc = (typeof a.description === 'string' ? a.description : '').toLowerCase();
903
+ if (desc.length > 0) {
904
+ if (qLower.length > 5 && desc.includes(qLower)) {
905
+ baseScore += 4;
906
+ }
907
+ for (const t of queryTokens) {
908
+ if (desc.includes(t)) {
909
+ baseScore += 2;
910
+ }
911
+ }
912
+ for (const w of allQueryWords) {
913
+ if (desc.includes(w)) {
914
+ baseScore += 1;
915
+ }
916
+ }
917
+ }
918
+ // 5. Positive signal: Searched graphs & graph relevance scores
919
+ for (const g of searchedGraphs) {
920
+ const gid = (typeof g === 'string' ? g : (g.graph_id || g.id || '')).toLowerCase().trim();
921
+ const gCurator = (typeof g === 'object' && g.curator ? g.curator : '').toLowerCase().trim();
922
+ const isOwner = (gid && (gid === aSlug || gid.includes(aSlug) || aSlug.includes(gid))) ||
923
+ (gCurator && (gCurator === aName || gCurator.includes(aName) || aName.includes(gCurator)));
924
+ if (isOwner) {
925
+ // If graph has a router relevance score, use it directly.
926
+ // If unscored (e.g. broadcast or unranked search), award positive score ONLY if
927
+ // this graph actually returned matching results in rowsByGraph.
928
+ const rowsForGraph = rowsByGraph.get(gid) || (aSlug ? rowsByGraph.get(aSlug) : null) || [];
929
+ const relScore = (typeof g === 'object' && typeof g.relevanceScore === 'number')
930
+ ? g.relevanceScore
931
+ : (rowsForGraph.length > 0 ? 0.8 : 0);
932
+ if (relScore > 0) {
933
+ baseScore += relScore * 5.0;
934
+ hasExplicitMatch = true;
935
+ if (relScore >= 0.5)
936
+ directMatch = true;
937
+ }
938
+ }
939
+ }
940
+ let score = baseScore;
941
+ // ── Tier Intent Weighting ──
942
+ // Require base token relevance score >= 6 before applying any status preference boost
943
+ // so irrelevant profiles (sam-horn, taxtalk-online) are excluded on local fallback.
944
+ if (baseScore >= 6) {
945
+ if (category === 'c_suite_agent') {
946
+ if (isBrandOrCorporateQuery) {
947
+ score += 6.0;
948
+ }
949
+ else {
950
+ score -= 5.0;
951
+ }
952
+ }
953
+ else if (category === 'classic_agent') {
954
+ if (isPhilosophicalQuery) {
955
+ score += 5.0;
956
+ }
957
+ else {
958
+ score -= 10.0; // Classic thinkers must never outrank living practitioners on contemporary queries
959
+ }
960
+ }
961
+ else if (category === 'human_agent' || a.is_verified_real_person) {
962
+ score += 3.0; // Prioritize living human practitioners over synthetic agents
963
+ }
964
+ }
965
+ scoredAnalysts.push({ analyst: a, score, hasBlindSpot, hasExplicitMatch, directMatch });
966
+ }
967
+ // Require minimum threshold score (>= 6.0), explicit domain/topic match, and no disqualifying blind spot match
968
+ const MIN_EXPERT_FIT_SCORE = 6.0;
969
+ const validCandidates = scoredAnalysts
970
+ .filter(s => s.score >= MIN_EXPERT_FIT_SCORE && (s.directMatch || (s.hasExplicitMatch && !s.hasBlindSpot)))
971
+ .sort((a, b) => b.score - a.score);
972
+ const maxLimit = Math.min(Math.max(options?.limit || 3, 1), 3);
973
+ const topCandidates = validCandidates.slice(0, maxLimit);
974
+ const status = options?.status;
975
+ return topCandidates.map(c => {
976
+ const matchedAnalyst = c.analyst;
977
+ let lane = '';
978
+ const expertInStr = Array.isArray(matchedAnalyst.expert_in)
979
+ ? matchedAnalyst.expert_in.filter(Boolean).join(', ')
980
+ : (typeof matchedAnalyst.expert_in === 'string' ? matchedAnalyst.expert_in : '');
981
+ if (expertInStr.trim().length > 0) {
982
+ lane = truncateAtWordBoundary(expertInStr.trim(), 60);
983
+ }
984
+ else if (matchedAnalyst.description && matchedAnalyst.description.trim().length > 0) {
985
+ const firstClause = (matchedAnalyst.description.split(/[,.;]/)[0] || '').trim();
986
+ lane = truncateAtWordBoundary(firstClause, 60);
987
+ }
988
+ else if (matchedAnalyst.what_they_offer && typeof matchedAnalyst.what_they_offer === 'string' && matchedAnalyst.what_they_offer.trim().length > 0) {
989
+ lane = truncateAtWordBoundary(matchedAnalyst.what_they_offer.trim(), 60);
990
+ }
991
+ const reason = (status === 'thin' || status === 'empty')
992
+ ? (lane && lane.length > 3 ? `closest expert lane for ${lane}` : 'closest expert lane for this topic')
993
+ : (lane && lane.length > 3 ? `covers ${lane} directly` : 'covers this domain directly');
994
+ const cat = matchedAnalyst.category || getCategory(matchedAnalyst);
995
+ const statusRaw = (matchedAnalyst.status || matchedAnalyst.Status || '').toLowerCase().trim();
996
+ const isOnRequest = statusRaw === 'unclaimed' || statusRaw === 'on request' || statusRaw === 'on_request';
997
+ const expertStatus = isOnRequest ? 'on_request' : 'active';
998
+ const consult_tool = (matchedAnalyst.consult_tool === 'consult_human_agent' || matchedAnalyst.consult_tool === 'consult_analyst')
999
+ ? matchedAnalyst.consult_tool
1000
+ : (cat === 'human_agent' ? 'consult_human_agent' : 'consult_analyst');
1001
+ return {
1002
+ analyst_id: matchedAnalyst.analyst_id,
1003
+ display_name: cleanDisplayName(matchedAnalyst.name),
1004
+ category: cat,
1005
+ status: expertStatus,
1006
+ consult_tool,
1007
+ reason,
1008
+ out_of_lane: false
1009
+ };
1010
+ });
1011
+ }
1012
+ export async function generateNextMoves(rows, query, searchedGraphs, status, onTopicCount, suggestedAction, catalog = getGraphs(), analysts = getAnalysts(), options) {
398
1013
  const nextMoves = {
399
1014
  scope_prompt: true,
400
1015
  presentation: 'internal',
@@ -530,97 +1145,102 @@ export function generateNextMoves(rows, query, searchedGraphs, status, onTopicCo
530
1145
  }
531
1146
  }
532
1147
  else {
533
- // Empty brand footprint: pick top domain graph from catalog without keyword-matching on bare brand name
534
- const domainGraphs = catalog.filter(g => g.graph_type === 'domain' || !g.curator);
535
- const topDomain = domainGraphs[0] || catalog[0];
536
- if (topDomain) {
537
- const topDisplay = buildDisplayName(topDomain);
1148
+ // Empty brand footprint: try to find relevant unsearched domain graph if score >= 0.10
1149
+ let relevantCandidates = [];
1150
+ try {
1151
+ relevantCandidates = getRelevantGraphs(query);
1152
+ }
1153
+ catch {
1154
+ relevantCandidates = [];
1155
+ }
1156
+ const unsearchedDomain = relevantCandidates.find(cand => cand.graph && !searchedGraphIds.has(cand.graph.graph_id) && (cand.score ?? 0) >= 0.10)?.graph;
1157
+ const primarySearched = searchedGraphs[0];
1158
+ const primaryGid = typeof primarySearched === 'string' ? primarySearched : primarySearched?.graph_id;
1159
+ const primaryMeta = catalog.find(g => g.graph_id === primaryGid);
1160
+ const primaryDisplay = primaryMeta ? buildDisplayName(primaryMeta) : (primaryGid || 'the graph');
1161
+ if (unsearchedDomain) {
1162
+ const topDisplay = buildDisplayName(unsearchedDomain);
538
1163
  nextMoves.thread = {
539
1164
  kind: 'honest_thin',
540
- graph_id: topDomain.graph_id,
541
- graph_display: topDisplay,
1165
+ graph_id: primaryGid || unsearchedDomain.graph_id,
1166
+ graph_display: primaryDisplay,
542
1167
  adjacent: {
543
- graph_id: topDomain.graph_id,
1168
+ graph_id: unsearchedDomain.graph_id,
544
1169
  graph_display: topDisplay,
545
- reason: topDomain.headline || topDomain.domain || topDomain.name,
1170
+ reason: unsearchedDomain.headline || unsearchedDomain.domain || unsearchedDomain.name,
546
1171
  }
547
1172
  };
548
1173
  }
1174
+ else {
1175
+ nextMoves.thread = {
1176
+ kind: 'honest_thin',
1177
+ graph_id: primaryGid,
1178
+ graph_display: primaryDisplay,
1179
+ text: `That's what Fodda holds on this right now.`,
1180
+ };
1181
+ }
549
1182
  }
550
1183
  }
551
- else if (maxRemainder > 0 && bestGraphId) {
552
- const gMeta = catalog.find(g => g.graph_id === bestGraphId);
553
- const display = gMeta ? buildDisplayName(gMeta) : bestGraphId;
554
- nextMoves.thread = {
555
- kind: 'more_in_graph',
556
- graph_id: bestGraphId,
557
- graph_display: display,
558
- remaining_count: maxRemainder,
559
- theme: bestGraphTheme,
560
- };
561
- }
562
- else if (status === 'thin' || status === 'empty') {
563
- // honest_thin ONLY when status is thin or empty
564
- let relevantCandidates = [];
565
- try {
566
- relevantCandidates = getRelevantGraphs(query);
567
- }
568
- catch {
569
- relevantCandidates = [];
570
- }
571
- const candidateGraphs = relevantCandidates.map(c => c.graph).filter(Boolean);
572
- if (candidateGraphs.length === 0 && catalog?.length > 0) {
573
- candidateGraphs.push(...catalog);
574
- }
575
- const closest = candidateGraphs.find(g => !searchedGraphIds.has(g.graph_id)) || candidateGraphs[0];
576
- if (closest) {
577
- const closestDisplay = buildDisplayName(closest);
578
- const primarySearched = searchedGraphs[0];
579
- const primaryGid = typeof primarySearched === 'string' ? primarySearched : primarySearched?.graph_id;
580
- const primaryMeta = catalog.find(g => g.graph_id === primaryGid);
581
- const primaryDisplay = primaryMeta ? buildDisplayName(primaryMeta) : (primaryGid || closestDisplay);
1184
+ else {
1185
+ const bestMeta = bestGraphId ? catalog.find(g => g.graph_id === bestGraphId) : undefined;
1186
+ const trendCount = bestMeta?.trend_count;
1187
+ const gOnTopicTotal = bestGraphId
1188
+ ? (rowsByGraph.get(bestGraphId)?.[0]?.on_topic_total ?? options?.onTopicTotal ?? (maxRemainder + rows.length))
1189
+ : undefined;
1190
+ const isSmallReportOrExhausted = (typeof trendCount === 'number' && trendCount > 0 && trendCount <= 15) ||
1191
+ (typeof trendCount === 'number' && trendCount > 0 && typeof gOnTopicTotal === 'number' && gOnTopicTotal >= 0.6 * trendCount);
1192
+ const canEmitMoreInGraph = maxRemainder > 0 && bestGraphId && !isSmallReportOrExhausted;
1193
+ if (canEmitMoreInGraph && bestGraphId) {
1194
+ const display = bestMeta ? buildDisplayName(bestMeta) : bestGraphId;
582
1195
  nextMoves.thread = {
583
- kind: 'honest_thin',
584
- graph_id: primaryGid || closest.graph_id,
585
- graph_display: primaryDisplay,
586
- adjacent: {
587
- graph_id: closest.graph_id,
588
- graph_display: closestDisplay,
589
- reason: closest.headline || closest.domain || closest.name,
590
- }
1196
+ kind: 'more_in_graph',
1197
+ graph_id: bestGraphId,
1198
+ graph_display: display,
1199
+ remaining_count: maxRemainder,
1200
+ theme: bestGraphTheme,
591
1201
  };
592
1202
  }
593
- // If no candidate exists, thread is undefined (no fabrication)
594
- }
595
- else {
596
- // status is 'ok' with 0 remainder everywhere: check for unsearched adjacent room
597
- let relevantCandidates = [];
598
- try {
599
- relevantCandidates = getRelevantGraphs(query);
600
- }
601
- catch {
602
- relevantCandidates = [];
603
- }
604
- const unsearched = relevantCandidates.find(cand => cand.graph && !searchedGraphIds.has(cand.graph.graph_id) && (cand.score ?? 0) >= 0.10);
605
- if (unsearched) {
606
- const adjG = unsearched.graph;
607
- const adjDisplay = buildDisplayName(adjG);
1203
+ else {
1204
+ // Fall through to unsearched adjacent_room or honest_thin
1205
+ let relevantCandidates = [];
1206
+ try {
1207
+ relevantCandidates = getRelevantGraphs(query);
1208
+ }
1209
+ catch {
1210
+ relevantCandidates = [];
1211
+ }
1212
+ const unsearchedCandidate = relevantCandidates.find(cand => cand.graph && !searchedGraphIds.has(cand.graph.graph_id) && (cand.score ?? 0) >= 0.10);
608
1213
  const primarySearched = searchedGraphs[0];
609
1214
  const primaryGid = typeof primarySearched === 'string' ? primarySearched : primarySearched?.graph_id;
610
1215
  const primaryMeta = catalog.find(g => g.graph_id === primaryGid);
611
- const primaryDisplay = primaryMeta ? buildDisplayName(primaryMeta) : (primaryGid || adjDisplay);
612
- nextMoves.thread = {
613
- kind: 'adjacent_room',
614
- graph_id: primaryGid || adjG.graph_id,
615
- graph_display: primaryDisplay,
616
- adjacent: {
617
- graph_id: adjG.graph_id,
618
- graph_display: adjDisplay,
619
- reason: adjG.headline || adjG.domain || adjG.description || adjG.name,
620
- }
621
- };
1216
+ const primaryDisplay = primaryMeta ? buildDisplayName(primaryMeta) : (primaryGid || 'the graph');
1217
+ if (unsearchedCandidate) {
1218
+ const adjG = unsearchedCandidate.graph;
1219
+ const adjDisplay = buildDisplayName(adjG);
1220
+ nextMoves.thread = {
1221
+ kind: (status === 'thin' || status === 'empty' || isSmallReportOrExhausted) ? 'honest_thin' : 'adjacent_room',
1222
+ graph_id: primaryGid || adjG.graph_id,
1223
+ graph_display: primaryDisplay,
1224
+ adjacent: {
1225
+ graph_id: adjG.graph_id,
1226
+ graph_display: adjDisplay,
1227
+ reason: adjG.headline || adjG.domain || adjG.description || adjG.name,
1228
+ }
1229
+ };
1230
+ }
1231
+ else if (status === 'thin' || status === 'empty' || isSmallReportOrExhausted) {
1232
+ // Honest thin without hallucinated adjacent graph: never fabricate catalog[0] or blind unsearched fallback
1233
+ nextMoves.thread = {
1234
+ kind: 'honest_thin',
1235
+ graph_id: primaryGid,
1236
+ graph_display: primaryDisplay,
1237
+ text: `That's what Fodda holds on this right now.`,
1238
+ };
1239
+ }
1240
+ else {
1241
+ nextMoves.thread = undefined;
1242
+ }
622
1243
  }
623
- // If no unsearched relevant room, thread is undefined (drop the line, no fabrication)
624
1244
  }
625
1245
  // ── 2. Specific (Go specific: brands, statistics_source, expert) ──
626
1246
  const specific = {};
@@ -635,39 +1255,61 @@ export function generateNextMoves(rows, query, searchedGraphs, status, onTopicCo
635
1255
  else {
636
1256
  const tokens = specificQueryTokens(query);
637
1257
  const onTopicRows = rows.filter(r => rowMatchesQueryTokens(r, tokens, catalog) || (rowScore(r) >= 0.75 * (TIER_NOMINAL_SCORE[resolveRowTier(r, searchedGraphs, catalog)] ?? 0.8)));
638
- const brandSourceRows = onTopicRows.length > 0 ? onTopicRows : rows;
639
- const brandCounts = new Map();
640
- for (const r of brandSourceRows) {
641
- const brandList = [];
642
- if (Array.isArray(r.brandNames))
643
- brandList.push(...r.brandNames);
644
- if (Array.isArray(r.brands))
645
- brandList.push(...r.brands);
646
- if (typeof r.brand === 'string' && r.brand)
647
- brandList.push(r.brand);
648
- if (typeof r.company === 'string' && r.company)
649
- brandList.push(r.company);
650
- if (Array.isArray(r.entities?.brands))
651
- brandList.push(...r.entities.brands);
652
- for (const b of brandList) {
653
- if (typeof b === 'string' && b.trim().length > 1) {
654
- const cleaned = b.trim();
655
- brandCounts.set(cleaned, (brandCounts.get(cleaned) || 0) + 1);
1258
+ // If no on-topic rows exist, leave specific.brands undefined (do not fall back to off-topic noise rows)
1259
+ if (onTopicRows.length > 0) {
1260
+ const publisherTokens = buildPublisherExclusionSet(searchedGraphs, catalog, onTopicRows);
1261
+ const brandCounts = new Map();
1262
+ for (const r of onTopicRows) {
1263
+ if (!isRowBrandEligible(r))
1264
+ continue;
1265
+ const brandList = [];
1266
+ if (Array.isArray(r.brandNames))
1267
+ brandList.push(...r.brandNames);
1268
+ else if (typeof r.brandNames === 'string')
1269
+ brandList.push(...r.brandNames.split('|'));
1270
+ if (Array.isArray(r.brands))
1271
+ brandList.push(...r.brands);
1272
+ if (typeof r.brand === 'string' && r.brand)
1273
+ brandList.push(r.brand);
1274
+ if (typeof r.Brand === 'string' && r.Brand)
1275
+ brandList.push(...r.Brand.split('|'));
1276
+ else if (Array.isArray(r.Brand))
1277
+ brandList.push(...r.Brand);
1278
+ if (typeof r.company === 'string' && r.company)
1279
+ brandList.push(r.company);
1280
+ if (Array.isArray(r.entities?.brands))
1281
+ brandList.push(...r.entities.brands);
1282
+ for (const b of brandList) {
1283
+ if (typeof b === 'string' && b.trim().length > 1) {
1284
+ const clean = b.trim();
1285
+ const norm = normalizeBrandKey(clean);
1286
+ if (!norm)
1287
+ continue;
1288
+ if (isPublisherBrand(clean, publisherTokens))
1289
+ continue;
1290
+ const existing = brandCounts.get(norm);
1291
+ if (existing) {
1292
+ existing.count++;
1293
+ }
1294
+ else {
1295
+ brandCounts.set(norm, { count: 1, displayName: clean });
1296
+ }
1297
+ }
656
1298
  }
657
1299
  }
658
- }
659
- if (brandCounts.size > 0) {
660
- const sortedBrands = [...brandCounts.entries()]
661
- .filter(([b]) => {
662
- const bLow = b.toLowerCase();
1300
+ if (brandCounts.size > 0) {
663
1301
  const qLow = query.toLowerCase();
664
- return bLow !== qLow && !qLow.includes(bLow) && !bLow.includes(qLow);
665
- })
666
- .sort((a, b) => b[1] - a[1])
667
- .map(([brand]) => brand)
668
- .slice(0, 2);
669
- if (sortedBrands.length > 0) {
670
- specific.brands = sortedBrands;
1302
+ const sortedBrands = [...brandCounts.values()]
1303
+ .filter(b => {
1304
+ const bLow = b.displayName.toLowerCase();
1305
+ return bLow !== qLow && !qLow.includes(bLow) && !bLow.includes(qLow);
1306
+ })
1307
+ .sort((a, b) => b.count - a.count)
1308
+ .map(b => b.displayName)
1309
+ .slice(0, 2);
1310
+ if (sortedBrands.length > 0) {
1311
+ specific.brands = sortedBrands;
1312
+ }
671
1313
  }
672
1314
  }
673
1315
  }
@@ -699,65 +1341,155 @@ export function generateNextMoves(rows, query, searchedGraphs, status, onTopicCo
699
1341
  else {
700
1342
  const qLower = query.toLowerCase();
701
1343
  const isStatOrMarketShaped = suggestedAction !== undefined ||
1344
+ status === 'thin' ||
1345
+ status === 'empty' ||
702
1346
  isDemandShaped(query) ||
703
1347
  options?.knownBrand !== undefined ||
704
1348
  (specific.brands && specific.brands.length > 0) ||
705
1349
  /(?:market|spend|sales|growth|size|volume|rate|adoption|share|stats?|numbers?|forecast|demographics?|economic|inflation|pricing|consumer|retail|cpg|beauty|fashion|auto|tech|work|travel|food|drink|beverage|culture|trends?|brand|performance|activity)/i.test(qLower);
706
1350
  if (isStatOrMarketShaped) {
707
- if (/(?:retail|spend|sales|commerce|shopping|store|grocery|cpg|consumer)/i.test(qLower)) {
708
- specific.statistics_source = 'Census retail trade and spending data';
709
- }
710
- else if (/(?:search|demand|interest|popular|google|volume|buzz|social)/i.test(qLower)) {
711
- specific.statistics_source = 'Google Trends search volume and breakout queries';
712
- }
713
- else if (/(?:employment|labor|job|wage|worker|workplace|talent|hiring)/i.test(qLower)) {
714
- specific.statistics_source = 'BLS labor and employment metrics';
715
- }
716
- else if (/(?:economic|inflation|cpi|interest\s+rate|gdp|macro|fed|recession)/i.test(qLower)) {
717
- specific.statistics_source = 'FRED macroeconomic series';
718
- }
719
- else if (/(?:beauty|fashion|apparel|luxury|sport|wellness|food|beverage)/i.test(qLower)) {
720
- specific.statistics_source = 'Census and Google Trends market demand data';
1351
+ let suggestHandled = false;
1352
+ if (options?.foddaRequest || options?.suggestFn || options?.suggestPromise) {
1353
+ const sources = await fetchSupplementalSuggest(query, options);
1354
+ if (sources && sources.length > 0) {
1355
+ const validSources = sources.filter(s => s && s.returns_draft !== true && s.name);
1356
+ if (validSources.length > 0) {
1357
+ const topSources = validSources.slice(0, 2);
1358
+ if (topSources.length === 2 && topSources[0] && topSources[1]) {
1359
+ specific.statistics_source = `${topSources[0].name} and ${topSources[1].name}`;
1360
+ }
1361
+ else if (topSources[0]) {
1362
+ specific.statistics_source = topSources[0].name;
1363
+ }
1364
+ suggestHandled = true;
1365
+ options?.sessionTracker?.recordSuggestPath?.('suggest');
1366
+ console.error(`[next_moves] statistics_source from suggest: "${specific.statistics_source}"`);
1367
+ }
1368
+ }
721
1369
  }
722
- else {
723
- specific.statistics_source = 'Census and FRED market statistics';
1370
+ if (!suggestHandled) {
1371
+ options?.sessionTracker?.recordSuggestPath?.('regex-fallback');
1372
+ console.error(`[next_moves] statistics_source from regex-fallback`);
1373
+ if (/(?:retail|spend|sales|commerce|shopping|store|grocery|cpg|consumer)/i.test(qLower)) {
1374
+ specific.statistics_source = 'Census retail trade and spending data';
1375
+ }
1376
+ else if (/(?:search|demand|interest|popular|google|volume|buzz|social)/i.test(qLower)) {
1377
+ specific.statistics_source = 'Google Trends search volume and breakout queries';
1378
+ }
1379
+ else if (/(?:employment|labor|job|wage|worker|workplace|talent|hiring)/i.test(qLower)) {
1380
+ specific.statistics_source = 'BLS labor and employment metrics';
1381
+ }
1382
+ else if (/(?:economic|inflation|cpi|interest\s+rate|gdp|macro|fed|recession)/i.test(qLower)) {
1383
+ specific.statistics_source = 'FRED macroeconomic series';
1384
+ }
1385
+ else if (/(?:beauty|fashion|apparel|luxury|sport|wellness|food|beverage)/i.test(qLower)) {
1386
+ specific.statistics_source = 'Census and Google Trends market demand data';
1387
+ }
1388
+ else {
1389
+ specific.statistics_source = 'Census and FRED market statistics';
1390
+ }
724
1391
  }
725
1392
  }
726
1393
  }
727
- // Expert: first Active analyst whose graph was searched or scored >= threshold in routing
728
- const activeAnalysts = analysts.filter(a => {
729
- const st = (a.status || a.Status || '').toLowerCase().trim();
730
- return !st || st === 'active';
731
- });
732
- const isCurrentAnalyst = (a) => {
733
- if (!options?.currentAnalystId)
734
- return false;
735
- const cur = options.currentAnalystId.toLowerCase().trim();
736
- return (a.analyst_id && a.analyst_id.toLowerCase().trim() === cur) ||
737
- (a.name && a.name.toLowerCase().trim() === cur);
738
- };
739
- const matchedAnalyst = activeAnalysts.find(a => {
740
- if (isCurrentAnalyst(a))
741
- return false;
742
- const aSlug = (a.analyst_id || '').toLowerCase().trim();
743
- if (searchedGraphIds.has(aSlug))
744
- return true;
745
- const aName = (a.name || '').toLowerCase();
746
- const aDesc = (a.description || '').toLowerCase();
747
- if (tokens.some(t => aName.includes(t) || aDesc.includes(t)))
748
- return true;
749
- return false;
1394
+ // Expert: scored lane-fit picker over active analysts using shared findCandidateExperts
1395
+ const candidateExperts = findCandidateExperts(query, {
1396
+ analysts,
1397
+ graphs: catalog,
1398
+ limit: 1,
1399
+ currentAnalystId: options?.currentAnalystId,
1400
+ searchedGraphs,
1401
+ rowsByGraph,
1402
+ status,
1403
+ isBrandTracker: options?.isBrandTracker
750
1404
  });
751
- if (matchedAnalyst) {
752
- specific.expert = {
753
- analyst_id: matchedAnalyst.analyst_id,
754
- display_name: matchedAnalyst.name,
755
- reason: matchedAnalyst.description ? `covers ${matchedAnalyst.description.slice(0, 50).trim()} directly` : 'covers this domain directly',
756
- };
1405
+ if (candidateExperts.length > 0 && candidateExperts[0]) {
1406
+ specific.expert = candidateExperts[0];
757
1407
  }
758
1408
  if (Object.keys(specific).length > 0) {
759
1409
  nextMoves.specific = specific;
760
1410
  }
1411
+ nextMoves.scope = options?.knownBrand
1412
+ ? `Want this cut to ${options.knownBrand} specifically?`
1413
+ : `If you tell me the brand or brief you're working on, I'll cut this to that.`;
1414
+ const cleanTopic = options?.knownBrand || options?.brandDisplayName || nextMoves.thread?.theme || query.trim();
1415
+ const isOnRequestExpert = specific.expert?.status === 'on_request';
1416
+ const isExpertHuman = specific.expert?.category === 'human_agent' || specific.expert?.consult_tool === 'consult_human_agent';
1417
+ let expertTargetTool = 'find_expert';
1418
+ let expertName = 'Consult specialist';
1419
+ let expertDescription = 'Get practitioner judgment and domain depth on this topic.';
1420
+ let expertReason = 'Consult a domain specialist to interpret qualitative nuances.';
1421
+ let expertPrompt = `Who should I consult about ${cleanTopic}?`;
1422
+ let expertParams = { query: cleanTopic };
1423
+ if (specific.expert) {
1424
+ if (isOnRequestExpert) {
1425
+ expertTargetTool = 'request_expert_intro';
1426
+ expertName = `Request intro to ${specific.expert.display_name}`;
1427
+ expertDescription = 'Request an advisory introduction or consultation with this prospective Human Agent.';
1428
+ expertReason = `Connect with ${specific.expert.display_name} for human advisory or consultation.`;
1429
+ expertPrompt = `Request an introduction or advisory consultation with ${specific.expert.display_name} regarding ${cleanTopic}.`;
1430
+ expertParams = { expert_id: specific.expert.analyst_id, query: cleanTopic };
1431
+ }
1432
+ else if (isExpertHuman) {
1433
+ expertTargetTool = 'consult_human_agent';
1434
+ expertName = `Consult ${specific.expert.display_name}`;
1435
+ expertDescription = 'Consult this verified Human Agent twin for practitioner depth and proprietary frameworks.';
1436
+ expertReason = `Consult ${specific.expert.display_name} for authoritative interpretation and practitioner depth.`;
1437
+ expertPrompt = `Ask ${specific.expert.display_name} about ${cleanTopic}`;
1438
+ expertParams = { analyst_id: specific.expert.analyst_id, query: `How should we evaluate the strategic opportunity and risks in ${cleanTopic}?` };
1439
+ }
1440
+ else {
1441
+ expertTargetTool = specific.expert.consult_tool || 'consult_analyst';
1442
+ expertName = `Consult ${specific.expert.display_name}`;
1443
+ expertDescription = 'Consult this specialist analyst for domain intelligence.';
1444
+ expertReason = `Consult ${specific.expert.display_name} for authoritative interpretation and practitioner depth.`;
1445
+ expertPrompt = `Ask ${specific.expert.display_name} about ${cleanTopic}`;
1446
+ expertParams = { analyst_id: specific.expert.analyst_id, query: cleanTopic };
1447
+ }
1448
+ }
1449
+ const actions = [
1450
+ {
1451
+ action: 'pressure_test',
1452
+ name: 'Pressure-test thesis',
1453
+ description: 'Evaluate assertions against counter-evidence and executive divergence.',
1454
+ reason: 'Evaluate whether findings rely on untested assumptions',
1455
+ target_tool: 'verify_market_claim',
1456
+ tool_display_name: 'Market Claim Verifier',
1457
+ suggested_prompt: `Pressure-test whether the market momentum around ${cleanTopic} holds up against counter-evidence.`,
1458
+ suggested_parameters: { claim: `Pressure-test whether ${cleanTopic} strategy and market momentum holds up` },
1459
+ available: true,
1460
+ },
1461
+ {
1462
+ action: 'ask_expert',
1463
+ name: expertName,
1464
+ description: expertDescription,
1465
+ reason: expertReason,
1466
+ target_tool: expertTargetTool,
1467
+ suggested_prompt: expertPrompt,
1468
+ suggested_parameters: expertParams,
1469
+ available: true,
1470
+ },
1471
+ {
1472
+ action: 'create_brief',
1473
+ name: 'Commission executive brief',
1474
+ description: 'Turn this intelligence into a finished strategic deliverable.',
1475
+ reason: `Package verified evidence, quantitative statistics, and executive quotes on ${cleanTopic} into a deliverable.`,
1476
+ target_tool: 'request_deliverable',
1477
+ suggested_prompt: `Commission an executive brief on ${cleanTopic}`,
1478
+ suggested_parameters: { skill_slug: 'research_brief', brief: `Synthesize verified findings, market metrics, and strategic implications for ${cleanTopic}` },
1479
+ available: true,
1480
+ },
1481
+ {
1482
+ action: 'track_topic',
1483
+ name: 'Track topic shifts',
1484
+ description: 'Establish automated weekly monitoring on this subject.',
1485
+ reason: `Detect newly emerging signals, shifts in executive tone, and trend momentum on ${cleanTopic} weekly.`,
1486
+ target_tool: 'manage_scheduled_reports',
1487
+ suggested_prompt: `Set up weekly tracking on ${cleanTopic}`,
1488
+ suggested_parameters: { action: 'create', topic: cleanTopic, cadence: 'weekly' },
1489
+ available: true,
1490
+ },
1491
+ ];
1492
+ nextMoves.actions = actions;
761
1493
  return nextMoves;
762
1494
  }
763
1495
  /**
@@ -828,13 +1560,16 @@ export function generateConsultNextMoves(result, query, analystId, options, cata
828
1560
  }
829
1561
  }
830
1562
  else {
831
- // Next angle token check per §2.A.5: must share >=1 content token (>=3 chars) with sources_used or uncited_themes
1563
+ // Next angle token check per §2.A.5: must share >=1 content token (>=3 chars) with sources_used, uncited_themes, query, or expert topics
832
1564
  let nextAngleValid = false;
833
1565
  if (typeof nextAngleRaw === 'string' && nextAngleRaw.trim().length > 0) {
834
1566
  const angleTokens = specificQueryTokens(nextAngleRaw);
835
- const sourceTitles = (result?.sources_used || []).map((s) => (s.title || s.name || '').toLowerCase());
1567
+ const sourceTitles = (result?.sources_used || [])
1568
+ .filter((s) => s.origin !== 'profile' && !(s.title || '').includes('Official Profile'))
1569
+ .map((s) => (s.title || s.name || '').toLowerCase());
836
1570
  const themeTexts = uncitedThemes.map((t) => t.toLowerCase());
837
- const groundingCorpus = [...sourceTitles, ...themeTexts].join(' ');
1571
+ const expertTopics = Array.isArray(matchedAnalyst?.topics) ? matchedAnalyst.topics.join(' ').toLowerCase() : '';
1572
+ const groundingCorpus = [...sourceTitles, ...themeTexts, expertTopics, query.toLowerCase()].join(' ');
838
1573
  const hasSharedToken = angleTokens.some(tok => tok.length >= 3 && groundingCorpus.includes(tok));
839
1574
  if (hasSharedToken || (sourceTitles.length === 0 && themeTexts.length === 0)) {
840
1575
  nextAngleValid = true;
@@ -905,7 +1640,8 @@ export function generateConsultNextMoves(result, query, analystId, options, cata
905
1640
  if (matchedAnalyst.slug)
906
1641
  expertOwnIds.add(String(matchedAnalyst.slug).toLowerCase());
907
1642
  }
908
- const SHELF_RELEVANCE_FLOOR = 0.10;
1643
+ const SHELF_RELEVANCE_FLOOR = 0.50;
1644
+ const queryTokens = specificQueryTokens(query);
909
1645
  const shelfCandidateGraphs = [];
910
1646
  for (const cand of relevantCandidates) {
911
1647
  if ((cand.score ?? 0) < SHELF_RELEVANCE_FLOOR)
@@ -920,6 +1656,26 @@ export function generateConsultNextMoves(result, query, analystId, options, cata
920
1656
  continue;
921
1657
  if (g.name && g.name.toLowerCase() === expertDisplayName.toLowerCase())
922
1658
  continue;
1659
+ // Shelf on an expert consult must only suggest published research/trend graphs (report, domain),
1660
+ // never other individual expert agents or classic historical figures.
1661
+ const gType = (g.graph_type || '').toLowerCase();
1662
+ if (gType === 'expert' || gType === 'analyst' || cand.graphTier === 'static_expert' || cand.graphTier === 'living') {
1663
+ continue;
1664
+ }
1665
+ // Must match at least 2 distinct domain tokens from the query, or have a direct topic match
1666
+ let tokenMatchCount = 0;
1667
+ if (queryTokens.length > 0) {
1668
+ const graphText = `${g.name || ''} ${g.domain || ''} ${(Array.isArray(g.topics) ? g.topics : []).join(' ')} ${g.headline || ''} ${g.one_liner || ''} ${g.description || ''}`.toLowerCase();
1669
+ const words = new Set(graphText.split(/[^a-z0-9]+/));
1670
+ for (const t of queryTokens) {
1671
+ if (t.length >= 3 && words.has(t)) {
1672
+ tokenMatchCount++;
1673
+ }
1674
+ }
1675
+ }
1676
+ const minMatchesNeeded = queryTokens.length >= 2 ? 2 : 1;
1677
+ if (tokenMatchCount < minMatchesNeeded)
1678
+ continue;
923
1679
  if (shelfCandidateGraphs.length < 2 && !shelfCandidateGraphs.some(sg => sg.graph_id === g.graph_id)) {
924
1680
  shelfCandidateGraphs.push(g);
925
1681
  }
@@ -948,8 +1704,12 @@ export function generateConsultNextMoves(result, query, analystId, options, cata
948
1704
  shelfSentence += `.`;
949
1705
  }
950
1706
  }
1707
+ else if (hasDeliverableOffering) {
1708
+ shelfSentence = `${expertDisplayName} takes scoped briefs on this if you need a deliverable.`;
1709
+ nextMoves.shelf = undefined;
1710
+ }
951
1711
  else {
952
- // Empty candidate list — omit sentence 2 per Brief 1.46.29
1712
+ // Empty candidate list and no deliverable offerings — omit sentence 2 per Brief 1.46.29
953
1713
  shelfSentence = '';
954
1714
  nextMoves.shelf = undefined;
955
1715
  }
@@ -964,7 +1724,7 @@ export function generateConsultNextMoves(result, query, analystId, options, cata
964
1724
  const topRef = result.referrals[0];
965
1725
  specific.expert = {
966
1726
  analyst_id: topRef.id || topRef.analyst_id || topRef.slug,
967
- display_name: topRef.name || topRef.curator,
1727
+ display_name: cleanDisplayName(topRef.name || topRef.curator),
968
1728
  reason: topRef.reason || 'related expertise',
969
1729
  };
970
1730
  }
@@ -979,11 +1739,46 @@ export function generateConsultNextMoves(result, query, analystId, options, cata
979
1739
  else {
980
1740
  scopeSentence = `If you tell me the brand or brief you're working on, I'll cut this to that.`;
981
1741
  }
1742
+ nextMoves.scope = scopeSentence;
982
1743
  nextMoves.consult_envelope = {
983
1744
  thread_line: threadSentence,
984
1745
  shelf_line: shelfSentence || undefined,
985
1746
  scope_line: scopeSentence,
986
1747
  };
1748
+ const consultActions = [
1749
+ {
1750
+ action: 'pressure_test',
1751
+ name: 'Pressure-test perspective',
1752
+ description: "Verify expert assertions and claims against broader market data.",
1753
+ reason: `Verify ${expertDisplayName}'s perspective against empirical market evidence and executive divergence.`,
1754
+ target_tool: 'verify_market_claim',
1755
+ tool_display_name: 'Market Claim Verifier',
1756
+ suggested_prompt: `Pressure-test ${expertDisplayName}'s perspective on ${query.trim()}`,
1757
+ suggested_parameters: { claim: `Pressure-test ${expertDisplayName}'s thesis and assertions on ${query.trim()}` },
1758
+ available: true,
1759
+ },
1760
+ {
1761
+ action: 'create_brief',
1762
+ name: 'Commission deliverable',
1763
+ description: `Commission a finished deliverable from ${expertDisplayName}.`,
1764
+ reason: `Turn ${expertDisplayName}'s insights and framework into a formatted deliverable.`,
1765
+ target_tool: 'request_deliverable',
1766
+ suggested_prompt: `Commission a strategic briefing from ${expertDisplayName} on ${query.trim()}`,
1767
+ suggested_parameters: { analyst_id: cleanAnalystId || expertGraphId, skill_slug: 'research_brief', brief: `Strategic brief incorporating ${expertDisplayName}'s framework on ${query.trim()}` },
1768
+ available: true,
1769
+ },
1770
+ {
1771
+ action: 'track_topic',
1772
+ name: 'Track topic shifts',
1773
+ description: 'Establish automated weekly monitoring on this subject.',
1774
+ reason: `Monitor new signals and developments in ${expertDisplayName}'s coverage area.`,
1775
+ target_tool: 'manage_scheduled_reports',
1776
+ suggested_prompt: `Set up weekly tracking on ${query.trim()}`,
1777
+ suggested_parameters: { action: 'create', topic: query.trim(), cadence: 'weekly' },
1778
+ available: true,
1779
+ },
1780
+ ];
1781
+ nextMoves.actions = consultActions;
987
1782
  return nextMoves;
988
1783
  }
989
1784
  /**