fodda-mcp 1.38.0 → 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 (346) hide show
  1. package/README.md +14 -91
  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 +278 -83
  8. package/dist/brandTemplate.js.map +1 -1
  9. package/dist/catalogCache.d.ts +39 -0
  10. package/dist/catalogCache.d.ts.map +1 -1
  11. package/dist/catalogCache.js +293 -22
  12. package/dist/catalogCache.js.map +1 -1
  13. package/dist/coverageRelevance.d.ts +251 -0
  14. package/dist/coverageRelevance.d.ts.map +1 -0
  15. package/dist/coverageRelevance.js +1857 -0
  16. package/dist/coverageRelevance.js.map +1 -0
  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 +442 -29
  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 +411 -106
  32. package/dist/index.js.map +1 -1
  33. package/dist/pricingCache.d.ts.map +1 -1
  34. package/dist/pricingCache.js +55 -3
  35. package/dist/pricingCache.js.map +1 -1
  36. package/dist/queryCache.d.ts.map +1 -1
  37. package/dist/queryCache.js +2 -0
  38. package/dist/queryCache.js.map +1 -1
  39. package/dist/reportBriefing.d.ts +89 -0
  40. package/dist/reportBriefing.d.ts.map +1 -0
  41. package/dist/reportBriefing.js +662 -0
  42. package/dist/reportBriefing.js.map +1 -0
  43. package/dist/searchTemplate.d.ts.map +1 -1
  44. package/dist/searchTemplate.js +65 -53
  45. package/dist/searchTemplate.js.map +1 -1
  46. package/dist/services/graphRouter.d.ts +7 -0
  47. package/dist/services/graphRouter.d.ts.map +1 -0
  48. package/dist/services/graphRouter.js +7 -0
  49. package/dist/services/graphRouter.js.map +1 -0
  50. package/dist/services/notifications.d.ts +2 -2
  51. package/dist/services/notifications.js +3 -3
  52. package/dist/services/notifications.js.map +1 -1
  53. package/dist/sessionTracker.d.ts +42 -3
  54. package/dist/sessionTracker.d.ts.map +1 -1
  55. package/dist/sessionTracker.js +261 -14
  56. package/dist/sessionTracker.js.map +1 -1
  57. package/dist/skillClient.d.ts.map +1 -1
  58. package/dist/skillClient.js +11 -6
  59. package/dist/skillClient.js.map +1 -1
  60. package/dist/svgVisuals.d.ts +17 -19
  61. package/dist/svgVisuals.d.ts.map +1 -1
  62. package/dist/svgVisuals.js +189 -233
  63. package/dist/svgVisuals.js.map +1 -1
  64. package/dist/systemPrompt.d.ts +7 -2
  65. package/dist/systemPrompt.d.ts.map +1 -1
  66. package/dist/systemPrompt.js +153 -81
  67. package/dist/systemPrompt.js.map +1 -1
  68. package/dist/telemetry.d.ts.map +1 -1
  69. package/dist/telemetry.js +15 -0
  70. package/dist/telemetry.js.map +1 -1
  71. package/dist/test_adjacent_trends_polymorphic.d.ts +2 -0
  72. package/dist/test_adjacent_trends_polymorphic.d.ts.map +1 -0
  73. package/dist/test_adjacent_trends_polymorphic.js +154 -0
  74. package/dist/test_adjacent_trends_polymorphic.js.map +1 -0
  75. package/dist/test_book_a_call_live.d.ts +2 -0
  76. package/dist/test_book_a_call_live.d.ts.map +1 -0
  77. package/dist/test_book_a_call_live.js +161 -0
  78. package/dist/test_book_a_call_live.js.map +1 -0
  79. package/dist/test_brand_intelligence_endpoint.js +2 -2
  80. package/dist/test_byo_mcp_submission_clarity.d.ts +2 -0
  81. package/dist/test_byo_mcp_submission_clarity.d.ts.map +1 -0
  82. package/dist/test_byo_mcp_submission_clarity.js +142 -0
  83. package/dist/test_byo_mcp_submission_clarity.js.map +1 -0
  84. package/dist/test_capabilities_guidance.d.ts +2 -0
  85. package/dist/test_capabilities_guidance.d.ts.map +1 -0
  86. package/dist/test_capabilities_guidance.js +372 -0
  87. package/dist/test_capabilities_guidance.js.map +1 -0
  88. package/dist/test_client_provenance.d.ts +2 -0
  89. package/dist/test_client_provenance.d.ts.map +1 -0
  90. package/dist/test_client_provenance.js +77 -0
  91. package/dist/test_client_provenance.js.map +1 -0
  92. package/dist/test_connection_surface_cleanup.d.ts +2 -0
  93. package/dist/test_connection_surface_cleanup.d.ts.map +1 -0
  94. package/dist/test_connection_surface_cleanup.js +203 -0
  95. package/dist/test_connection_surface_cleanup.js.map +1 -0
  96. package/dist/test_consult_routing.d.ts +2 -0
  97. package/dist/test_consult_routing.d.ts.map +1 -0
  98. package/dist/test_consult_routing.js +86 -0
  99. package/dist/test_consult_routing.js.map +1 -0
  100. package/dist/test_consult_split.d.ts +2 -0
  101. package/dist/test_consult_split.d.ts.map +1 -0
  102. package/dist/test_consult_split.js +30 -0
  103. package/dist/test_consult_split.js.map +1 -0
  104. package/dist/test_coverage_relevance.d.ts +12 -0
  105. package/dist/test_coverage_relevance.d.ts.map +1 -0
  106. package/dist/test_coverage_relevance.js +162 -0
  107. package/dist/test_coverage_relevance.js.map +1 -0
  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.d.ts +2 -0
  113. package/dist/test_dcr_and_legacy_deprecation.d.ts.map +1 -0
  114. package/dist/test_dcr_and_legacy_deprecation.js +143 -0
  115. package/dist/test_dcr_and_legacy_deprecation.js.map +1 -0
  116. package/dist/test_demand_signal_logging.d.ts +2 -0
  117. package/dist/test_demand_signal_logging.d.ts.map +1 -0
  118. package/dist/test_demand_signal_logging.js +92 -0
  119. package/dist/test_demand_signal_logging.js.map +1 -0
  120. package/dist/test_earnings_activity_scope.d.ts +5 -0
  121. package/dist/test_earnings_activity_scope.d.ts.map +1 -0
  122. package/dist/test_earnings_activity_scope.js +101 -0
  123. package/dist/test_earnings_activity_scope.js.map +1 -0
  124. package/dist/test_earnings_divergence_live.d.ts +5 -0
  125. package/dist/test_earnings_divergence_live.d.ts.map +1 -0
  126. package/dist/test_earnings_divergence_live.js +135 -0
  127. package/dist/test_earnings_divergence_live.js.map +1 -0
  128. package/dist/test_earnings_hygiene_and_binding.d.ts +5 -0
  129. package/dist/test_earnings_hygiene_and_binding.d.ts.map +1 -0
  130. package/dist/test_earnings_hygiene_and_binding.js +104 -0
  131. package/dist/test_earnings_hygiene_and_binding.js.map +1 -0
  132. package/dist/test_exec_quotes_render.d.ts +2 -0
  133. package/dist/test_exec_quotes_render.d.ts.map +1 -0
  134. package/dist/test_exec_quotes_render.js +86 -0
  135. package/dist/test_exec_quotes_render.js.map +1 -0
  136. package/dist/test_expert_intro.d.ts +2 -0
  137. package/dist/test_expert_intro.d.ts.map +1 -0
  138. package/dist/test_expert_intro.js +57 -0
  139. package/dist/test_expert_intro.js.map +1 -0
  140. package/dist/test_expert_layer.d.ts +11 -0
  141. package/dist/test_expert_layer.d.ts.map +1 -0
  142. package/dist/test_expert_layer.js +356 -0
  143. package/dist/test_expert_layer.js.map +1 -0
  144. package/dist/test_financial_snapshot_render.d.ts +2 -0
  145. package/dist/test_financial_snapshot_render.d.ts.map +1 -0
  146. package/dist/test_financial_snapshot_render.js +167 -0
  147. package/dist/test_financial_snapshot_render.js.map +1 -0
  148. package/dist/test_gap_alert.d.ts +12 -0
  149. package/dist/test_gap_alert.d.ts.map +1 -0
  150. package/dist/test_gap_alert.js +51 -0
  151. package/dist/test_gap_alert.js.map +1 -0
  152. package/dist/test_grok_offering_surfaces.d.ts +2 -0
  153. package/dist/test_grok_offering_surfaces.d.ts.map +1 -0
  154. package/dist/test_grok_offering_surfaces.js +155 -0
  155. package/dist/test_grok_offering_surfaces.js.map +1 -0
  156. package/dist/test_human_agent_live.d.ts +2 -0
  157. package/dist/test_human_agent_live.d.ts.map +1 -0
  158. package/dist/test_human_agent_live.js +48 -0
  159. package/dist/test_human_agent_live.js.map +1 -0
  160. package/dist/test_identity_gap.d.ts +2 -0
  161. package/dist/test_identity_gap.d.ts.map +1 -0
  162. package/dist/test_identity_gap.js +171 -0
  163. package/dist/test_identity_gap.js.map +1 -0
  164. package/dist/test_intelligence_and_verifier.d.ts +17 -0
  165. package/dist/test_intelligence_and_verifier.d.ts.map +1 -0
  166. package/dist/test_intelligence_and_verifier.js +162 -0
  167. package/dist/test_intelligence_and_verifier.js.map +1 -0
  168. package/dist/test_latency_mcp_brief5.d.ts +12 -0
  169. package/dist/test_latency_mcp_brief5.d.ts.map +1 -0
  170. package/dist/test_latency_mcp_brief5.js +200 -0
  171. package/dist/test_latency_mcp_brief5.js.map +1 -0
  172. package/dist/test_limit_and_credit_card_capture.d.ts +21 -0
  173. package/dist/test_limit_and_credit_card_capture.d.ts.map +1 -0
  174. package/dist/test_limit_and_credit_card_capture.js +216 -0
  175. package/dist/test_limit_and_credit_card_capture.js.map +1 -0
  176. package/dist/test_list_graphs_topics.js +1 -1
  177. package/dist/test_list_graphs_topics.js.map +1 -1
  178. package/dist/test_live_deployed_mcp_search.d.ts +2 -0
  179. package/dist/test_live_deployed_mcp_search.d.ts.map +1 -0
  180. package/dist/test_live_deployed_mcp_search.js +85 -0
  181. package/dist/test_live_deployed_mcp_search.js.map +1 -0
  182. package/dist/test_live_deployed_split.d.ts +2 -0
  183. package/dist/test_live_deployed_split.d.ts.map +1 -0
  184. package/dist/test_live_deployed_split.js +72 -0
  185. package/dist/test_live_deployed_split.js.map +1 -0
  186. package/dist/test_live_endpoint_runner.js +2 -2
  187. package/dist/test_live_endpoint_runner.js.map +1 -1
  188. package/dist/test_live_mcp.js +1 -1
  189. package/dist/test_live_mcp.js.map +1 -1
  190. package/dist/test_live_neil_widget.d.ts +2 -0
  191. package/dist/test_live_neil_widget.d.ts.map +1 -0
  192. package/dist/test_live_neil_widget.js +84 -0
  193. package/dist/test_live_neil_widget.js.map +1 -0
  194. package/dist/test_longpoll_status.d.ts +2 -0
  195. package/dist/test_longpoll_status.d.ts.map +1 -0
  196. package/dist/test_longpoll_status.js +117 -0
  197. package/dist/test_longpoll_status.js.map +1 -0
  198. package/dist/test_mcp_find_expert_and_vocabulary_routing.d.ts +2 -0
  199. package/dist/test_mcp_find_expert_and_vocabulary_routing.d.ts.map +1 -0
  200. package/dist/test_mcp_find_expert_and_vocabulary_routing.js +130 -0
  201. package/dist/test_mcp_find_expert_and_vocabulary_routing.js.map +1 -0
  202. package/dist/test_next_moves.d.ts +2 -0
  203. package/dist/test_next_moves.d.ts.map +1 -0
  204. package/dist/test_next_moves.js +714 -0
  205. package/dist/test_next_moves.js.map +1 -0
  206. package/dist/test_next_moves_transcripts.d.ts +2 -0
  207. package/dist/test_next_moves_transcripts.d.ts.map +1 -0
  208. package/dist/test_next_moves_transcripts.js +495 -0
  209. package/dist/test_next_moves_transcripts.js.map +1 -0
  210. package/dist/test_on_request_caller_identity.d.ts +13 -0
  211. package/dist/test_on_request_caller_identity.d.ts.map +1 -0
  212. package/dist/test_on_request_caller_identity.js +131 -0
  213. package/dist/test_on_request_caller_identity.js.map +1 -0
  214. package/dist/test_on_request_intercept.d.ts +10 -0
  215. package/dist/test_on_request_intercept.d.ts.map +1 -0
  216. package/dist/test_on_request_intercept.js +95 -0
  217. package/dist/test_on_request_intercept.js.map +1 -0
  218. package/dist/test_onboarding_consent_and_errors.d.ts +2 -0
  219. package/dist/test_onboarding_consent_and_errors.d.ts.map +1 -0
  220. package/dist/test_onboarding_consent_and_errors.js +110 -0
  221. package/dist/test_onboarding_consent_and_errors.js.map +1 -0
  222. package/dist/test_onboarding_live_scenarios.d.ts +2 -0
  223. package/dist/test_onboarding_live_scenarios.d.ts.map +1 -0
  224. package/dist/test_onboarding_live_scenarios.js +189 -0
  225. package/dist/test_onboarding_live_scenarios.js.map +1 -0
  226. package/dist/test_referral_live.d.ts +2 -0
  227. package/dist/test_referral_live.d.ts.map +1 -0
  228. package/dist/test_referral_live.js +33 -0
  229. package/dist/test_referral_live.js.map +1 -0
  230. package/dist/test_referral_piers.d.ts +2 -0
  231. package/dist/test_referral_piers.d.ts.map +1 -0
  232. package/dist/test_referral_piers.js +33 -0
  233. package/dist/test_referral_piers.js.map +1 -0
  234. package/dist/test_report_editorial_briefing.d.ts +21 -0
  235. package/dist/test_report_editorial_briefing.d.ts.map +1 -0
  236. package/dist/test_report_editorial_briefing.js +356 -0
  237. package/dist/test_report_editorial_briefing.js.map +1 -0
  238. package/dist/test_search_graph_quality.d.ts +2 -0
  239. package/dist/test_search_graph_quality.d.ts.map +1 -0
  240. package/dist/test_search_graph_quality.js +493 -0
  241. package/dist/test_search_graph_quality.js.map +1 -0
  242. package/dist/test_service_to_service_auth.d.ts +2 -0
  243. package/dist/test_service_to_service_auth.d.ts.map +1 -0
  244. package/dist/test_service_to_service_auth.js +199 -0
  245. package/dist/test_service_to_service_auth.js.map +1 -0
  246. package/dist/test_session_next_moves_telemetry.d.ts +2 -0
  247. package/dist/test_session_next_moves_telemetry.d.ts.map +1 -0
  248. package/dist/test_session_next_moves_telemetry.js +100 -0
  249. package/dist/test_session_next_moves_telemetry.js.map +1 -0
  250. package/dist/test_sources_live.d.ts +2 -0
  251. package/dist/test_sources_live.d.ts.map +1 -0
  252. package/dist/test_sources_live.js +30 -0
  253. package/dist/test_sources_live.js.map +1 -0
  254. package/dist/test_specialist_and_analyst_tiers.d.ts +2 -0
  255. package/dist/test_specialist_and_analyst_tiers.d.ts.map +1 -0
  256. package/dist/test_specialist_and_analyst_tiers.js +155 -0
  257. package/dist/test_specialist_and_analyst_tiers.js.map +1 -0
  258. package/dist/test_v2_2_visual_hardening.d.ts +5 -0
  259. package/dist/test_v2_2_visual_hardening.d.ts.map +1 -0
  260. package/dist/test_v2_2_visual_hardening.js +333 -0
  261. package/dist/test_v2_2_visual_hardening.js.map +1 -0
  262. package/dist/test_verify_deprecate_render_spec.d.ts +2 -0
  263. package/dist/test_verify_deprecate_render_spec.d.ts.map +1 -0
  264. package/dist/test_verify_deprecate_render_spec.js +217 -0
  265. package/dist/test_verify_deprecate_render_spec.js.map +1 -0
  266. package/dist/test_verify_neil_demo.d.ts +2 -0
  267. package/dist/test_verify_neil_demo.d.ts.map +1 -0
  268. package/dist/test_verify_neil_demo.js +149 -0
  269. package/dist/test_verify_neil_demo.js.map +1 -0
  270. package/dist/test_waitable_research_and_grounded_links.d.ts +2 -0
  271. package/dist/test_waitable_research_and_grounded_links.d.ts.map +1 -0
  272. package/dist/test_waitable_research_and_grounded_links.js +129 -0
  273. package/dist/test_waitable_research_and_grounded_links.js.map +1 -0
  274. package/dist/test_widget_guards.d.ts +2 -0
  275. package/dist/test_widget_guards.d.ts.map +1 -0
  276. package/dist/test_widget_guards.js +229 -0
  277. package/dist/test_widget_guards.js.map +1 -0
  278. package/dist/test_zero_retention_alerts.d.ts +14 -0
  279. package/dist/test_zero_retention_alerts.d.ts.map +1 -0
  280. package/dist/test_zero_retention_alerts.js +76 -0
  281. package/dist/test_zero_retention_alerts.js.map +1 -0
  282. package/dist/toolHandlers.d.ts +38 -1
  283. package/dist/toolHandlers.d.ts.map +1 -1
  284. package/dist/toolHandlers.js +4290 -711
  285. package/dist/toolHandlers.js.map +1 -1
  286. package/dist/tools.d.ts +22 -1
  287. package/dist/tools.d.ts.map +1 -1
  288. package/dist/tools.js +29 -4
  289. package/dist/tools.js.map +1 -1
  290. package/dist/verify_tools_endpoint.js +19 -5
  291. package/dist/verify_tools_endpoint.js.map +1 -1
  292. package/dist/widgetShell.d.ts +2 -2
  293. package/dist/widgetShell.d.ts.map +1 -1
  294. package/dist/widgetShell.js +1 -1
  295. package/dist/widgetShell.js.map +1 -1
  296. package/package.json +1 -1
  297. package/server.json +3 -31
  298. package/dist/debug_tools.d.ts +0 -2
  299. package/dist/debug_tools.d.ts.map +0 -1
  300. package/dist/debug_tools.js +0 -19
  301. package/dist/debug_tools.js.map +0 -1
  302. package/dist/inspect_list_graphs.d.ts +0 -2
  303. package/dist/inspect_list_graphs.d.ts.map +0 -1
  304. package/dist/inspect_list_graphs.js +0 -25
  305. package/dist/inspect_list_graphs.js.map +0 -1
  306. package/dist/scratch_test.d.ts.map +0 -1
  307. package/dist/scripts/simulate_gemini.d.ts +0 -2
  308. package/dist/scripts/simulate_gemini.d.ts.map +0 -1
  309. package/dist/scripts/simulate_gemini.js +0 -100
  310. package/dist/scripts/simulate_gemini.js.map +0 -1
  311. package/dist/scripts/verify_registry.d.ts +0 -2
  312. package/dist/scripts/verify_registry.d.ts.map +0 -1
  313. package/dist/scripts/verify_registry.js +0 -77
  314. package/dist/scripts/verify_registry.js.map +0 -1
  315. package/dist/src/index.d.ts +0 -2
  316. package/dist/src/index.d.ts.map +0 -1
  317. package/dist/src/index.js +0 -332
  318. package/dist/src/index.js.map +0 -1
  319. package/dist/src/tools.d.ts +0 -12
  320. package/dist/src/tools.d.ts.map +0 -1
  321. package/dist/src/tools.js +0 -104
  322. package/dist/src/tools.js.map +0 -1
  323. package/dist/src/types.d.ts +0 -44
  324. package/dist/src/types.d.ts.map +0 -1
  325. package/dist/src/types.js +0 -2
  326. package/dist/src/types.js.map +0 -1
  327. package/dist/src/verify.d.ts +0 -2
  328. package/dist/src/verify.d.ts.map +0 -1
  329. package/dist/src/verify.js +0 -47
  330. package/dist/src/verify.js.map +0 -1
  331. package/dist/src/verify_hmac.d.ts +0 -2
  332. package/dist/src/verify_hmac.d.ts.map +0 -1
  333. package/dist/src/verify_hmac.js +0 -106
  334. package/dist/src/verify_hmac.js.map +0 -1
  335. package/dist/src/verify_tools_endpoint.d.ts +0 -2
  336. package/dist/src/verify_tools_endpoint.d.ts.map +0 -1
  337. package/dist/src/verify_tools_endpoint.js +0 -40
  338. package/dist/src/verify_tools_endpoint.js.map +0 -1
  339. package/dist/test-waverunner-response.d.ts +0 -2
  340. package/dist/test-waverunner-response.d.ts.map +0 -1
  341. package/dist/test-waverunner-response.js +0 -20
  342. package/dist/test-waverunner-response.js.map +0 -1
  343. package/dist/test_coverage_r2.d.ts +0 -2
  344. package/dist/test_coverage_r2.d.ts.map +0 -1
  345. package/dist/test_coverage_scratch.d.ts +0 -2
  346. package/dist/test_coverage_scratch.d.ts.map +0 -1
