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.
- package/README.md +14 -91
- package/dist/agents/fodda-researcher/skills.d.ts +1 -1
- package/dist/agents/fodda-researcher/skills.d.ts.map +1 -1
- package/dist/agents/fodda-researcher/skills.js +1 -1
- package/dist/brandTemplate.d.ts +1 -1
- package/dist/brandTemplate.d.ts.map +1 -1
- package/dist/brandTemplate.js +278 -83
- package/dist/brandTemplate.js.map +1 -1
- package/dist/catalogCache.d.ts +39 -0
- package/dist/catalogCache.d.ts.map +1 -1
- package/dist/catalogCache.js +293 -22
- package/dist/catalogCache.js.map +1 -1
- package/dist/coverageRelevance.d.ts +251 -0
- package/dist/coverageRelevance.d.ts.map +1 -0
- package/dist/coverageRelevance.js +1857 -0
- package/dist/coverageRelevance.js.map +1 -0
- package/dist/editorialFill.d.ts +5 -0
- package/dist/editorialFill.d.ts.map +1 -1
- package/dist/editorialFill.js +1 -1
- package/dist/editorialFill.js.map +1 -1
- package/dist/enrichment.d.ts +26 -0
- package/dist/enrichment.d.ts.map +1 -1
- package/dist/enrichment.js +442 -29
- package/dist/enrichment.js.map +1 -1
- package/dist/errorHandling.d.ts +35 -7
- package/dist/errorHandling.d.ts.map +1 -1
- package/dist/errorHandling.js +225 -155
- package/dist/errorHandling.js.map +1 -1
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +411 -106
- package/dist/index.js.map +1 -1
- package/dist/pricingCache.d.ts.map +1 -1
- package/dist/pricingCache.js +55 -3
- package/dist/pricingCache.js.map +1 -1
- package/dist/queryCache.d.ts.map +1 -1
- package/dist/queryCache.js +2 -0
- package/dist/queryCache.js.map +1 -1
- package/dist/reportBriefing.d.ts +89 -0
- package/dist/reportBriefing.d.ts.map +1 -0
- package/dist/reportBriefing.js +662 -0
- package/dist/reportBriefing.js.map +1 -0
- package/dist/searchTemplate.d.ts.map +1 -1
- package/dist/searchTemplate.js +65 -53
- package/dist/searchTemplate.js.map +1 -1
- package/dist/services/graphRouter.d.ts +7 -0
- package/dist/services/graphRouter.d.ts.map +1 -0
- package/dist/services/graphRouter.js +7 -0
- package/dist/services/graphRouter.js.map +1 -0
- package/dist/services/notifications.d.ts +2 -2
- package/dist/services/notifications.js +3 -3
- package/dist/services/notifications.js.map +1 -1
- package/dist/sessionTracker.d.ts +42 -3
- package/dist/sessionTracker.d.ts.map +1 -1
- package/dist/sessionTracker.js +261 -14
- package/dist/sessionTracker.js.map +1 -1
- package/dist/skillClient.d.ts.map +1 -1
- package/dist/skillClient.js +11 -6
- package/dist/skillClient.js.map +1 -1
- package/dist/svgVisuals.d.ts +17 -19
- package/dist/svgVisuals.d.ts.map +1 -1
- package/dist/svgVisuals.js +189 -233
- package/dist/svgVisuals.js.map +1 -1
- package/dist/systemPrompt.d.ts +7 -2
- package/dist/systemPrompt.d.ts.map +1 -1
- package/dist/systemPrompt.js +153 -81
- package/dist/systemPrompt.js.map +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +15 -0
- package/dist/telemetry.js.map +1 -1
- package/dist/test_adjacent_trends_polymorphic.d.ts +2 -0
- package/dist/test_adjacent_trends_polymorphic.d.ts.map +1 -0
- package/dist/test_adjacent_trends_polymorphic.js +154 -0
- package/dist/test_adjacent_trends_polymorphic.js.map +1 -0
- package/dist/test_book_a_call_live.d.ts +2 -0
- package/dist/test_book_a_call_live.d.ts.map +1 -0
- package/dist/test_book_a_call_live.js +161 -0
- package/dist/test_book_a_call_live.js.map +1 -0
- package/dist/test_brand_intelligence_endpoint.js +2 -2
- package/dist/test_byo_mcp_submission_clarity.d.ts +2 -0
- package/dist/test_byo_mcp_submission_clarity.d.ts.map +1 -0
- package/dist/test_byo_mcp_submission_clarity.js +142 -0
- package/dist/test_byo_mcp_submission_clarity.js.map +1 -0
- package/dist/test_capabilities_guidance.d.ts +2 -0
- package/dist/test_capabilities_guidance.d.ts.map +1 -0
- package/dist/test_capabilities_guidance.js +372 -0
- package/dist/test_capabilities_guidance.js.map +1 -0
- package/dist/test_client_provenance.d.ts +2 -0
- package/dist/test_client_provenance.d.ts.map +1 -0
- package/dist/test_client_provenance.js +77 -0
- package/dist/test_client_provenance.js.map +1 -0
- package/dist/test_connection_surface_cleanup.d.ts +2 -0
- package/dist/test_connection_surface_cleanup.d.ts.map +1 -0
- package/dist/test_connection_surface_cleanup.js +203 -0
- package/dist/test_connection_surface_cleanup.js.map +1 -0
- package/dist/test_consult_routing.d.ts +2 -0
- package/dist/test_consult_routing.d.ts.map +1 -0
- package/dist/test_consult_routing.js +86 -0
- package/dist/test_consult_routing.js.map +1 -0
- package/dist/test_consult_split.d.ts +2 -0
- package/dist/test_consult_split.d.ts.map +1 -0
- package/dist/test_consult_split.js +30 -0
- package/dist/test_consult_split.js.map +1 -0
- package/dist/test_coverage_relevance.d.ts +12 -0
- package/dist/test_coverage_relevance.d.ts.map +1 -0
- package/dist/test_coverage_relevance.js +162 -0
- package/dist/test_coverage_relevance.js.map +1 -0
- package/dist/test_coverage_replay.d.ts +2 -0
- package/dist/test_coverage_replay.d.ts.map +1 -0
- package/dist/test_coverage_replay.js +170 -0
- package/dist/test_coverage_replay.js.map +1 -0
- package/dist/test_dcr_and_legacy_deprecation.d.ts +2 -0
- package/dist/test_dcr_and_legacy_deprecation.d.ts.map +1 -0
- package/dist/test_dcr_and_legacy_deprecation.js +143 -0
- package/dist/test_dcr_and_legacy_deprecation.js.map +1 -0
- package/dist/test_demand_signal_logging.d.ts +2 -0
- package/dist/test_demand_signal_logging.d.ts.map +1 -0
- package/dist/test_demand_signal_logging.js +92 -0
- package/dist/test_demand_signal_logging.js.map +1 -0
- package/dist/test_earnings_activity_scope.d.ts +5 -0
- package/dist/test_earnings_activity_scope.d.ts.map +1 -0
- package/dist/test_earnings_activity_scope.js +101 -0
- package/dist/test_earnings_activity_scope.js.map +1 -0
- package/dist/test_earnings_divergence_live.d.ts +5 -0
- package/dist/test_earnings_divergence_live.d.ts.map +1 -0
- package/dist/test_earnings_divergence_live.js +135 -0
- package/dist/test_earnings_divergence_live.js.map +1 -0
- package/dist/test_earnings_hygiene_and_binding.d.ts +5 -0
- package/dist/test_earnings_hygiene_and_binding.d.ts.map +1 -0
- package/dist/test_earnings_hygiene_and_binding.js +104 -0
- package/dist/test_earnings_hygiene_and_binding.js.map +1 -0
- package/dist/test_exec_quotes_render.d.ts +2 -0
- package/dist/test_exec_quotes_render.d.ts.map +1 -0
- package/dist/test_exec_quotes_render.js +86 -0
- package/dist/test_exec_quotes_render.js.map +1 -0
- package/dist/test_expert_intro.d.ts +2 -0
- package/dist/test_expert_intro.d.ts.map +1 -0
- package/dist/test_expert_intro.js +57 -0
- package/dist/test_expert_intro.js.map +1 -0
- package/dist/test_expert_layer.d.ts +11 -0
- package/dist/test_expert_layer.d.ts.map +1 -0
- package/dist/test_expert_layer.js +356 -0
- package/dist/test_expert_layer.js.map +1 -0
- package/dist/test_financial_snapshot_render.d.ts +2 -0
- package/dist/test_financial_snapshot_render.d.ts.map +1 -0
- package/dist/test_financial_snapshot_render.js +167 -0
- package/dist/test_financial_snapshot_render.js.map +1 -0
- package/dist/test_gap_alert.d.ts +12 -0
- package/dist/test_gap_alert.d.ts.map +1 -0
- package/dist/test_gap_alert.js +51 -0
- package/dist/test_gap_alert.js.map +1 -0
- package/dist/test_grok_offering_surfaces.d.ts +2 -0
- package/dist/test_grok_offering_surfaces.d.ts.map +1 -0
- package/dist/test_grok_offering_surfaces.js +155 -0
- package/dist/test_grok_offering_surfaces.js.map +1 -0
- package/dist/test_human_agent_live.d.ts +2 -0
- package/dist/test_human_agent_live.d.ts.map +1 -0
- package/dist/test_human_agent_live.js +48 -0
- package/dist/test_human_agent_live.js.map +1 -0
- package/dist/test_identity_gap.d.ts +2 -0
- package/dist/test_identity_gap.d.ts.map +1 -0
- package/dist/test_identity_gap.js +171 -0
- package/dist/test_identity_gap.js.map +1 -0
- package/dist/test_intelligence_and_verifier.d.ts +17 -0
- package/dist/test_intelligence_and_verifier.d.ts.map +1 -0
- package/dist/test_intelligence_and_verifier.js +162 -0
- package/dist/test_intelligence_and_verifier.js.map +1 -0
- package/dist/test_latency_mcp_brief5.d.ts +12 -0
- package/dist/test_latency_mcp_brief5.d.ts.map +1 -0
- package/dist/test_latency_mcp_brief5.js +200 -0
- package/dist/test_latency_mcp_brief5.js.map +1 -0
- package/dist/test_limit_and_credit_card_capture.d.ts +21 -0
- package/dist/test_limit_and_credit_card_capture.d.ts.map +1 -0
- package/dist/test_limit_and_credit_card_capture.js +216 -0
- package/dist/test_limit_and_credit_card_capture.js.map +1 -0
- package/dist/test_list_graphs_topics.js +1 -1
- package/dist/test_list_graphs_topics.js.map +1 -1
- package/dist/test_live_deployed_mcp_search.d.ts +2 -0
- package/dist/test_live_deployed_mcp_search.d.ts.map +1 -0
- package/dist/test_live_deployed_mcp_search.js +85 -0
- package/dist/test_live_deployed_mcp_search.js.map +1 -0
- package/dist/test_live_deployed_split.d.ts +2 -0
- package/dist/test_live_deployed_split.d.ts.map +1 -0
- package/dist/test_live_deployed_split.js +72 -0
- package/dist/test_live_deployed_split.js.map +1 -0
- package/dist/test_live_endpoint_runner.js +2 -2
- package/dist/test_live_endpoint_runner.js.map +1 -1
- package/dist/test_live_mcp.js +1 -1
- package/dist/test_live_mcp.js.map +1 -1
- package/dist/test_live_neil_widget.d.ts +2 -0
- package/dist/test_live_neil_widget.d.ts.map +1 -0
- package/dist/test_live_neil_widget.js +84 -0
- package/dist/test_live_neil_widget.js.map +1 -0
- package/dist/test_longpoll_status.d.ts +2 -0
- package/dist/test_longpoll_status.d.ts.map +1 -0
- package/dist/test_longpoll_status.js +117 -0
- package/dist/test_longpoll_status.js.map +1 -0
- package/dist/test_mcp_find_expert_and_vocabulary_routing.d.ts +2 -0
- package/dist/test_mcp_find_expert_and_vocabulary_routing.d.ts.map +1 -0
- package/dist/test_mcp_find_expert_and_vocabulary_routing.js +130 -0
- package/dist/test_mcp_find_expert_and_vocabulary_routing.js.map +1 -0
- package/dist/test_next_moves.d.ts +2 -0
- package/dist/test_next_moves.d.ts.map +1 -0
- package/dist/test_next_moves.js +714 -0
- package/dist/test_next_moves.js.map +1 -0
- package/dist/test_next_moves_transcripts.d.ts +2 -0
- package/dist/test_next_moves_transcripts.d.ts.map +1 -0
- package/dist/test_next_moves_transcripts.js +495 -0
- package/dist/test_next_moves_transcripts.js.map +1 -0
- package/dist/test_on_request_caller_identity.d.ts +13 -0
- package/dist/test_on_request_caller_identity.d.ts.map +1 -0
- package/dist/test_on_request_caller_identity.js +131 -0
- package/dist/test_on_request_caller_identity.js.map +1 -0
- package/dist/test_on_request_intercept.d.ts +10 -0
- package/dist/test_on_request_intercept.d.ts.map +1 -0
- package/dist/test_on_request_intercept.js +95 -0
- package/dist/test_on_request_intercept.js.map +1 -0
- package/dist/test_onboarding_consent_and_errors.d.ts +2 -0
- package/dist/test_onboarding_consent_and_errors.d.ts.map +1 -0
- package/dist/test_onboarding_consent_and_errors.js +110 -0
- package/dist/test_onboarding_consent_and_errors.js.map +1 -0
- package/dist/test_onboarding_live_scenarios.d.ts +2 -0
- package/dist/test_onboarding_live_scenarios.d.ts.map +1 -0
- package/dist/test_onboarding_live_scenarios.js +189 -0
- package/dist/test_onboarding_live_scenarios.js.map +1 -0
- package/dist/test_referral_live.d.ts +2 -0
- package/dist/test_referral_live.d.ts.map +1 -0
- package/dist/test_referral_live.js +33 -0
- package/dist/test_referral_live.js.map +1 -0
- package/dist/test_referral_piers.d.ts +2 -0
- package/dist/test_referral_piers.d.ts.map +1 -0
- package/dist/test_referral_piers.js +33 -0
- package/dist/test_referral_piers.js.map +1 -0
- package/dist/test_report_editorial_briefing.d.ts +21 -0
- package/dist/test_report_editorial_briefing.d.ts.map +1 -0
- package/dist/test_report_editorial_briefing.js +356 -0
- package/dist/test_report_editorial_briefing.js.map +1 -0
- package/dist/test_search_graph_quality.d.ts +2 -0
- package/dist/test_search_graph_quality.d.ts.map +1 -0
- package/dist/test_search_graph_quality.js +493 -0
- package/dist/test_search_graph_quality.js.map +1 -0
- package/dist/test_service_to_service_auth.d.ts +2 -0
- package/dist/test_service_to_service_auth.d.ts.map +1 -0
- package/dist/test_service_to_service_auth.js +199 -0
- package/dist/test_service_to_service_auth.js.map +1 -0
- package/dist/test_session_next_moves_telemetry.d.ts +2 -0
- package/dist/test_session_next_moves_telemetry.d.ts.map +1 -0
- package/dist/test_session_next_moves_telemetry.js +100 -0
- package/dist/test_session_next_moves_telemetry.js.map +1 -0
- package/dist/test_sources_live.d.ts +2 -0
- package/dist/test_sources_live.d.ts.map +1 -0
- package/dist/test_sources_live.js +30 -0
- package/dist/test_sources_live.js.map +1 -0
- package/dist/test_specialist_and_analyst_tiers.d.ts +2 -0
- package/dist/test_specialist_and_analyst_tiers.d.ts.map +1 -0
- package/dist/test_specialist_and_analyst_tiers.js +155 -0
- package/dist/test_specialist_and_analyst_tiers.js.map +1 -0
- package/dist/test_v2_2_visual_hardening.d.ts +5 -0
- package/dist/test_v2_2_visual_hardening.d.ts.map +1 -0
- package/dist/test_v2_2_visual_hardening.js +333 -0
- package/dist/test_v2_2_visual_hardening.js.map +1 -0
- package/dist/test_verify_deprecate_render_spec.d.ts +2 -0
- package/dist/test_verify_deprecate_render_spec.d.ts.map +1 -0
- package/dist/test_verify_deprecate_render_spec.js +217 -0
- package/dist/test_verify_deprecate_render_spec.js.map +1 -0
- package/dist/test_verify_neil_demo.d.ts +2 -0
- package/dist/test_verify_neil_demo.d.ts.map +1 -0
- package/dist/test_verify_neil_demo.js +149 -0
- package/dist/test_verify_neil_demo.js.map +1 -0
- package/dist/test_waitable_research_and_grounded_links.d.ts +2 -0
- package/dist/test_waitable_research_and_grounded_links.d.ts.map +1 -0
- package/dist/test_waitable_research_and_grounded_links.js +129 -0
- package/dist/test_waitable_research_and_grounded_links.js.map +1 -0
- package/dist/test_widget_guards.d.ts +2 -0
- package/dist/test_widget_guards.d.ts.map +1 -0
- package/dist/test_widget_guards.js +229 -0
- package/dist/test_widget_guards.js.map +1 -0
- package/dist/test_zero_retention_alerts.d.ts +14 -0
- package/dist/test_zero_retention_alerts.d.ts.map +1 -0
- package/dist/test_zero_retention_alerts.js +76 -0
- package/dist/test_zero_retention_alerts.js.map +1 -0
- package/dist/toolHandlers.d.ts +38 -1
- package/dist/toolHandlers.d.ts.map +1 -1
- package/dist/toolHandlers.js +4290 -711
- package/dist/toolHandlers.js.map +1 -1
- package/dist/tools.d.ts +22 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +29 -4
- package/dist/tools.js.map +1 -1
- package/dist/verify_tools_endpoint.js +19 -5
- package/dist/verify_tools_endpoint.js.map +1 -1
- package/dist/widgetShell.d.ts +2 -2
- package/dist/widgetShell.d.ts.map +1 -1
- package/dist/widgetShell.js +1 -1
- package/dist/widgetShell.js.map +1 -1
- package/package.json +1 -1
- package/server.json +3 -31
- package/dist/debug_tools.d.ts +0 -2
- package/dist/debug_tools.d.ts.map +0 -1
- package/dist/debug_tools.js +0 -19
- package/dist/debug_tools.js.map +0 -1
- package/dist/inspect_list_graphs.d.ts +0 -2
- package/dist/inspect_list_graphs.d.ts.map +0 -1
- package/dist/inspect_list_graphs.js +0 -25
- package/dist/inspect_list_graphs.js.map +0 -1
- package/dist/scratch_test.d.ts.map +0 -1
- package/dist/scripts/simulate_gemini.d.ts +0 -2
- package/dist/scripts/simulate_gemini.d.ts.map +0 -1
- package/dist/scripts/simulate_gemini.js +0 -100
- package/dist/scripts/simulate_gemini.js.map +0 -1
- package/dist/scripts/verify_registry.d.ts +0 -2
- package/dist/scripts/verify_registry.d.ts.map +0 -1
- package/dist/scripts/verify_registry.js +0 -77
- package/dist/scripts/verify_registry.js.map +0 -1
- package/dist/src/index.d.ts +0 -2
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/index.js +0 -332
- package/dist/src/index.js.map +0 -1
- package/dist/src/tools.d.ts +0 -12
- package/dist/src/tools.d.ts.map +0 -1
- package/dist/src/tools.js +0 -104
- package/dist/src/tools.js.map +0 -1
- package/dist/src/types.d.ts +0 -44
- package/dist/src/types.d.ts.map +0 -1
- package/dist/src/types.js +0 -2
- package/dist/src/types.js.map +0 -1
- package/dist/src/verify.d.ts +0 -2
- package/dist/src/verify.d.ts.map +0 -1
- package/dist/src/verify.js +0 -47
- package/dist/src/verify.js.map +0 -1
- package/dist/src/verify_hmac.d.ts +0 -2
- package/dist/src/verify_hmac.d.ts.map +0 -1
- package/dist/src/verify_hmac.js +0 -106
- package/dist/src/verify_hmac.js.map +0 -1
- package/dist/src/verify_tools_endpoint.d.ts +0 -2
- package/dist/src/verify_tools_endpoint.d.ts.map +0 -1
- package/dist/src/verify_tools_endpoint.js +0 -40
- package/dist/src/verify_tools_endpoint.js.map +0 -1
- package/dist/test-waverunner-response.d.ts +0 -2
- package/dist/test-waverunner-response.d.ts.map +0 -1
- package/dist/test-waverunner-response.js +0 -20
- package/dist/test-waverunner-response.js.map +0 -1
- package/dist/test_coverage_r2.d.ts +0 -2
- package/dist/test_coverage_r2.d.ts.map +0 -1
- package/dist/test_coverage_scratch.d.ts +0 -2
- 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
|