@@ -0,0 +1,1857 @@
1
+ /**
2
+ * Coverage relevance heuristic — counts how many result rows are actually
3
+ * on-topic for the query, so the coverage annotation can flag "thin" when a
4
+ * cross-graph fan-out pads the result set with off-topic rows.
5
+ *
6
+ * Why raw count isn't enough: an all-graph search fans out to every accessible
7
+ * graph and each graph returns its own best matches. For a narrow query
8
+ * ("Chinese automotive trends EV brands"), unrelated graphs (women's health,
9
+ * beauty, social media) still contribute their locally-top rows, so the merged
10
+ * set can hit 10 rows while only 2–3 are on-topic — and the old count-only
11
+ * check reported coverage "ok" and never nudged the client toward
12
+ * get_supplemental_context.
13
+ *
14
+ * How a row is judged on-topic (positive-evidence-of-off-topic model — rows
15
+ * are only marked off-topic when we can actually tell):
16
+ * 1. Lexical: the row's text (title/summary/brands/places + its source
17
+ * graph's name/domain/topics) contains at least one SPECIFIC query token
18
+ * (generic research vocabulary like "trends", "market", "brands" is
19
+ * stopworded out). Match → on-topic.
20
+ * 2. Score rescue: no lexical match, but the row's relevance score clears
21
+ * ON_TOPIC_RESCUE_RATIO of its tier's scale. Scores are NOT comparable
22
+ * across tiers (domain-graph composite scores cluster near ~2.0 for
23
+ * on-topic matches; report-graph vector scores near ~0.8), so each tier
24
+ * normalizes against max(nominal tier scale, best observed score in that
25
+ * tier within this result set). Using the nominal as a floor prevents a
26
+ * tier whose rows are ALL off-topic from normalizing itself to look good.
27
+ * 3. No score at all → counted on-topic (score-less rows from statistics /
28
+ * insights endpoints must never regress coverage).
29
+ *
30
+ * If the query has no specific tokens left after stopwording (e.g. "top
31
+ * emerging trends"), relevance can't be judged and `evaluated: false` is
32
+ * returned — callers keep the legacy count-only behavior.
33
+ */
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';
36
+ /** Nominal on-topic relevance-score scale per graph tier (observed in QA:
37
+ * domain composites ~2.0, report vector scores ~0.8; expert sits between). */
38
+ export const TIER_NOMINAL_SCORE = {
39
+ domain: 2.0,
40
+ expert: 1.0,
41
+ report: 0.8,
42
+ };
43
+ /** Fallback tier when a row's graph can't be resolved — the most permissive
44
+ * scale, so unresolved rows are rarely marked off-topic. */
45
+ const FALLBACK_TIER = 'report';
46
+ /** A row with no lexical overlap must score at least this fraction of its
47
+ * tier scale to still count as on-topic. */
48
+ export const ON_TOPIC_RESCUE_RATIO = 0.75;
49
+ /** English function words + research vocabulary present in almost every graph
50
+ * row — these carry no topical signal and are excluded from query tokens. */
51
+ const GENERIC_QUERY_TOKENS = new Set([
52
+ // function words
53
+ 'a', 'an', 'the', 'and', 'or', 'of', 'in', 'on', 'for', 'to', 'with', 'from', 'by', 'at',
54
+ 'as', 'is', 'are', 'was', 'be', 'been', 'how', 'what', 'which', 'who', 'why', 'when',
55
+ 'where', 'will', 'can', 'do', 'does', 'about', 'into', 'over', 'under', 'between', 'vs',
56
+ 'versus', 'their', 'its', 'this', 'that', 'these', 'those', 'it', 'not', 'no', 'more',
57
+ 'most', 'less', 'least', 'than', 'also', 'other', 'across', 'around', 'within', 'per',
58
+ 'via', 'both', 'all', 'any', 'some', 'each', 'such', 'we', 'us', 'our', 'you', 'your',
59
+ // domain-generic research vocabulary
60
+ 'trend', 'trends', 'market', 'markets', 'marketing', 'brand', 'brands', 'branding',
61
+ 'consumer', 'consumers', 'industry', 'industries', 'growth', 'data', 'insight',
62
+ 'insights', 'analysis', 'global', 'new', 'top', 'key', 'major', 'latest', 'future',
63
+ 'emerging', 'current', 'recent', 'report', 'reports', 'research', 'sector', 'sectors',
64
+ 'landscape', 'opportunity', 'opportunities', 'innovation', 'innovations', 'strategy',
65
+ 'strategies', 'signal', 'signals', 'impact', 'update', 'updates', 'overview',
66
+ ]);
67
+ const TIER_ALIASES = {
68
+ 'industry report': 'report',
69
+ 'analyst': 'expert',
70
+ };
71
+ /** Tokenize a query down to its topically-specific terms. */
72
+ export function specificQueryTokens(query) {
73
+ return [...new Set(query.toLowerCase().split(/[^a-z0-9]+/)
74
+ .filter(t => t.length >= 2 && !GENERIC_QUERY_TOKENS.has(t)))];
75
+ }
76
+ export function rowScore(row) {
77
+ return row.relevance_score || row.semantic_score || row._score || row.score || 0;
78
+ }
79
+ export function rowGraphId(row) {
80
+ return row._use_this_graphId || row.graphId || row.graph_id || row.psfk_graph_slug || undefined;
81
+ }
82
+ export function resolveRowTier(row, searchedGraphs, catalog) {
83
+ const gid = rowGraphId(row);
84
+ let type;
85
+ if (gid) {
86
+ const g = catalog.find(x => x.graph_id === gid) || searchedGraphs.find(x => x?.graph_id === gid);
87
+ type = g?.graph_type;
88
+ }
89
+ if (!type && searchedGraphs.length === 1)
90
+ type = searchedGraphs[0]?.graph_type;
91
+ type = TIER_ALIASES[type || ''] || type;
92
+ return type && TIER_NOMINAL_SCORE[type] !== undefined ? type : FALLBACK_TIER;
93
+ }
94
+ /** Does the row's text (or its source graph's metadata) contain any specific query token? */
95
+ export function rowMatchesQueryTokens(row, tokens, catalog) {
96
+ if (tokens.length === 0)
97
+ return false;
98
+ const parts = [
99
+ row.title, row.trendName, row.name, row.display, row.label,
100
+ row.summary, row.description, row.trendDescription, row.whyNow,
101
+ row.graphName, row.statistic, row.claim, row.insight, row.text,
102
+ ];
103
+ const gid = rowGraphId(row);
104
+ if (gid) {
105
+ parts.push(String(gid).replace(/[-_/,]/g, ' '));
106
+ const g = catalog.find(x => x.graph_id === gid);
107
+ if (g)
108
+ parts.push(g.name, g.domain, ...(Array.isArray(g.topics) ? g.topics : []));
109
+ }
110
+ for (const arr of [row.brandNames, row.place, row.sectors, row.topics]) {
111
+ if (Array.isArray(arr))
112
+ parts.push(...arr);
113
+ }
114
+ const words = new Set(parts.filter((x) => typeof x === 'string').join(' ').toLowerCase().split(/[^a-z0-9]+/));
115
+ // Exact word match always counts; prefix match only for tokens ≥4 chars
116
+ // (so "china" matches "chinas" but "car" never matches "carbon").
117
+ for (const t of tokens) {
118
+ if (words.has(t))
119
+ return true;
120
+ if (t.length >= 4) {
121
+ for (const w of words) {
122
+ if (w.startsWith(t))
123
+ return true;
124
+ }
125
+ }
126
+ }
127
+ return false;
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
+ }
370
+ /**
371
+ * Count rows that are on-topic for the query. `catalog` is injectable for
372
+ * tests; defaults to the live graph catalog.
373
+ */
374
+ export function countOnTopicRows(rows, query, searchedGraphs, catalog = getGraphs()) {
375
+ const tokens = specificQueryTokens(query);
376
+ if (tokens.length === 0 || rows.length === 0) {
377
+ return { onTopic: rows.length, evaluated: false };
378
+ }
379
+ const scored = rows.map(r => {
380
+ const score = rowScore(r);
381
+ const tier = resolveRowTier(r, searchedGraphs, catalog);
382
+ return { r, score, tier };
383
+ });
384
+ let onTopic = 0;
385
+ for (const { r, score, tier } of scored) {
386
+ if (rowMatchesQueryTokens(r, tokens, catalog)) {
387
+ onTopic++;
388
+ continue;
389
+ }
390
+ // No token match and no score: nothing supports the row being on-topic.
391
+ if (score <= 0)
392
+ continue;
393
+ // Rescue by score alone must be judged against the tier's NOMINAL scale,
394
+ // never the result set's own max — otherwise the top rows of any set
395
+ // (including a fully off-topic one) self-certify as on-topic.
396
+ const scale = TIER_NOMINAL_SCORE[tier] ?? 0.8;
397
+ if (score >= scale * ON_TOPIC_RESCUE_RATIO)
398
+ onTopic++;
399
+ }
400
+ return { onTopic, evaluated: true };
401
+ }
402
+ export function isDemandShaped(query) {
403
+ const q = query.toLowerCase();
404
+ const demandKeywords = [
405
+ /\binterest\b/,
406
+ /\bgrowing\b/,
407
+ /\bdemand\b/,
408
+ /\bsearch\b/,
409
+ /\battention\b/,
410
+ /\bvolume\b/,
411
+ /\bforecasts?\b/,
412
+ /\bpopularity\b/,
413
+ /\bpopular\b/,
414
+ /\bgrowth\b/,
415
+ /\bhistorical\b/,
416
+ /\bdata\s+series\b/,
417
+ /\bstatistics\b/,
418
+ /\bcharts?\b/
419
+ ];
420
+ return demandKeywords.some(pat => pat.test(q));
421
+ }
422
+ export async function addCoverageAnnotation(data, query, searchedGraphs, limit, skipEvidenceCheck = false, catalog = getGraphs(), options) {
423
+ if (!data || typeof data !== 'object')
424
+ return data;
425
+ let normalizedData = data;
426
+ if (Array.isArray(data)) {
427
+ normalizedData = { rows: data, dataStatus: 'ok' };
428
+ }
429
+ const layerMap = {
430
+ 'industry report': 'report'
431
+ };
432
+ const uniqueTypes = new Set(searchedGraphs.map(g => layerMap[g.graph_type] || g.graph_type).filter(Boolean));
433
+ const layersSearched = Array.from(uniqueTypes);
434
+ // ── Check for explicit backend errors (e.g. NEO4J_AUTH_MISSING, auth failures, 5xx) ──
435
+ const errDetail = normalizedData.error || normalizedData.error_code || normalizedData.code;
436
+ const isErrStatus = normalizedData.dataStatus === 'error' || normalizedData.status === 'error';
437
+ if (errDetail || isErrStatus) {
438
+ normalizedData.coverage = {
439
+ status: 'error',
440
+ error: (typeof errDetail === 'string' ? errDetail : JSON.stringify(errDetail)) || 'Backend service error',
441
+ results_returned: 0,
442
+ layers_searched: layersSearched,
443
+ };
444
+ console.error(`[coverage] status: error (${normalizedData.coverage.error}), query: "${query}"`);
445
+ return normalizedData;
446
+ }
447
+ // Explicitly target rows/results/trends/matches fields, mirroring normalizeTrends
448
+ let rows = normalizedData.rows;
449
+ if (!Array.isArray(rows)) {
450
+ if (Array.isArray(normalizedData.results)) {
451
+ rows = normalizedData.results;
452
+ }
453
+ else if (Array.isArray(normalizedData.trends)) {
454
+ rows = normalizedData.trends;
455
+ }
456
+ else if (Array.isArray(normalizedData.matches)) {
457
+ rows = normalizedData.matches;
458
+ }
459
+ else if (Array.isArray(normalizedData.statistics)) {
460
+ rows = normalizedData.statistics;
461
+ }
462
+ else if (Array.isArray(normalizedData.items)) {
463
+ rows = normalizedData.items;
464
+ }
465
+ else {
466
+ // Find any array property
467
+ for (const key of Object.keys(normalizedData)) {
468
+ if (Array.isArray(normalizedData[key])) {
469
+ rows = normalizedData[key];
470
+ break;
471
+ }
472
+ }
473
+ }
474
+ }
475
+ if (!Array.isArray(rows)) {
476
+ rows = [];
477
+ }
478
+ const resultCount = rows.length;
479
+ let status = 'ok';
480
+ let onTopicCount;
481
+ let isThinRelevance = false;
482
+ if (resultCount === 0) {
483
+ status = 'empty';
484
+ }
485
+ else {
486
+ let isThinCount = resultCount < 3;
487
+ if (limit !== undefined && limit < 3 && resultCount === limit) {
488
+ isThinCount = false;
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));
498
+ let isThinEvidence = false;
499
+ const skipEvidence = skipEvidenceCheck || options?.include_evidence === false || !hasEvidenceArrays;
500
+ if (!skipEvidence && resultCount >= 3) {
501
+ isThinEvidence = rows.every((r) => {
502
+ const count = Array.isArray(r.evidence)
503
+ ? r.evidence.length
504
+ : Array.isArray(r.evidence_items)
505
+ ? r.evidence_items.length
506
+ : Array.isArray(r.evidenceItems)
507
+ ? r.evidenceItems.length
508
+ : Array.isArray(r.trendEvidence)
509
+ ? r.trendEvidence.length
510
+ : (r.evidence_count || r.evidenceCount || 0);
511
+ return count < 3;
512
+ });
513
+ }
514
+ // ── Relevance-aware thinness ──
515
+ // Raw count can look healthy while cross-graph fan-out pads the set with
516
+ // off-topic rows (each graph contributes its locally-best matches even
517
+ // for unrelated queries). Count only on-topic rows; requiring
518
+ // onTopic < resultCount means this never fires when every returned row
519
+ // is on-topic (that case is already governed by the count/limit rules).
520
+ const relevance = countOnTopicRows(rows, query, searchedGraphs, catalog);
521
+ if (relevance.evaluated) {
522
+ onTopicCount = relevance.onTopic;
523
+ if (relevance.onTopic < 3 && relevance.onTopic < resultCount) {
524
+ isThinRelevance = true;
525
+ }
526
+ }
527
+ if (isThinCount || isThinEvidence || isThinRelevance) {
528
+ status = 'thin';
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
+ }
544
+ }
545
+ const isDemand = isDemandShaped(query);
546
+ let suggestedAction = undefined;
547
+ if (status === 'empty' || status === 'thin' || isDemand) {
548
+ let reason = '';
549
+ if (status === 'empty') {
550
+ reason = 'curated coverage empty; supplemental provides external trends and data series';
551
+ }
552
+ else if (status === 'thin') {
553
+ reason = isThinRelevance
554
+ ? `curated coverage thin: only ${onTopicCount} of ${resultCount} results are on-topic for this query; supplemental covers demand and participation signals for this domain`
555
+ : 'curated coverage thin; supplemental covers demand and participation signals for this domain';
556
+ }
557
+ else {
558
+ reason = 'curated coverage is OK, but query is explicitly demand- or attention-shaped; supplemental adds value via live signals';
559
+ }
560
+ const suggestedArgs = { query };
561
+ // Sourced from graph ids present in the result rows, fall back to searchedGraphs
562
+ const rowGraphIds = [...new Set(rows.map((r) => r.graphId || r._use_this_graphId || r.psfk_graph_slug || r.graph_id).filter(Boolean))];
563
+ if (rowGraphIds.length > 0) {
564
+ suggestedArgs.graph_ids = rowGraphIds;
565
+ }
566
+ else if (searchedGraphs.length > 0) {
567
+ suggestedArgs.graph_ids = searchedGraphs.map(g => g.graph_id);
568
+ }
569
+ suggestedAction = {
570
+ tool: 'get_supplemental_context',
571
+ arguments: suggestedArgs,
572
+ reason
573
+ };
574
+ }
575
+ normalizedData.coverage = {
576
+ status,
577
+ results_returned: resultCount,
578
+ layers_searched: layersSearched,
579
+ };
580
+ if (onTopicCount !== undefined) {
581
+ normalizedData.coverage.results_on_topic = onTopicCount;
582
+ }
583
+ if (suggestedAction) {
584
+ normalizedData.coverage.suggested_action = suggestedAction;
585
+ }
586
+ // Machine-only recovery ladder: thin/empty coverage is never announced to
587
+ // the user. The client recovers via suggested_action first; escalation
588
+ // exists only for when that recovery also comes back dry.
589
+ if (status === 'thin' || status === 'empty') {
590
+ normalizedData.coverage.presentation = 'internal';
591
+ normalizedData.coverage.escalation = {
592
+ when: 'Only if suggested_action also returns nothing usable for this query',
593
+ say: 'This is what we have on this right now.',
594
+ options: [
595
+ {
596
+ tool: 'deep_research_topic',
597
+ arguments: { query },
598
+ reason: 'commission a Deep Dive report combining expert graph intelligence with live web research',
599
+ },
600
+ {
601
+ action: 'web_llm_research',
602
+ reason: 'run your own web/LLM research pass, clearly attributing non-Fodda findings',
603
+ },
604
+ ],
605
+ };
606
+ }
607
+ normalizedData.next_moves = await generateNextMoves(rows, query, searchedGraphs, status, onTopicCount, suggestedAction, catalog, options?.analysts || getAnalysts(), options);
608
+ console.error(`[coverage] status: ${status}, results: ${resultCount}/${limit || 'default'}${onTopicCount !== undefined ? `, on-topic: ${onTopicCount}` : ''}, query: "${query}"`);
609
+ return normalizedData;
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
+ }
680
+ /**
681
+ * Clean and truncate a single theme candidate to <= 3 words, lowercase.
682
+ */
683
+ export function cleanThemeCandidate(raw) {
684
+ if (!raw || typeof raw !== 'string')
685
+ return null;
686
+ let cleaned = raw
687
+ .replace(/^\[(?:REVIW|REVIEW|DRAFT|WIP)\]\s*/i, '')
688
+ .replace(/[&/+,]+/g, ' ')
689
+ .replace(/[^a-zA-Z0-9\s-]/g, '')
690
+ .toLowerCase()
691
+ .trim();
692
+ if (!cleaned)
693
+ return null;
694
+ const stopWords = new Set(['and', 'or', 'the', 'in', 'of', 'for', 'with', 'to', 'a', 'an']);
695
+ let words = cleaned.split(/\s+/).filter(Boolean);
696
+ while (words.length > 0 && words[0] && stopWords.has(words[0]))
697
+ words.shift();
698
+ while (words.length > 0 && words[words.length - 1] && stopWords.has(words[words.length - 1]))
699
+ words.pop();
700
+ if (words.length === 0)
701
+ return null;
702
+ if (words.length > 3) {
703
+ words = words.slice(0, 3);
704
+ while (words.length > 0 && words[words.length - 1] && stopWords.has(words[words.length - 1]))
705
+ words.pop();
706
+ }
707
+ return words.length > 0 ? words.join(' ') : null;
708
+ }
709
+ /**
710
+ * Format raw theme candidates (topics, sectors, trend names, titles)
711
+ * into concise, lowercase theme phrases (<= 3 words each).
712
+ */
713
+ export function formatThemePhrase(rawCandidates, fallback = 'emerging signals') {
714
+ const cleanedThemes = [];
715
+ const seen = new Set();
716
+ for (const raw of rawCandidates) {
717
+ const cleaned = cleanThemeCandidate(raw);
718
+ if (cleaned && !seen.has(cleaned)) {
719
+ seen.add(cleaned);
720
+ cleanedThemes.push(cleaned);
721
+ }
722
+ }
723
+ if (cleanedThemes.length === 0) {
724
+ return cleanThemeCandidate(fallback) || fallback.toLowerCase();
725
+ }
726
+ if (cleanedThemes.length === 1 && cleanedThemes[0]) {
727
+ return cleanedThemes[0];
728
+ }
729
+ return `${cleanedThemes[0]} and ${cleanedThemes[1]}`;
730
+ }
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) {
1013
+ const nextMoves = {
1014
+ scope_prompt: true,
1015
+ presentation: 'internal',
1016
+ };
1017
+ if (options?.knownBrand) {
1018
+ nextMoves.known_brand = options.knownBrand;
1019
+ }
1020
+ const searchedGraphIds = new Set();
1021
+ for (const g of searchedGraphs) {
1022
+ const gid = typeof g === 'string' ? g : (g.graph_id || g.id || g.slug);
1023
+ if (gid)
1024
+ searchedGraphIds.add(gid);
1025
+ }
1026
+ for (const r of rows) {
1027
+ const gid = rowGraphId(r);
1028
+ if (gid)
1029
+ searchedGraphIds.add(gid);
1030
+ }
1031
+ // ── 1. Thread (Pull the thread) ──
1032
+ const tokens = specificQueryTokens(query);
1033
+ const rowsByGraph = new Map();
1034
+ for (const r of rows) {
1035
+ const gid = rowGraphId(r) || (searchedGraphs[0]?.graph_id) || 'unknown';
1036
+ if (!rowsByGraph.has(gid))
1037
+ rowsByGraph.set(gid, []);
1038
+ rowsByGraph.get(gid).push(r);
1039
+ }
1040
+ let maxRemainder = 0;
1041
+ let bestGraphId;
1042
+ let bestGraphTheme;
1043
+ // First pass: check per-graph on_topic_total / total attached to gRows[0]
1044
+ for (const [gid, gRows] of rowsByGraph.entries()) {
1045
+ const onTopicRows = gRows.filter(r => rowMatchesQueryTokens(r, tokens, catalog) || (rowScore(r) >= 0.75 * (TIER_NOMINAL_SCORE[resolveRowTier(r, searchedGraphs, catalog)] ?? 0.8)));
1046
+ const renderedCount = gRows.length;
1047
+ const gTotal = gRows[0]?.on_topic_total ?? gRows[0]?.total_count ?? gRows[0]?.total;
1048
+ let remainder = 0;
1049
+ if (typeof gTotal === 'number' && gTotal > renderedCount) {
1050
+ remainder = gTotal - renderedCount;
1051
+ }
1052
+ if (remainder > maxRemainder) {
1053
+ maxRemainder = remainder;
1054
+ bestGraphId = gid;
1055
+ const themes = [];
1056
+ for (const r of onTopicRows) {
1057
+ if (Array.isArray(r.topics))
1058
+ themes.push(...r.topics);
1059
+ if (Array.isArray(r.sectors))
1060
+ themes.push(...r.sectors);
1061
+ }
1062
+ const graphMeta = catalog.find(g => g.graph_id === gid);
1063
+ if (graphMeta?.topics?.length)
1064
+ themes.push(...graphMeta.topics);
1065
+ for (const r of onTopicRows) {
1066
+ if (r.title)
1067
+ themes.push(r.title);
1068
+ if (r.trend_name)
1069
+ themes.push(r.trend_name);
1070
+ }
1071
+ bestGraphTheme = formatThemePhrase(themes, graphMeta?.domain || 'emerging signals');
1072
+ }
1073
+ }
1074
+ // Second pass: if per-graph totals were not present on rows, check aggregate multi-graph totals in options
1075
+ if (maxRemainder === 0) {
1076
+ const totalMulti = options?.onTopicTotal ?? options?.total;
1077
+ if (typeof totalMulti === 'number' && totalMulti > rows.length) {
1078
+ maxRemainder = totalMulti - rows.length;
1079
+ let maxCount = 0;
1080
+ for (const [gid, gRows] of rowsByGraph.entries()) {
1081
+ if (gRows.length > maxCount) {
1082
+ maxCount = gRows.length;
1083
+ bestGraphId = gid;
1084
+ }
1085
+ }
1086
+ if (bestGraphId) {
1087
+ const gRows = rowsByGraph.get(bestGraphId) || [];
1088
+ const themes = [];
1089
+ for (const r of gRows) {
1090
+ if (Array.isArray(r.topics))
1091
+ themes.push(...r.topics);
1092
+ if (Array.isArray(r.sectors))
1093
+ themes.push(...r.sectors);
1094
+ }
1095
+ const graphMeta = catalog.find(g => g.graph_id === bestGraphId);
1096
+ if (graphMeta?.topics?.length)
1097
+ themes.push(...graphMeta.topics);
1098
+ for (const r of gRows) {
1099
+ if (r.title)
1100
+ themes.push(r.title);
1101
+ if (r.trend_name)
1102
+ themes.push(r.trend_name);
1103
+ }
1104
+ bestGraphTheme = formatThemePhrase(themes, graphMeta?.domain || 'emerging signals');
1105
+ }
1106
+ }
1107
+ }
1108
+ if (options?.isBrandTracker) {
1109
+ if (rows.length > 0) {
1110
+ let maxCount = 0;
1111
+ let topGraphId;
1112
+ for (const [gid, gRows] of rowsByGraph.entries()) {
1113
+ if (gRows.length > maxCount) {
1114
+ maxCount = gRows.length;
1115
+ topGraphId = gid;
1116
+ }
1117
+ }
1118
+ if (topGraphId) {
1119
+ const gRows = rowsByGraph.get(topGraphId) || [];
1120
+ const themes = [];
1121
+ for (const r of gRows) {
1122
+ if (Array.isArray(r.topics))
1123
+ themes.push(...r.topics);
1124
+ if (Array.isArray(r.sectors))
1125
+ themes.push(...r.sectors);
1126
+ }
1127
+ const gMeta = catalog.find(g => g.graph_id === topGraphId);
1128
+ if (gMeta?.topics?.length)
1129
+ themes.push(...gMeta.topics);
1130
+ for (const r of gRows) {
1131
+ if (r.trend_name)
1132
+ themes.push(r.trend_name);
1133
+ if (r.title)
1134
+ themes.push(r.title);
1135
+ }
1136
+ const bestTheme = formatThemePhrase(themes, gMeta?.domain || 'emerging signals');
1137
+ const display = gMeta ? buildDisplayName(gMeta) : (gRows[0]?.graphName || topGraphId);
1138
+ nextMoves.thread = {
1139
+ kind: 'more_in_graph',
1140
+ graph_id: topGraphId,
1141
+ graph_display: display,
1142
+ remaining_count: maxCount,
1143
+ theme: bestTheme,
1144
+ };
1145
+ }
1146
+ }
1147
+ else {
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);
1163
+ nextMoves.thread = {
1164
+ kind: 'honest_thin',
1165
+ graph_id: primaryGid || unsearchedDomain.graph_id,
1166
+ graph_display: primaryDisplay,
1167
+ adjacent: {
1168
+ graph_id: unsearchedDomain.graph_id,
1169
+ graph_display: topDisplay,
1170
+ reason: unsearchedDomain.headline || unsearchedDomain.domain || unsearchedDomain.name,
1171
+ }
1172
+ };
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
+ }
1182
+ }
1183
+ }
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;
1195
+ nextMoves.thread = {
1196
+ kind: 'more_in_graph',
1197
+ graph_id: bestGraphId,
1198
+ graph_display: display,
1199
+ remaining_count: maxRemainder,
1200
+ theme: bestGraphTheme,
1201
+ };
1202
+ }
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);
1213
+ const primarySearched = searchedGraphs[0];
1214
+ const primaryGid = typeof primarySearched === 'string' ? primarySearched : primarySearched?.graph_id;
1215
+ const primaryMeta = catalog.find(g => g.graph_id === primaryGid);
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
+ }
1243
+ }
1244
+ }
1245
+ // ── 2. Specific (Go specific: brands, statistics_source, expert) ──
1246
+ const specific = {};
1247
+ // Brands: extract competitive landscape or top 2 brand entities present in returned rows
1248
+ if (options?.competitiveLandscape && options.competitiveLandscape.length > 0) {
1249
+ specific.brands = options.competitiveLandscape.slice(0, 2);
1250
+ }
1251
+ else if (options?.isBrandTracker) {
1252
+ // For brand reports, if competitiveLandscape is empty or omitted, leave specific.brands undefined — never pad!
1253
+ specific.brands = undefined;
1254
+ }
1255
+ else {
1256
+ const tokens = specificQueryTokens(query);
1257
+ const onTopicRows = rows.filter(r => rowMatchesQueryTokens(r, tokens, catalog) || (rowScore(r) >= 0.75 * (TIER_NOMINAL_SCORE[resolveRowTier(r, searchedGraphs, catalog)] ?? 0.8)));
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
+ }
1298
+ }
1299
+ }
1300
+ if (brandCounts.size > 0) {
1301
+ const qLow = query.toLowerCase();
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
+ }
1313
+ }
1314
+ }
1315
+ }
1316
+ // Statistics source: supplemental source or scored stat dataset
1317
+ if (options?.earningsStatsSource) {
1318
+ specific.statistics_source = options.earningsStatsSource;
1319
+ }
1320
+ else if (options?.earningsTicker || options?.isBrandTracker) {
1321
+ // Format with brand display name: "Lululemon's latest earnings and financial results"
1322
+ const brandName = options?.brandDisplayName || options?.brandName || query || 'the company';
1323
+ const cleanName = brandName.trim();
1324
+ let possessiveName = `${cleanName}'s`;
1325
+ if (cleanName.endsWith("'s") || cleanName.endsWith("’s")) {
1326
+ possessiveName = cleanName;
1327
+ }
1328
+ else if (cleanName.endsWith("s") || cleanName.endsWith("S")) {
1329
+ possessiveName = `${cleanName}'`;
1330
+ }
1331
+ if (options?.earningsTicker) {
1332
+ specific.statistics_source = `${possessiveName} latest earnings and financial results`;
1333
+ }
1334
+ else if (options?.isBrandTracker) {
1335
+ // If no competitor brands were found, fall back to market demand signals so line 2 is never empty
1336
+ if (!specific.brands || specific.brands.length === 0) {
1337
+ specific.statistics_source = 'Google Trends and market demand signals';
1338
+ }
1339
+ }
1340
+ }
1341
+ else {
1342
+ const qLower = query.toLowerCase();
1343
+ const isStatOrMarketShaped = suggestedAction !== undefined ||
1344
+ status === 'thin' ||
1345
+ status === 'empty' ||
1346
+ isDemandShaped(query) ||
1347
+ options?.knownBrand !== undefined ||
1348
+ (specific.brands && specific.brands.length > 0) ||
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);
1350
+ if (isStatOrMarketShaped) {
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
+ }
1369
+ }
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
+ }
1391
+ }
1392
+ }
1393
+ }
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
1404
+ });
1405
+ if (candidateExperts.length > 0 && candidateExperts[0]) {
1406
+ specific.expert = candidateExperts[0];
1407
+ }
1408
+ if (Object.keys(specific).length > 0) {
1409
+ nextMoves.specific = specific;
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;
1493
+ return nextMoves;
1494
+ }
1495
+ /**
1496
+ * Generate consult-specific next moves envelope adhering to Render Spec 1.3.
1497
+ *
1498
+ * Sentence 1 (Thread): Expert's 1st-person next move (using expert_thread.next_angle,
1499
+ * with fallback to uncited themes / remaining count; out-of-lane declination uses top referral).
1500
+ * Sentence 2 (Shelf): Fodda platform merchandising via catalogCache.getRelevantGraphs()
1501
+ * (strictly excluding the expert's own graph).
1502
+ * Sentence 3 (Scope): Deliverable / project scope line.
1503
+ */
1504
+ export function generateConsultNextMoves(result, query, analystId, options, catalog = getGraphs(), analysts = getAnalysts()) {
1505
+ const nextMoves = {
1506
+ scope_prompt: true,
1507
+ presentation: 'internal',
1508
+ };
1509
+ if (options?.knownBrand) {
1510
+ nextMoves.known_brand = options.knownBrand;
1511
+ }
1512
+ const cleanAnalystId = (analystId || '').toLowerCase().trim();
1513
+ const matchedAnalyst = analysts.find(a => {
1514
+ const aId = (a.analyst_id || a.id || a.slug || '').toLowerCase().trim();
1515
+ const aName = (a.name || '').toLowerCase().trim();
1516
+ return aId === cleanAnalystId || aName === cleanAnalystId;
1517
+ });
1518
+ const expertDisplayName = matchedAnalyst?.name || analystId;
1519
+ const expertGraphId = matchedAnalyst?.analyst_id || analystId;
1520
+ const expertThread = result?.expert_thread || {};
1521
+ // Read next_angle strictly from expert_thread.next_angle per §2.A.5
1522
+ const nextAngleRaw = expertThread?.next_angle;
1523
+ const uncitedThemes = Array.isArray(expertThread?.uncited_themes) ? expertThread.uncited_themes : [];
1524
+ const coverage = (result?.coverage || 'ok').toLowerCase().trim();
1525
+ const isOutOfLane = coverage === 'out' || expertThread?.on_topic_total === 0;
1526
+ // ── Sentence 1: Thread (Expert's 1st Person / Referral on decline) ──
1527
+ let threadSentence = '';
1528
+ if (isOutOfLane) {
1529
+ const activeReferrals = Array.isArray(result?.referrals)
1530
+ ? result.referrals.filter((r) => {
1531
+ const rStatus = (r.status || r.Status || '').toLowerCase().trim();
1532
+ return !rStatus || rStatus === 'active';
1533
+ })
1534
+ : [];
1535
+ if (activeReferrals.length > 0) {
1536
+ const topRef = activeReferrals[0];
1537
+ const refName = topRef.name || topRef.curator || 'another expert';
1538
+ const refReason = topRef.reason ? `who covers ${topRef.reason}` : 'who covers this topic directly';
1539
+ threadSentence = `For inquiries on this topic, I'd recommend connecting with ${refName} ${refReason}.`;
1540
+ nextMoves.thread = {
1541
+ kind: 'adjacent_room',
1542
+ graph_id: topRef.id || topRef.analyst_id || topRef.slug,
1543
+ graph_display: refName,
1544
+ adjacent: {
1545
+ graph_id: topRef.id || topRef.analyst_id || topRef.slug,
1546
+ graph_display: refName,
1547
+ reason: topRef.reason || 'related expertise',
1548
+ },
1549
+ text: threadSentence,
1550
+ };
1551
+ }
1552
+ else {
1553
+ threadSentence = `That's what Fodda holds on this right now; if you have a related topic in my graph, let me know.`;
1554
+ nextMoves.thread = {
1555
+ kind: 'honest_thin',
1556
+ graph_id: expertGraphId,
1557
+ graph_display: expertDisplayName,
1558
+ text: threadSentence,
1559
+ };
1560
+ }
1561
+ }
1562
+ else {
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
1564
+ let nextAngleValid = false;
1565
+ if (typeof nextAngleRaw === 'string' && nextAngleRaw.trim().length > 0) {
1566
+ const angleTokens = specificQueryTokens(nextAngleRaw);
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());
1570
+ const themeTexts = uncitedThemes.map((t) => t.toLowerCase());
1571
+ const expertTopics = Array.isArray(matchedAnalyst?.topics) ? matchedAnalyst.topics.join(' ').toLowerCase() : '';
1572
+ const groundingCorpus = [...sourceTitles, ...themeTexts, expertTopics, query.toLowerCase()].join(' ');
1573
+ const hasSharedToken = angleTokens.some(tok => tok.length >= 3 && groundingCorpus.includes(tok));
1574
+ if (hasSharedToken || (sourceTitles.length === 0 && themeTexts.length === 0)) {
1575
+ nextAngleValid = true;
1576
+ }
1577
+ }
1578
+ if (nextAngleValid && typeof nextAngleRaw === 'string') {
1579
+ let cleanNextAngle = nextAngleRaw.trim();
1580
+ if (!/[.!?]$/.test(cleanNextAngle)) {
1581
+ cleanNextAngle += '.';
1582
+ }
1583
+ threadSentence = cleanNextAngle;
1584
+ nextMoves.thread = {
1585
+ kind: 'expert_thread',
1586
+ graph_id: expertGraphId,
1587
+ graph_display: expertDisplayName,
1588
+ next_angle: cleanNextAngle,
1589
+ text: threadSentence,
1590
+ };
1591
+ }
1592
+ else if (uncitedThemes.length > 0) {
1593
+ const topTheme = uncitedThemes[0];
1594
+ threadSentence = `If you want to stay on this, we can look into ${topTheme} in my graph.`;
1595
+ nextMoves.thread = {
1596
+ kind: 'expert_thread',
1597
+ graph_id: expertGraphId,
1598
+ graph_display: expertDisplayName,
1599
+ theme: topTheme,
1600
+ uncited_themes: uncitedThemes,
1601
+ text: threadSentence,
1602
+ };
1603
+ }
1604
+ else {
1605
+ const onTopicTotal = typeof expertThread?.on_topic_total === 'number' ? expertThread.on_topic_total : undefined;
1606
+ const citedCount = typeof expertThread?.cited_count === 'number' ? expertThread.cited_count : (Array.isArray(result?.sources_used) ? result.sources_used.length : 0);
1607
+ let remainder = (onTopicTotal !== undefined && onTopicTotal > citedCount) ? (onTopicTotal - citedCount) : 0;
1608
+ if (remainder >= 10) {
1609
+ threadSentence = `There are many more trends in my graph exploring this topic — want me to pull those?`;
1610
+ }
1611
+ else if (remainder > 0) {
1612
+ threadSentence = `There are several more trends in my graph exploring this topic — want me to pull those?`;
1613
+ }
1614
+ else {
1615
+ threadSentence = `If you want to stay on this, we can explore deeper signals in my graph.`;
1616
+ }
1617
+ nextMoves.thread = {
1618
+ kind: 'expert_thread',
1619
+ graph_id: expertGraphId,
1620
+ graph_display: expertDisplayName,
1621
+ remaining_count: remainder > 0 ? remainder : undefined,
1622
+ text: threadSentence,
1623
+ };
1624
+ }
1625
+ }
1626
+ // ── Sentence 2: Shelf (Platform Voice Merchandising) ──
1627
+ let relevantCandidates = [];
1628
+ try {
1629
+ relevantCandidates = getRelevantGraphs(query);
1630
+ }
1631
+ catch {
1632
+ relevantCandidates = [];
1633
+ }
1634
+ const expertOwnIds = new Set([cleanAnalystId, expertGraphId.toLowerCase()]);
1635
+ if (matchedAnalyst) {
1636
+ if (matchedAnalyst.analyst_id)
1637
+ expertOwnIds.add(matchedAnalyst.analyst_id.toLowerCase());
1638
+ if (matchedAnalyst.id)
1639
+ expertOwnIds.add(String(matchedAnalyst.id).toLowerCase());
1640
+ if (matchedAnalyst.slug)
1641
+ expertOwnIds.add(String(matchedAnalyst.slug).toLowerCase());
1642
+ }
1643
+ const SHELF_RELEVANCE_FLOOR = 0.50;
1644
+ const queryTokens = specificQueryTokens(query);
1645
+ const shelfCandidateGraphs = [];
1646
+ for (const cand of relevantCandidates) {
1647
+ if ((cand.score ?? 0) < SHELF_RELEVANCE_FLOOR)
1648
+ continue;
1649
+ const g = cand.graph;
1650
+ if (!g)
1651
+ continue;
1652
+ const gid = (g.graph_id || g.id || '').toLowerCase();
1653
+ if (expertOwnIds.has(gid))
1654
+ continue;
1655
+ if (g.curator && g.curator.toLowerCase() === expertDisplayName.toLowerCase())
1656
+ continue;
1657
+ if (g.name && g.name.toLowerCase() === expertDisplayName.toLowerCase())
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;
1679
+ if (shelfCandidateGraphs.length < 2 && !shelfCandidateGraphs.some(sg => sg.graph_id === g.graph_id)) {
1680
+ shelfCandidateGraphs.push(g);
1681
+ }
1682
+ }
1683
+ let shelfSentence = '';
1684
+ const hasDeliverableOffering = Array.isArray(matchedAnalyst?.offerings) && matchedAnalyst.offerings.length > 0;
1685
+ if (shelfCandidateGraphs.length > 0) {
1686
+ const shelfGraphs = shelfCandidateGraphs.map(g => ({
1687
+ graph_id: g.graph_id,
1688
+ graph_display: buildDisplayName(g),
1689
+ domain: g.domain,
1690
+ headline: g.headline,
1691
+ reason: g.headline || g.domain || g.name,
1692
+ }));
1693
+ nextMoves.shelf = shelfGraphs;
1694
+ if (shelfGraphs.length >= 2 && shelfGraphs[0]?.graph_display && shelfGraphs[1]?.graph_display) {
1695
+ shelfSentence = `Fodda also holds trend signals on this in ${shelfGraphs[0].graph_display} and ${shelfGraphs[1].graph_display} if you want the wider picture`;
1696
+ }
1697
+ else if (shelfGraphs.length >= 1 && shelfGraphs[0]?.graph_display) {
1698
+ shelfSentence = `Fodda also holds trend signals on this in ${shelfGraphs[0].graph_display} if you want the wider picture`;
1699
+ }
1700
+ if (hasDeliverableOffering) {
1701
+ shelfSentence += `, and ${expertDisplayName} takes scoped briefs on this if you need a deliverable.`;
1702
+ }
1703
+ else {
1704
+ shelfSentence += `.`;
1705
+ }
1706
+ }
1707
+ else if (hasDeliverableOffering) {
1708
+ shelfSentence = `${expertDisplayName} takes scoped briefs on this if you need a deliverable.`;
1709
+ nextMoves.shelf = undefined;
1710
+ }
1711
+ else {
1712
+ // Empty candidate list and no deliverable offerings — omit sentence 2 per Brief 1.46.29
1713
+ shelfSentence = '';
1714
+ nextMoves.shelf = undefined;
1715
+ }
1716
+ const specific = {};
1717
+ if (nextMoves.shelf && nextMoves.shelf.length > 0) {
1718
+ specific.shelf_graphs = nextMoves.shelf;
1719
+ }
1720
+ if (Array.isArray(expertThread?.brands) && expertThread.brands.length > 0) {
1721
+ specific.brands = expertThread.brands.slice(0, 2);
1722
+ }
1723
+ if (Array.isArray(result?.referrals) && result.referrals.length > 0) {
1724
+ const topRef = result.referrals[0];
1725
+ specific.expert = {
1726
+ analyst_id: topRef.id || topRef.analyst_id || topRef.slug,
1727
+ display_name: cleanDisplayName(topRef.name || topRef.curator),
1728
+ reason: topRef.reason || 'related expertise',
1729
+ };
1730
+ }
1731
+ if (Object.keys(specific).length > 0) {
1732
+ nextMoves.specific = specific;
1733
+ }
1734
+ // ── Sentence 3: Scope (Platform Voice, Render Spec 1.2 Copy) ──
1735
+ let scopeSentence = '';
1736
+ if (options?.knownBrand) {
1737
+ scopeSentence = `Want this cut to ${options.knownBrand} specifically?`;
1738
+ }
1739
+ else {
1740
+ scopeSentence = `If you tell me the brand or brief you're working on, I'll cut this to that.`;
1741
+ }
1742
+ nextMoves.scope = scopeSentence;
1743
+ nextMoves.consult_envelope = {
1744
+ thread_line: threadSentence,
1745
+ shelf_line: shelfSentence || undefined,
1746
+ scope_line: scopeSentence,
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;
1782
+ return nextMoves;
1783
+ }
1784
+ /**
1785
+ * Render the consult 3-sentence closing envelope complying with Render Spec 1.3.
1786
+ */
1787
+ export function renderConsultClosingEnvelope(nextMoves) {
1788
+ if (!nextMoves?.consult_envelope) {
1789
+ return renderClosingBlock(nextMoves);
1790
+ }
1791
+ const { thread_line, shelf_line, scope_line } = nextMoves.consult_envelope;
1792
+ const lines = [thread_line, shelf_line, scope_line].filter((l) => Boolean(l));
1793
+ return {
1794
+ lines,
1795
+ text: lines.join(' '),
1796
+ };
1797
+ }
1798
+ /**
1799
+ * Universal closing block renderer supporting both general research (Render Spec 1.2/1.3)
1800
+ * and consult-specific responses (Render Spec 1.3).
1801
+ */
1802
+ export function renderClosingBlock(nextMoves) {
1803
+ if (!nextMoves)
1804
+ return { lines: [], text: '' };
1805
+ if (nextMoves.consult_envelope) {
1806
+ const { thread_line, shelf_line, scope_line } = nextMoves.consult_envelope;
1807
+ const lines = [thread_line, shelf_line, scope_line].filter((l) => Boolean(l));
1808
+ return {
1809
+ lines,
1810
+ text: lines.join(' '),
1811
+ };
1812
+ }
1813
+ const lines = [];
1814
+ // Line 1: Pull the thread
1815
+ if (nextMoves.thread) {
1816
+ const t = nextMoves.thread;
1817
+ if (t.text) {
1818
+ lines.push(t.text);
1819
+ }
1820
+ else if (t.kind === 'more_in_graph' && t.remaining_count && t.remaining_count > 0) {
1821
+ const countPhrase = t.remaining_count >= 10 ? 'many more trends' : 'several more trends';
1822
+ lines.push(`There are ${countPhrase} in ${t.graph_display || 'the graph'} exploring ${t.theme || 'this topic'} — want me to pull those?`);
1823
+ }
1824
+ else if (t.kind === 'adjacent_room' && t.adjacent) {
1825
+ lines.push(`We also have related coverage in ${t.adjacent.graph_display} — want me to pull that?`);
1826
+ }
1827
+ else if (t.kind === 'honest_thin' && t.adjacent) {
1828
+ lines.push(`That's what Fodda holds on this right now; the closest adjacent hit is ${t.adjacent.reason || 'related research'} in ${t.adjacent.graph_display} — want it?`);
1829
+ }
1830
+ }
1831
+ // Line 2: Go specific
1832
+ if (nextMoves.specific) {
1833
+ const s = nextMoves.specific;
1834
+ const options = [];
1835
+ if (s.brands && s.brands.length > 0) {
1836
+ options.push(`look into ${s.brands.join(' or ')}`);
1837
+ }
1838
+ if (s.statistics_source) {
1839
+ options.push(`pull quantitative data from ${s.statistics_source}`);
1840
+ }
1841
+ if (s.expert && options.length < 2) {
1842
+ options.push(`consult ${s.expert.display_name}`);
1843
+ }
1844
+ if (options.length > 0) {
1845
+ lines.push(`Or we can ${options.slice(0, 2).join(' or ')}.`);
1846
+ }
1847
+ }
1848
+ // Line 3: Scope to the job
1849
+ if (nextMoves.known_brand) {
1850
+ lines.push(`Want this cut to ${nextMoves.known_brand} specifically?`);
1851
+ }
1852
+ else {
1853
+ lines.push(`If you tell me the brand or brief you're working on, I'll cut this to that.`);
1854
+ }
1855
+ return { lines, text: lines.join(' ') };
1856
+ }
1857
+ //# sourceMappingURL=coverageRelevance.js.map