From 9560eb23b600b5afe87f26912023f2e1b1022bf8 Mon Sep 17 00:00:00 2001 From: lev-goryachev Date: Thu, 12 Mar 2026 11:03:44 +0100 Subject: [PATCH 1/4] Expand GTM integration coverage. Unify the branch work into one change that aligns the toolless bot architecture with broader provider support for discovery, outreach, and market research workflows. --- BOT_BEST_PRACTICES.md | 502 +++ INTEGRATIONS.md | 211 + RECRUITMENT_INTEGRATION_REQUIREMENTS.md | 241 + SKILL_API_SERVICE_SHORTLIST.md | 242 + flexus_client_kit/ckit_cloudtool.py | 1 + flexus_client_kit/ckit_integrations_db.py | 197 +- flexus_client_kit/ckit_skills.py | 4 + .../integrations/facebook/accounts.py | 43 + .../integrations/facebook/ads.py | 243 + .../integrations/facebook/adsets.py | 61 + .../integrations/facebook/audiences.py | 207 + .../integrations/facebook/campaigns.py | 39 +- .../integrations/facebook/client.py | 4 +- .../integrations/facebook/exceptions.py | 4 +- .../integrations/facebook/fi_facebook.py | 228 +- .../integrations/facebook/insights.py | 211 + .../integrations/facebook/models.py | 42 + .../integrations/facebook/pixels.py | 90 + .../integrations/facebook/rules.py | 129 + .../integrations/facebook/targeting.py | 134 + .../integrations/facebook/utils.py | 6 +- flexus_client_kit/integrations/fi_adzuna.py | 142 + flexus_client_kit/integrations/fi_amazon.py | 216 + flexus_client_kit/integrations/fi_apify.py | 3961 +++++++++++++++++ flexus_client_kit/integrations/fi_apollo.py | 124 + .../integrations/fi_appstoreconnect.py | 154 + .../integrations/fi_bing_webmaster.py | 102 + flexus_client_kit/integrations/fi_bombora.py | 245 + .../integrations/fi_builtwith.py | 153 + flexus_client_kit/integrations/fi_calendly.py | 130 + flexus_client_kit/integrations/fi_capterra.py | 178 + .../integrations/fi_chargebee.py | 235 + flexus_client_kit/integrations/fi_cint.py | 436 ++ flexus_client_kit/integrations/fi_clearbit.py | 108 + .../integrations/fi_coresignal.py | 141 + .../integrations/fi_crossbeam.py | 438 ++ .../integrations/fi_crunchbase.py | 184 + flexus_client_kit/integrations/fi_datadog.py | 187 + .../integrations/fi_dataforseo.py | 108 + .../integrations/fi_delighted.py | 176 + flexus_client_kit/integrations/fi_docusign.py | 391 ++ flexus_client_kit/integrations/fi_dovetail.py | 207 + flexus_client_kit/integrations/fi_dynata.py | 205 + flexus_client_kit/integrations/fi_ebay.py | 202 + .../integrations/fi_event_registry.py | 163 + .../integrations/fi_fireflies.py | 127 + flexus_client_kit/integrations/fi_g2.py | 190 + flexus_client_kit/integrations/fi_ga4.py | 146 + flexus_client_kit/integrations/fi_gdelt.py | 148 + flexus_client_kit/integrations/fi_gdrive.py | 172 + flexus_client_kit/integrations/fi_github.py | 79 - .../integrations/fi_glassdoor.py | 34 + flexus_client_kit/integrations/fi_gnews.py | 165 + flexus_client_kit/integrations/fi_gong.py | 129 + .../integrations/fi_google_ads.py | 54 + .../integrations/fi_google_calendar.py | 200 +- .../integrations/fi_google_play.py | 162 + .../integrations/fi_google_search_console.py | 46 + .../integrations/fi_google_sheets.py | 140 - .../integrations/fi_google_shopping.py | 54 + flexus_client_kit/integrations/fi_grafana.py | 200 + flexus_client_kit/integrations/fi_hasdata.py | 158 + .../integrations/fi_instagram.py | 48 + flexus_client_kit/integrations/fi_jira.py | 252 -- .../integrations/fi_launchdarkly.py | 117 + .../integrations/fi_levelsfyi.py | 56 + flexus_client_kit/integrations/fi_linkedin.py | 833 ++-- .../integrations/fi_linkedin_b2b.py | 1175 +++++ .../integrations/fi_linkedin_jobs.py | 34 + flexus_client_kit/integrations/fi_lucid.py | 107 + .../integrations/fi_mediastack.py | 131 + flexus_client_kit/integrations/fi_meta.py | 440 ++ flexus_client_kit/integrations/fi_mixpanel.py | 201 + .../integrations/fi_mongo_store.py | 32 +- flexus_client_kit/integrations/fi_mturk.py | 557 +++ flexus_client_kit/integrations/fi_newsapi.py | 331 ++ .../integrations/fi_newscatcher.py | 172 + flexus_client_kit/integrations/fi_newsdata.py | 129 + .../integrations/fi_optimizely.py | 113 + flexus_client_kit/integrations/fi_outreach.py | 332 ++ flexus_client_kit/integrations/fi_oxylabs.py | 94 + flexus_client_kit/integrations/fi_paddle.py | 349 ++ flexus_client_kit/integrations/fi_pandadoc.py | 342 ++ .../integrations/fi_partnerstack.py | 463 ++ flexus_client_kit/integrations/fi_pdl.py | 176 + flexus_client_kit/integrations/fi_perigon.py | 165 + .../integrations/fi_pinterest.py | 125 + .../integrations/fi_pipedrive.py | 197 + .../integrations/fi_producthunt.py | 189 + flexus_client_kit/integrations/fi_prolific.py | 295 ++ .../integrations/fi_purespectrum.py | 237 + .../integrations/fi_qualtrics.py | 388 ++ flexus_client_kit/integrations/fi_recurly.py | 188 + flexus_client_kit/integrations/fi_reddit.py | 145 + .../integrations/fi_respondent.py | 228 + .../integrations/fi_salesforce.py | 174 + .../integrations/fi_salesloft.py | 291 ++ flexus_client_kit/integrations/fi_segment.py | 139 + flexus_client_kit/integrations/fi_serpapi.py | 345 ++ flexus_client_kit/integrations/fi_shopify.py | 12 +- flexus_client_kit/integrations/fi_sixsense.py | 187 + .../integrations/fi_stackexchange.py | 163 + flexus_client_kit/integrations/fi_statsig.py | 105 + .../integrations/fi_surveymonkey.py | 220 + .../integrations/fi_theirstack.py | 143 + flexus_client_kit/integrations/fi_tiktok.py | 126 + flexus_client_kit/integrations/fi_toloka.py | 220 + .../integrations/fi_trustpilot.py | 220 + flexus_client_kit/integrations/fi_typeform.py | 201 + .../integrations/fi_userinterviews.py | 252 ++ .../integrations/fi_usertesting.py | 180 + .../integrations/fi_wappalyzer.py | 113 + .../integrations/fi_wikimedia.py | 158 + flexus_client_kit/integrations/fi_x.py | 785 ++++ flexus_client_kit/integrations/fi_x_ads.py | 374 ++ flexus_client_kit/integrations/fi_yelp.py | 168 + flexus_client_kit/integrations/fi_youtube.py | 419 ++ flexus_client_kit/integrations/fi_zendesk.py | 266 ++ .../integrations/fi_zendesk_sell.py | 260 ++ flexus_client_kit/integrations/fi_zoom.py | 190 + flexus_simple_bots/admonster/__init__.py | 0 .../admonster/ad_monster-1024x1536.webp | Bin 80514 -> 0 bytes .../admonster/ad_monster-256x256.webp | Bin 10660 -> 0 bytes flexus_simple_bots/admonster/admonster_bot.py | 134 - .../admonster/admonster_install.py | 113 - .../admonster/admonster_prompts.py | 252 -- .../admonster/admonster_s1.yaml | 211 - .../admonster/experiment_execution.py | 682 --- .../admonster/forms/meta_runtime.html | 1106 ----- .../admonster/setup_schema.json | 10 - .../boss/skills/idea-to-first-money/SKILL.md | 4 +- .../botticelli/botticelli_bot.py | 438 +- .../botticelli/botticelli_prompts.py | 13 +- flexus_simple_bots/executor/executor_bot.py | 92 +- .../executor/executor_install.py | 46 +- .../executor/executor_prompts.py | 2 + .../executor/experiment_execution.py | 76 + flexus_simple_bots/executor/setup_schema.json | 11 +- .../executor/skills/_admonster/SKILL.md | 71 + .../executor/skills/_botticelli/SKILL.md | 68 + .../skills/_channel-partner-overlap/SKILL.md | 54 + .../skills/_channel-performance/SKILL.md | 61 + .../skills/_churn-early-warning/SKILL.md | 63 + .../skills/_churn-exit-interview/SKILL.md | 59 + .../executor/skills/_churn-learning/SKILL.md | 231 + .../skills/_churn-save-playbook/SKILL.md | 55 + .../executor/skills/_churn-win-back/SKILL.md | 54 + .../skills/_creative-ad-brief/SKILL.md | 57 + .../skills/_creative-paid-channels/SKILL.md | 216 + .../executor/skills/_paid-google-ads/SKILL.md | 60 + .../skills/_paid-linkedin-ads/SKILL.md | 60 + .../executor/skills/_paid-meta-ads/SKILL.md | 63 + .../skills/_partner-ecosystem/SKILL.md | 177 + .../skills/_partner-enablement/SKILL.md | 61 + .../skills/_partner-recruiting/SKILL.md | 61 + .../executor/skills/_pilot-delivery/SKILL.md | 194 + .../executor/skills/_pilot-feedback/SKILL.md | 60 + .../executor/skills/_playbook-cs-ops/SKILL.md | 65 + .../skills/_playbook-sales-ops/SKILL.md | 62 + .../_retention-expansion-signals/SKILL.md | 62 + .../skills/_retention-health-scoring/SKILL.md | 59 + .../skills/_retention-intelligence/SKILL.md | 197 + .../_retention-lifecycle-campaigns/SKILL.md | 50 + .../executor/skills/_retention-nps/SKILL.md | 61 + .../skills/_scale-metrics-dashboard/SKILL.md | 61 + .../skills/_scale-unit-economics/SKILL.md | 66 + .../integrations/survey_research.py | 8 +- .../productman/productman_bot.py | 146 +- flexus_simple_bots/prompts_common.py | 26 + .../researcher/researcher_bot.py | 65 +- .../researcher/researcher_install.py | 99 +- .../researcher/researcher_prompts.py | 2 + .../skills/_customer-discovery/SKILL.md | 256 ++ .../_discovery-context-import/RESEARCH.md | 1148 +++++ .../skills/_discovery-context-import/SKILL.md | 62 + .../skills/_discovery-scheduling/RESEARCH.md | 3262 ++++++++++++++ .../skills/_discovery-scheduling/SKILL.md | 60 + .../skills/_discovery-survey/RESEARCH.md | 1072 +++++ .../skills/_discovery-survey/SKILL.md | 66 + .../researcher/skills/_market-signal/SKILL.md | 142 + .../skills/_pain-alternatives/SKILL.md | 199 + .../skills/_pain-friction-behavioral/SKILL.md | 61 + .../skills/_pain-ongoing-monitoring/SKILL.md | 60 + .../_pipeline-call-intelligence/SKILL.md | 61 + .../skills/_pipeline-crm-sync/SKILL.md | 64 + .../_pipeline-outreach-sequencing/SKILL.md | 61 + .../skills/_pipeline-qualification/SKILL.md | 196 + .../researcher/skills/_productman/SKILL.md | 63 + .../skills/_segment-behavioral/RESEARCH.md | 1288 ++++++ .../skills/_segment-behavioral/SKILL.md | 58 + .../skills/_segment-qualification/SKILL.md | 211 + .../skills/_segment-social-graph/RESEARCH.md | 912 ++++ .../skills/_segment-social-graph/SKILL.md | 50 + .../_segment-voice-of-market/RESEARCH.md | 933 ++++ .../skills/_segment-voice-of-market/SKILL.md | 50 + .../_signal-competitive-web/RESEARCH.md | 893 ++++ .../skills/_signal-competitive-web/SKILL.md | 68 + .../skills/_signal-news-events/RESEARCH.md | 272 ++ .../skills/_signal-news-events/SKILL.md | 73 + .../_signal-professional-network/RESEARCH.md | 890 ++++ .../_signal-professional-network/SKILL.md | 56 + .../skills/_signal-reviews-voice/RESEARCH.md | 1202 +++++ .../skills/_signal-reviews-voice/SKILL.md | 68 + .../_signal-social-community/RESEARCH.md | 311 ++ .../skills/_signal-social-community/SKILL.md | 86 + .../skills/_signal-talent-tech/RESEARCH.md | 882 ++++ .../skills/_signal-talent-tech/SKILL.md | 77 + .../skills/discovery-recruitment/SKILL.md | 60 +- .../skills/_experiment-analysis/RESEARCH.md | 1477 ++++++ .../skills/_experiment-analysis/SKILL.md | 61 + .../skills/_experiment-design/RESEARCH.md | 1083 +++++ .../skills/_experiment-design/SKILL.md | 55 + .../skills/_experiment-learning/RESEARCH.md | 1391 ++++++ .../skills/_experiment-learning/SKILL.md | 53 + .../skills/_gtm-launch-plan/RESEARCH.md | 1044 +++++ .../skills/_gtm-launch-plan/SKILL.md | 64 + .../skills/_mvp-feasibility/RESEARCH.md | 1270 ++++++ .../skills/_mvp-feasibility/SKILL.md | 57 + .../skills/_mvp-roadmap/RESEARCH.md | 1013 +++++ .../strategist/skills/_mvp-roadmap/SKILL.md | 119 + .../skills/_offer-validation/RESEARCH.md | 1942 ++++++++ .../skills/_offer-validation/SKILL.md | 47 + .../RESEARCH.md | 923 ++++ .../_pricing-competitive-benchmark/SKILL.md | 57 + .../skills/_pricing-tier-design/RESEARCH.md | 800 ++++ .../skills/_pricing-tier-design/SKILL.md | 56 + .../skills/_rtm-partner-channel/RESEARCH.md | 1043 +++++ .../skills/_rtm-partner-channel/SKILL.md | 50 + .../skills/_rtm-sales-playbook/RESEARCH.md | 1007 +++++ .../skills/_rtm-sales-playbook/SKILL.md | 60 + .../strategist/strategist_bot.py | 57 +- .../strategist/strategist_install.py | 49 +- .../strategist/strategist_prompts.py | 2 + setup.py | 2 + 234 files changed, 58559 insertions(+), 4421 deletions(-) create mode 100644 BOT_BEST_PRACTICES.md create mode 100644 INTEGRATIONS.md create mode 100644 RECRUITMENT_INTEGRATION_REQUIREMENTS.md create mode 100644 SKILL_API_SERVICE_SHORTLIST.md create mode 100644 flexus_client_kit/integrations/facebook/audiences.py create mode 100644 flexus_client_kit/integrations/facebook/insights.py create mode 100644 flexus_client_kit/integrations/facebook/pixels.py create mode 100644 flexus_client_kit/integrations/facebook/rules.py create mode 100644 flexus_client_kit/integrations/facebook/targeting.py create mode 100644 flexus_client_kit/integrations/fi_adzuna.py create mode 100644 flexus_client_kit/integrations/fi_amazon.py create mode 100644 flexus_client_kit/integrations/fi_apify.py create mode 100644 flexus_client_kit/integrations/fi_apollo.py create mode 100644 flexus_client_kit/integrations/fi_appstoreconnect.py create mode 100644 flexus_client_kit/integrations/fi_bing_webmaster.py create mode 100644 flexus_client_kit/integrations/fi_bombora.py create mode 100644 flexus_client_kit/integrations/fi_builtwith.py create mode 100644 flexus_client_kit/integrations/fi_calendly.py create mode 100644 flexus_client_kit/integrations/fi_capterra.py create mode 100644 flexus_client_kit/integrations/fi_chargebee.py create mode 100644 flexus_client_kit/integrations/fi_cint.py create mode 100644 flexus_client_kit/integrations/fi_clearbit.py create mode 100644 flexus_client_kit/integrations/fi_coresignal.py create mode 100644 flexus_client_kit/integrations/fi_crossbeam.py create mode 100644 flexus_client_kit/integrations/fi_crunchbase.py create mode 100644 flexus_client_kit/integrations/fi_datadog.py create mode 100644 flexus_client_kit/integrations/fi_dataforseo.py create mode 100644 flexus_client_kit/integrations/fi_delighted.py create mode 100644 flexus_client_kit/integrations/fi_docusign.py create mode 100644 flexus_client_kit/integrations/fi_dovetail.py create mode 100644 flexus_client_kit/integrations/fi_dynata.py create mode 100644 flexus_client_kit/integrations/fi_ebay.py create mode 100644 flexus_client_kit/integrations/fi_event_registry.py create mode 100644 flexus_client_kit/integrations/fi_fireflies.py create mode 100644 flexus_client_kit/integrations/fi_g2.py create mode 100644 flexus_client_kit/integrations/fi_ga4.py create mode 100644 flexus_client_kit/integrations/fi_gdelt.py create mode 100644 flexus_client_kit/integrations/fi_gdrive.py delete mode 100644 flexus_client_kit/integrations/fi_github.py create mode 100644 flexus_client_kit/integrations/fi_glassdoor.py create mode 100644 flexus_client_kit/integrations/fi_gnews.py create mode 100644 flexus_client_kit/integrations/fi_gong.py create mode 100644 flexus_client_kit/integrations/fi_google_ads.py create mode 100644 flexus_client_kit/integrations/fi_google_play.py create mode 100644 flexus_client_kit/integrations/fi_google_search_console.py delete mode 100644 flexus_client_kit/integrations/fi_google_sheets.py create mode 100644 flexus_client_kit/integrations/fi_google_shopping.py create mode 100644 flexus_client_kit/integrations/fi_grafana.py create mode 100644 flexus_client_kit/integrations/fi_hasdata.py create mode 100644 flexus_client_kit/integrations/fi_instagram.py delete mode 100644 flexus_client_kit/integrations/fi_jira.py create mode 100644 flexus_client_kit/integrations/fi_launchdarkly.py create mode 100644 flexus_client_kit/integrations/fi_levelsfyi.py create mode 100644 flexus_client_kit/integrations/fi_linkedin_b2b.py create mode 100644 flexus_client_kit/integrations/fi_linkedin_jobs.py create mode 100644 flexus_client_kit/integrations/fi_lucid.py create mode 100644 flexus_client_kit/integrations/fi_mediastack.py create mode 100644 flexus_client_kit/integrations/fi_meta.py create mode 100644 flexus_client_kit/integrations/fi_mixpanel.py create mode 100644 flexus_client_kit/integrations/fi_mturk.py create mode 100644 flexus_client_kit/integrations/fi_newsapi.py create mode 100644 flexus_client_kit/integrations/fi_newscatcher.py create mode 100644 flexus_client_kit/integrations/fi_newsdata.py create mode 100644 flexus_client_kit/integrations/fi_optimizely.py create mode 100644 flexus_client_kit/integrations/fi_outreach.py create mode 100644 flexus_client_kit/integrations/fi_oxylabs.py create mode 100644 flexus_client_kit/integrations/fi_paddle.py create mode 100644 flexus_client_kit/integrations/fi_pandadoc.py create mode 100644 flexus_client_kit/integrations/fi_partnerstack.py create mode 100644 flexus_client_kit/integrations/fi_pdl.py create mode 100644 flexus_client_kit/integrations/fi_perigon.py create mode 100644 flexus_client_kit/integrations/fi_pinterest.py create mode 100644 flexus_client_kit/integrations/fi_pipedrive.py create mode 100644 flexus_client_kit/integrations/fi_producthunt.py create mode 100644 flexus_client_kit/integrations/fi_prolific.py create mode 100644 flexus_client_kit/integrations/fi_purespectrum.py create mode 100644 flexus_client_kit/integrations/fi_qualtrics.py create mode 100644 flexus_client_kit/integrations/fi_recurly.py create mode 100644 flexus_client_kit/integrations/fi_reddit.py create mode 100644 flexus_client_kit/integrations/fi_respondent.py create mode 100644 flexus_client_kit/integrations/fi_salesforce.py create mode 100644 flexus_client_kit/integrations/fi_salesloft.py create mode 100644 flexus_client_kit/integrations/fi_segment.py create mode 100644 flexus_client_kit/integrations/fi_serpapi.py create mode 100644 flexus_client_kit/integrations/fi_sixsense.py create mode 100644 flexus_client_kit/integrations/fi_stackexchange.py create mode 100644 flexus_client_kit/integrations/fi_statsig.py create mode 100644 flexus_client_kit/integrations/fi_surveymonkey.py create mode 100644 flexus_client_kit/integrations/fi_theirstack.py create mode 100644 flexus_client_kit/integrations/fi_tiktok.py create mode 100644 flexus_client_kit/integrations/fi_toloka.py create mode 100644 flexus_client_kit/integrations/fi_trustpilot.py create mode 100644 flexus_client_kit/integrations/fi_typeform.py create mode 100644 flexus_client_kit/integrations/fi_userinterviews.py create mode 100644 flexus_client_kit/integrations/fi_usertesting.py create mode 100644 flexus_client_kit/integrations/fi_wappalyzer.py create mode 100644 flexus_client_kit/integrations/fi_wikimedia.py create mode 100644 flexus_client_kit/integrations/fi_x.py create mode 100644 flexus_client_kit/integrations/fi_x_ads.py create mode 100644 flexus_client_kit/integrations/fi_yelp.py create mode 100644 flexus_client_kit/integrations/fi_youtube.py create mode 100644 flexus_client_kit/integrations/fi_zendesk.py create mode 100644 flexus_client_kit/integrations/fi_zendesk_sell.py create mode 100644 flexus_client_kit/integrations/fi_zoom.py delete mode 100644 flexus_simple_bots/admonster/__init__.py delete mode 100644 flexus_simple_bots/admonster/ad_monster-1024x1536.webp delete mode 100644 flexus_simple_bots/admonster/ad_monster-256x256.webp delete mode 100644 flexus_simple_bots/admonster/admonster_bot.py delete mode 100644 flexus_simple_bots/admonster/admonster_install.py delete mode 100644 flexus_simple_bots/admonster/admonster_prompts.py delete mode 100644 flexus_simple_bots/admonster/admonster_s1.yaml delete mode 100644 flexus_simple_bots/admonster/experiment_execution.py delete mode 100644 flexus_simple_bots/admonster/forms/meta_runtime.html delete mode 100644 flexus_simple_bots/admonster/setup_schema.json create mode 100644 flexus_simple_bots/executor/experiment_execution.py create mode 100644 flexus_simple_bots/executor/skills/_admonster/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_botticelli/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_channel-partner-overlap/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_channel-performance/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_churn-early-warning/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_churn-exit-interview/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_churn-learning/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_churn-save-playbook/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_churn-win-back/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_creative-ad-brief/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_creative-paid-channels/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_paid-google-ads/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_paid-linkedin-ads/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_paid-meta-ads/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_partner-ecosystem/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_partner-enablement/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_partner-recruiting/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_pilot-delivery/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_pilot-feedback/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_playbook-cs-ops/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_playbook-sales-ops/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_retention-expansion-signals/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_retention-health-scoring/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_retention-intelligence/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_retention-lifecycle-campaigns/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_retention-nps/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_scale-metrics-dashboard/SKILL.md create mode 100644 flexus_simple_bots/executor/skills/_scale-unit-economics/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_customer-discovery/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_discovery-context-import/RESEARCH.md create mode 100644 flexus_simple_bots/researcher/skills/_discovery-context-import/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_discovery-scheduling/RESEARCH.md create mode 100644 flexus_simple_bots/researcher/skills/_discovery-scheduling/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_discovery-survey/RESEARCH.md create mode 100644 flexus_simple_bots/researcher/skills/_discovery-survey/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_market-signal/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_pain-alternatives/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_pain-friction-behavioral/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_pain-ongoing-monitoring/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_pipeline-call-intelligence/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_pipeline-crm-sync/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_pipeline-outreach-sequencing/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_pipeline-qualification/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_productman/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_segment-behavioral/RESEARCH.md create mode 100644 flexus_simple_bots/researcher/skills/_segment-behavioral/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_segment-qualification/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_segment-social-graph/RESEARCH.md create mode 100644 flexus_simple_bots/researcher/skills/_segment-social-graph/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_segment-voice-of-market/RESEARCH.md create mode 100644 flexus_simple_bots/researcher/skills/_segment-voice-of-market/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_signal-competitive-web/RESEARCH.md create mode 100644 flexus_simple_bots/researcher/skills/_signal-competitive-web/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_signal-news-events/RESEARCH.md create mode 100644 flexus_simple_bots/researcher/skills/_signal-news-events/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_signal-professional-network/RESEARCH.md create mode 100644 flexus_simple_bots/researcher/skills/_signal-professional-network/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_signal-reviews-voice/RESEARCH.md create mode 100644 flexus_simple_bots/researcher/skills/_signal-reviews-voice/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_signal-social-community/RESEARCH.md create mode 100644 flexus_simple_bots/researcher/skills/_signal-social-community/SKILL.md create mode 100644 flexus_simple_bots/researcher/skills/_signal-talent-tech/RESEARCH.md create mode 100644 flexus_simple_bots/researcher/skills/_signal-talent-tech/SKILL.md create mode 100644 flexus_simple_bots/strategist/skills/_experiment-analysis/RESEARCH.md create mode 100644 flexus_simple_bots/strategist/skills/_experiment-analysis/SKILL.md create mode 100644 flexus_simple_bots/strategist/skills/_experiment-design/RESEARCH.md create mode 100644 flexus_simple_bots/strategist/skills/_experiment-design/SKILL.md create mode 100644 flexus_simple_bots/strategist/skills/_experiment-learning/RESEARCH.md create mode 100644 flexus_simple_bots/strategist/skills/_experiment-learning/SKILL.md create mode 100644 flexus_simple_bots/strategist/skills/_gtm-launch-plan/RESEARCH.md create mode 100644 flexus_simple_bots/strategist/skills/_gtm-launch-plan/SKILL.md create mode 100644 flexus_simple_bots/strategist/skills/_mvp-feasibility/RESEARCH.md create mode 100644 flexus_simple_bots/strategist/skills/_mvp-feasibility/SKILL.md create mode 100644 flexus_simple_bots/strategist/skills/_mvp-roadmap/RESEARCH.md create mode 100644 flexus_simple_bots/strategist/skills/_mvp-roadmap/SKILL.md create mode 100644 flexus_simple_bots/strategist/skills/_offer-validation/RESEARCH.md create mode 100644 flexus_simple_bots/strategist/skills/_offer-validation/SKILL.md create mode 100644 flexus_simple_bots/strategist/skills/_pricing-competitive-benchmark/RESEARCH.md create mode 100644 flexus_simple_bots/strategist/skills/_pricing-competitive-benchmark/SKILL.md create mode 100644 flexus_simple_bots/strategist/skills/_pricing-tier-design/RESEARCH.md create mode 100644 flexus_simple_bots/strategist/skills/_pricing-tier-design/SKILL.md create mode 100644 flexus_simple_bots/strategist/skills/_rtm-partner-channel/RESEARCH.md create mode 100644 flexus_simple_bots/strategist/skills/_rtm-partner-channel/SKILL.md create mode 100644 flexus_simple_bots/strategist/skills/_rtm-sales-playbook/RESEARCH.md create mode 100644 flexus_simple_bots/strategist/skills/_rtm-sales-playbook/SKILL.md diff --git a/BOT_BEST_PRACTICES.md b/BOT_BEST_PRACTICES.md new file mode 100644 index 00000000..f575a574 --- /dev/null +++ b/BOT_BEST_PRACTICES.md @@ -0,0 +1,502 @@ +# Bot & Integration Best Practices + +Patterns extracted from frog, owl3, fb2, `ckit_integrations_db`, `ckit_bunch_of_functions`, and `AGENTS.md`. +The goal: less code, more value. Follow all patterns that reduce code — they are how you pass review. +The canonical educational example is always `flexus_simple_bots/frog/`. + +--- + +## File Structure + +Every bot is three files + assets: + +``` +mybot/ + __init__.py + mybot_bot.py # TOOLS list, @rcx.on_tool_call handlers, main loop + mybot_prompts.py # System prompts only, nothing else + mybot_install.py # marketplace_upsert_dev_bot + EXPERTS + mybot-1024x1536.webp + mybot-256x256.webp + setup_schema.json # Load with json.loads(), not inline + skills/ # SKILL.md files for on-demand instructions + forms/ # Optional: custom HTML for microfrontend pdocs +``` + +--- + +## MCP First + +If a provider has an official MCP server, use it — delete the custom `fi_*.py`. +MCP-related changes belong on the `oleg_mcp_within_bot` branch. + +**Current limitation:** one API key per provider. If an integration needs multiple simultaneous keys, write a custom integration until multi-key is implemented. + +--- + +## Integration: `BunchOfPythonFunctions` (preferred) + +For external API integrations, use `BunchOfPythonFunctions` instead of a monolithic dispatch class. +The framework turns plain Python functions into LLM tools automatically — schema from type hints, description from docstring first line. + +### Writing functions for a Bunch + +```python +# integrations/facebook/campaigns.py + +async def list_campaigns( + client: FacebookAdsClient, # first param = ContextType, always + account_id: str, + status: Optional[str] = None, + limit: int = 25, +) -> str: + """List campaigns for an ad account.""" + # ... +``` + +Rules: +- **First param is always ContextType** (e.g. `FacebookAdsClient`). It's injected by the framework, not exposed to model. +- **Type hints are mandatory** — they become the JSON schema. `Optional[T]` = optional param. `Literal["a","b"]` = enum. +- **Docstring first line** = method description shown to model. +- **Raise `OpError`** for expected user errors (returns `"ERROR: ..."` to model). Other exceptions are logged as warnings and also returned as errors. + +### Assembling and registering a Bunch + +```python +# fi_facebook2.py +def make_facebook_bunch(groups: list[str] | None = None) -> BunchOfPythonFunctions: + b = BunchOfPythonFunctions("facebook", "Facebook/Instagram Marketing API.", ContextType=FacebookAdsClient) + for g in (groups or ALL_FACEBOOK_GROUPS): + if g == "campaign": + from flexus_client_kit.integrations.facebook import campaigns + b.add("campaign.", [campaigns.list_campaigns, campaigns.create_campaign]) + ... + return b + +class IntegrationFacebook2: + def __init__(self, fclient, rcx, bunch): + self.client = FacebookAdsClient(fclient, rcx) + self._bunch = bunch + + async def called_by_model(self, toolcall, model_produced_args): + auth_err = await self.client.ensure_auth() + if auth_err: + return auth_err + return await self._bunch.called_by_model(self.client, toolcall, model_produced_args) +``` + +The model interacts via `op="list"` → `op="help"` → `op="call"` — all handled by the framework. + +--- + +## Integrations Registry: `ckit_integrations_db` + +Declare integrations at **module level** (evaluated once at startup), not inside the main loop. + +```python +# mybot_bot.py (module level) +MYBOT_INTEGRATIONS = ckit_integrations_db.static_integrations_load( + mybot_install.MYBOT_ROOTDIR, + allowlist=["flexus_policy_document", "gmail", "facebook[campaign, adset]"], + builtin_skills=mybot_install.MYBOT_SKILLS, +) + +TOOLS = [ + MY_TOOL, + *[t for rec in MYBOT_INTEGRATIONS for t in rec.integr_tools], +] +``` + +Bracket syntax `facebook[campaign, adset]` loads only those groups — keeps the model's tool context small. +**Note:** bracket syntax only works in code-defined bots, not `manifest.json`. + +In the main loop, initialize once: + +```python +async def mybot_main_loop(fclient, rcx): + setup = ckit_bot_exec.official_setup_mixing_procedure(mybot_install.SETUP_SCHEMA, rcx.persona.persona_setup) + integr_objects = await ckit_integrations_db.main_loop_integrations_init(MYBOT_INTEGRATIONS, rcx) + pdoc = integr_objects["flexus_policy_document"] + ... +``` + +--- + +## Tool Definition + +### Strict mode (simple tools) + +```python +MY_TOOL = ckit_cloudtool.CloudTool( + strict=True, + name="my_tool", + description="What it does.", + parameters={ + "type": "object", + "properties": { + "required_param": {"type": "string"}, + "optional_param": {"type": ["string", "null"]}, # null = optional in strict mode + "enum_param": {"type": "string", "enum": ["a", "b", "c"]}, + }, + "required": ["required_param", "optional_param", "enum_param"], # ALL params listed even optional ones + "additionalProperties": False, + }, +) +``` + +`strict=True` requires: all params in `required`, `additionalProperties: false` on every nested object, optional params use `["type", "null"]`. + +### One tool, multiple operations (preferred over separate tools per operation) + +```python +UPDATE_STRATEGY_TOOL = ckit_cloudtool.CloudTool( + strict=False, # strict=False when enum values come from a runtime list + name="update_strategy_section", + description="Update a section of the strategy document. Fill in order: calibration → diagnostic → metrics.", + parameters={ + "type": "object", + "properties": { + "section": {"type": "string", "enum": PIPELINE}, + "data": {"type": "object", "description": "Section content, freeform"}, + }, + }, +) +``` + +Handler switches on `args["section"]`. Fewer tools = cleaner model context = fewer hallucinations. + +--- + +## Skills: on-demand instruction sets + +Skills are large instruction sets loaded into prompt only when needed, reducing prompt size at rest. + +### File structure + +``` +skills/ + my-skill-name/ + SKILL.md # frontmatter + instructions + optional json schema block +``` + +```markdown +--- +name: my-skill-name +description: Brief description shown to model when listing available skills +--- + +# My Skill + +Instructions here... + +Optional JSON schema (picked up by load_skill_schemas()): +```json +{ + "type": "object", + "properties": { ... } +} +``` +``` + +### Loading skills at startup + +```python +# mybot_install.py +MYBOT_ROOTDIR = Path(__file__).parent +MYBOT_SKILLS = ckit_skills.static_skills_find(MYBOT_ROOTDIR, shared_skills_allowlist="*") + +# In EXPERTS: +fexp_builtin_skills=ckit_skills.read_name_description(MYBOT_ROOTDIR, MYBOT_SKILLS) +``` + +### Extracting schemas from SKILL.md (owl3 pattern) + +When skills define JSON schemas, collect them at startup instead of duplicating in bot code: + +```python +def load_skill_schemas() -> Dict[str, Any]: + skills_dir = BOT_DIR / "skills" + schema = {} + for skill_dir in sorted(skills_dir.iterdir()): + if not skill_dir.name.startswith("filling-"): + continue + md = (skill_dir / "SKILL.md").read_text() + m = re.search(r"```json\s*(\{.*?\})\s*```", md, re.DOTALL) + assert m, f"{skill_dir} has no schema block" + schema[skill_dir.name.replace("filling-", "")] = json.loads(m.group(1)) + return schema + +SKILL_SCHEMAS = load_skill_schemas() # module-level, evaluated once +``` + +Embed into pdoc on write so the Schemed form editor works: `doc["strategy"]["schema"] = SKILL_SCHEMAS`. + +--- + +## Fewer Bots, Fewer Experts — More Skills + +**Principle:** before creating a new bot or a new expert, ask: can a skill solve this? + +A skill is a SKILL.md loaded on demand into the existing prompt. It costs nothing at rest, loads instantly, and requires zero new code. A new bot or expert costs: new file set, new install, new main loop, new deployment, new maintenance surface. + +| Need | Wrong | Right | +|------|-------|-------| +| Bot needs to know how to fill a form section | New expert with different prompt | Skill with filling instructions + JSON schema | +| Different behavior for a sub-task | New expert | Skill loaded in the same chat | +| Step-by-step domain guide | Separate bot | Skill in the parent bot | +| Specialized worker that runs 10 min and terminates | Subchat expert (acceptable) | — | + +Owl3 eliminated all domain-specific expert variants by moving every section's instructions into `skills/filling-section*/SKILL.md`. The bot stays one expert with one prompt; the model calls `flexus_fetch_skill` when it needs to know what goes into a particular section. + +**When a new expert IS justified:** +- Subchat that must terminate on a specific condition (needs its own Lark kernel) +- Genuinely separate toolset that would pollute the default context (e.g. `huntmode` in frog blocking irrelevant tools) +- A2A task receiver that should never talk to users directly + +**When a new bot IS justified:** +- Different schedule, different kanban board, different domain owner +- Integration that requires a separate OAuth app / separate credentials +- Ops/infra bot vs user-facing bot + +Otherwise: one bot, one default expert, many skills. + +--- + +## Experts and Lark Kernels + +Experts = separate system prompt + toolset. `"default"` is mandatory. + +```python +EXPERTS = [ + ("default", ckit_bot_install.FMarketplaceExpertInput( + fexp_system_prompt=mybot_prompts.main_prompt, + fexp_python_kernel=DEFAULT_LARK, # optional, runs before/after each assistant message + fexp_block_tools="", + fexp_allow_tools="", + fexp_description="What this expert does", + fexp_builtin_skills=ckit_skills.read_name_description(MYBOT_ROOTDIR, MYBOT_SKILLS), + )), + ("worker", ckit_bot_install.FMarketplaceExpertInput( + fexp_system_prompt=mybot_prompts.worker_prompt, + fexp_python_kernel=WORKER_LARK, # must set subchat_result to terminate + fexp_block_tools="tool_a,tool_b", # comma-separated, wildcards ok: "*setup*" + fexp_allow_tools="", + fexp_description="Subchat worker expert", + )), +] + +# In install(): +marketable_experts=[(name, exp.filter_tools(tools)) for name, exp in EXPERTS], +``` + +### Lark kernel: intercept assistant messages + +```python +WORKER_LARK = """ +msg = messages[-1] +if msg["role"] == "assistant" and "DONE" in str(msg["content"]): + subchat_result = msg["content"] # terminates subchat, returns this string as tool result +elif msg["role"] == "assistant" and len(msg["tool_calls"]) == 0: + post_cd_instruction = "Keep going, don't stop early." +""" +``` + +Inputs: `messages`, `coins`, `budget`. Outputs: `subchat_result`, `post_cd_instruction`, `error`, `kill_tools`. +Prints go into `ftm_provenance.kernel_logs` — visible in logs for debugging. +**Subchat expert must set `subchat_result`** to complete; without it the subchat never terminates. + +--- + +## Subchats (parallel workers) + +```python +@rcx.on_tool_call(SPAWN_WORK_TOOL.name) +async def handle_spawn(toolcall, args): + N = args["N"] + subchats = await ckit_ask_model.bot_subchat_create_multiple( + client=fclient, + who_is_asking="mybot_worker", + persona_id=rcx.persona.persona_id, + first_question=[f"Process item #{i}" for i in range(N)], + first_calls=["null" for _ in range(N)], + title=[f"Item #{i}" for i in range(N)], + fcall_id=toolcall.fcall_id, + fexp_name="worker", + ) + raise ckit_cloudtool.WaitForSubchats(subchats) +``` + +Tools with side effects must fake results when running scenarios: + +```python +if rcx.running_test_scenario: + return await ckit_scenario.scenario_generate_tool_result_via_model(rcx.fclient, toolcall, Path(__file__).read_text()) +``` + +--- + +## Main Loop + +```python +async def mybot_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext) -> None: + setup = ckit_bot_exec.official_setup_mixing_procedure(mybot_install.SETUP_SCHEMA, rcx.persona.persona_setup) + integr_objects = await ckit_integrations_db.main_loop_integrations_init(MYBOT_INTEGRATIONS, rcx) + + @rcx.on_tool_call(MY_TOOL.name) + async def handle_my_tool(toolcall, args): + ... + return "result" + + try: + while not ckit_shutdown.shutdown_event.is_set(): + await rcx.unpark_collected_events(sleep_if_no_work=10.0) + finally: + logger.info("%s exit", rcx.persona.persona_id) + # Close sockets, unsubscribe from external sources here +``` + +Use `ckit_shutdown.wait(seconds)` for polling loops — other sleep methods block shutdown. + +--- + +## Choosing Data Storage + +| Data type | Storage | +|-----------|---------| +| Config that rarely changes, admin-editable | **Bot settings** (`persona_setup`) | +| High-write logs, caches, per-user state, temp files | **MongoDB** — use TTL indexes for cleanup | +| Structured docs shared across bots, visible/editable in UI | **Policy documents** | +| Work items needing scheduling, prioritization, tracking | **Kanban** | +| External contacts | **ERP/CRM** | + +--- + +## Policy Document Forms (choose one) + +1. **QA** — questions/answers format, minimal code, most reliable. Use first. +2. **Schemed** — has `schema` + data in same doc, Schemed editor in UI. Use when QA can't represent the structure. +3. **Microfrontend** — custom HTML, maximum flexibility, maximum effort. Use only when Schemed can't. + +Microfrontend setup: +```python +# In pdoc meta: +"meta": {"microfrontend": BOT_NAME, "created_at": "..."} +# UI loads: /v1/marketplace/{microfrontend}/{version}/forms/{top_level_tag}.html + +# In install(): +marketable_forms=ckit_bot_install.load_form_bundles(__file__), +``` + +--- + +## Setup Schema + +Load from file, not inline: + +```python +# mybot_install.py +SETUP_SCHEMA = json.loads((MYBOT_ROOTDIR / "setup_schema.json").read_text()) + +# In install(): +marketable_setup_default=SETUP_SCHEMA, +``` + +Setup field types: `string_short`, `string_long`, `string_multiline`, `bool`, `int`, `float`. +Fields grouped by `bs_group` appear as tabs in UI. + +--- + +## ERP/CRM Subscriptions + +```python +# In main loop: +@rcx.on_erp_change("crm_contact") +async def on_contact_change(action, new_record, old_record): + if action == "INSERT": + logger.info("New contact: %s", new_record.contact_first_name) + +# In main(): +asyncio.run(ckit_bot_exec.run_bots_in_this_group( + ... + subscribe_to_erp_tables=["crm_contact"], +)) +``` + +--- + +## Schedule Patterns + +```python +# mybot_install.py +marketable_schedule=[ + prompts_common.SCHED_TASK_SORT_10M | {"sched_when": "EVERY:5m"}, # sort inbox + prompts_common.SCHED_TODO_5M | {"sched_when": "EVERY:2m", "sched_first_question": "Work on the task."}, +], +``` + +--- + +## System Prompt Guidelines + +- Fix root cause, not specific test failures. Never write "if you see X then do Y". +- Minimize size — rewrite existing language instead of appending rules. +- Avoid excessive formatting and emojis. Use `#`, `##`, `###`, bullet lists. +- `💿` and `✍️` have special technical meaning in Flexus — only use them intentionally. +- Iterate: small change → run scenario → read score.yaml → repeat. + +--- + +## Scenario Testing + +```bash +python -m flexus_simple_bots.mybot.mybot_bot --scenario flexus_simple_bots/mybot/default__s1.yaml +``` + +Results in `scenario-dumps/mybot__s1-score.yaml` (gitignored). Read score.yaml — it's small and informative. +`shaky` in score = model is improvising (trajectory deviated from happy path). + +--- + +## A2A Communication + +```python +# Tell model to hand off a task to another bot: +# flexus_hand_over_task(to_bot="productman", description="...", fexp_name="default") +# Returns immediately — task goes into target bot's inbox queue. +``` + +--- + +## Pipeline Architecture: Boss + Worker Bots + +For multi-stage GTM pipelines, use a boss→worker architecture rather than many sequential bots. + +```mermaid +flowchart LR + User --> Boss + Boss -->|"A2A task"| Researcher + Boss -->|"A2A task"| Strategist + Boss -->|"A2A task"| Executor +``` + +**Boss bot** owns all flow logic: stage gating, sequencing, deciding when to hand off to which worker. It reads pdoc state and creates kanban tasks for workers via `flexus_hand_over_task`. Workers don't know what came before or what comes after — they just do their job and write results to pdoc. + +**Worker bots** are domain specialists with 1 default expert + many skills. Each skill covers what was formerly a separate bot. + +**Tool isolation is informational, not permissive.** Load only the tools a bot needs via `static_integrations_load` allowlist and bracket syntax `facebook[campaign]`. Don't use `fexp_block_tools` for primary isolation — the bot simply shouldn't know about irrelevant tools. + +**New expert only when** a subchat genuinely needs its own Lark kernel to terminate (different `subchat_result` logic). Domain variation → skill. Operational mode → skill. Everything else → default expert. + +**Target ratio:** ~10 skills per bot, 1 expert per bot, 3 worker bots for a full GTM pipeline. + +--- + +## Code Style Reminders + +- No comments that narrate what the code does. Comments only for tricks, hacks, `XXX` future improvements. +- No docstrings on integration classes/methods (exception: `BunchOfPythonFunctions` functions — first line IS the schema description). +- Prefer `import xxx` then `xxx.f()` over `from xxx import f` for Flexus modules. +- No imports inside functions. +- No `MY_CONST = "my_const"` — use strings directly (breaks `\bsearch\b`). +- Trailing commas, indent independent of bracket position. +- Short variables for short-lived locals; longer names for ugly stack-persistent ones. diff --git a/INTEGRATIONS.md b/INTEGRATIONS.md new file mode 100644 index 00000000..2e1e6ba8 --- /dev/null +++ b/INTEGRATIONS.md @@ -0,0 +1,211 @@ +# Integrations & MCP Presets + +## MCP Presets + +Available in the Flexus MCP catalog. Prefer MCP over a custom integration when both exist. + +| Preset | Source | Type | Command / URL | +|--------|--------|------|---------------| +| Amplitude | official | remote | https://mcp.amplitude.com/mcp | +| Asana | official | remote | https://mcp.asana.com/v2/mcp | +| Atlassian (Jira & Confluence) | official | remote | https://mcp.atlassian.com/v1/mcp | +| Bright Data | official | remote | https://mcp.brightdata.com/mcp | +| Chroma | flexus | local | `uvx chroma-mcp` | +| Context7 | flexus | remote | https://mcp.context7.com/mcp | +| Discord | flexus | local | `npx mcp-discord` | +| Dropbox | flexus | local | `node ./dbx-mcp-server/...` | +| Fetch | official | remote | https://remote.mcpservers.org/fetch/mcp | +| Fibery | flexus | local | `uvx fibery-mcp-server` | +| GitHub | official | remote | https://api.githubcopilot.com/mcp/ | +| Google Sheets | flexus | local | `uvx mcp-google-sheets` | +| HubSpot | official | remote | https://mcp.hubspot.com | +| Intercom | official | remote | https://mcp.intercom.com/mcp | +| Linear | official | remote | https://mcp.linear.app/mcp | +| n8n | flexus | local | `npx mcp-n8n` | +| Notion | official | local | `npx @notionhq/notion-mcp-server` | +| PostHog | official | remote | https://mcp.posthog.com/mcp | +| PostgreSQL | flexus | local | `npx @modelcontextprotocol/server-postgres` | +| Sentry | official | remote | https://mcp.sentry.dev/mcp | +| SEMrush | official | remote | https://mcp.semrush.com/v1/mcp | +| SerpApi | official | remote | https://mcp.serpapi.com/mcp | +| Similarweb | official | remote | https://mcp.similarweb.com | +| Slack | flexus | local | `npx @zencoderai/slack-mcp-server` | +| Stripe | official | remote | https://mcp.stripe.com | +| Supabase | official | remote | https://mcp.supabase.com/mcp | + +--- + +## Custom Integrations (`fi_*.py`) + +Status legend: +- **untested** — implemented but not verified with a real API key +- **mcp-preferred** — official MCP preset exists; use MCP instead of this integration +- **multi-cred** — requires multiple credentials; Flexus UI doesn't support this yet, falls back to env vars + +### Search & News + +| File | Provider | Status | +|------|----------|--------| +| `fi_bing_webmaster.py` | Bing Webmaster Tools | untested | +| `fi_event_registry.py` | Event Registry | untested | +| `fi_gdelt.py` | GDELT Project | untested | +| `fi_gnews.py` | GNews | untested | +| `fi_google_ads.py` | Google Ads (Keyword Planner) | untested | +| `fi_google_search_console.py` | Google Search Console | untested | +| `fi_google_shopping.py` | Google Shopping | untested | +| `fi_mediastack.py` | Mediastack | untested | +| `fi_newsapi.py` | NewsAPI | untested | +| `fi_newscatcher.py` | NewsCatcher | untested | +| `fi_newsdata.py` | Newsdata.io | untested | +| `fi_perigon.py` | Perigon | untested | +| `fi_semrush.py` | SEMrush | **mcp-preferred** → `semrush.json` | +| `fi_wikimedia.py` | Wikimedia | untested | + +### Social & Community + +| File | Provider | Status | +|------|----------|--------| +| `fi_facebook2.py` | Facebook | untested | +| `fi_instagram.py` | Instagram | untested | +| `fi_linkedin.py` | LinkedIn (Open Perms) | untested | +| `fi_linkedin_b2b.py` | LinkedIn B2B | untested | +| `fi_linkedin_jobs.py` | LinkedIn Jobs | untested | +| `fi_messenger.py` | Messenger | untested | +| `fi_meta.py` | Meta Ads | untested | +| `fi_pinterest.py` | Pinterest | **multi-cred** (APP_ID + APP_SECRET) | +| `fi_producthunt.py` | Product Hunt | untested | +| `fi_reddit.py` | Reddit | **multi-cred** (CLIENT_ID + CLIENT_SECRET) | +| `fi_stackexchange.py` | Stack Exchange | untested | +| `fi_tiktok.py` | TikTok | **multi-cred** (CLIENT_KEY + CLIENT_SECRET) | +| `fi_x.py` | X (Twitter) | **paid plan required** — needs Bearer Token from [developer.x.com](https://developer.x.com); search/counts endpoints require Basic ($200/mo) at minimum. 31 methods implemented. Not implemented: streaming (`/2/tweets/sample/stream`, `/2/tweets/search/stream`) — SSE, not REST; bookmarks — requires OAuth 2.0 PKCE user context, Bearer Token not sufficient. | +| `fi_x_ads.py` | X Ads | untested | +| `fi_youtube.py` | YouTube Data API v3 | 49 methods (44 REST + 5 binary-upload stubs). Read endpoints use API key (free, 10k units/day via [Google Cloud Console](https://console.developers.google.com/)). Write endpoints and private data (members, ratings, captions.download) require OAuth 2.0 token (`oauth_token` in auth). Not executable: `videos.insert`, `captions.insert`, `channel_banners.insert`, `thumbnails.set`, `watermarks.set` — multipart binary upload not feasible via JSON integration (returns `BINARY_UPLOAD_REQUIRED`). | + +### Reviews & Competitive Intel + +| File | Provider | Status | +|------|----------|--------| +| `fi_builtwith.py` | BuiltWith | untested | +| `fi_capterra.py` | Capterra | untested | +| `fi_g2.py` | G2 | untested | +| `fi_glassdoor.py` | Glassdoor | untested | +| `fi_levelsfyi.py` | Levels.fyi | untested | +| `fi_similarweb.py` | SimilarWeb | **mcp-preferred** → `similarweb.json` | +| `fi_trustpilot.py` | Trustpilot | untested | +| `fi_wappalyzer.py` | Wappalyzer | untested | +| `fi_yelp.py` | Yelp | untested | + +### Data Providers & Scraping + +| File | Provider | Status | +|------|----------|--------| +| `fi_adzuna.py` | Adzuna | **multi-cred** (APP_ID + APP_KEY) | +| `fi_bombora.py` | Bombora | untested | +| `fi_brightdata.py` | Bright Data | **mcp-preferred** → `brightdata.json` | +| `fi_clearbit.py` | Clearbit | untested | +| `fi_coresignal.py` | CoreSignal | untested | +| `fi_crunchbase.py` | Crunchbase | untested | +| `fi_dataforseo.py` | DataForSEO | **multi-cred** (LOGIN + PASSWORD) | +| `fi_hasdata.py` | HasData | untested | +| `fi_oxylabs.py` | Oxylabs | **multi-cred** (USERNAME + PASSWORD) | +| `fi_pdl.py` | People Data Labs | untested | +| `fi_sixsense.py` | 6sense | untested | +| `fi_theirstack.py` | TheirStack | untested | + +### CRM & Sales + +| File | Provider | Status | +|------|----------|--------| +| `fi_apollo.py` | Apollo.io | untested | +| `fi_crossbeam.py` | Crossbeam | untested | +| `fi_gong.py` | Gong | untested | +| `fi_hubspot.py` | HubSpot | **mcp-preferred** → `hubspot.json` | +| `fi_intercom.py` | Intercom | **mcp-preferred** → `intercom.json` | +| `fi_outreach.py` | Outreach | untested | +| `fi_partnerstack.py` | PartnerStack | untested | +| `fi_pipedrive.py` | Pipedrive | untested | +| `fi_salesforce.py` | Salesforce | untested | +| `fi_salesloft.py` | Salesloft | untested | +| `fi_zendesk.py` | Zendesk Support | untested | need to create app in marketplace +| `fi_zendesk_sell.py` | Zendesk Sell | untested | + +### Analytics & Monitoring + +| File | Provider | Status | +|------|----------|--------| +| `fi_amplitude.py` | Amplitude | **mcp-preferred** → `amplitude.json` | +| `fi_datadog.py` | Datadog | untested | +| `fi_ga4.py` | Google Analytics 4 | untested | +| `fi_google_analytics.py` | Google Analytics (UA) | untested | +| `fi_grafana.py` | Grafana | untested | +| `fi_launchdarkly.py` | LaunchDarkly | untested | +| `fi_mixpanel.py` | Mixpanel | untested | +| `fi_optimizely.py` | Optimizely | untested | +| `fi_posthog.py` | PostHog | **mcp-preferred** → `posthog.json` | +| `fi_segment.py` | Segment | untested | +| `fi_sentry.py` | Sentry | **mcp-preferred** → `sentry.json` | +| `fi_statsig.py` | Statsig | untested | + +### Payments & Commerce + +| File | Provider | Status | +|------|----------|--------| +| `fi_amazon.py` | Amazon SP-API | **multi-cred** (LWA_CLIENT_ID + SECRET + REFRESH_TOKEN) | +| `fi_chargebee.py` | Chargebee | untested | +| `fi_ebay.py` | eBay | **multi-cred** (APP_ID + CERT_ID) | +| `fi_paddle.py` | Paddle | untested | +| `fi_recurly.py` | Recurly | untested | +| `fi_shopify.py` | Shopify | untested | +| `fi_stripe.py` | Stripe | **mcp-preferred** → `stripe.json` | + +### Productivity & Collaboration + +| File | Provider | Status | +|------|----------|--------| +| `fi_asana.py` | Asana | **mcp-preferred** → `asana.json` | +| `fi_calendly.py` | Calendly | untested | +| `fi_confluence.py` | Confluence | **mcp-preferred** → `atlassian.json` | +| `fi_discord2.py` | Discord | untested | +| `fi_docusign.py` | DocuSign | untested | +| `fi_fireflies.py` | Fireflies.ai | untested | +| `fi_gdrive.py` | Google Drive | untested | +| `fi_gmail.py` | Gmail | untested | +| `fi_google_calendar.py` | Google Calendar | untested | +| `fi_google_sheets.py` | Google Sheets | **mcp-preferred** → `google_sheets.json` | +| `fi_jira.py` | Jira | **mcp-preferred** → `atlassian.json` | +| `fi_linear.py` | Linear | **mcp-preferred** → `linear.json` | +| `fi_notion.py` | Notion | **mcp-preferred** → `notion.json` | +| `fi_pandadoc.py` | PandaDoc | untested | +| `fi_resend.py` | Resend | untested | +| `fi_slack.py` | Slack | active — used by `karen` bot core | +| `fi_telegram.py` | Telegram | untested | +| `fi_typeform.py` | Typeform | untested | +| `fi_zoom.py` | Zoom | untested | + +### Dev & Infrastructure + +| File | Provider | Status | +|------|----------|--------| +| `fi_appstoreconnect.py` | App Store Connect | untested | +| `fi_github.py` | GitHub | **mcp-preferred** → `github.json` | +| `fi_google_play.py` | Google Play | untested | +| `fi_postgres.py` | PostgreSQL | active — used by `slonik` bot core | + +### Research & Surveys + +| File | Provider | Status | +|------|----------|--------| +| `fi_cint.py` | Cint | untested | +| `fi_delighted.py` | Delighted | untested | +| `fi_dovetail.py` | Dovetail | untested | +| `fi_mturk.py` | Amazon MTurk | untested | +| `fi_prolific.py` | Prolific | untested | +| `fi_qualtrics.py` | Qualtrics | untested | +| `fi_surveymonkey.py` | SurveyMonkey | untested | +| `fi_userinterviews.py` | User Interviews | untested | +| `fi_usertesting.py` | UserTesting | untested | + + +/// + +https://coresignal.com/solutions/jobs-data-api/ -- may be act like reseller \ No newline at end of file diff --git a/RECRUITMENT_INTEGRATION_REQUIREMENTS.md b/RECRUITMENT_INTEGRATION_REQUIREMENTS.md new file mode 100644 index 00000000..d8cab914 --- /dev/null +++ b/RECRUITMENT_INTEGRATION_REQUIREMENTS.md @@ -0,0 +1,241 @@ +# Recruitment Integration Requirements + +This document tells colleagues exactly what must be registered for each recruitment provider before the Flexus `Researcher` bot can use the integration. + +Rules: +- Credentials belong in the runtime secret store or environment, not in bot setup fields. +- Reusable IDs may be passed per call unless stated otherwise. +- If a provider requires commercial approval, do that first. Do not ask engineering to debug a provider that has not granted API access yet. + +## 1. Prolific + +- Access model: self-serve API token for researcher accounts. +- Required credentials: + - `PROLIFIC_API_TOKEN` +- Where to get it: + - Prolific researcher workspace API settings. +- Where to register it: + - runtime environment or secret manager used by the Flexus deployment. +- Required approvals: + - none beyond having a Prolific researcher account with API access enabled. +- Runtime IDs often needed: + - `study_id` + - `participant_group_id` + - `submission_id` + - `webhook_id` +- Notes: + - Use participant groups for allowlists and blocklists. + - Prefer webhooks over polling when inbound webhook delivery is available. + +## 2. Cint + +- Access model: enterprise bearer API key for Exchange Demand API. +- Required credentials: + - `CINT_API_KEY` +- Optional runtime configuration: + - `CINT_API_VERSION` +- Optional but recommended request discipline: + - `Idempotency-Key` on POST calls to protect against duplicate project / target-group / fielding operations during retries +- Where to get it: + - from Cint account team / developer onboarding and the API Starter Kit issued during integration onboarding. +- Where to register it: + - runtime environment or secret manager. +- Required approvals: + - enterprise API access approved by Cint + - dedicated `Integration Consultant` assigned by Cint + - test / onboarding environment access + - production go-live approval +- Required organizational context: + - `account_id` + - available `business_unit_id` values + - available `project_manager_id` or account users used for target-group ownership + - service clients if the account routes through them +- Runtime IDs often needed: + - `account_id` + - `project_id` + - `target_group_id` + - `business_unit_id` + - `profile_id` + - user identifiers for business-unit discovery +- Notes: + - Cint account discovery is part of the integration surface: account listing, account users, service clients, and user business units. + - Fielding launch is async. + - Quota distribution and feasibility should be checked before scaling. + - Supplier-level quota distribution should be used before launch for harder quota structures. + - Price retrieval and price prediction can require `Private Exchange`; if not enabled, those methods may return `403`. + - To get real end-to-end value from the full surface, colleagues must ensure the account has all needed demand capabilities enabled, not just a JWT token. + +## 3. MTurk + +- Access model: AWS SigV4 request signing. +- Required credentials: + - `AWS_ACCESS_KEY_ID` + - `AWS_SECRET_ACCESS_KEY` +- Optional credentials: + - `AWS_SESSION_TOKEN` +- Optional runtime configuration: + - `MTURK_SANDBOX=true` +- Where to get them: + - AWS account used for MTurk Requester access. +- Where to register them: + - runtime environment or secret manager. +- Required approvals: + - MTurk requester account in good standing. +- Runtime IDs often needed: + - `hit_id` + - `assignment_id` + - `qualification_type_id` + - `worker_id` + - `hit_type_id` +- Notes: + - Sandbox should be used for dry runs before real spend. + - Qualification and notification setup is not optional if quality matters. + +## 4. UserTesting + +- Access model: reviewed bearer token after enterprise API approval. +- Required credentials after approval: + - `USERTESTING_ACCESS_TOKEN` +- Optional app-registration values: + - `USERTESTING_CLIENT_ID` + - `USERTESTING_CLIENT_SECRET` +- Where to get them: + - UserTesting developer portal after the API team approves the app. +- Where to register them: + - runtime environment or secret manager. +- Required approvals: + - enterprise plan + - developer app review / approval +- Runtime IDs often needed: + - `test_id` + - `session_id` +- Notes: + - Current Flexus integration is results-focused. + - If the business wants programmatic test creation later, provide the exact approved API docs first. + +## 5. User Interviews + +- Access model: bearer API key for Research Hub API surface. +- Required credentials: + - `USERINTERVIEWS_API_KEY` +- Where to get it: + - User Interviews account / API onboarding for Hub integrations. +- Where to register it: + - runtime environment or secret manager. +- Required approvals: + - API-enabled User Interviews account. +- Runtime IDs often needed: + - `participant_id` +- Notes: + - Current public Flexus surface is participant profile management. + - If project or invite APIs are desired, the exact public docs must be supplied first. + +## 6. Respondent + +- Access model: partner API key + secret. +- Required credentials: + - `RESPONDENT_API_KEY` + - `RESPONDENT_API_SECRET` +- Where to get them: + - Respondent partner onboarding after staging review. +- Where to register them: + - runtime environment or secret manager. +- Required approvals: + - staging implementation review + - production credentials approval + - signed MSA with Respondent +- Runtime IDs often needed: + - `organization_id` + - `team_id` + - `researcher_id` + - `project_id` + - `screener_response_id` +- Notes: + - Production approval requires proving project creation, response handling, invite, attended, reject, and report flows. + - Moderated studies also need scheduling and messaging mechanisms in place. + +## 7. PureSpectrum + +- Access model: enterprise access-token header for Buy API. +- Required credentials: + - `PURESPECTRUM_ACCESS_TOKEN` +- Optional runtime configuration: + - `PURESPECTRUM_ENV=staging` +- Where to get it: + - PureSpectrum product or support team. +- Where to register it: + - runtime environment or secret manager. +- Required approvals: + - enterprise API access / buyer account setup. +- Runtime IDs often needed: + - `survey_id` + - supplier and traffic-channel identifiers if the account uses them +- Notes: + - Survey creation normally needs category, localization, IR, LOI, live URL, and field time. + - Use staging during onboarding. + +## 8. Dynata + +- Access model: split by API family. +- Demand API required values: + - `DYNATA_DEMAND_API_KEY` + - `DYNATA_DEMAND_BASE_URL` +- REX required values: + - `DYNATA_REX_ACCESS_KEY` + - `DYNATA_REX_SECRET_KEY` + - `DYNATA_REX_BASE_URL` +- Where to get them: + - Dynata account manager / developer onboarding. +- Where to register them: + - runtime environment or secret manager. +- Required approvals: + - Demand API and REX onboarding as needed by the business workflow. +- Runtime IDs often needed: + - `project_id` + - `quota_cell_id` + - `respondent_id` +- Notes: + - Demand and REX are separate products. Do not assume one credential set unlocks both. + - Base URLs are explicitly stored as config because Dynata supplies them during onboarding. + +## 9. Lucid Marketplace + +- Access model: consultant-led API key provisioning. +- Required credentials: + - `LUCID_API_KEY` +- Optional runtime configuration: + - `LUCID_ENV=sandbox` +- Where to get it: + - Lucid / Cint Marketplace consultant onboarding. +- Where to register it: + - runtime environment or secret manager. +- Required approvals: + - marketplace account setup + - environment access from integrations team + - consultant-provided Demand API guide or Postman collection +- Runtime IDs often needed: + - project and quota identifiers from the consultant-provided API contract +- Notes: + - Publicly fetchable docs confirm auth and environment model but not the full demand endpoint map. + - Before asking engineering for deeper Lucid support, provide the sanctioned Postman collection. + +## 10. Toloka + +- Access model: ApiKey header. +- Required credentials: + - `TOLOKA_API_KEY` +- Optional runtime configuration: + - `TOLOKA_ENV=sandbox` +- Where to get it: + - Toloka requester account API settings. +- Where to register it: + - runtime environment or secret manager. +- Required approvals: + - requester account with API access. +- Runtime IDs often needed: + - `project_id` + - `pool_id` + - `assignment_id` +- Notes: + - Use sandbox for early dry runs. + - Pool, task, and webhook settings should be validated before live worker spend. diff --git a/SKILL_API_SERVICE_SHORTLIST.md b/SKILL_API_SERVICE_SHORTLIST.md new file mode 100644 index 00000000..07e50e4c --- /dev/null +++ b/SKILL_API_SERVICE_SHORTLIST.md @@ -0,0 +1,242 @@ +# Skill API Service Shortlist + +## experiment-hypothesis +- Apify - https://apify.com/ +- Semrush - https://www.semrush.com/ +- NewsAPI - https://newsapi.org/ +- Product Hunt - https://www.producthunt.com/ +- Perplexity - https://www.perplexity.ai/ +- YouTube Data API - https://developers.google.com/youtube/v3 +- GitHub API - https://docs.github.com/rest +- SerpApi - https://serpapi.com/ +- Crunchbase - https://www.crunchbase.com/ +- Similarweb - https://www.similarweb.com/ + +## discovery-recruitment +- Prolific - https://www.prolific.com/ +- Cint - https://www.cint.com/ +- Amazon Mechanical Turk - https://www.mturk.com/ +- UserTesting - https://www.usertesting.com/ +- User Interviews - https://www.userinterviews.com/ +- Respondent - https://www.respondent.io/ +- PureSpectrum - https://www.purespectrum.com/ +- Dynata - https://www.dynata.com/ +- Lucid Marketplace - https://lucid.co/ +- Toloka - https://toloka.ai/ + +## discovery-interview-capture +- Zoom - https://zoom.us/ +- Gong - https://www.gong.io/ +- Fireflies.ai - https://fireflies.ai/ +- Dovetail - https://dovetail.com/ +- Fathom - https://fathom.video/ +- Recall.ai - https://recall.ai/ +- Rev - https://www.rev.com/ +- Rev AI - https://www.rev.ai/ +- Amazon Transcribe - https://aws.amazon.com/transcribe/ +- Soniox - https://soniox.com/ + +## pain-alternatives-landscape +- Trustpilot - https://www.trustpilot.com/ +- Google Places API - https://developers.google.com/maps/documentation/places/web-service/overview +- Yelp Fusion API - https://docs.developer.yelp.com/ +- Reddit - https://www.reddit.com/ +- Stack Exchange API - https://api.stackexchange.com/ +- X API - https://developer.x.com/ +- DataForSEO - https://dataforseo.com/ +- Foursquare Places API - https://developer.foursquare.com/ +- Ahrefs - https://ahrefs.com/ +- BuiltWith - https://builtwith.com/ + +## pain-wtp-research +- SurveyMonkey - https://www.surveymonkey.com/ +- Typeform - https://www.typeform.com/ +- Qualtrics - https://www.qualtrics.com/ +- QuestionPro - https://www.questionpro.com/ +- Alchemer - https://www.alchemer.com/ +- SurveySparrow - https://surveysparrow.com/ +- Google Forms API - https://developers.google.com/workspace/forms/api/guides +- Zendesk CSAT Surveys - https://developer.zendesk.com/api-reference/ticketing/ticket-management/csat_surveys +- Medallia - https://developer.medallia.com/ +- Delighted API - https://api.delighted.com/ + +## signal-search-seo +- Moz API - https://moz.com/api/docs +- Majestic - https://developer-support.majestic.com/api/ +- Google Ads API - https://developers.google.com/google-ads/api/docs/keyword-planning/overview +- Google Analytics Data API - https://developers.google.com/analytics/devguides/reporting/data/v1 +- PageSpeed Insights API - https://developers.google.com/speed/docs/insights/v5/get-started +- SE Ranking - https://seranking.com/ +- seoClarity - https://www.seoclarity.net/ +- Google Search Console - https://search.google.com/search-console/ +- Bing Webmaster Tools - https://www.bing.com/webmasters/ +- Cloudflare Analytics API - https://developers.cloudflare.com/analytics/graphql-api + +## segment-firmographic +- Apollo - https://www.apollo.io/ +- People Data Labs - https://www.peopledatalabs.com/ +- ZoomInfo - https://www.zoominfo.com/ +- FullContact - https://www.fullcontact.com/ +- Proxycurl - https://nubela.co/proxycurl/ +- Coresignal - https://coresignal.com/ +- Dun & Bradstreet - https://developer.dnb.com/ +- BuiltWith - https://builtwith.com/ +- OpenCorporates - https://api.opencorporates.com/documentation/API-Reference +- TheirStack - https://theirstack.com/ + +## segment-icp-scoring +- 6sense - https://api.6sense.com/docs +- Demandbase - https://developer.demandbase.com/ +- Bombora - https://bombora.com/ +- Marketo Engage - https://developers.marketo.com/rest-api/ +- Attio - https://docs.attio.com/rest-api/overview +- Twilio Segment - https://www.twilio.com/docs/segment/api/public-api +- Clay - https://university.clay.com/docs/http-api-integration-overview +- Outreach - https://developers.outreach.io/api/ +- Salesloft - https://developer.salesloft.com/ +- Customer.io - https://customer.io/docs/api/ + +## positioning-market-map +- Wappalyzer - https://www.wappalyzer.com/api/ +- Meta Graph API - https://developers.facebook.com/docs/graph-api/ +- GitLab API - https://docs.gitlab.com/api/rest +- npm Registry - https://docs.npmjs.com/about-the-public-npm-registry +- Discourse API - https://docs.discourse.org/ +- Vimeo API - https://developer.vimeo.com/ +- WordPress REST API - https://developer.wordpress.org/rest-api/ +- Hacker News API - https://github.com/HackerNews/API +- Bluesky API - https://docs.bsky.app/docs/api/at-protocol-xrpc-api +- Mastodon API - https://docs.joinmastodon.org/api +- Cloudflare Radar API - https://developers.cloudflare.com/radar/api-reference/ + +## positioning-messaging +- Help Scout - https://developer.helpscout.com/ +- Front - https://dev.frontapp.com/docs/welcome +- Canny - https://developers.canny.io/api-reference +- Pendo - https://developers.pendo.io/ +- Kustomer - https://developer.kustomer.com/ +- Freshdesk - https://developer.freshdesk.com/api/ +- UserVoice - https://developer.uservoice.com/docs/api/v2/getting-started +- Userback - https://docs.userback.io/reference +- Productboard - https://developer.productboard.com/ +- ChurnZero - https://app.churnzero.net/developers + +## offer-design +- Zuora - https://developer.zuora.com/ +- Maxio - https://developers.maxio.com/http/getting-started/overview +- RevenueCat - https://www.revenuecat.com/docs/api-v2 +- Lemon Squeezy - https://docs.lemonsqueezy.com/api +- Orb - https://docs.withorb.com/overview +- Metronome - https://docs.metronome.com/api +- Stripe - https://stripe.com/ +- Paddle - https://www.paddle.com/ +- Chargebee - https://www.chargebee.com/ +- Recurly - https://recurly.com/ + +## pricing-model-design +- m3ter - https://www.m3ter.com/docs/api +- Kill Bill - https://apidocs.killbill.io/ +- ChargeOver - https://developer.chargeover.com/docs/api +- Stax Bill - https://developer.staxbill.com/ +- Billsby - https://support.billsby.com/reference +- OneBill - https://dev.onebillsoftware.com/ +- Zenskar - https://docs.zenskar.com/reference +- Ordway - https://ordwaylabs.stoplight.io/docs/ordway/c8689b2db4056-overview +- Amberflo - https://docs.amberflo.io/reference +- Togai - https://docs.togai.com/api-reference/getting-started + +## pricing-pilot-packaging +- PandaDoc - https://www.pandadoc.com/ +- DocuSign - https://www.docusign.com/ +- Dropbox Sign - https://sign.dropbox.com/api/documentation +- Adobe Acrobat Sign - https://developer.adobe.com/acrobat-sign/docs/overview/developer_guide/ +- SignNow - https://docs.signnow.com/ +- Ironclad - https://developer.ironcladapp.com/ +- Juro - https://api-docs.juro.com/ +- Concord - https://help.concord.app/concord-api +- Contractbook - https://api.contractbook.com/ +- Qwilr - https://docs.qwilr.com/ + +## gtm-channel-strategy +- LinkedIn Marketing API - https://developer.linkedin.com/product-catalog/marketing +- TikTok API for Business - https://business-api.tiktok.com/ +- Snap Marketing API - https://docs.snap.com/api/marketing-api/Ads-API/introduction +- Microsoft Advertising API - https://learn.microsoft.com/en-us/advertising/ +- Taboola Backstage API - https://developers.taboola.com/backstage-api/reference/backstage-api +- Outbrain Amplify API - https://developer.outbrain.com/apis +- Spotify Ads API - https://developer.spotify.com/documentation/ads-api/guides +- Criteo Marketing Solutions API - https://developers.criteo.com/marketing-solutions/v2020.07/docs/criteo-marketing-solutions +- StackAdapt API - https://docs.stackadapt.com/ +- Yahoo DSP API - https://developer.yahooinc.com/dsp/api/docs/traffic/ + +## mvp-scope +- Jira - https://www.atlassian.com/software/jira +- Linear - https://linear.app/ +- Asana - https://developers.asana.com/docs/ +- ClickUp - https://developer.clickup.com/ +- Shortcut - https://developer.shortcut.com/api/rest/v3 +- monday.com - https://developer.monday.com/api-reference +- Azure DevOps - https://learn.microsoft.com/en-us/rest/api/azure/devops/ +- ProductPlan - https://productplan.readme.io/reference/overview +- Aha! - https://aha.io/api +- LaunchDarkly - https://launchdarkly.com/ + +## mvp-validation-criteria +- Mixpanel - https://mixpanel.com/ +- Amplitude - https://amplitude.com/ +- Fullstory - https://developer.fullstory.com/ +- VWO - https://developers.vwo.com/reference +- Split - https://docs.split.io/reference/feature-flag-overview +- Heap - https://www.heap.io/ +- PostHog - https://posthog.com/ +- Hotjar - https://help.hotjar.com/hc/en-us/articles/12244109929751-Hotjar-API-Reference +- Mouseflow - https://api-docs.mouseflow.com/ +- Contentsquare - https://docs.contentsquare.com/en/api/metrics + +## pipeline-contact-enrichment +- Lusha - https://www.lusha.com/ +- Hunter - https://hunter.io/api-documentation/ +- Snov.io - https://snov.io/api +- NeverBounce - https://developers.neverbounce.com/reference +- ZeroBounce - https://www.zerobounce.net/docs/email-validation-api-quickstart/ +- Dropcontact - https://developer.dropcontact.com/ +- RocketReach - https://docs.rocketreach.co/reference/rocketreach-api +- Kaspr - https://kaspr.stoplight.io/docs/kaspr-api/branches/main/2ptd62aajjv62-introduction +- Anymail Finder - https://anymailfinder.com/email-finder-api/docs +- Kickbox - https://docs.kickbox.com/docs/using-the-api + +## pilot-onboarding +- Calendly - https://developer.calendly.com/api-docs +- Google Calendar API - https://developers.google.com/calendar/api +- Nylas - https://developer.nylas.com/docs/v3/getting-started/quickstart +- Chili Piper API - https://www.chilipiper.com/integrations/chili-piper-api +- SavvyCal - https://savvycal.com/docs/api +- Appcues - https://api.appcues.net/v2/docs +- Userflow - https://docs.userflow.com/docs/api +- WalkMe - https://developer.walkme.com/reference +- Planhat - https://docs.planhat.com/ +- Vitally - https://docs.vitally.io/en/collections/10410457-rest-api + +## pilot-success-tracking +- Gainsight - https://support.gainsight.com/gainsight_nxt/API_and_Developer_Docs +- Totango - https://support.totango.com/hc/en-us/articles/203639605-Totango-HTTP-API-Overview +- ClientSuccess - https://clientsuccess.readme.io/v2.0/ +- Custify - https://docs.custify.com/ +- HubSpot Deals API - https://developers.hubspot.com/docs/api/crm/deals +- Pipedrive - https://developers.pipedrive.com/docs/api/v1/Deals +- Close CRM - https://developer.close.com/resources/opportunities/ +- AskNicely - https://asknicely.asknice.ly/help/apidocs +- Survicate - https://developers.survicate.com/ +- CustomerSure - https://developer.customersure.com/v1/ + +## pilot-conversion +- Proposify - https://apidocs.proposify.com/ +- Oneflow - https://developer.oneflow.com/ +- GetAccept - https://app.getaccept.com/api/ +- Zoho Sign - https://www.zoho.com/sign/api/ +- DealHub - https://developers.dealhub.io/ +- Zoho CRM - https://www.zoho.com/crm/developer/docs/ +- Freshsales - https://developers.freshworks.com/crm/api/ +- Insightly - https://api.insight.ly/v2.3/Help +- Nutshell - https://developers.nutshell.com/ +- Salesflare - https://api.salesflare.com/docs diff --git a/flexus_client_kit/ckit_cloudtool.py b/flexus_client_kit/ckit_cloudtool.py index ea3d8e54..a0c153a2 100644 --- a/flexus_client_kit/ckit_cloudtool.py +++ b/flexus_client_kit/ckit_cloudtool.py @@ -109,6 +109,7 @@ class CloudTool: name: str description: str parameters: dict + # in Athropic there is also "defer_loading", that is kind of interesting "official" way to do schema discovery def openai_style_tool(self): def add_order(obj): diff --git a/flexus_client_kit/ckit_integrations_db.py b/flexus_client_kit/ckit_integrations_db.py index 3bffd007..0eb9e838 100644 --- a/flexus_client_kit/ckit_integrations_db.py +++ b/flexus_client_kit/ckit_integrations_db.py @@ -1,3 +1,5 @@ +import importlib +import inspect from dataclasses import dataclass, field from pathlib import Path from typing import Any, Awaitable, Callable @@ -77,31 +79,63 @@ async def _init_gmail(rcx, setup): elif name == "google_calendar": from flexus_client_kit.integrations import fi_google_calendar + + has_legacy_api = ( + hasattr(fi_google_calendar, "GOOGLE_CALENDAR_TOOL") + and hasattr(fi_google_calendar, "REQUIRED_SCOPES") + ) + + if has_legacy_api: + gcal_tool = getattr(fi_google_calendar, "GOOGLE_CALENDAR_TOOL") + gcal_scopes = getattr(fi_google_calendar, "REQUIRED_SCOPES") + else: + gcal_tool = ckit_cloudtool.CloudTool( + strict=True, + name=getattr(fi_google_calendar, "PROVIDER_NAME", "google_calendar"), + description="google_calendar: calendar provider. op=help|status|list_methods|call", + parameters={ + "type": "object", + "properties": { + "op": {"type": "string", "enum": ["help", "status", "list_methods", "call"]}, + "args": {"type": ["object", "null"]}, + }, + "required": ["op", "args"], + "additionalProperties": False, + }, + ) + gcal_scopes = [] + + integration_cls = getattr(fi_google_calendar, "IntegrationGoogleCalendar") async def _init_gcal(rcx, setup): - return fi_google_calendar.IntegrationGoogleCalendar(rcx.fclient, rcx) + try: + return integration_cls(rcx.fclient, rcx) + except TypeError: + try: + return integration_cls(rcx) + except TypeError: + return integration_cls() + result.append(IntegrationRecord( integr_name=name, - integr_tools=[fi_google_calendar.GOOGLE_CALENDAR_TOOL], + integr_tools=[gcal_tool], integr_init=_init_gcal, - integr_setup_handlers=lambda obj, rcx: [rcx.on_tool_call("google_calendar")(obj.called_by_model)], + integr_setup_handlers=lambda obj, rcx, _t=gcal_tool: [rcx.on_tool_call(_t.name)(obj.called_by_model)], integr_provider="google", - integr_scopes=fi_google_calendar.REQUIRED_SCOPES, - integr_prompt="", + integr_scopes=gcal_scopes, )) elif name == "jira": - from flexus_client_kit.integrations import fi_jira + fi_jira = importlib.import_module("flexus_client_kit.integrations.fi_jira") async def _init_jira(rcx, setup): url = (setup or {}).get("jira_instance_url", "") - return fi_jira.IntegrationJira(rcx.fclient, rcx, jira_instance_url=url) + return getattr(fi_jira, "IntegrationJira")(rcx.fclient, rcx, jira_instance_url=url) result.append(IntegrationRecord( integr_name=name, - integr_tools=[fi_jira.JIRA_TOOL], + integr_tools=[getattr(fi_jira, "JIRA_TOOL")], integr_init=_init_jira, integr_setup_handlers=lambda obj, rcx: [rcx.on_tool_call("jira")(obj.called_by_model)], integr_provider="atlassian", - integr_scopes=fi_jira.REQUIRED_SCOPES, - integr_prompt="", + integr_scopes=getattr(fi_jira, "REQUIRED_SCOPES"), )) elif name.startswith("facebook"): # "facebook[account, adset]" @@ -124,10 +158,7 @@ async def _init_facebook(rcx, setup, _bunch=fb_bunch): elif name == "linkedin": from flexus_client_kit.integrations import fi_linkedin async def _init_linkedin(rcx, setup): - ad_account_id = (setup or {}).get("ad_account_id", "") - return fi_linkedin.IntegrationLinkedIn( - rcx.fclient, rcx, ad_account_id=ad_account_id - ) + return fi_linkedin.IntegrationLinkedIn(rcx) result.append(IntegrationRecord( integr_name=name, integr_tools=[fi_linkedin.LINKEDIN_TOOL], @@ -135,20 +166,57 @@ async def _init_linkedin(rcx, setup): integr_setup_handlers=lambda obj, rcx: [rcx.on_tool_call("linkedin")(obj.called_by_model)], integr_provider="linkedin", integr_scopes=[ - "r_profile_basicinfo", + "openid", + "profile", "email", "w_member_social", ], integr_prompt="", )) + elif name == "linkedin_b2b": + from flexus_client_kit.integrations import fi_linkedin_b2b + async def _init_linkedin_b2b(rcx, setup): + return fi_linkedin_b2b.IntegrationLinkedinB2B( + rcx, + ad_account_id=(setup or {}).get("ad_account_id", ""), + organization_id=(setup or {}).get("organization_id", ""), + linkedin_api_version=(setup or {}).get("linkedin_api_version", "202509"), + ) + result.append(IntegrationRecord( + integr_name=name, + integr_tools=[fi_linkedin_b2b.LINKEDIN_B2B_TOOL], + integr_init=_init_linkedin_b2b, + integr_setup_handlers=lambda obj, rcx: [rcx.on_tool_call("linkedin_b2b")(obj.called_by_model)], + integr_provider="linkedin", + integr_scopes=[ + "r_ads", + "rw_ads", + "r_ads_reporting", + "r_organization_admin", + "rw_organization_admin", + "r_organization_social", + "w_organization_social", + "r_organization_social_feed", + "w_organization_social_feed", + "r_organization_followers", + "r_events", + "rw_events", + "r_marketing_leadgen_automation", + "rw_conversions", + "r_member_profileAnalytics", + "r_member_postAnalytics", + "rw_dmp_segments", + ], + )) + elif name == "github": - from flexus_client_kit.integrations import fi_github + fi_github = importlib.import_module("flexus_client_kit.integrations.fi_github") async def _init_github(rcx, setup): - return fi_github.IntegrationGitHub(rcx.fclient, rcx) + return getattr(fi_github, "IntegrationGitHub")(rcx.fclient, rcx) result.append(IntegrationRecord( integr_name=name, - integr_tools=[fi_github.GITHUB_TOOL], + integr_tools=[getattr(fi_github, "GITHUB_TOOL")], integr_init=_init_github, integr_setup_handlers=lambda obj, rcx: [rcx.on_tool_call("github")(obj.called_by_model)], integr_provider="github", @@ -246,8 +314,97 @@ def _setup_crm(obj, rcx, _tam=tools_and_methods): integr_prompt=fi_crm.LOG_CRM_ACTIVITIES_PROMPT if "log_activity" in subset else "", )) + elif name == "newsapi": + from flexus_client_kit.integrations import fi_newsapi + newsapi_tool = ckit_cloudtool.CloudTool( + strict=True, + name=fi_newsapi.PROVIDER_NAME, + description=f"{fi_newsapi.PROVIDER_NAME}: data provider. op=help|status|list_methods|call", + parameters={ + "type": "object", + "properties": { + "op": {"type": "string", "enum": ["help", "status", "list_methods", "call"]}, + "args": {"type": ["object", "null"]}, + }, + "required": ["op", "args"], + "additionalProperties": False, + }, + ) + async def _init_newsapi(rcx, setup): + return fi_newsapi.IntegrationNewsapi(rcx) + result.append(IntegrationRecord( + integr_name=fi_newsapi.PROVIDER_NAME, + integr_tools=[newsapi_tool], + integr_init=_init_newsapi, + integr_setup_handlers=lambda obj, rcx, _t=newsapi_tool: [rcx.on_tool_call(_t.name)(obj.called_by_model)], + integr_provider=fi_newsapi.PROVIDER_NAME, + )) + else: - raise ValueError(f"Unknown integration {name!r}") + # Generic handler for any fi_{name}.py integration that follows the standard pattern. + # Avoids writing an explicit elif branch for every one of the 70+ API providers. + + # Import fi_{name}.py at runtime by name (e.g. "reddit" → fi_reddit.py). + # We can't do this at the top of the file because the name is only known at call time. + mod = importlib.import_module(f"flexus_client_kit.integrations.fi_{name}") + + # Each fi_*.py defines exactly one class named Integration* (e.g. IntegrationReddit). + # inspect.getmembers lists all class objects in the module. + # The c.__module__ == mod.__name__ guard skips classes that were *imported into* the + # module from elsewhere (e.g. base classes), keeping only the one defined there. + integration_class = next( + (c for _, c in inspect.getmembers(mod, inspect.isclass) + if c.__name__.startswith("Integration") and c.__module__ == mod.__name__), + None, + ) + if integration_class is None: + raise ValueError(f"No Integration* class found in fi_{name}.py") + + # fi_*.py defines PROVIDER_NAME = "reddit" (may differ from the file name fi_x.py → "x"). + provider_name = getattr(mod, "PROVIDER_NAME", name) + + # All fi_*.py integrations speak the same op=help|status|list_methods|call protocol, + # so one tool schema covers all of them. + generic_tool = ckit_cloudtool.CloudTool( + strict=True, + name=provider_name, + description=f"{provider_name}: data provider. op=help|status|list_methods|call", + parameters={ + "type": "object", + "properties": { + "op": {"type": "string", "enum": ["help", "status", "list_methods", "call"]}, + "args": {"type": ["object", "null"]}, + }, + "required": ["op", "args"], + "additionalProperties": False, + }, + ) + + # XXX: _make_generic_init is a factory function, not a plain closure, to avoid a + # classic Python loop-capture bug. If we wrote `async def _init(rcx, setup): cls(rcx)` + # directly in the loop body, every _init would close over the *same* `integration_class` + # variable and all end up calling the last provider's class after the loop finishes. + # Passing `klass` as a function argument freezes its value for each iteration. + def _make_generic_init(klass): + async def _init(rcx, setup, _cls=klass): + # XXX: fi_*.py constructors are inconsistent: some accept (rcx), some accept + # nothing. Try the more common (rcx) first; fall back to () on TypeError. + try: + return _cls(rcx) + except TypeError: + return _cls() + return _init + + result.append(IntegrationRecord( + integr_name=provider_name, + integr_tools=[generic_tool], + integr_init=_make_generic_init(integration_class), + # _t=generic_tool captures the current tool into the lambda for the same reason + # as _make_generic_init above: without it all lambdas would share the last tool. + integr_setup_handlers=lambda obj, rcx, _t=generic_tool: [ + rcx.on_tool_call(_t.name)(obj.called_by_model) + ], + )) return result @@ -257,7 +414,7 @@ def _parse_bracket_list(name: str) -> list[str] | None: return [g.strip() for g in name.split("[", 1)[1].rstrip("]").split(",")] -async def main_loop_integrations_init(records: list[IntegrationRecord], rcx: ckit_bot_exec.RobotContext, setup: dict) -> dict[str, Any]: +async def main_loop_integrations_init(records: list[IntegrationRecord], rcx, setup: dict | None = None) -> dict[str, Any]: from flexus_client_kit.integrations import fi_messenger if any(rec.integr_need_mongo for rec in records) and rcx.personal_mongo is None: from pymongo import AsyncMongoClient diff --git a/flexus_client_kit/ckit_skills.py b/flexus_client_kit/ckit_skills.py index 46189474..b97dd494 100644 --- a/flexus_client_kit/ckit_skills.py +++ b/flexus_client_kit/ckit_skills.py @@ -105,6 +105,8 @@ def static_skills_find(bot_root_dir: Path, shared_skills_allowlist: str) -> List is_shared = (d == shared_dir) for p in d.glob("*/SKILL.md"): name = p.parent.name + if name.startswith("_"): + continue if is_shared and not _match_allowlist(name, shared_skills_allowlist): continue _validate_skill(p, p.read_text()) @@ -117,6 +119,8 @@ def read_name_description(bot_root_dir: Path, skills: List[str]) -> str: result = [] for name in skills: for d in _skill_dirs(bot_root_dir): + if name.startswith("_"): + continue p = d / name / "SKILL.md" if p.is_file(): front = _parse_frontmatter(p.read_text()) diff --git a/flexus_client_kit/integrations/facebook/accounts.py b/flexus_client_kit/integrations/facebook/accounts.py index 4da4cbd6..b7d4b572 100644 --- a/flexus_client_kit/integrations/facebook/accounts.py +++ b/flexus_client_kit/integrations/facebook/accounts.py @@ -134,6 +134,49 @@ def _format_account_summary(acc: Dict[str, Any]) -> str: return result +async def list_account_users(client: "FacebookAdsClient", ad_account_id: str) -> str: + if not ad_account_id: + return "ERROR: ad_account_id parameter is required" + try: + ad_account_id = validate_ad_account_id(ad_account_id) + except FacebookValidationError as e: + return f"ERROR: {e.message}" + if client.is_test_mode: + return f"Users for {ad_account_id}:\n Test User (ID: 123456789) — ADMIN\n" + data = await client.request( + "GET", f"{ad_account_id}/users", + params={"fields": "id,name,role,status", "limit": 50}, + ) + users = data.get("data", []) + if not users: + return f"No users found for {ad_account_id}" + result = f"Users for {ad_account_id} ({len(users)} total):\n\n" + for u in users: + result += f" **{u.get('name', 'Unknown')}** (ID: {u.get('id', 'N/A')}) — {u.get('role', 'N/A')}\n" + return result + + +async def list_pages(client: "FacebookAdsClient") -> str: + if client.is_test_mode: + return "Pages you manage:\n Test Page (ID: 111111111) — ACTIVE\n" + data = await client.request( + "GET", "me/accounts", + params={"fields": "id,name,category,tasks,access_token", "limit": 50}, + ) + pages = data.get("data", []) + if not pages: + return "No pages found. You need to be an admin of at least one Facebook Page to create ads." + result = f"Pages you manage ({len(pages)} total):\n\n" + for page in pages: + tasks = ", ".join(page.get("tasks", [])) + result += f" **{page.get('name', 'Unnamed')}** (ID: {page['id']})\n" + result += f" Category: {page.get('category', 'N/A')}\n" + if tasks: + result += f" Tasks: {tasks}\n" + result += "\n" + return result + + def _mock_list_ad_accounts() -> str: return """Found 1 ad account: **Test Ad Account** diff --git a/flexus_client_kit/integrations/facebook/ads.py b/flexus_client_kit/integrations/facebook/ads.py index 4de121c9..0e1a59b1 100644 --- a/flexus_client_kit/integrations/facebook/ads.py +++ b/flexus_client_kit/integrations/facebook/ads.py @@ -215,6 +215,249 @@ async def create_ad( """ +async def get_ad(client: "FacebookAdsClient", ad_id: str) -> str: + if not ad_id: + return "ERROR: ad_id is required" + if client.is_test_mode: + return f"Ad {ad_id}:\n Name: Test Ad\n Status: PAUSED\n Adset ID: 234567890\n Creative ID: 987654321\n" + data = await client.request( + "GET", ad_id, + params={"fields": "id,name,status,adset_id,creative,created_time,updated_time,effective_status,bid_amount"}, + ) + result = f"Ad {ad_id}:\n" + result += f" Name: {data.get('name', 'N/A')}\n" + result += f" Status: {data.get('status', 'N/A')} (effective: {data.get('effective_status', 'N/A')})\n" + result += f" Adset ID: {data.get('adset_id', 'N/A')}\n" + creative = data.get("creative", {}) + if creative: + result += f" Creative ID: {creative.get('id', 'N/A')}\n" + result += f" Created: {data.get('created_time', 'N/A')}\n" + return result + + +async def update_ad( + client: "FacebookAdsClient", + ad_id: str, + name: Optional[str] = None, + status: Optional[str] = None, +) -> str: + if not ad_id: + return "ERROR: ad_id is required" + if not any([name, status]): + return "ERROR: At least one field to update is required (name, status)" + if status and status not in ["ACTIVE", "PAUSED", "ARCHIVED", "DELETED"]: + return "ERROR: status must be one of: ACTIVE, PAUSED, ARCHIVED, DELETED" + if client.is_test_mode: + updates = [] + if name: + updates.append(f"name -> {name}") + if status: + updates.append(f"status -> {status}") + return f"Ad {ad_id} updated:\n" + "\n".join(f" - {u}" for u in updates) + data: Dict[str, Any] = {} + if name: + data["name"] = name + if status: + data["status"] = status + result = await client.request("POST", ad_id, data=data) + if result.get("success"): + return f"Ad {ad_id} updated successfully." + return f"Failed to update ad. Response: {result}" + + +async def delete_ad(client: "FacebookAdsClient", ad_id: str) -> str: + if not ad_id: + return "ERROR: ad_id is required" + if client.is_test_mode: + return f"Ad {ad_id} deleted successfully." + result = await client.request("DELETE", ad_id) + if result.get("success"): + return f"Ad {ad_id} deleted successfully." + return f"Failed to delete ad. Response: {result}" + + +async def list_ads( + client: "FacebookAdsClient", + ad_account_id: Optional[str] = None, + adset_id: Optional[str] = None, + status_filter: Optional[str] = None, +) -> str: + if not ad_account_id and not adset_id: + return "ERROR: Either ad_account_id or adset_id is required" + parent = adset_id if adset_id else ad_account_id + endpoint = f"{parent}/ads" + if client.is_test_mode: + return f"Ads for {parent}:\n Test Ad (ID: 111222333444555) — PAUSED\n" + params: Dict[str, Any] = { + "fields": "id,name,status,adset_id,creative{id},effective_status", + "limit": 100, + } + if status_filter: + params["effective_status"] = f'["{status_filter.upper()}"]' + data = await client.request("GET", endpoint, params=params) + ads = data.get("data", []) + if not ads: + return f"No ads found for {parent}" + result = f"Ads for {parent} ({len(ads)} total):\n\n" + for ad in ads: + creative_id = ad.get("creative", {}).get("id", "N/A") if ad.get("creative") else "N/A" + result += f" **{ad.get('name', 'Unnamed')}** (ID: {ad['id']})\n" + result += f" Status: {ad.get('status', 'N/A')} | Adset: {ad.get('adset_id', 'N/A')} | Creative: {creative_id}\n\n" + return result + + +async def list_creatives( + client: "FacebookAdsClient", + ad_account_id: str, +) -> str: + if not ad_account_id: + return "ERROR: ad_account_id is required" + if client.is_test_mode: + return f"Creatives for {ad_account_id}:\n Test Creative (ID: 987654321)\n" + data = await client.request( + "GET", f"{ad_account_id}/adcreatives", + params={"fields": "id,name,status,object_story_spec,thumbnail_url", "limit": 100}, + ) + creatives = data.get("data", []) + if not creatives: + return f"No creatives found for {ad_account_id}" + result = f"Ad Creatives for {ad_account_id} ({len(creatives)} total):\n\n" + for c in creatives: + result += f" **{c.get('name', 'Unnamed')}** (ID: {c['id']})\n" + if c.get("status"): + result += f" Status: {c['status']}\n" + result += "\n" + return result + + +async def get_creative(client: "FacebookAdsClient", creative_id: str) -> str: + if not creative_id: + return "ERROR: creative_id is required" + if client.is_test_mode: + return f"Creative {creative_id}:\n Name: Test Creative\n Status: ACTIVE\n" + data = await client.request( + "GET", creative_id, + params={"fields": "id,name,status,object_story_spec,call_to_action_type,thumbnail_url,image_hash,video_id"}, + ) + result = f"Creative {creative_id}:\n" + result += f" Name: {data.get('name', 'N/A')}\n" + result += f" Status: {data.get('status', 'N/A')}\n" + if data.get("call_to_action_type"): + result += f" CTA: {data['call_to_action_type']}\n" + if data.get("image_hash"): + result += f" Image Hash: {data['image_hash']}\n" + if data.get("video_id"): + result += f" Video ID: {data['video_id']}\n" + return result + + +async def update_creative( + client: "FacebookAdsClient", + creative_id: str, + name: Optional[str] = None, +) -> str: + if not creative_id: + return "ERROR: creative_id is required" + if not name: + return "ERROR: name is required (only name can be updated on existing creatives)" + if client.is_test_mode: + return f"Creative {creative_id} updated: name -> {name}" + result = await client.request("POST", creative_id, data={"name": name}) + if result.get("success"): + return f"Creative {creative_id} updated: name -> {name}" + return f"Failed to update creative. Response: {result}" + + +async def delete_creative(client: "FacebookAdsClient", creative_id: str) -> str: + if not creative_id: + return "ERROR: creative_id is required" + if client.is_test_mode: + return f"Creative {creative_id} deleted successfully." + result = await client.request("DELETE", creative_id) + if result.get("success"): + return f"Creative {creative_id} deleted successfully." + return f"Failed to delete creative. Response: {result}" + + +async def preview_creative( + client: "FacebookAdsClient", + creative_id: str, + ad_format: str = "DESKTOP_FEED_STANDARD", +) -> str: + if not creative_id: + return "ERROR: creative_id is required" + try: + AdFormat(ad_format) + except ValueError: + valid = [f.value for f in AdFormat] + return f"ERROR: Invalid ad_format. Must be one of: {', '.join(valid)}" + if client.is_test_mode: + return f"Creative Preview for {creative_id}:\n Format: {ad_format}\n Preview URL: https://facebook.com/ads/preview/mock_{creative_id}\n" + data = await client.request("GET", f"{creative_id}/previews", params={"ad_format": ad_format}) + previews = data.get("data", []) + if not previews: + return "No preview available for this creative" + body = previews[0].get("body", "") + if body: + return f"Creative Preview for {creative_id} ({ad_format}):\n{body[:500]}...\n" + return f"Preview available but no body content. Response: {previews[0]}" + + +async def upload_video( + client: "FacebookAdsClient", + video_url: str, + ad_account_id: Optional[str] = None, + title: Optional[str] = None, + description: Optional[str] = None, +) -> str: + if not video_url: + return "ERROR: video_url is required" + account_id = ad_account_id or client.ad_account_id + if not account_id: + return "ERROR: ad_account_id is required" + if client.is_test_mode: + return f"Video uploaded from URL:\n Video ID: mock_video_123\n Account: {account_id}\n URL: {video_url}\n" + form_data: Dict[str, Any] = { + "file_url": video_url, + "access_token": client.access_token, + } + if title: + form_data["title"] = title + if description: + form_data["description"] = description + logger.info(f"Uploading video from URL to {account_id}/advideos") + result = await client.request("POST", f"{account_id}/advideos", form_data=form_data) + video_id = result.get("id") + if not video_id: + return f"Failed to upload video. Response: {result}" + return f"Video uploaded successfully!\n Video ID: {video_id}\n Account: {account_id}\n Use video_id in create_creative with video_data spec.\n" + + +async def list_videos( + client: "FacebookAdsClient", + ad_account_id: str, +) -> str: + if not ad_account_id: + return "ERROR: ad_account_id is required" + if client.is_test_mode: + return f"Videos for {ad_account_id}:\n Test Video (ID: mock_video_123) — READY\n" + data = await client.request( + "GET", f"{ad_account_id}/advideos", + params={"fields": "id,title,description,length,status,created_time", "limit": 50}, + ) + videos = data.get("data", []) + if not videos: + return f"No videos found for {ad_account_id}" + result = f"Ad Videos for {ad_account_id} ({len(videos)} total):\n\n" + for v in videos: + result += f" **{v.get('title', 'Untitled')}** (ID: {v['id']})\n" + result += f" Status: {v.get('status', 'N/A')} | Length: {v.get('length', 'N/A')}s\n" + if v.get("description"): + result += f" Description: {v['description'][:80]}\n" + result += "\n" + return result + + async def preview_ad(client: "FacebookAdsClient", ad_id: str, ad_format: str = "DESKTOP_FEED_STANDARD") -> str: if not ad_id: return "ERROR: ad_id is required" diff --git a/flexus_client_kit/integrations/facebook/adsets.py b/flexus_client_kit/integrations/facebook/adsets.py index 6a74ecd2..981f248f 100644 --- a/flexus_client_kit/integrations/facebook/adsets.py +++ b/flexus_client_kit/integrations/facebook/adsets.py @@ -222,6 +222,67 @@ async def validate_targeting( return output +async def get_adset(client: "FacebookAdsClient", adset_id: str) -> str: + if not adset_id: + return "ERROR: adset_id is required" + if client.is_test_mode: + return f"Ad Set {adset_id}:\n Name: Test Ad Set\n Status: ACTIVE\n Optimization: LINK_CLICKS\n Daily Budget: 20.00 USD\n" + data = await client.request( + "GET", adset_id, + params={"fields": "id,name,status,optimization_goal,billing_event,bid_strategy,bid_amount,daily_budget,lifetime_budget,targeting,campaign_id,created_time,updated_time,start_time,end_time"}, + ) + result = f"Ad Set {adset_id}:\n" + result += f" Name: {data.get('name', 'N/A')}\n" + result += f" Status: {data.get('status', 'N/A')}\n" + result += f" Campaign ID: {data.get('campaign_id', 'N/A')}\n" + result += f" Optimization: {data.get('optimization_goal', 'N/A')}\n" + result += f" Billing Event: {data.get('billing_event', 'N/A')}\n" + result += f" Bid Strategy: {data.get('bid_strategy', 'N/A')}\n" + if data.get("daily_budget"): + result += f" Daily Budget: {format_currency(int(data['daily_budget']))}\n" + if data.get("lifetime_budget"): + result += f" Lifetime Budget: {format_currency(int(data['lifetime_budget']))}\n" + result += f" Created: {data.get('created_time', 'N/A')}\n" + return result + + +async def delete_adset(client: "FacebookAdsClient", adset_id: str) -> str: + if not adset_id: + return "ERROR: adset_id is required" + if client.is_test_mode: + return f"Ad Set {adset_id} deleted successfully." + result = await client.request("DELETE", adset_id) + if result.get("success"): + return f"Ad Set {adset_id} deleted successfully." + return f"Failed to delete ad set. Response: {result}" + + +async def list_adsets_for_account( + client: "FacebookAdsClient", + ad_account_id: str, + status_filter: Optional[str] = None, +) -> str: + if not ad_account_id: + return "ERROR: ad_account_id is required" + if client.is_test_mode: + return f"Ad Sets for account {ad_account_id}:\n Test Ad Set (ID: 234567890123456) — ACTIVE\n" + params: Dict[str, Any] = {"fields": ADSET_FIELDS + ",campaign_id", "limit": 100} + if status_filter: + params["filtering"] = json.dumps([{"field": "adset.delivery_info", "operator": "IN", "value": [status_filter.upper()]}]) + data = await client.request("GET", f"{ad_account_id}/adsets", params=params) + adsets = data.get("data", []) + if not adsets: + return f"No ad sets found for account {ad_account_id}" + result = f"Ad Sets for account {ad_account_id} ({len(adsets)} total):\n\n" + for adset in adsets: + result += f" **{adset.get('name', 'Unnamed')}** (ID: {adset['id']})\n" + result += f" Status: {adset.get('status', 'N/A')} | Campaign: {adset.get('campaign_id', 'N/A')}\n" + if adset.get("daily_budget"): + result += f" Daily Budget: {format_currency(int(adset['daily_budget']))}\n" + result += "\n" + return result + + def _mock_list_adsets(campaign_id: str) -> str: return f"""Ad Sets for Campaign {campaign_id}: **Test Ad Set** diff --git a/flexus_client_kit/integrations/facebook/audiences.py b/flexus_client_kit/integrations/facebook/audiences.py new file mode 100644 index 00000000..b0a630c5 --- /dev/null +++ b/flexus_client_kit/integrations/facebook/audiences.py @@ -0,0 +1,207 @@ +from __future__ import annotations +import hashlib +import logging +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +from flexus_client_kit.integrations.facebook.models import CustomAudienceSubtype + +if TYPE_CHECKING: + from flexus_client_kit.integrations.facebook.client import FacebookAdsClient + +logger = logging.getLogger("facebook.operations.audiences") + +AUDIENCE_FIELDS = "id,name,subtype,description,approximate_count,delivery_status,created_time,updated_time" + + +async def list_custom_audiences( + client: "FacebookAdsClient", + ad_account_id: str, +) -> str: + if not ad_account_id: + return "ERROR: ad_account_id is required" + if client.is_test_mode: + return f"Custom Audiences for {ad_account_id}:\n Test Audience (ID: 111222333) — CUSTOM — ~5,000 people\n" + data = await client.request( + "GET", f"{ad_account_id}/customaudiences", + params={"fields": AUDIENCE_FIELDS, "limit": 100}, + ) + audiences = data.get("data", []) + if not audiences: + return f"No custom audiences found for {ad_account_id}" + result = f"Custom Audiences for {ad_account_id} ({len(audiences)} total):\n\n" + for a in audiences: + count = a.get("approximate_count", "Unknown") + result += f" **{a.get('name', 'Unnamed')}** (ID: {a['id']})\n" + result += f" Type: {a.get('subtype', 'N/A')} | Size: ~{count} people\n" + if a.get("description"): + result += f" Description: {a['description'][:80]}\n" + result += "\n" + return result + + +async def create_custom_audience( + client: "FacebookAdsClient", + ad_account_id: str, + name: str, + subtype: str = "CUSTOM", + description: Optional[str] = None, + customer_file_source: Optional[str] = None, +) -> str: + if not ad_account_id: + return "ERROR: ad_account_id is required" + if not name: + return "ERROR: name is required" + try: + CustomAudienceSubtype(subtype) + except ValueError: + valid = [s.value for s in CustomAudienceSubtype] + return f"ERROR: Invalid subtype. Must be one of: {', '.join(valid)}" + if client.is_test_mode: + return f"Custom audience created:\n Name: {name}\n ID: mock_audience_123\n Subtype: {subtype}\n" + data: Dict[str, Any] = { + "name": name, + "subtype": subtype, + } + if description: + data["description"] = description + if customer_file_source: + data["customer_file_source"] = customer_file_source + result = await client.request("POST", f"{ad_account_id}/customaudiences", data=data) + audience_id = result.get("id") + if not audience_id: + return f"Failed to create audience. Response: {result}" + return f"Custom audience created:\n Name: {name}\n ID: {audience_id}\n Subtype: {subtype}\n Use add_users_to_audience to populate it.\n" + + +async def create_lookalike_audience( + client: "FacebookAdsClient", + ad_account_id: str, + origin_audience_id: str, + country: str, + ratio: float = 0.01, + name: Optional[str] = None, +) -> str: + if not ad_account_id: + return "ERROR: ad_account_id is required" + if not origin_audience_id: + return "ERROR: origin_audience_id is required" + if not country: + return "ERROR: country is required (e.g. 'US', 'GB')" + if not 0.01 <= ratio <= 0.20: + return "ERROR: ratio must be between 0.01 (1%) and 0.20 (20%)" + audience_name = name or f"Lookalike ({country}, {int(ratio*100)}%) of {origin_audience_id}" + if client.is_test_mode: + return f"Lookalike audience created:\n Name: {audience_name}\n ID: mock_lookalike_456\n Country: {country}\n Ratio: {ratio*100:.0f}%\n" + data: Dict[str, Any] = { + "name": audience_name, + "subtype": "LOOKALIKE", + "origin_audience_id": origin_audience_id, + "lookalike_spec": { + "country": country, + "ratio": ratio, + "type": "similarity", + }, + } + result = await client.request("POST", f"{ad_account_id}/customaudiences", data=data) + audience_id = result.get("id") + if not audience_id: + return f"Failed to create lookalike audience. Response: {result}" + return f"Lookalike audience created:\n Name: {audience_name}\n ID: {audience_id}\n Country: {country}\n Ratio: {ratio*100:.0f}%\n Source: {origin_audience_id}\n" + + +async def get_custom_audience( + client: "FacebookAdsClient", + audience_id: str, +) -> str: + if not audience_id: + return "ERROR: audience_id is required" + if client.is_test_mode: + return f"Audience {audience_id}:\n Name: Test Audience\n Subtype: CUSTOM\n Size: ~5,000 people\n Status: ready\n" + data = await client.request( + "GET", audience_id, + params={"fields": AUDIENCE_FIELDS + ",rule,lookalike_spec,pixel_id"}, + ) + result = f"Audience {audience_id}:\n" + result += f" Name: {data.get('name', 'N/A')}\n" + result += f" Subtype: {data.get('subtype', 'N/A')}\n" + result += f" Size: ~{data.get('approximate_count', 'Unknown')} people\n" + if data.get("description"): + result += f" Description: {data['description']}\n" + if data.get("delivery_status"): + delivery = data["delivery_status"] + result += f" Delivery Status: {delivery.get('code', 'N/A')} — {delivery.get('description', '')}\n" + result += f" Created: {data.get('created_time', 'N/A')}\n" + return result + + +async def update_custom_audience( + client: "FacebookAdsClient", + audience_id: str, + name: Optional[str] = None, + description: Optional[str] = None, +) -> str: + if not audience_id: + return "ERROR: audience_id is required" + if not any([name, description]): + return "ERROR: At least one field to update is required (name, description)" + if client.is_test_mode: + updates = [] + if name: + updates.append(f"name -> {name}") + if description: + updates.append(f"description -> {description}") + return f"Audience {audience_id} updated:\n" + "\n".join(f" - {u}" for u in updates) + data: Dict[str, Any] = {} + if name: + data["name"] = name + if description: + data["description"] = description + result = await client.request("POST", audience_id, data=data) + if result.get("success"): + return f"Audience {audience_id} updated successfully." + return f"Failed to update audience. Response: {result}" + + +async def delete_custom_audience( + client: "FacebookAdsClient", + audience_id: str, +) -> str: + if not audience_id: + return "ERROR: audience_id is required" + if client.is_test_mode: + return f"Audience {audience_id} deleted successfully." + result = await client.request("DELETE", audience_id) + if result.get("success"): + return f"Audience {audience_id} deleted successfully." + return f"Failed to delete audience. Response: {result}" + + +async def add_users_to_audience( + client: "FacebookAdsClient", + audience_id: str, + emails: List[str], + phones: Optional[List[str]] = None, +) -> str: + if not audience_id: + return "ERROR: audience_id is required" + if not emails: + return "ERROR: emails list is required and cannot be empty" + hashed_emails = [hashlib.sha256(e.strip().lower().encode()).hexdigest() for e in emails] + schema = ["EMAIL"] + user_data = [[h] for h in hashed_emails] + if phones: + schema = ["EMAIL", "PHONE"] + hashed_phones = [hashlib.sha256(''.join(c for c in p if c.isdigit()).encode()).hexdigest() for p in phones] + user_data = [[e, p] for e, p in zip(hashed_emails, hashed_phones)] + if client.is_test_mode: + return f"Users added to audience {audience_id}:\n Emails: {len(emails)} (SHA-256 hashed)\n Schema: {schema}\n" + payload: Dict[str, Any] = { + "payload": { + "schema": schema, + "data": user_data, + } + } + result = await client.request("POST", f"{audience_id}/users", data=payload) + num_received = result.get("num_received", 0) + num_invalid = result.get("num_invalid_entries", 0) + return f"Users added to audience {audience_id}:\n Received: {num_received}\n Invalid: {num_invalid}\n Accepted: {num_received - num_invalid}\n" diff --git a/flexus_client_kit/integrations/facebook/campaigns.py b/flexus_client_kit/integrations/facebook/campaigns.py index e077920b..06d88fb6 100644 --- a/flexus_client_kit/integrations/facebook/campaigns.py +++ b/flexus_client_kit/integrations/facebook/campaigns.py @@ -3,7 +3,7 @@ from typing import Any, Dict, List, Optional, TYPE_CHECKING from flexus_client_kit.integrations.facebook.models import CampaignObjective from flexus_client_kit.integrations.facebook.utils import format_currency, validate_budget, normalize_insights_data -from flexus_client_kit.integrations.facebook.exceptions import FacebookAPIError, FacebookValidationError +from flexus_client_kit.integrations.facebook.exceptions import FacebookAPIError, FacebookError, FacebookValidationError if TYPE_CHECKING: from flexus_client_kit.integrations.facebook.client import FacebookAdsClient logger = logging.getLogger("facebook.operations.campaigns") @@ -242,7 +242,7 @@ async def bulk_update_campaigns(client: "FacebookAdsClient", campaigns: List[Dic errors.append(f"{campaign_id}: Update failed") except FacebookAPIError as e: errors.append(f"{campaign_id}: {e.message}") - except Exception as e: + except (FacebookError, ValueError) as e: errors.append(f"{campaign_id}: {str(e)}") output = f"Bulk update completed:\n\n" output += f"Success: {len(results)}\n" @@ -279,6 +279,41 @@ async def get_insights(client: "FacebookAdsClient", campaign_id: str, days: int """ +async def get_campaign(client: "FacebookAdsClient", campaign_id: str) -> str: + if not campaign_id: + return "ERROR: campaign_id is required" + if client.is_test_mode: + return f"Campaign {campaign_id}:\n Name: Test Campaign\n Status: ACTIVE\n Objective: OUTCOME_TRAFFIC\n Daily Budget: 50.00 USD\n" + data = await client.request( + "GET", campaign_id, + params={"fields": "id,name,status,objective,daily_budget,lifetime_budget,special_ad_categories,created_time,updated_time,start_time,stop_time,budget_remaining"}, + ) + result = f"Campaign {campaign_id}:\n" + result += f" Name: {data.get('name', 'N/A')}\n" + result += f" Status: {data.get('status', 'N/A')}\n" + result += f" Objective: {data.get('objective', 'N/A')}\n" + if data.get("daily_budget"): + result += f" Daily Budget: {format_currency(int(data['daily_budget']))}\n" + if data.get("lifetime_budget"): + result += f" Lifetime Budget: {format_currency(int(data['lifetime_budget']))}\n" + if data.get("budget_remaining"): + result += f" Budget Remaining: {format_currency(int(data['budget_remaining']))}\n" + result += f" Created: {data.get('created_time', 'N/A')}\n" + result += f" Updated: {data.get('updated_time', 'N/A')}\n" + return result + + +async def delete_campaign(client: "FacebookAdsClient", campaign_id: str) -> str: + if not campaign_id: + return "ERROR: campaign_id is required" + if client.is_test_mode: + return f"Campaign {campaign_id} deleted successfully." + result = await client.request("DELETE", campaign_id) + if result.get("success"): + return f"Campaign {campaign_id} deleted successfully." + return f"Failed to delete campaign. Response: {result}" + + def _mock_list_campaigns() -> str: return """Found 2 campaigns: Test Campaign 1 (ID: 123456789) - ACTIVE, Daily: 50.00 USD diff --git a/flexus_client_kit/integrations/facebook/client.py b/flexus_client_kit/integrations/facebook/client.py index 8f5f5bbb..f46f2bcd 100644 --- a/flexus_client_kit/integrations/facebook/client.py +++ b/flexus_client_kit/integrations/facebook/client.py @@ -72,8 +72,8 @@ async def ensure_auth(self) -> Optional[str]: "Content-Type": "application/json", } return None - except Exception as e: - logger.info(f"Failed to get Facebook token: {e}") + except (AttributeError, KeyError, ValueError) as e: + logger.info("Failed to get Facebook token", exc_info=e) return await self._prompt_oauth_connection() async def request( diff --git a/flexus_client_kit/integrations/facebook/exceptions.py b/flexus_client_kit/integrations/facebook/exceptions.py index e8ba2c62..6cd44110 100644 --- a/flexus_client_kit/integrations/facebook/exceptions.py +++ b/flexus_client_kit/integrations/facebook/exceptions.py @@ -113,8 +113,8 @@ async def parse_api_error(response: httpx.Response) -> FacebookAPIError: code=response.status_code, message=f"HTTP {response.status_code}: {response.text[:500]}", ) - except Exception as e: - logger.warning(f"Error parsing FB API error response: {e}") + except (KeyError, ValueError) as e: + logger.warning("Error parsing FB API error response", exc_info=e) return FacebookAPIError( code=response.status_code, message=f"HTTP {response.status_code}: {response.text[:500]}", diff --git a/flexus_client_kit/integrations/facebook/fi_facebook.py b/flexus_client_kit/integrations/facebook/fi_facebook.py index e1dfc58e..883fc690 100644 --- a/flexus_client_kit/integrations/facebook/fi_facebook.py +++ b/flexus_client_kit/integrations/facebook/fi_facebook.py @@ -9,10 +9,40 @@ FacebookAuthError, FacebookValidationError, ) -from flexus_client_kit.integrations.facebook.accounts import list_ad_accounts, get_ad_account_info, update_spending_limit -from flexus_client_kit.integrations.facebook.campaigns import list_campaigns, create_campaign, update_campaign, duplicate_campaign, archive_campaign, bulk_update_campaigns, get_insights -from flexus_client_kit.integrations.facebook.adsets import list_adsets, create_adset, update_adset, validate_targeting -from flexus_client_kit.integrations.facebook.ads import upload_image, create_creative, create_ad, preview_ad +from flexus_client_kit.integrations.facebook.accounts import ( + list_ad_accounts, get_ad_account_info, update_spending_limit, + list_account_users, list_pages, +) +from flexus_client_kit.integrations.facebook.campaigns import ( + list_campaigns, create_campaign, update_campaign, duplicate_campaign, + archive_campaign, bulk_update_campaigns, get_insights, + get_campaign, delete_campaign, +) +from flexus_client_kit.integrations.facebook.adsets import ( + list_adsets, create_adset, update_adset, validate_targeting, + get_adset, delete_adset, list_adsets_for_account, +) +from flexus_client_kit.integrations.facebook.ads import ( + upload_image, create_creative, create_ad, preview_ad, + get_ad, update_ad, delete_ad, list_ads, + list_creatives, get_creative, update_creative, delete_creative, preview_creative, + upload_video, list_videos, +) +from flexus_client_kit.integrations.facebook.insights import ( + get_account_insights, get_campaign_insights, get_adset_insights, get_ad_insights, + create_async_report, get_async_report_status, +) +from flexus_client_kit.integrations.facebook.audiences import ( + list_custom_audiences, create_custom_audience, create_lookalike_audience, + get_custom_audience, update_custom_audience, delete_custom_audience, add_users_to_audience, +) +from flexus_client_kit.integrations.facebook.pixels import list_pixels, create_pixel, get_pixel_stats +from flexus_client_kit.integrations.facebook.targeting import ( + search_interests, search_behaviors, get_reach_estimate, get_delivery_estimate, +) +from flexus_client_kit.integrations.facebook.rules import ( + list_ad_rules, create_ad_rule, update_ad_rule, delete_ad_rule, execute_ad_rule, +) if TYPE_CHECKING: from flexus_client_kit import ckit_client, ckit_bot_exec @@ -39,58 +69,92 @@ }, ) -HELP = """Help: +HELP = """Facebook Marketing API — Available Operations: + **Connection:** -facebook(op="connect") - Generate OAuth link to connect your Facebook account. + connect — Generate OAuth link to connect your Facebook account. + **Account Operations:** -facebook(op="list_ad_accounts") - Lists all accessible ad accounts. -facebook(op="get_ad_account_info", args={"ad_account_id": "act_123"}) - Get detailed info about an ad account. -facebook(op="status", args={"ad_account_id": "act_123"}) - Shows current ad account status and active campaigns. + list_ad_accounts — List all accessible ad accounts. + get_ad_account_info(ad_account_id) — Detailed info about an ad account. + update_spending_limit(ad_account_id, spending_limit) — Set monthly spending cap (cents). + list_account_users(ad_account_id) — List users with access to an ad account. + list_pages() — List Facebook Pages you manage (for page_id in creatives). + status(ad_account_id) — Current account status and active campaigns. + **Campaign Operations:** -facebook(op="list_campaigns", args={"ad_account_id": "act_123", "status": "ACTIVE"}) - Lists campaigns. Optional filters: status (ACTIVE, PAUSED, ARCHIVED). -facebook(op="create_campaign", args={ - "ad_account_id": "act_123", - "name": "Summer Sale 2025", - "objective": "OUTCOME_TRAFFIC", - "daily_budget": 5000, - "status": "PAUSED" -}) - Creates a new campaign. Budget is in cents (5000 = $50.00). -facebook(op="update_campaign", args={"campaign_id": "123", "status": "ACTIVE"}) - Update campaign settings. -facebook(op="get_insights", args={"campaign_id": "123456", "days": 30}) - Gets performance metrics. + list_campaigns(ad_account_id, status?) — List campaigns, optional status filter. + get_campaign(campaign_id) — Get full details of a single campaign. + create_campaign(ad_account_id, name, objective, daily_budget?, lifetime_budget?, status?) — Create campaign. Budget in cents. + update_campaign(campaign_id, name?, status?, daily_budget?, lifetime_budget?) — Update campaign. + delete_campaign(campaign_id) — Permanently delete a campaign. + duplicate_campaign(campaign_id, new_name, ad_account_id?) — Duplicate a campaign. + archive_campaign(campaign_id) — Archive a campaign. + bulk_update_campaigns(campaigns) — Bulk update multiple campaigns. + **Ad Set Operations:** -facebook(op="list_adsets", args={"campaign_id": "123"}) - Lists ad sets for a campaign. -facebook(op="create_adset", args={ - "ad_account_id": "act_123", - "campaign_id": "123456", - "name": "US 18-35", - "targeting": {"geo_locations": {"countries": ["US"]}} -}) - Creates an ad set with targeting. -**Creative & Ads Operations:** -facebook(op="upload_image", args={"image_url": "https://..."}) - Upload image for ad creative. -facebook(op="create_creative", args={ - "name": "Product Creative", - "page_id": "123", - "image_hash": "abc123", - "link": "https://..." -}) - Create ad creative. -facebook(op="create_ad", args={ - "adset_id": "456", - "creative_id": "789", - "name": "Product Ad" -}) - Create ad from creative. + list_adsets(campaign_id) — List ad sets in a campaign. + list_adsets_for_account(ad_account_id, status_filter?) — List all ad sets in an account. + get_adset(adset_id) — Get full details of a single ad set. + create_adset(ad_account_id, campaign_id, name, targeting, optimization_goal?, billing_event?, status?) — Create ad set. + update_adset(adset_id, name?, status?, daily_budget?, bid_amount?) — Update ad set. + delete_adset(adset_id) — Permanently delete an ad set. + validate_targeting(targeting_spec, ad_account_id?) — Validate targeting spec. + +**Ads Operations:** + list_ads(ad_account_id?, adset_id?, status_filter?) — List ads in account or ad set. + get_ad(ad_id) — Get full details of a single ad. + create_ad(name, adset_id, creative_id, status?, ad_account_id?) — Create an ad. + update_ad(ad_id, name?, status?) — Update ad name or status. + delete_ad(ad_id) — Permanently delete an ad. + preview_ad(ad_id, ad_format?) — Generate ad preview. + +**Creative Operations:** + list_creatives(ad_account_id) — List all ad creatives in account. + get_creative(creative_id) — Get creative details. + create_creative(name, page_id, image_hash, link, message?, headline?, description?, call_to_action_type?, ad_account_id?) — Create creative. + update_creative(creative_id, name) — Update creative name. + delete_creative(creative_id) — Delete a creative. + preview_creative(creative_id, ad_format?) — Preview a creative. + upload_image(image_url?, image_path?, ad_account_id?) — Upload image, returns image_hash. + upload_video(video_url, ad_account_id?, title?, description?) — Upload video from URL. + list_videos(ad_account_id) — List ad videos in account. + +**Insights & Reporting:** + get_insights(campaign_id, days?) — Campaign performance metrics. + get_account_insights(ad_account_id, days?, breakdowns?, metrics?, date_preset?) — Account-level insights. + get_campaign_insights(campaign_id, days?, breakdowns?, metrics?, date_preset?) — Campaign insights with breakdowns. + get_adset_insights(adset_id, days?, breakdowns?, metrics?, date_preset?) — Ad set insights. + get_ad_insights(ad_id, days?, breakdowns?, metrics?, date_preset?) — Ad-level insights. + create_async_report(ad_account_id, level?, fields?, date_preset?, breakdowns?) — Create async report job. + get_async_report_status(report_run_id) — Check async report status. + +**Custom Audiences:** + list_custom_audiences(ad_account_id) — List custom audiences. + create_custom_audience(ad_account_id, name, subtype?, description?) — Create custom audience. Subtypes: CUSTOM, WEBSITE, APP, ENGAGEMENT. + create_lookalike_audience(ad_account_id, origin_audience_id, country, ratio?, name?) — Create lookalike audience (ratio: 0.01–0.20). + get_custom_audience(audience_id) — Get audience details. + update_custom_audience(audience_id, name?, description?) — Update audience. + delete_custom_audience(audience_id) — Delete audience. + add_users_to_audience(audience_id, emails, phones?) — Add users via SHA-256 hashed emails/phones. + +**Pixels:** + list_pixels(ad_account_id) — List Meta pixels in account. + create_pixel(ad_account_id, name) — Create new pixel. + get_pixel_stats(pixel_id, start_time?, end_time?, aggregation?) — Get pixel event stats. + +**Targeting Research:** + search_interests(q, limit?) — Search interest targeting options by keyword. + search_behaviors(q, limit?) — Search behavior targeting options by keyword. + get_reach_estimate(ad_account_id, targeting, optimization_goal?) — Estimate audience reach. + get_delivery_estimate(ad_account_id, targeting, optimization_goal?, bid_amount?) — Estimate delivery curve. + +**Ad Rules Engine:** + list_ad_rules(ad_account_id) — List automated ad rules. + create_ad_rule(ad_account_id, name, evaluation_spec, execution_spec, schedule_spec?, status?) — Create automated rule. + update_ad_rule(rule_id, name?, status?) — Update rule name or status. + delete_ad_rule(rule_id) — Delete a rule. + execute_ad_rule(rule_id) — Manually trigger a rule immediately. """ @@ -170,6 +234,58 @@ args.get("ad_account_id"), ), "preview_ad": lambda client, args: preview_ad(client, args.get("ad_id", ""), args.get("ad_format", "DESKTOP_FEED_STANDARD")), + # Account extensions + "list_account_users": lambda client, args: list_account_users(client, args.get("ad_account_id", "")), + "list_pages": lambda client, args: list_pages(client), + # Campaign extensions + "get_campaign": lambda client, args: get_campaign(client, args.get("campaign_id", "")), + "delete_campaign": lambda client, args: delete_campaign(client, args.get("campaign_id", "")), + # Ad set extensions + "get_adset": lambda client, args: get_adset(client, args.get("adset_id", "")), + "delete_adset": lambda client, args: delete_adset(client, args.get("adset_id", "")), + "list_adsets_for_account": lambda client, args: list_adsets_for_account(client, args.get("ad_account_id", ""), args.get("status_filter")), + # Ads extensions + "get_ad": lambda client, args: get_ad(client, args.get("ad_id", "")), + "update_ad": lambda client, args: update_ad(client, args.get("ad_id", ""), args.get("name"), args.get("status")), + "delete_ad": lambda client, args: delete_ad(client, args.get("ad_id", "")), + "list_ads": lambda client, args: list_ads(client, args.get("ad_account_id"), args.get("adset_id"), args.get("status_filter")), + "list_creatives": lambda client, args: list_creatives(client, args.get("ad_account_id", "")), + "get_creative": lambda client, args: get_creative(client, args.get("creative_id", "")), + "update_creative": lambda client, args: update_creative(client, args.get("creative_id", ""), args.get("name")), + "delete_creative": lambda client, args: delete_creative(client, args.get("creative_id", "")), + "preview_creative": lambda client, args: preview_creative(client, args.get("creative_id", ""), args.get("ad_format", "DESKTOP_FEED_STANDARD")), + "upload_video": lambda client, args: upload_video(client, args.get("video_url", ""), args.get("ad_account_id"), args.get("title"), args.get("description")), + "list_videos": lambda client, args: list_videos(client, args.get("ad_account_id", "")), + # Insights + "get_account_insights": lambda client, args: get_account_insights(client, args.get("ad_account_id", ""), int(args.get("days", 30)), args.get("breakdowns"), args.get("metrics"), args.get("date_preset")), + "get_campaign_insights": lambda client, args: get_campaign_insights(client, args.get("campaign_id", ""), int(args.get("days", 30)), args.get("breakdowns"), args.get("metrics"), args.get("date_preset")), + "get_adset_insights": lambda client, args: get_adset_insights(client, args.get("adset_id", ""), int(args.get("days", 30)), args.get("breakdowns"), args.get("metrics"), args.get("date_preset")), + "get_ad_insights": lambda client, args: get_ad_insights(client, args.get("ad_id", ""), int(args.get("days", 30)), args.get("breakdowns"), args.get("metrics"), args.get("date_preset")), + "create_async_report": lambda client, args: create_async_report(client, args.get("ad_account_id", ""), args.get("level", "campaign"), args.get("fields"), args.get("date_preset", "last_30d"), args.get("breakdowns")), + "get_async_report_status": lambda client, args: get_async_report_status(client, args.get("report_run_id", "")), + # Audiences + "list_custom_audiences": lambda client, args: list_custom_audiences(client, args.get("ad_account_id", "")), + "create_custom_audience": lambda client, args: create_custom_audience(client, args.get("ad_account_id", ""), args.get("name", ""), args.get("subtype", "CUSTOM"), args.get("description"), args.get("customer_file_source")), + "create_lookalike_audience": lambda client, args: create_lookalike_audience(client, args.get("ad_account_id", ""), args.get("origin_audience_id", ""), args.get("country", ""), float(args.get("ratio", 0.01)), args.get("name")), + "get_custom_audience": lambda client, args: get_custom_audience(client, args.get("audience_id", "")), + "update_custom_audience": lambda client, args: update_custom_audience(client, args.get("audience_id", ""), args.get("name"), args.get("description")), + "delete_custom_audience": lambda client, args: delete_custom_audience(client, args.get("audience_id", "")), + "add_users_to_audience": lambda client, args: add_users_to_audience(client, args.get("audience_id", ""), args.get("emails", []), args.get("phones")), + # Pixels + "list_pixels": lambda client, args: list_pixels(client, args.get("ad_account_id", "")), + "create_pixel": lambda client, args: create_pixel(client, args.get("ad_account_id", ""), args.get("name", "")), + "get_pixel_stats": lambda client, args: get_pixel_stats(client, args.get("pixel_id", ""), args.get("start_time"), args.get("end_time"), args.get("aggregation", "day")), + # Targeting + "search_interests": lambda client, args: search_interests(client, args.get("q", ""), int(args.get("limit", 20))), + "search_behaviors": lambda client, args: search_behaviors(client, args.get("q", ""), int(args.get("limit", 20))), + "get_reach_estimate": lambda client, args: get_reach_estimate(client, args.get("ad_account_id", ""), args.get("targeting", {}), args.get("optimization_goal", "LINK_CLICKS")), + "get_delivery_estimate": lambda client, args: get_delivery_estimate(client, args.get("ad_account_id", ""), args.get("targeting", {}), args.get("optimization_goal", "LINK_CLICKS"), args.get("bid_amount")), + # Ad Rules + "list_ad_rules": lambda client, args: list_ad_rules(client, args.get("ad_account_id", "")), + "create_ad_rule": lambda client, args: create_ad_rule(client, args.get("ad_account_id", ""), args.get("name", ""), args.get("evaluation_spec", {}), args.get("execution_spec", {}), args.get("schedule_spec"), args.get("status", "ENABLED")), + "update_ad_rule": lambda client, args: update_ad_rule(client, args.get("rule_id", ""), args.get("name"), args.get("status")), + "delete_ad_rule": lambda client, args: delete_ad_rule(client, args.get("rule_id", "")), + "execute_ad_rule": lambda client, args: execute_ad_rule(client, args.get("rule_id", "")), } @@ -187,7 +303,6 @@ def __init__( self.pdoc_integration = pdoc_integration async def _ensure_ad_account_id(self, toolcall: ckit_cloudtool.FCloudtoolCall) -> None: - """Load ad_account_id from /company/ad-ops-config if not set.""" if self.client.ad_account_id or not self.pdoc_integration: return try: @@ -195,9 +310,8 @@ async def _ensure_ad_account_id(self, toolcall: ckit_cloudtool.FCloudtoolCall) - ad_account_id = config.pdoc_content.get("facebook_ad_account_id", "") if ad_account_id: self.client.ad_account_id = ad_account_id - logger.info(f"Loaded ad_account_id from pdoc: {ad_account_id}") - except Exception as e: - logger.debug(f"Could not load ad_account_id from pdoc: {e}") + except (AttributeError, KeyError, ValueError) as e: + logger.debug("Could not load ad_account_id from pdoc", exc_info=e) async def called_by_model( self, @@ -233,7 +347,7 @@ async def called_by_model( except FacebookValidationError as e: return f"ERROR: {e.message}" except Exception as e: - logger.warning(f"Unexpected error in {op}: {e}", exc_info=e) + logger.error("Unexpected error in %s", op, exc_info=e) return f"ERROR: {str(e)}" async def _handle_connect(self) -> str: diff --git a/flexus_client_kit/integrations/facebook/insights.py b/flexus_client_kit/integrations/facebook/insights.py new file mode 100644 index 00000000..89c4bfab --- /dev/null +++ b/flexus_client_kit/integrations/facebook/insights.py @@ -0,0 +1,211 @@ +from __future__ import annotations +import logging +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +from flexus_client_kit.integrations.facebook.models import InsightsBreakdown, InsightsDatePreset + +if TYPE_CHECKING: + from flexus_client_kit.integrations.facebook.client import FacebookAdsClient + +logger = logging.getLogger("facebook.operations.insights") + +DEFAULT_METRICS = "impressions,clicks,spend,reach,frequency,ctr,cpc,cpm,actions,cost_per_action_type,video_avg_time_watched_actions,video_p100_watched_actions" + + +def _format_insights_data(data: Dict[str, Any], label: str) -> str: + result = f"Insights for {label}:\n\n" + items = data.get("data", []) + if not items: + return f"No insights data found for {label}\n" + for item in items: + result += f" Date: {item.get('date_start', 'N/A')} — {item.get('date_stop', 'N/A')}\n" + result += f" Impressions: {item.get('impressions', '0')}\n" + result += f" Clicks: {item.get('clicks', '0')}\n" + result += f" Spend: ${item.get('spend', '0')}\n" + result += f" Reach: {item.get('reach', '0')}\n" + result += f" CTR: {item.get('ctr', '0')}%\n" + result += f" CPC: ${item.get('cpc', '0')}\n" + result += f" CPM: ${item.get('cpm', '0')}\n" + actions = item.get("actions", []) + if actions: + result += " Actions:\n" + for action in actions[:5]: + result += f" - {action.get('action_type', 'N/A')}: {action.get('value', '0')}\n" + breakdowns = item.get("age") or item.get("gender") or item.get("country") or item.get("publisher_platform") + if breakdowns: + result += f" Breakdown: {breakdowns}\n" + result += "\n" + return result + + +async def get_account_insights( + client: "FacebookAdsClient", + ad_account_id: str, + days: int = 30, + breakdowns: Optional[List[str]] = None, + metrics: Optional[str] = None, + date_preset: Optional[str] = None, +) -> str: + if not ad_account_id: + return "ERROR: ad_account_id is required" + if client.is_test_mode: + return f"Account Insights for {ad_account_id} (last {days} days):\n Impressions: 15,000\n Clicks: 450\n Spend: $120.00\n CTR: 3.0%\n" + params: Dict[str, Any] = { + "fields": metrics or DEFAULT_METRICS, + "level": "account", + "limit": 50, + } + if date_preset: + params["date_preset"] = date_preset + else: + params["date_preset"] = _days_to_preset(days) + if breakdowns: + params["breakdowns"] = ",".join(breakdowns) + data = await client.request("GET", f"{ad_account_id}/insights", params=params) + return _format_insights_data(data, ad_account_id) + + +async def get_campaign_insights( + client: "FacebookAdsClient", + campaign_id: str, + days: int = 30, + breakdowns: Optional[List[str]] = None, + metrics: Optional[str] = None, + date_preset: Optional[str] = None, +) -> str: + if not campaign_id: + return "ERROR: campaign_id is required" + if client.is_test_mode: + return f"Campaign Insights for {campaign_id} (last {days} days):\n Impressions: 10,000\n Clicks: 300\n Spend: $80.00\n CTR: 3.0%\n" + params: Dict[str, Any] = { + "fields": metrics or DEFAULT_METRICS, + "limit": 50, + } + if date_preset: + params["date_preset"] = date_preset + else: + params["date_preset"] = _days_to_preset(days) + if breakdowns: + params["breakdowns"] = ",".join(breakdowns) + data = await client.request("GET", f"{campaign_id}/insights", params=params) + return _format_insights_data(data, campaign_id) + + +async def get_adset_insights( + client: "FacebookAdsClient", + adset_id: str, + days: int = 30, + breakdowns: Optional[List[str]] = None, + metrics: Optional[str] = None, + date_preset: Optional[str] = None, +) -> str: + if not adset_id: + return "ERROR: adset_id is required" + if client.is_test_mode: + return f"Ad Set Insights for {adset_id} (last {days} days):\n Impressions: 5,000\n Clicks: 150\n Spend: $40.00\n CTR: 3.0%\n" + params: Dict[str, Any] = { + "fields": metrics or DEFAULT_METRICS, + "limit": 50, + } + if date_preset: + params["date_preset"] = date_preset + else: + params["date_preset"] = _days_to_preset(days) + if breakdowns: + params["breakdowns"] = ",".join(breakdowns) + data = await client.request("GET", f"{adset_id}/insights", params=params) + return _format_insights_data(data, adset_id) + + +async def get_ad_insights( + client: "FacebookAdsClient", + ad_id: str, + days: int = 30, + breakdowns: Optional[List[str]] = None, + metrics: Optional[str] = None, + date_preset: Optional[str] = None, +) -> str: + if not ad_id: + return "ERROR: ad_id is required" + if client.is_test_mode: + return f"Ad Insights for {ad_id} (last {days} days):\n Impressions: 2,000\n Clicks: 60\n Spend: $15.00\n CTR: 3.0%\n" + params: Dict[str, Any] = { + "fields": metrics or DEFAULT_METRICS, + "limit": 50, + } + if date_preset: + params["date_preset"] = date_preset + else: + params["date_preset"] = _days_to_preset(days) + if breakdowns: + params["breakdowns"] = ",".join(breakdowns) + data = await client.request("GET", f"{ad_id}/insights", params=params) + return _format_insights_data(data, ad_id) + + +async def create_async_report( + client: "FacebookAdsClient", + ad_account_id: str, + level: str = "campaign", + fields: Optional[str] = None, + date_preset: str = "last_30d", + breakdowns: Optional[List[str]] = None, +) -> str: + if not ad_account_id: + return "ERROR: ad_account_id is required" + valid_levels = ["account", "campaign", "adset", "ad"] + if level not in valid_levels: + return f"ERROR: level must be one of: {', '.join(valid_levels)}" + if client.is_test_mode: + return f"Async report created:\n Report Run ID: mock_report_run_123\n Status: Job Created\n Use get_async_report_status to check progress.\n" + data: Dict[str, Any] = { + "level": level, + "fields": fields or DEFAULT_METRICS, + "date_preset": date_preset, + } + if breakdowns: + data["breakdowns"] = ",".join(breakdowns) + result = await client.request("POST", f"{ad_account_id}/insights", data=data) + report_run_id = result.get("report_run_id") + if not report_run_id: + return f"Failed to create async report. Response: {result}" + return f"Async report created:\n Report Run ID: {report_run_id}\n Level: {level}\n Date Preset: {date_preset}\n Use get_async_report_status(report_run_id='{report_run_id}') to check progress.\n" + + +async def get_async_report_status( + client: "FacebookAdsClient", + report_run_id: str, +) -> str: + if not report_run_id: + return "ERROR: report_run_id is required" + if client.is_test_mode: + return f"Report {report_run_id} status: Job Completed (100%)\n Use insights endpoint with async_status filter to retrieve results.\n" + data = await client.request( + "GET", report_run_id, + params={"fields": "id,async_status,async_percent_completion,date_start,date_stop"}, + ) + status = data.get("async_status", "Unknown") + pct = data.get("async_percent_completion", 0) + result = f"Report {report_run_id}:\n" + result += f" Status: {status} ({pct}%)\n" + if data.get("date_start"): + result += f" Date Range: {data['date_start']} — {data.get('date_stop', 'N/A')}\n" + if status == "Job Completed": + result += f"\n Report is ready. Retrieve results at:\n GET /{report_run_id}/insights\n" + return result + + +def _days_to_preset(days: int) -> str: + if days <= 1: + return InsightsDatePreset.TODAY.value + if days <= 7: + return InsightsDatePreset.LAST_7D.value + if days <= 14: + return InsightsDatePreset.LAST_14D.value + if days <= 28: + return InsightsDatePreset.LAST_28D.value + if days <= 30: + return InsightsDatePreset.LAST_30D.value + if days <= 90: + return InsightsDatePreset.LAST_90D.value + return InsightsDatePreset.MAXIMUM.value diff --git a/flexus_client_kit/integrations/facebook/models.py b/flexus_client_kit/integrations/facebook/models.py index 5b883fed..9d4f5ff0 100644 --- a/flexus_client_kit/integrations/facebook/models.py +++ b/flexus_client_kit/integrations/facebook/models.py @@ -6,6 +6,48 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +class CustomAudienceSubtype(str, Enum): + CUSTOM = "CUSTOM" + WEBSITE = "WEBSITE" + APP = "APP" + ENGAGEMENT = "ENGAGEMENT" + LOOKALIKE = "LOOKALIKE" + VIDEO = "VIDEO" + LEAD_GENERATION = "LEAD_GENERATION" + ON_SITE_LEAD = "ON_SITE_LEAD" + + +class InsightsBreakdown(str, Enum): + AGE = "age" + GENDER = "gender" + COUNTRY = "country" + REGION = "region" + PLACEMENT = "publisher_platform" + DEVICE = "device_platform" + IMPRESSION_DEVICE = "impression_device" + PLATFORM_POSITION = "platform_position" + + +class InsightsDatePreset(str, Enum): + TODAY = "today" + YESTERDAY = "yesterday" + LAST_7D = "last_7d" + LAST_14D = "last_14d" + LAST_28D = "last_28d" + LAST_30D = "last_30d" + LAST_90D = "last_90d" + THIS_MONTH = "this_month" + LAST_MONTH = "last_month" + MAXIMUM = "maximum" + + +class AdRuleStatus(str, Enum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + DELETED = "DELETED" + HAS_ISSUES = "HAS_ISSUES" + + class CampaignObjective(str, Enum): TRAFFIC = "OUTCOME_TRAFFIC" SALES = "OUTCOME_SALES" diff --git a/flexus_client_kit/integrations/facebook/pixels.py b/flexus_client_kit/integrations/facebook/pixels.py new file mode 100644 index 00000000..a9a0d9b2 --- /dev/null +++ b/flexus_client_kit/integrations/facebook/pixels.py @@ -0,0 +1,90 @@ +from __future__ import annotations +import logging +from typing import Any, Dict, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from flexus_client_kit.integrations.facebook.client import FacebookAdsClient + +logger = logging.getLogger("facebook.operations.pixels") + + +async def list_pixels( + client: "FacebookAdsClient", + ad_account_id: str, +) -> str: + if not ad_account_id: + return "ERROR: ad_account_id is required" + if client.is_test_mode: + return f"Pixels for {ad_account_id}:\n Test Pixel (ID: 111222333) — ACTIVE — 1,250 events last 7 days\n" + data = await client.request( + "GET", f"{ad_account_id}/adspixels", + params={"fields": "id,name,code,creation_time,last_fired_time,owner_business", "limit": 50}, + ) + pixels = data.get("data", []) + if not pixels: + return f"No pixels found for {ad_account_id}" + result = f"Pixels for {ad_account_id} ({len(pixels)} total):\n\n" + for p in pixels: + result += f" **{p.get('name', 'Unnamed')}** (ID: {p['id']})\n" + result += f" Last fired: {p.get('last_fired_time', 'Never')}\n" + result += f" Created: {p.get('creation_time', 'N/A')}\n\n" + return result + + +async def create_pixel( + client: "FacebookAdsClient", + ad_account_id: str, + name: str, +) -> str: + if not ad_account_id: + return "ERROR: ad_account_id is required" + if not name: + return "ERROR: name is required" + if client.is_test_mode: + return f"Pixel created:\n Name: {name}\n ID: mock_pixel_789\n Install the pixel code on your website to start tracking events.\n" + result = await client.request( + "POST", f"{ad_account_id}/adspixels", + data={"name": name}, + ) + pixel_id = result.get("id") + if not pixel_id: + return f"Failed to create pixel. Response: {result}" + return f"Pixel created:\n Name: {name}\n ID: {pixel_id}\n Install the pixel code on your website to start tracking events.\n View stats with: get_pixel_stats(pixel_id='{pixel_id}')\n" + + +async def get_pixel_stats( + client: "FacebookAdsClient", + pixel_id: str, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + aggregation: str = "day", +) -> str: + if not pixel_id: + return "ERROR: pixel_id is required" + valid_aggregations = ["day", "hour", "week", "month"] + if aggregation not in valid_aggregations: + return f"ERROR: aggregation must be one of: {', '.join(valid_aggregations)}" + if client.is_test_mode: + return f"Pixel Stats for {pixel_id}:\n PageView: 3,450 events\n Purchase: 127 events\n AddToCart: 342 events\n Lead: 89 events\n" + params: Dict[str, Any] = { + "aggregation": aggregation, + "fields": "event_name,count,start_time,end_time", + "limit": 200, + } + if start_time: + params["start_time"] = start_time + if end_time: + params["end_time"] = end_time + data = await client.request("GET", f"{pixel_id}/stats", params=params) + stats = data.get("data", []) + if not stats: + return f"No stats found for pixel {pixel_id}" + event_totals: Dict[str, int] = {} + for entry in stats: + event = entry.get("event_name", "Unknown") + count = int(entry.get("count", 0)) + event_totals[event] = event_totals.get(event, 0) + count + result = f"Pixel Stats for {pixel_id}:\n\n" + for event, count in sorted(event_totals.items(), key=lambda x: -x[1]): + result += f" {event}: {count:,} events\n" + return result diff --git a/flexus_client_kit/integrations/facebook/rules.py b/flexus_client_kit/integrations/facebook/rules.py new file mode 100644 index 00000000..9167f1d6 --- /dev/null +++ b/flexus_client_kit/integrations/facebook/rules.py @@ -0,0 +1,129 @@ +from __future__ import annotations +import logging +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from flexus_client_kit.integrations.facebook.client import FacebookAdsClient + +logger = logging.getLogger("facebook.operations.rules") + +RULE_FIELDS = "id,name,status,evaluation_spec,execution_spec,schedule_spec,created_time,updated_time" + + +async def list_ad_rules( + client: "FacebookAdsClient", + ad_account_id: str, +) -> str: + if not ad_account_id: + return "ERROR: ad_account_id is required" + if client.is_test_mode: + return f"Ad Rules for {ad_account_id}:\n Pause on low CTR (ID: 111222) — ENABLED\n Scale budget on ROAS (ID: 222333) — ENABLED\n" + data = await client.request( + "GET", f"{ad_account_id}/adrules_library", + params={"fields": RULE_FIELDS, "limit": 50}, + ) + rules = data.get("data", []) + if not rules: + return f"No ad rules found for {ad_account_id}" + result = f"Ad Rules for {ad_account_id} ({len(rules)} total):\n\n" + for rule in rules: + result += f" **{rule.get('name', 'Unnamed')}** (ID: {rule['id']})\n" + result += f" Status: {rule.get('status', 'N/A')}\n" + exec_spec = rule.get("execution_spec", {}) + if exec_spec.get("execution_type"): + result += f" Action: {exec_spec['execution_type']}\n" + result += "\n" + return result + + +async def create_ad_rule( + client: "FacebookAdsClient", + ad_account_id: str, + name: str, + evaluation_spec: Dict[str, Any], + execution_spec: Dict[str, Any], + schedule_spec: Optional[Dict[str, Any]] = None, + status: str = "ENABLED", +) -> str: + if not ad_account_id: + return "ERROR: ad_account_id is required" + if not name: + return "ERROR: name is required" + if not evaluation_spec: + return "ERROR: evaluation_spec is required (defines conditions to check)" + if not execution_spec: + return "ERROR: execution_spec is required (defines action to take)" + if client.is_test_mode: + return f"Ad rule created:\n Name: {name}\n ID: mock_rule_123\n Status: {status}\n" + data: Dict[str, Any] = { + "name": name, + "evaluation_spec": evaluation_spec, + "execution_spec": execution_spec, + "status": status, + } + if schedule_spec: + data["schedule_spec"] = schedule_spec + result = await client.request("POST", f"{ad_account_id}/adrules_library", data=data) + rule_id = result.get("id") + if not rule_id: + return f"Failed to create ad rule. Response: {result}" + return f"Ad rule created:\n Name: {name}\n ID: {rule_id}\n Status: {status}\n" + + +async def update_ad_rule( + client: "FacebookAdsClient", + rule_id: str, + name: Optional[str] = None, + status: Optional[str] = None, +) -> str: + if not rule_id: + return "ERROR: rule_id is required" + if not any([name, status]): + return "ERROR: At least one field to update is required (name, status)" + valid_statuses = ["ENABLED", "DISABLED", "DELETED"] + if status and status not in valid_statuses: + return f"ERROR: status must be one of: {', '.join(valid_statuses)}" + if client.is_test_mode: + updates = [] + if name: + updates.append(f"name -> {name}") + if status: + updates.append(f"status -> {status}") + return f"Ad rule {rule_id} updated:\n" + "\n".join(f" - {u}" for u in updates) + data: Dict[str, Any] = {} + if name: + data["name"] = name + if status: + data["status"] = status + result = await client.request("POST", rule_id, data=data) + if result.get("success"): + return f"Ad rule {rule_id} updated successfully." + return f"Failed to update ad rule. Response: {result}" + + +async def delete_ad_rule( + client: "FacebookAdsClient", + rule_id: str, +) -> str: + if not rule_id: + return "ERROR: rule_id is required" + if client.is_test_mode: + return f"Ad rule {rule_id} deleted successfully." + result = await client.request("DELETE", rule_id) + if result.get("success"): + return f"Ad rule {rule_id} deleted successfully." + return f"Failed to delete ad rule. Response: {result}" + + +async def execute_ad_rule( + client: "FacebookAdsClient", + rule_id: str, +) -> str: + if not rule_id: + return "ERROR: rule_id is required" + if client.is_test_mode: + return f"Ad rule {rule_id} executed successfully. Actions applied to matching objects." + result = await client.request("POST", f"{rule_id}/execute", data={}) + if result.get("success"): + return f"Ad rule {rule_id} executed successfully. Actions applied to matching objects." + return f"Failed to execute ad rule. Response: {result}" diff --git a/flexus_client_kit/integrations/facebook/targeting.py b/flexus_client_kit/integrations/facebook/targeting.py new file mode 100644 index 00000000..f4bfcfa1 --- /dev/null +++ b/flexus_client_kit/integrations/facebook/targeting.py @@ -0,0 +1,134 @@ +from __future__ import annotations +import json +import logging +from typing import Any, Dict, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from flexus_client_kit.integrations.facebook.client import FacebookAdsClient + +logger = logging.getLogger("facebook.operations.targeting") + + +async def search_interests( + client: "FacebookAdsClient", + q: str, + limit: int = 20, +) -> str: + if not q: + return "ERROR: q (search query) is required" + if client.is_test_mode: + return f"Interests matching '{q}':\n Travel (ID: 6003263) — ~600M people\n Adventure Travel (ID: 6003021) — ~50M people\n" + data = await client.request( + "GET", "search", + params={ + "type": "adinterest", + "q": q, + "limit": min(limit, 50), + "locale": "en_US", + }, + ) + items = data.get("data", []) + if not items: + return f"No interests found matching '{q}'" + result = f"Interests matching '{q}' ({len(items)} results):\n\n" + for item in items: + audience_size = item.get("audience_size", "Unknown") + path = " > ".join(item.get("path", [])) + result += f" **{item.get('name', 'N/A')}** (ID: {item.get('id', 'N/A')})\n" + result += f" Audience: ~{audience_size:,} people\n" if isinstance(audience_size, int) else f" Audience: {audience_size}\n" + if path: + result += f" Category: {path}\n" + result += "\n" + return result + + +async def search_behaviors( + client: "FacebookAdsClient", + q: str, + limit: int = 20, +) -> str: + if not q: + return "ERROR: q (search query) is required" + if client.is_test_mode: + return f"Behaviors matching '{q}':\n Frequent Travelers (ID: 6002714) — ~120M people\n" + data = await client.request( + "GET", "search", + params={ + "type": "adbehavior", + "q": q, + "limit": min(limit, 50), + "locale": "en_US", + }, + ) + items = data.get("data", []) + if not items: + return f"No behaviors found matching '{q}'" + result = f"Behaviors matching '{q}' ({len(items)} results):\n\n" + for item in items: + audience_size = item.get("audience_size", "Unknown") + result += f" **{item.get('name', 'N/A')}** (ID: {item.get('id', 'N/A')})\n" + result += f" Audience: ~{audience_size:,} people\n" if isinstance(audience_size, int) else f" Audience: {audience_size}\n" + result += "\n" + return result + + +async def get_reach_estimate( + client: "FacebookAdsClient", + ad_account_id: str, + targeting: Dict[str, Any], + optimization_goal: str = "LINK_CLICKS", +) -> str: + if not ad_account_id: + return "ERROR: ad_account_id is required" + if not targeting: + return "ERROR: targeting spec is required" + if client.is_test_mode: + return f"Reach Estimate for {ad_account_id}:\n Estimated Audience: 1,200,000 — 1,800,000 people\n Optimization: {optimization_goal}\n" + params: Dict[str, Any] = { + "targeting_spec": json.dumps(targeting), + "optimization_goal": optimization_goal, + } + data = await client.request("GET", f"{ad_account_id}/reachestimate", params=params) + users = data.get("users", "Unknown") + estimate_ready = data.get("estimate_ready", False) + result = f"Reach Estimate for {ad_account_id}:\n" + result += f" Estimated Audience: {users:,} people\n" if isinstance(users, int) else f" Estimated Audience: {users}\n" + result += f" Estimate Ready: {estimate_ready}\n" + result += f" Optimization Goal: {optimization_goal}\n" + return result + + +async def get_delivery_estimate( + client: "FacebookAdsClient", + ad_account_id: str, + targeting: Dict[str, Any], + optimization_goal: str = "LINK_CLICKS", + bid_amount: Optional[int] = None, +) -> str: + if not ad_account_id: + return "ERROR: ad_account_id is required" + if not targeting: + return "ERROR: targeting spec is required" + if client.is_test_mode: + return f"Delivery Estimate for {ad_account_id}:\n Daily Min Spend: $5.00\n Daily Max Spend: $50.00\n Min Reach: 800\n Max Reach: 3,200\n Optimization: {optimization_goal}\n" + params: Dict[str, Any] = { + "targeting_spec": json.dumps(targeting), + "optimization_goal": optimization_goal, + } + if bid_amount: + params["bid_amount"] = bid_amount + data = await client.request("GET", f"{ad_account_id}/delivery_estimate", params=params) + estimates = data.get("data", []) + if not estimates: + return f"No delivery estimates found for {ad_account_id}" + result = f"Delivery Estimate for {ad_account_id}:\n\n" + for est in estimates: + result += f" Optimization: {est.get('optimization_goal', optimization_goal)}\n" + daily = est.get("daily_outcomes_curve", []) + if daily: + first = daily[0] + last = daily[-1] + result += f" Daily Spend Range: ${first.get('spend', 0)/100:.2f} — ${last.get('spend', 0)/100:.2f}\n" + result += f" Daily Reach Range: {first.get('reach', 0):,} — {last.get('reach', 0):,}\n" + result += "\n" + return result diff --git a/flexus_client_kit/integrations/facebook/utils.py b/flexus_client_kit/integrations/facebook/utils.py index fa9c3e54..2b06d3dd 100644 --- a/flexus_client_kit/integrations/facebook/utils.py +++ b/flexus_client_kit/integrations/facebook/utils.py @@ -54,7 +54,7 @@ def validate_targeting_spec(spec: Dict[str, Any]) -> Tuple[bool, str]: if spec["age_min"] > spec["age_max"]: return False, "age_min cannot be greater than age_max" return True, "" - except Exception as e: + except (KeyError, TypeError, ValueError) as e: return False, f"Validation error: {str(e)}" @@ -126,8 +126,8 @@ def normalize_insights_data(raw_data: Dict[str, Any]) -> Insights: date_start=raw_data.get("date_start"), date_stop=raw_data.get("date_stop"), ) - except Exception as e: - logger.warning(f"Error normalizing insights data: {e}", exc_info=e) + except (KeyError, TypeError, ValueError) as e: + logger.warning("Error normalizing insights data", exc_info=e) return Insights() diff --git a/flexus_client_kit/integrations/fi_adzuna.py b/flexus_client_kit/integrations/fi_adzuna.py new file mode 100644 index 00000000..87a9fc0e --- /dev/null +++ b/flexus_client_kit/integrations/fi_adzuna.py @@ -0,0 +1,142 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("adzuna") + +PROVIDER_NAME = "adzuna" +METHOD_IDS = [ + "adzuna.jobs.search_ads.v1", + "adzuna.jobs.regional_data.v1", +] + +_BASE_URL = "https://api.adzuna.com/v1/api" + + +class IntegrationAdzuna: + # XXX: requires multiple credentials (ADZUNA_APP_ID + ADZUNA_APP_KEY). + # manual auth (single api_key field) does not cover this provider. + # currently reads from env vars as a fallback. + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + app_id = os.environ.get("ADZUNA_APP_ID", "") + app_key = os.environ.get("ADZUNA_APP_KEY", "") + if not app_id or not app_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set ADZUNA_APP_ID and ADZUNA_APP_KEY env vars."}, indent=2, ensure_ascii=False) + + if method_id == "adzuna.jobs.search_ads.v1": + return await self._search_ads(args, app_id, app_key) + if method_id == "adzuna.jobs.regional_data.v1": + return await self._regional_data(args, app_id, app_key) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _search_ads(self, args: Dict[str, Any], app_id: str, app_key: str) -> str: + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "args.query required."}, indent=2, ensure_ascii=False) + + geo = args.get("geo") or {} + if isinstance(geo, str): + geo = {"country": geo} + country = str(geo.get("country", "us")).lower() or "us" + city = str(geo.get("city", "")) + limit = min(int(args.get("limit", 20)), 50) + + params: Dict[str, Any] = { + "app_id": app_id, + "app_key": app_key, + "results_per_page": limit, + "what": query, + "sort_by": "date", + "max_days_old": 30, + } + if city: + params["where"] = city + + url = f"{_BASE_URL}/jobs/{country}/search/1" + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(url, params=params) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + results = data.get("results", []) + include_raw = bool(args.get("include_raw")) + out = {"ok": True, "total": data.get("count", len(results)), "results": results} + if not include_raw: + out["results"] = [ + { + "title": j.get("title"), + "company": j.get("company", {}).get("display_name"), + "location": j.get("location", {}).get("display_name"), + "salary_min": j.get("salary_min"), + "salary_max": j.get("salary_max"), + "created": j.get("created"), + "redirect_url": j.get("redirect_url"), + } + for j in results + ] + summary = f"Found {len(results)} job(s) from {PROVIDER_NAME}." + return summary + "\n\n```json\n" + json.dumps(out, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _regional_data(self, args: Dict[str, Any], app_id: str, app_key: str) -> str: + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "args.query required."}, indent=2, ensure_ascii=False) + + geo = args.get("geo") or {} + if isinstance(geo, str): + geo = {"country": geo} + country = str(geo.get("country", "us")).lower() or "us" + + url = f"{_BASE_URL}/jobs/{country}/histogram" + params = {"app_id": app_id, "app_key": app_key, "what": query} + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(url, params=params) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + summary = f"Regional salary histogram from {PROVIDER_NAME} for '{query}'." + return summary + "\n\n```json\n" + json.dumps({"ok": True, "data": data}, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_amazon.py b/flexus_client_kit/integrations/fi_amazon.py new file mode 100644 index 00000000..27faf96f --- /dev/null +++ b/flexus_client_kit/integrations/fi_amazon.py @@ -0,0 +1,216 @@ +import json +import logging +import os +import time +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("amazon") + +PROVIDER_NAME = "amazon" +METHOD_IDS = [ + "amazon.catalog.get_item.v1", + "amazon.catalog.search_items.v1", + "amazon.pricing.get_item_offers_batch.v1", + "amazon.pricing.get_listing_offers_batch.v1", +] + +_BASE_URL = "https://sellingpartnerapi-na.amazon.com" +_LWA_URL = "https://api.amazon.com/auth/o2/token" + +_token_cache: list = ["", 0.0] # [access_token, expiry_timestamp] + + +def _auth_missing() -> str: + missing = [v for v in ("AMAZON_LWA_CLIENT_ID", "AMAZON_LWA_CLIENT_SECRET", "AMAZON_REFRESH_TOKEN") if not os.environ.get(v)] + if not missing: + return "" + return json.dumps({ + "ok": False, + "error_code": "AUTH_MISSING", + "message": f"Set env vars: {', '.join(missing)}", + }, indent=2, ensure_ascii=False) + + +async def _get_access_token() -> str: + if _token_cache[0] and time.time() < _token_cache[1] - 30: + return _token_cache[0] + async with httpx.AsyncClient(timeout=15.0) as client: + r = await client.post( + _LWA_URL, + data={ + "grant_type": "refresh_token", + "refresh_token": os.environ.get("AMAZON_REFRESH_TOKEN", ""), + "client_id": os.environ.get("AMAZON_LWA_CLIENT_ID", ""), + "client_secret": os.environ.get("AMAZON_LWA_CLIENT_SECRET", ""), + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + r.raise_for_status() + resp = r.json() + _token_cache[0] = resp["access_token"] + _token_cache[1] = time.time() + resp.get("expires_in", 3600) + return _token_cache[0] + + +class IntegrationAmazon: + # XXX: requires multiple credentials (AMAZON_LWA_CLIENT_ID + AMAZON_LWA_CLIENT_SECRET + AMAZON_REFRESH_TOKEN). + # manual auth (single api_key field) does not cover this provider. + # currently reads from env vars as a fallback. + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + missing = [v for v in ("AMAZON_LWA_CLIENT_ID", "AMAZON_LWA_CLIENT_SECRET", "AMAZON_REFRESH_TOKEN") if not os.environ.get(v)] + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available", + "method_count": len(METHOD_IDS), + "auth": ("missing: " + ", ".join(missing)) if missing else "configured", + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if auth_err := _auth_missing(): + return auth_err + if method_id == "amazon.catalog.search_items.v1": + return await self._catalog_search(args) + if method_id == "amazon.catalog.get_item.v1": + return await self._catalog_get_item(args) + if method_id == "amazon.pricing.get_item_offers_batch.v1": + return await self._pricing_item_offers(args) + if method_id == "amazon.pricing.get_listing_offers_batch.v1": + return await self._pricing_listing_offers(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _catalog_search(self, args: Dict[str, Any]) -> str: + query = str(args.get("query", "")).strip() + limit = min(int(args.get("limit", 10)), 20) + marketplace_id = os.environ.get("AMAZON_MARKETPLACE_ID", "ATVPDKIKX0DER") + try: + token = await _get_access_token() + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get( + _BASE_URL + "/catalog/2022-04-01/items", + params={ + "marketplaceIds": marketplace_id, + "keywords": query, + "includedData": "summaries,attributes", + "pageSize": limit, + }, + headers={"x-amz-access-token": token, "Accept": "application/json"}, + ) + if r.status_code >= 400: + logger.info("amazon HTTP %s: %s", r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + items = data.get("items", []) + compact = [ + { + "asin": it.get("asin", ""), + "name": (it.get("summaries") or [{}])[0].get("itemName", ""), + "brand": (it.get("summaries") or [{}])[0].get("brand", ""), + } + for it in items + ] + result = {"ok": True, "query": query, "total": data.get("numberOfResults", len(items)), "results": items if args.get("include_raw") else compact} + return f"Found {len(items)} item(s) from {PROVIDER_NAME}.\n\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _catalog_get_item(self, args: Dict[str, Any]) -> str: + asin = str(args.get("asin", "")).strip() + if not asin: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.asin required"}, indent=2, ensure_ascii=False) + marketplace_id = os.environ.get("AMAZON_MARKETPLACE_ID", "ATVPDKIKX0DER") + try: + token = await _get_access_token() + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get( + _BASE_URL + f"/catalog/2022-04-01/items/{asin}", + params={"marketplaceIds": marketplace_id, "includedData": "summaries,attributes,salesRanks"}, + headers={"x-amz-access-token": token, "Accept": "application/json"}, + ) + if r.status_code >= 400: + logger.info("amazon HTTP %s: %s", r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + return f"Found 1 result(s) from {PROVIDER_NAME}.\n\n```json\n{json.dumps({'ok': True, 'result': data}, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _pricing_item_offers(self, args: Dict[str, Any]) -> str: + asin = str(args.get("asin", "")).strip() + if not asin: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.asin required"}, indent=2, ensure_ascii=False) + marketplace_id = os.environ.get("AMAZON_MARKETPLACE_ID", "ATVPDKIKX0DER") + try: + token = await _get_access_token() + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.post( + _BASE_URL + "/batches/products/pricing/v0/itemOffers", + json={"requests": [{"uri": f"/products/pricing/v0/items/{asin}/offers", "method": "GET", "MarketplaceId": marketplace_id, "ItemCondition": "New"}]}, + headers={"x-amz-access-token": token, "Accept": "application/json", "Content-Type": "application/json"}, + ) + if r.status_code >= 400: + logger.info("amazon HTTP %s: %s", r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + return f"Found 1 result(s) from {PROVIDER_NAME}.\n\n```json\n{json.dumps({'ok': True, 'result': data}, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _pricing_listing_offers(self, args: Dict[str, Any]) -> str: + asin = str(args.get("asin", "")).strip() + seller_sku = str(args.get("seller_sku", "")).strip() + sku = seller_sku or asin + if not sku: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.asin or args.seller_sku required"}, indent=2, ensure_ascii=False) + marketplace_id = os.environ.get("AMAZON_MARKETPLACE_ID", "ATVPDKIKX0DER") + try: + token = await _get_access_token() + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.post( + _BASE_URL + "/batches/products/pricing/v0/listingOffers", + json={"requests": [{"uri": f"/products/pricing/v0/listings/{sku}/offers", "method": "GET", "MarketplaceId": marketplace_id, "ItemCondition": "New"}]}, + headers={"x-amz-access-token": token, "Accept": "application/json", "Content-Type": "application/json"}, + ) + if r.status_code >= 400: + logger.info("amazon HTTP %s: %s", r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + return f"Found 1 result(s) from {PROVIDER_NAME}.\n\n```json\n{json.dumps({'ok': True, 'result': data}, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_apify.py b/flexus_client_kit/integrations/fi_apify.py new file mode 100644 index 00000000..1d7443f2 --- /dev/null +++ b/flexus_client_kit/integrations/fi_apify.py @@ -0,0 +1,3961 @@ +import base64 +import json +import logging +import os +from typing import Any, Dict, Optional +from urllib.parse import quote + +import httpx + + +logger = logging.getLogger("apify") + +PROVIDER_NAME = "apify" +_BASE_URL = "https://api.apify.com" +_TIMEOUT = 60.0 +_TEXT_RESPONSE_PREFIXES = ( + "text/", + "application/json", + "application/xml", + "application/javascript", + "application/x-www-form-urlencoded", + "application/csv", + "application/xhtml+xml", +) + +METHOD_SPECS = [ + { + 'method_id': 'apify.PostChargeRun.v1', + 'operation_id': 'PostChargeRun', + 'http_method': 'POST', + 'path': '/v2/actor-runs/{runId}/charge', + 'summary': 'Charge events in run', + 'path_params': ['runId'], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': [], + }, + { + 'method_id': 'apify.PostResurrectRun.v1', + 'operation_id': 'PostResurrectRun', + 'http_method': 'POST', + 'path': '/v2/actor-runs/{runId}/resurrect', + 'summary': 'Resurrect run', + 'path_params': ['runId'], + 'query_params': ['build', 'memory', 'restartOnError', 'timeout'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_build_abort_post.v1', + 'operation_id': 'act_build_abort_post', + 'http_method': 'POST', + 'path': '/v2/acts/{actorId}/builds/{buildId}/abort', + 'summary': 'Abort build', + 'path_params': ['actorId', 'buildId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_build_default_get.v1', + 'operation_id': 'act_build_default_get', + 'http_method': 'GET', + 'path': '/v2/acts/{actorId}/builds/default', + 'summary': 'Get default build', + 'path_params': ['actorId'], + 'query_params': ['waitForFinish'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_build_get.v1', + 'operation_id': 'act_build_get', + 'http_method': 'GET', + 'path': '/v2/acts/{actorId}/builds/{buildId}', + 'summary': 'Get build', + 'path_params': ['actorId', 'buildId'], + 'query_params': ['waitForFinish'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_builds_get.v1', + 'operation_id': 'act_builds_get', + 'http_method': 'GET', + 'path': '/v2/acts/{actorId}/builds', + 'summary': 'Get list of builds', + 'path_params': ['actorId'], + 'query_params': ['desc', 'limit', 'offset'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_builds_post.v1', + 'operation_id': 'act_builds_post', + 'http_method': 'POST', + 'path': '/v2/acts/{actorId}/builds', + 'summary': 'Build Actor', + 'path_params': ['actorId'], + 'query_params': ['betaPackages', 'tag', 'useCache', 'version', 'waitForFinish'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_delete.v1', + 'operation_id': 'act_delete', + 'http_method': 'DELETE', + 'path': '/v2/acts/{actorId}', + 'summary': 'Delete Actor', + 'path_params': ['actorId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_get.v1', + 'operation_id': 'act_get', + 'http_method': 'GET', + 'path': '/v2/acts/{actorId}', + 'summary': 'Get Actor', + 'path_params': ['actorId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_openapi_json_get.v1', + 'operation_id': 'act_openapi_json_get', + 'http_method': 'GET', + 'path': '/v2/acts/{actorId}/builds/{buildId}/openapi.json', + 'summary': 'Get OpenAPI definition', + 'path_params': ['actorId', 'buildId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_put.v1', + 'operation_id': 'act_put', + 'http_method': 'PUT', + 'path': '/v2/acts/{actorId}', + 'summary': 'Update Actor', + 'path_params': ['actorId'], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_runSyncGetDatasetItems_get.v1', + 'operation_id': 'act_runSyncGetDatasetItems_get', + 'http_method': 'GET', + 'path': '/v2/acts/{actorId}/run-sync-get-dataset-items', + 'summary': 'Run Actor synchronously without input and get dataset items', + 'path_params': ['actorId'], + 'query_params': ['attachment', 'bom', 'build', 'clean', 'delimiter', 'desc', 'fields', 'flatten', 'format', 'limit', 'memory', 'offset', 'omit', 'restartOnError', 'simplified', 'skipEmpty', 'skipFailedPages', 'skipHeaderRow', 'skipHidden', 'timeout', 'unwind', 'webhooks', 'xmlRoot', 'xmlRow'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_runSyncGetDatasetItems_post.v1', + 'operation_id': 'act_runSyncGetDatasetItems_post', + 'http_method': 'POST', + 'path': '/v2/acts/{actorId}/run-sync-get-dataset-items', + 'summary': 'Run Actor synchronously with input and get dataset items', + 'path_params': ['actorId'], + 'query_params': ['attachment', 'bom', 'build', 'clean', 'delimiter', 'desc', 'fields', 'flatten', 'format', 'limit', 'memory', 'offset', 'omit', 'restartOnError', 'simplified', 'skipEmpty', 'skipFailedPages', 'skipHeaderRow', 'skipHidden', 'timeout', 'unwind', 'webhooks', 'xmlRoot', 'xmlRow'], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_runSync_get.v1', + 'operation_id': 'act_runSync_get', + 'http_method': 'GET', + 'path': '/v2/acts/{actorId}/run-sync', + 'summary': 'Without input', + 'path_params': ['actorId'], + 'query_params': ['build', 'memory', 'outputRecordKey', 'restartOnError', 'timeout', 'webhooks'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_runSync_post.v1', + 'operation_id': 'act_runSync_post', + 'http_method': 'POST', + 'path': '/v2/acts/{actorId}/run-sync', + 'summary': 'Run Actor synchronously with input and return output', + 'path_params': ['actorId'], + 'query_params': ['build', 'memory', 'outputRecordKey', 'restartOnError', 'timeout', 'webhooks'], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_run_abort_post.v1', + 'operation_id': 'act_run_abort_post', + 'http_method': 'POST', + 'path': '/v2/acts/{actorId}/runs/{runId}/abort', + 'summary': 'Abort run', + 'path_params': ['actorId', 'runId'], + 'query_params': ['gracefully'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_run_get.v1', + 'operation_id': 'act_run_get', + 'http_method': 'GET', + 'path': '/v2/acts/{actorId}/runs/{runId}', + 'summary': 'Get run', + 'path_params': ['actorId', 'runId'], + 'query_params': ['waitForFinish'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_run_metamorph_post.v1', + 'operation_id': 'act_run_metamorph_post', + 'http_method': 'POST', + 'path': '/v2/acts/{actorId}/runs/{runId}/metamorph', + 'summary': 'Metamorph run', + 'path_params': ['actorId', 'runId'], + 'query_params': ['build', 'targetActorId'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_run_resurrect_post.v1', + 'operation_id': 'act_run_resurrect_post', + 'http_method': 'POST', + 'path': '/v2/acts/{actorId}/runs/{runId}/resurrect', + 'summary': 'Resurrect run', + 'path_params': ['actorId', 'runId'], + 'query_params': ['build', 'memory', 'restartOnError', 'timeout'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_runs_get.v1', + 'operation_id': 'act_runs_get', + 'http_method': 'GET', + 'path': '/v2/acts/{actorId}/runs', + 'summary': 'Get list of runs', + 'path_params': ['actorId'], + 'query_params': ['desc', 'limit', 'offset', 'startedAfter', 'startedBefore', 'status'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_runs_last_get.v1', + 'operation_id': 'act_runs_last_get', + 'http_method': 'GET', + 'path': '/v2/acts/{actorId}/runs/last', + 'summary': 'Get last run', + 'path_params': ['actorId'], + 'query_params': ['status'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_runs_post.v1', + 'operation_id': 'act_runs_post', + 'http_method': 'POST', + 'path': '/v2/acts/{actorId}/runs', + 'summary': 'Run Actor', + 'path_params': ['actorId'], + 'query_params': ['build', 'forcePermissionLevel', 'memory', 'restartOnError', 'timeout', 'waitForFinish', 'webhooks'], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_version_delete.v1', + 'operation_id': 'act_version_delete', + 'http_method': 'DELETE', + 'path': '/v2/acts/{actorId}/versions/{versionNumber}', + 'summary': 'Delete version', + 'path_params': ['actorId', 'versionNumber'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_version_envVar_delete.v1', + 'operation_id': 'act_version_envVar_delete', + 'http_method': 'DELETE', + 'path': '/v2/acts/{actorId}/versions/{versionNumber}/env-vars/{envVarName}', + 'summary': 'Delete environment variable', + 'path_params': ['actorId', 'envVarName', 'versionNumber'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_version_envVar_get.v1', + 'operation_id': 'act_version_envVar_get', + 'http_method': 'GET', + 'path': '/v2/acts/{actorId}/versions/{versionNumber}/env-vars/{envVarName}', + 'summary': 'Get environment variable', + 'path_params': ['actorId', 'envVarName', 'versionNumber'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_version_envVar_put.v1', + 'operation_id': 'act_version_envVar_put', + 'http_method': 'PUT', + 'path': '/v2/acts/{actorId}/versions/{versionNumber}/env-vars/{envVarName}', + 'summary': 'Update environment variable', + 'path_params': ['actorId', 'envVarName', 'versionNumber'], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_version_envVars_get.v1', + 'operation_id': 'act_version_envVars_get', + 'http_method': 'GET', + 'path': '/v2/acts/{actorId}/versions/{versionNumber}/env-vars', + 'summary': 'Get list of environment variables', + 'path_params': ['actorId', 'versionNumber'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_version_envVars_post.v1', + 'operation_id': 'act_version_envVars_post', + 'http_method': 'POST', + 'path': '/v2/acts/{actorId}/versions/{versionNumber}/env-vars', + 'summary': 'Create environment variable', + 'path_params': ['actorId', 'versionNumber'], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_version_get.v1', + 'operation_id': 'act_version_get', + 'http_method': 'GET', + 'path': '/v2/acts/{actorId}/versions/{versionNumber}', + 'summary': 'Get version', + 'path_params': ['actorId', 'versionNumber'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_version_put.v1', + 'operation_id': 'act_version_put', + 'http_method': 'PUT', + 'path': '/v2/acts/{actorId}/versions/{versionNumber}', + 'summary': 'Update version', + 'path_params': ['actorId', 'versionNumber'], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_versions_get.v1', + 'operation_id': 'act_versions_get', + 'http_method': 'GET', + 'path': '/v2/acts/{actorId}/versions', + 'summary': 'Get list of versions', + 'path_params': ['actorId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_versions_post.v1', + 'operation_id': 'act_versions_post', + 'http_method': 'POST', + 'path': '/v2/acts/{actorId}/versions', + 'summary': 'Create version', + 'path_params': ['actorId'], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.act_webhooks_get.v1', + 'operation_id': 'act_webhooks_get', + 'http_method': 'GET', + 'path': '/v2/acts/{actorId}/webhooks', + 'summary': 'Get list of webhooks', + 'path_params': ['actorId'], + 'query_params': ['desc', 'limit', 'offset'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorBuild_abort_post.v1', + 'operation_id': 'actorBuild_abort_post', + 'http_method': 'POST', + 'path': '/v2/actor-builds/{buildId}/abort', + 'summary': 'Abort build', + 'path_params': ['buildId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorBuild_delete.v1', + 'operation_id': 'actorBuild_delete', + 'http_method': 'DELETE', + 'path': '/v2/actor-builds/{buildId}', + 'summary': 'Delete build', + 'path_params': ['buildId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': [], + }, + { + 'method_id': 'apify.actorBuild_get.v1', + 'operation_id': 'actorBuild_get', + 'http_method': 'GET', + 'path': '/v2/actor-builds/{buildId}', + 'summary': 'Get build', + 'path_params': ['buildId'], + 'query_params': ['waitForFinish'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorBuild_log_get.v1', + 'operation_id': 'actorBuild_log_get', + 'http_method': 'GET', + 'path': '/v2/actor-builds/{buildId}/log', + 'summary': 'Get log', + 'path_params': ['buildId'], + 'query_params': ['download', 'stream'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['text/plain'], + }, + { + 'method_id': 'apify.actorBuild_openapi_json_get.v1', + 'operation_id': 'actorBuild_openapi_json_get', + 'http_method': 'GET', + 'path': '/v2/actor-builds/{buildId}/openapi.json', + 'summary': 'Get OpenAPI definition', + 'path_params': ['buildId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorBuilds_get.v1', + 'operation_id': 'actorBuilds_get', + 'http_method': 'GET', + 'path': '/v2/actor-builds', + 'summary': 'Get user builds list', + 'path_params': [], + 'query_params': ['desc', 'limit', 'offset'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorRun_abort_post.v1', + 'operation_id': 'actorRun_abort_post', + 'http_method': 'POST', + 'path': '/v2/actor-runs/{runId}/abort', + 'summary': 'Abort run', + 'path_params': ['runId'], + 'query_params': ['gracefully'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorRun_delete.v1', + 'operation_id': 'actorRun_delete', + 'http_method': 'DELETE', + 'path': '/v2/actor-runs/{runId}', + 'summary': 'Delete run', + 'path_params': ['runId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': [], + }, + { + 'method_id': 'apify.actorRun_get.v1', + 'operation_id': 'actorRun_get', + 'http_method': 'GET', + 'path': '/v2/actor-runs/{runId}', + 'summary': 'Get run', + 'path_params': ['runId'], + 'query_params': ['waitForFinish'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorRun_metamorph_post.v1', + 'operation_id': 'actorRun_metamorph_post', + 'http_method': 'POST', + 'path': '/v2/actor-runs/{runId}/metamorph', + 'summary': 'Metamorph run', + 'path_params': ['runId'], + 'query_params': ['build', 'targetActorId'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorRun_put.v1', + 'operation_id': 'actorRun_put', + 'http_method': 'PUT', + 'path': '/v2/actor-runs/{runId}', + 'summary': 'Update run', + 'path_params': ['runId'], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorRun_reboot_post.v1', + 'operation_id': 'actorRun_reboot_post', + 'http_method': 'POST', + 'path': '/v2/actor-runs/{runId}/reboot', + 'summary': 'Reboot run', + 'path_params': ['runId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorRuns_get.v1', + 'operation_id': 'actorRuns_get', + 'http_method': 'GET', + 'path': '/v2/actor-runs', + 'summary': 'Get user runs list', + 'path_params': [], + 'query_params': ['desc', 'limit', 'offset', 'startedAfter', 'startedBefore', 'status'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorTask_delete.v1', + 'operation_id': 'actorTask_delete', + 'http_method': 'DELETE', + 'path': '/v2/actor-tasks/{actorTaskId}', + 'summary': 'Delete task', + 'path_params': ['actorTaskId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorTask_get.v1', + 'operation_id': 'actorTask_get', + 'http_method': 'GET', + 'path': '/v2/actor-tasks/{actorTaskId}', + 'summary': 'Get task', + 'path_params': ['actorTaskId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorTask_input_get.v1', + 'operation_id': 'actorTask_input_get', + 'http_method': 'GET', + 'path': '/v2/actor-tasks/{actorTaskId}/input', + 'summary': 'Get task input', + 'path_params': ['actorTaskId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorTask_input_put.v1', + 'operation_id': 'actorTask_input_put', + 'http_method': 'PUT', + 'path': '/v2/actor-tasks/{actorTaskId}/input', + 'summary': 'Update task input', + 'path_params': ['actorTaskId'], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorTask_put.v1', + 'operation_id': 'actorTask_put', + 'http_method': 'PUT', + 'path': '/v2/actor-tasks/{actorTaskId}', + 'summary': 'Update task', + 'path_params': ['actorTaskId'], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorTask_runSyncGetDatasetItems_get.v1', + 'operation_id': 'actorTask_runSyncGetDatasetItems_get', + 'http_method': 'GET', + 'path': '/v2/actor-tasks/{actorTaskId}/run-sync-get-dataset-items', + 'summary': 'Run task synchronously and get dataset items', + 'path_params': ['actorTaskId'], + 'query_params': ['attachment', 'bom', 'build', 'clean', 'delimiter', 'desc', 'fields', 'flatten', 'format', 'limit', 'memory', 'offset', 'omit', 'simplified', 'skipEmpty', 'skipFailedPages', 'skipHeaderRow', 'skipHidden', 'timeout', 'unwind', 'webhooks', 'xmlRoot', 'xmlRow'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorTask_runSyncGetDatasetItems_post.v1', + 'operation_id': 'actorTask_runSyncGetDatasetItems_post', + 'http_method': 'POST', + 'path': '/v2/actor-tasks/{actorTaskId}/run-sync-get-dataset-items', + 'summary': 'Run task synchronously and get dataset items', + 'path_params': ['actorTaskId'], + 'query_params': ['attachment', 'bom', 'build', 'clean', 'delimiter', 'desc', 'fields', 'flatten', 'format', 'limit', 'memory', 'offset', 'omit', 'restartOnError', 'simplified', 'skipEmpty', 'skipFailedPages', 'skipHeaderRow', 'skipHidden', 'timeout', 'unwind', 'webhooks', 'xmlRoot', 'xmlRow'], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorTask_runSync_get.v1', + 'operation_id': 'actorTask_runSync_get', + 'http_method': 'GET', + 'path': '/v2/actor-tasks/{actorTaskId}/run-sync', + 'summary': 'Run task synchronously', + 'path_params': ['actorTaskId'], + 'query_params': ['build', 'memory', 'outputRecordKey', 'timeout', 'webhooks'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorTask_runSync_post.v1', + 'operation_id': 'actorTask_runSync_post', + 'http_method': 'POST', + 'path': '/v2/actor-tasks/{actorTaskId}/run-sync', + 'summary': 'Run task synchronously', + 'path_params': ['actorTaskId'], + 'query_params': ['build', 'memory', 'outputRecordKey', 'restartOnError', 'timeout', 'webhooks'], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorTask_runs_get.v1', + 'operation_id': 'actorTask_runs_get', + 'http_method': 'GET', + 'path': '/v2/actor-tasks/{actorTaskId}/runs', + 'summary': 'Get list of task runs', + 'path_params': ['actorTaskId'], + 'query_params': ['desc', 'limit', 'offset', 'status'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorTask_runs_last_get.v1', + 'operation_id': 'actorTask_runs_last_get', + 'http_method': 'GET', + 'path': '/v2/actor-tasks/{actorTaskId}/runs/last', + 'summary': 'Get last run', + 'path_params': ['actorTaskId'], + 'query_params': ['status'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorTask_runs_post.v1', + 'operation_id': 'actorTask_runs_post', + 'http_method': 'POST', + 'path': '/v2/actor-tasks/{actorTaskId}/runs', + 'summary': 'Run task', + 'path_params': ['actorTaskId'], + 'query_params': ['build', 'memory', 'restartOnError', 'timeout', 'waitForFinish', 'webhooks'], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorTask_webhooks_get.v1', + 'operation_id': 'actorTask_webhooks_get', + 'http_method': 'GET', + 'path': '/v2/actor-tasks/{actorTaskId}/webhooks', + 'summary': 'Get list of webhooks', + 'path_params': ['actorTaskId'], + 'query_params': ['desc', 'limit', 'offset'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorTasks_get.v1', + 'operation_id': 'actorTasks_get', + 'http_method': 'GET', + 'path': '/v2/actor-tasks', + 'summary': 'Get list of tasks', + 'path_params': [], + 'query_params': ['desc', 'limit', 'offset'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.actorTasks_post.v1', + 'operation_id': 'actorTasks_post', + 'http_method': 'POST', + 'path': '/v2/actor-tasks', + 'summary': 'Create task', + 'path_params': [], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.acts_get.v1', + 'operation_id': 'acts_get', + 'http_method': 'GET', + 'path': '/v2/acts', + 'summary': 'Get list of Actors', + 'path_params': [], + 'query_params': ['desc', 'limit', 'my', 'offset', 'sortBy'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.acts_post.v1', + 'operation_id': 'acts_post', + 'http_method': 'POST', + 'path': '/v2/acts', + 'summary': 'Create Actor', + 'path_params': [], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.dataset_delete.v1', + 'operation_id': 'dataset_delete', + 'http_method': 'DELETE', + 'path': '/v2/datasets/{datasetId}', + 'summary': 'Delete dataset', + 'path_params': [], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': [], + }, + { + 'method_id': 'apify.dataset_get.v1', + 'operation_id': 'dataset_get', + 'http_method': 'GET', + 'path': '/v2/datasets/{datasetId}', + 'summary': 'Get dataset', + 'path_params': [], + 'query_params': ['token'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.dataset_items_get.v1', + 'operation_id': 'dataset_items_get', + 'http_method': 'GET', + 'path': '/v2/datasets/{datasetId}/items', + 'summary': 'Get dataset items', + 'path_params': [], + 'query_params': ['attachment', 'bom', 'clean', 'delimiter', 'fields', 'flatten', 'format', 'limit', 'omit', 'simplified', 'skipEmpty', 'skipFailedPages', 'skipHeaderRow', 'skipHidden', 'unwind', 'view', 'xmlRoot', 'xmlRow'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json', 'application/jsonl', 'application/rss+xml', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/xml', 'text/csv', 'text/html'], + }, + { + 'method_id': 'apify.dataset_items_post.v1', + 'operation_id': 'dataset_items_post', + 'http_method': 'POST', + 'path': '/v2/datasets/{datasetId}/items', + 'summary': 'Store items', + 'path_params': [], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.dataset_put.v1', + 'operation_id': 'dataset_put', + 'http_method': 'PUT', + 'path': '/v2/datasets/{datasetId}', + 'summary': 'Update dataset', + 'path_params': [], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.dataset_statistics_get.v1', + 'operation_id': 'dataset_statistics_get', + 'http_method': 'GET', + 'path': '/v2/datasets/{datasetId}/statistics', + 'summary': 'Get dataset statistics', + 'path_params': [], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.datasets_get.v1', + 'operation_id': 'datasets_get', + 'http_method': 'GET', + 'path': '/v2/datasets', + 'summary': 'Get list of datasets', + 'path_params': [], + 'query_params': ['ownership'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.datasets_post.v1', + 'operation_id': 'datasets_post', + 'http_method': 'POST', + 'path': '/v2/datasets', + 'summary': 'Create dataset', + 'path_params': [], + 'query_params': ['name'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.keyValueStore_delete.v1', + 'operation_id': 'keyValueStore_delete', + 'http_method': 'DELETE', + 'path': '/v2/key-value-stores/{storeId}', + 'summary': 'Delete store', + 'path_params': [], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': [], + }, + { + 'method_id': 'apify.keyValueStore_get.v1', + 'operation_id': 'keyValueStore_get', + 'http_method': 'GET', + 'path': '/v2/key-value-stores/{storeId}', + 'summary': 'Get store', + 'path_params': [], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.keyValueStore_keys_get.v1', + 'operation_id': 'keyValueStore_keys_get', + 'http_method': 'GET', + 'path': '/v2/key-value-stores/{storeId}/keys', + 'summary': 'Get list of keys', + 'path_params': [], + 'query_params': ['collection', 'exclusiveStartKey', 'limit', 'prefix'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.keyValueStore_put.v1', + 'operation_id': 'keyValueStore_put', + 'http_method': 'PUT', + 'path': '/v2/key-value-stores/{storeId}', + 'summary': 'Update store', + 'path_params': [], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.keyValueStore_record_delete.v1', + 'operation_id': 'keyValueStore_record_delete', + 'http_method': 'DELETE', + 'path': '/v2/key-value-stores/{storeId}/records/{recordKey}', + 'summary': 'Delete record', + 'path_params': [], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': [], + }, + { + 'method_id': 'apify.keyValueStore_record_get.v1', + 'operation_id': 'keyValueStore_record_get', + 'http_method': 'GET', + 'path': '/v2/key-value-stores/{storeId}/records/{recordKey}', + 'summary': 'Get record', + 'path_params': [], + 'query_params': ['attachment'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.keyValueStore_record_head.v1', + 'operation_id': 'keyValueStore_record_head', + 'http_method': 'HEAD', + 'path': '/v2/key-value-stores/{storeId}/records/{recordKey}', + 'summary': 'Check if a record exists', + 'path_params': [], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': [], + }, + { + 'method_id': 'apify.keyValueStore_record_put.v1', + 'operation_id': 'keyValueStore_record_put', + 'http_method': 'PUT', + 'path': '/v2/key-value-stores/{storeId}/records/{recordKey}', + 'summary': 'Store record', + 'path_params': [], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.keyValueStores_get.v1', + 'operation_id': 'keyValueStores_get', + 'http_method': 'GET', + 'path': '/v2/key-value-stores', + 'summary': 'Get list of key-value stores', + 'path_params': [], + 'query_params': ['ownership'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.keyValueStores_post.v1', + 'operation_id': 'keyValueStores_post', + 'http_method': 'POST', + 'path': '/v2/key-value-stores', + 'summary': 'Create key-value store', + 'path_params': [], + 'query_params': ['name'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.log_get.v1', + 'operation_id': 'log_get', + 'http_method': 'GET', + 'path': '/v2/logs/{buildOrRunId}', + 'summary': 'Get log', + 'path_params': ['buildOrRunId'], + 'query_params': ['download', 'raw', 'stream'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['text/plain'], + }, + { + 'method_id': 'apify.requestQueue_delete.v1', + 'operation_id': 'requestQueue_delete', + 'http_method': 'DELETE', + 'path': '/v2/request-queues/{queueId}', + 'summary': 'Delete request queue', + 'path_params': [], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': [], + }, + { + 'method_id': 'apify.requestQueue_get.v1', + 'operation_id': 'requestQueue_get', + 'http_method': 'GET', + 'path': '/v2/request-queues/{queueId}', + 'summary': 'Get request queue', + 'path_params': [], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.requestQueue_head_get.v1', + 'operation_id': 'requestQueue_head_get', + 'http_method': 'GET', + 'path': '/v2/request-queues/{queueId}/head', + 'summary': 'Get head', + 'path_params': [], + 'query_params': ['limit'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.requestQueue_head_lock_post.v1', + 'operation_id': 'requestQueue_head_lock_post', + 'http_method': 'POST', + 'path': '/v2/request-queues/{queueId}/head/lock', + 'summary': 'Get head and lock', + 'path_params': [], + 'query_params': ['limit'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.requestQueue_put.v1', + 'operation_id': 'requestQueue_put', + 'http_method': 'PUT', + 'path': '/v2/request-queues/{queueId}', + 'summary': 'Update request queue', + 'path_params': [], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.requestQueue_request_delete.v1', + 'operation_id': 'requestQueue_request_delete', + 'http_method': 'DELETE', + 'path': '/v2/request-queues/{queueId}/requests/{requestId}', + 'summary': 'Delete request', + 'path_params': [], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': [], + }, + { + 'method_id': 'apify.requestQueue_request_get.v1', + 'operation_id': 'requestQueue_request_get', + 'http_method': 'GET', + 'path': '/v2/request-queues/{queueId}/requests/{requestId}', + 'summary': 'Get request', + 'path_params': [], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.requestQueue_request_lock_delete.v1', + 'operation_id': 'requestQueue_request_lock_delete', + 'http_method': 'DELETE', + 'path': '/v2/request-queues/{queueId}/requests/{requestId}/lock', + 'summary': 'Delete request lock', + 'path_params': [], + 'query_params': ['forefront'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': [], + }, + { + 'method_id': 'apify.requestQueue_request_lock_put.v1', + 'operation_id': 'requestQueue_request_lock_put', + 'http_method': 'PUT', + 'path': '/v2/request-queues/{queueId}/requests/{requestId}/lock', + 'summary': 'Prolong request lock', + 'path_params': [], + 'query_params': ['forefront'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.requestQueue_request_put.v1', + 'operation_id': 'requestQueue_request_put', + 'http_method': 'PUT', + 'path': '/v2/request-queues/{queueId}/requests/{requestId}', + 'summary': 'Update request', + 'path_params': [], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.requestQueue_requests_batch_delete.v1', + 'operation_id': 'requestQueue_requests_batch_delete', + 'http_method': 'DELETE', + 'path': '/v2/request-queues/{queueId}/requests/batch', + 'summary': 'Delete requests', + 'path_params': [], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.requestQueue_requests_batch_post.v1', + 'operation_id': 'requestQueue_requests_batch_post', + 'http_method': 'POST', + 'path': '/v2/request-queues/{queueId}/requests/batch', + 'summary': 'Add requests', + 'path_params': [], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.requestQueue_requests_get.v1', + 'operation_id': 'requestQueue_requests_get', + 'http_method': 'GET', + 'path': '/v2/request-queues/{queueId}/requests', + 'summary': 'List requests', + 'path_params': [], + 'query_params': ['exclusiveStartId', 'limit'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.requestQueue_requests_post.v1', + 'operation_id': 'requestQueue_requests_post', + 'http_method': 'POST', + 'path': '/v2/request-queues/{queueId}/requests', + 'summary': 'Add request', + 'path_params': [], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.requestQueue_requests_unlock_post.v1', + 'operation_id': 'requestQueue_requests_unlock_post', + 'http_method': 'POST', + 'path': '/v2/request-queues/{queueId}/requests/unlock', + 'summary': 'Unlock requests', + 'path_params': [], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.requestQueues_get.v1', + 'operation_id': 'requestQueues_get', + 'http_method': 'GET', + 'path': '/v2/request-queues', + 'summary': 'Get list of request queues', + 'path_params': [], + 'query_params': ['ownership'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.requestQueues_post.v1', + 'operation_id': 'requestQueues_post', + 'http_method': 'POST', + 'path': '/v2/request-queues', + 'summary': 'Create request queue', + 'path_params': [], + 'query_params': ['name'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.schedule_delete.v1', + 'operation_id': 'schedule_delete', + 'http_method': 'DELETE', + 'path': '/v2/schedules/{scheduleId}', + 'summary': 'Delete schedule', + 'path_params': ['scheduleId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.schedule_get.v1', + 'operation_id': 'schedule_get', + 'http_method': 'GET', + 'path': '/v2/schedules/{scheduleId}', + 'summary': 'Get schedule', + 'path_params': ['scheduleId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.schedule_log_get.v1', + 'operation_id': 'schedule_log_get', + 'http_method': 'GET', + 'path': '/v2/schedules/{scheduleId}/log', + 'summary': 'Get schedule log', + 'path_params': ['scheduleId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.schedule_put.v1', + 'operation_id': 'schedule_put', + 'http_method': 'PUT', + 'path': '/v2/schedules/{scheduleId}', + 'summary': 'Update schedule', + 'path_params': ['scheduleId'], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.schedules_get.v1', + 'operation_id': 'schedules_get', + 'http_method': 'GET', + 'path': '/v2/schedules', + 'summary': 'Get list of schedules', + 'path_params': [], + 'query_params': ['desc', 'limit', 'offset'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.schedules_post.v1', + 'operation_id': 'schedules_post', + 'http_method': 'POST', + 'path': '/v2/schedules', + 'summary': 'Create schedule', + 'path_params': [], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.store_get.v1', + 'operation_id': 'store_get', + 'http_method': 'GET', + 'path': '/v2/store', + 'summary': 'Get list of Actors in store', + 'path_params': [], + 'query_params': ['allowsAgenticUsers', 'category', 'limit', 'offset', 'pricingModel', 'search', 'sortBy', 'username'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.user_get.v1', + 'operation_id': 'user_get', + 'http_method': 'GET', + 'path': '/v2/users/{userId}', + 'summary': 'Get public user data', + 'path_params': ['userId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.users_me_get.v1', + 'operation_id': 'users_me_get', + 'http_method': 'GET', + 'path': '/v2/users/me', + 'summary': 'Get private user data', + 'path_params': [], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.users_me_limits_get.v1', + 'operation_id': 'users_me_limits_get', + 'http_method': 'GET', + 'path': '/v2/users/me/limits', + 'summary': 'Get limits', + 'path_params': [], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.users_me_limits_put.v1', + 'operation_id': 'users_me_limits_put', + 'http_method': 'PUT', + 'path': '/v2/users/me/limits', + 'summary': 'Update limits', + 'path_params': [], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.users_me_usage_monthly_get.v1', + 'operation_id': 'users_me_usage_monthly_get', + 'http_method': 'GET', + 'path': '/v2/users/me/usage/monthly', + 'summary': 'Get monthly usage', + 'path_params': [], + 'query_params': ['date'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.webhookDispatch_get.v1', + 'operation_id': 'webhookDispatch_get', + 'http_method': 'GET', + 'path': '/v2/webhook-dispatches/{dispatchId}', + 'summary': 'Get webhook dispatch', + 'path_params': ['dispatchId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.webhookDispatches_get.v1', + 'operation_id': 'webhookDispatches_get', + 'http_method': 'GET', + 'path': '/v2/webhook-dispatches', + 'summary': 'Get list of webhook dispatches', + 'path_params': [], + 'query_params': ['desc', 'limit', 'offset'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.webhook_delete.v1', + 'operation_id': 'webhook_delete', + 'http_method': 'DELETE', + 'path': '/v2/webhooks/{webhookId}', + 'summary': 'Delete webhook', + 'path_params': ['webhookId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.webhook_get.v1', + 'operation_id': 'webhook_get', + 'http_method': 'GET', + 'path': '/v2/webhooks/{webhookId}', + 'summary': 'Get webhook', + 'path_params': ['webhookId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.webhook_put.v1', + 'operation_id': 'webhook_put', + 'http_method': 'PUT', + 'path': '/v2/webhooks/{webhookId}', + 'summary': 'Update webhook', + 'path_params': ['webhookId'], + 'query_params': [], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.webhook_test_post.v1', + 'operation_id': 'webhook_test_post', + 'http_method': 'POST', + 'path': '/v2/webhooks/{webhookId}/test', + 'summary': 'Test webhook', + 'path_params': ['webhookId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.webhook_webhookDispatches_get.v1', + 'operation_id': 'webhook_webhookDispatches_get', + 'http_method': 'GET', + 'path': '/v2/webhooks/{webhookId}/dispatches', + 'summary': 'Get collection', + 'path_params': ['webhookId'], + 'query_params': [], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.webhooks_get.v1', + 'operation_id': 'webhooks_get', + 'http_method': 'GET', + 'path': '/v2/webhooks', + 'summary': 'Get list of webhooks', + 'path_params': [], + 'query_params': ['desc', 'limit', 'offset'], + 'has_request_body': False, + 'request_content_types': [], + 'response_content_types': ['application/json'], + }, + { + 'method_id': 'apify.webhooks_post.v1', + 'operation_id': 'webhooks_post', + 'http_method': 'POST', + 'path': '/v2/webhooks', + 'summary': 'Create webhook', + 'path_params': [], + 'query_params': ['desc', 'limit', 'offset'], + 'has_request_body': True, + 'request_content_types': ['application/json'], + 'response_content_types': ['application/json'], + }, +] +METHOD_IDS = [spec["method_id"] for spec in METHOD_SPECS] +METHOD_SPECS_BY_ID = {spec["method_id"]: spec for spec in METHOD_SPECS} + + +class IntegrationApify: + def __init__(self, rcx=None): + self.rcx = rcx + + def _auth(self) -> Dict[str, Any]: + if self.rcx is not None: + return (self.rcx.external_auth.get(PROVIDER_NAME) or {}) + return {} + + def _token(self) -> str: + auth = self._auth() + return str( + auth.get("api_key", "") + or auth.get("token", "") + or auth.get("oauth_token", "") + or os.environ.get("APIFY_API_TOKEN", "") + ).strip() + + def _help(self) -> str: + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + "call args: method_id plus explicit endpoint args as documented in list_methods; optional body, body_base64, content_type, headers, accept, timeout_seconds, follow_redirects\n" + f"methods={len(METHOD_IDS)}" + ) + + def _status(self) -> str: + token = self._token() + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "ready" if token else "missing_credentials", + "has_api_token": bool(token), + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + + async def called_by_model(self, toolcall, model_produced_args: Optional[Dict[str, Any]]) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return self._help() + if op == "status": + return self._status() + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS, "methods": METHOD_SPECS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == 'apify.PostChargeRun.v1': + return await self._PostChargeRun(args) + if method_id == 'apify.PostResurrectRun.v1': + return await self._PostResurrectRun(args) + if method_id == 'apify.act_build_abort_post.v1': + return await self._act_build_abort_post(args) + if method_id == 'apify.act_build_default_get.v1': + return await self._act_build_default_get(args) + if method_id == 'apify.act_build_get.v1': + return await self._act_build_get(args) + if method_id == 'apify.act_builds_get.v1': + return await self._act_builds_get(args) + if method_id == 'apify.act_builds_post.v1': + return await self._act_builds_post(args) + if method_id == 'apify.act_delete.v1': + return await self._act_delete(args) + if method_id == 'apify.act_get.v1': + return await self._act_get(args) + if method_id == 'apify.act_openapi_json_get.v1': + return await self._act_openapi_json_get(args) + if method_id == 'apify.act_put.v1': + return await self._act_put(args) + if method_id == 'apify.act_runSyncGetDatasetItems_get.v1': + return await self._act_runSyncGetDatasetItems_get(args) + if method_id == 'apify.act_runSyncGetDatasetItems_post.v1': + return await self._act_runSyncGetDatasetItems_post(args) + if method_id == 'apify.act_runSync_get.v1': + return await self._act_runSync_get(args) + if method_id == 'apify.act_runSync_post.v1': + return await self._act_runSync_post(args) + if method_id == 'apify.act_run_abort_post.v1': + return await self._act_run_abort_post(args) + if method_id == 'apify.act_run_get.v1': + return await self._act_run_get(args) + if method_id == 'apify.act_run_metamorph_post.v1': + return await self._act_run_metamorph_post(args) + if method_id == 'apify.act_run_resurrect_post.v1': + return await self._act_run_resurrect_post(args) + if method_id == 'apify.act_runs_get.v1': + return await self._act_runs_get(args) + if method_id == 'apify.act_runs_last_get.v1': + return await self._act_runs_last_get(args) + if method_id == 'apify.act_runs_post.v1': + return await self._act_runs_post(args) + if method_id == 'apify.act_version_delete.v1': + return await self._act_version_delete(args) + if method_id == 'apify.act_version_envVar_delete.v1': + return await self._act_version_envVar_delete(args) + if method_id == 'apify.act_version_envVar_get.v1': + return await self._act_version_envVar_get(args) + if method_id == 'apify.act_version_envVar_put.v1': + return await self._act_version_envVar_put(args) + if method_id == 'apify.act_version_envVars_get.v1': + return await self._act_version_envVars_get(args) + if method_id == 'apify.act_version_envVars_post.v1': + return await self._act_version_envVars_post(args) + if method_id == 'apify.act_version_get.v1': + return await self._act_version_get(args) + if method_id == 'apify.act_version_put.v1': + return await self._act_version_put(args) + if method_id == 'apify.act_versions_get.v1': + return await self._act_versions_get(args) + if method_id == 'apify.act_versions_post.v1': + return await self._act_versions_post(args) + if method_id == 'apify.act_webhooks_get.v1': + return await self._act_webhooks_get(args) + if method_id == 'apify.actorBuild_abort_post.v1': + return await self._actorBuild_abort_post(args) + if method_id == 'apify.actorBuild_delete.v1': + return await self._actorBuild_delete(args) + if method_id == 'apify.actorBuild_get.v1': + return await self._actorBuild_get(args) + if method_id == 'apify.actorBuild_log_get.v1': + return await self._actorBuild_log_get(args) + if method_id == 'apify.actorBuild_openapi_json_get.v1': + return await self._actorBuild_openapi_json_get(args) + if method_id == 'apify.actorBuilds_get.v1': + return await self._actorBuilds_get(args) + if method_id == 'apify.actorRun_abort_post.v1': + return await self._actorRun_abort_post(args) + if method_id == 'apify.actorRun_delete.v1': + return await self._actorRun_delete(args) + if method_id == 'apify.actorRun_get.v1': + return await self._actorRun_get(args) + if method_id == 'apify.actorRun_metamorph_post.v1': + return await self._actorRun_metamorph_post(args) + if method_id == 'apify.actorRun_put.v1': + return await self._actorRun_put(args) + if method_id == 'apify.actorRun_reboot_post.v1': + return await self._actorRun_reboot_post(args) + if method_id == 'apify.actorRuns_get.v1': + return await self._actorRuns_get(args) + if method_id == 'apify.actorTask_delete.v1': + return await self._actorTask_delete(args) + if method_id == 'apify.actorTask_get.v1': + return await self._actorTask_get(args) + if method_id == 'apify.actorTask_input_get.v1': + return await self._actorTask_input_get(args) + if method_id == 'apify.actorTask_input_put.v1': + return await self._actorTask_input_put(args) + if method_id == 'apify.actorTask_put.v1': + return await self._actorTask_put(args) + if method_id == 'apify.actorTask_runSyncGetDatasetItems_get.v1': + return await self._actorTask_runSyncGetDatasetItems_get(args) + if method_id == 'apify.actorTask_runSyncGetDatasetItems_post.v1': + return await self._actorTask_runSyncGetDatasetItems_post(args) + if method_id == 'apify.actorTask_runSync_get.v1': + return await self._actorTask_runSync_get(args) + if method_id == 'apify.actorTask_runSync_post.v1': + return await self._actorTask_runSync_post(args) + if method_id == 'apify.actorTask_runs_get.v1': + return await self._actorTask_runs_get(args) + if method_id == 'apify.actorTask_runs_last_get.v1': + return await self._actorTask_runs_last_get(args) + if method_id == 'apify.actorTask_runs_post.v1': + return await self._actorTask_runs_post(args) + if method_id == 'apify.actorTask_webhooks_get.v1': + return await self._actorTask_webhooks_get(args) + if method_id == 'apify.actorTasks_get.v1': + return await self._actorTasks_get(args) + if method_id == 'apify.actorTasks_post.v1': + return await self._actorTasks_post(args) + if method_id == 'apify.acts_get.v1': + return await self._acts_get(args) + if method_id == 'apify.acts_post.v1': + return await self._acts_post(args) + if method_id == 'apify.dataset_delete.v1': + return await self._dataset_delete(args) + if method_id == 'apify.dataset_get.v1': + return await self._dataset_get(args) + if method_id == 'apify.dataset_items_get.v1': + return await self._dataset_items_get(args) + if method_id == 'apify.dataset_items_post.v1': + return await self._dataset_items_post(args) + if method_id == 'apify.dataset_put.v1': + return await self._dataset_put(args) + if method_id == 'apify.dataset_statistics_get.v1': + return await self._dataset_statistics_get(args) + if method_id == 'apify.datasets_get.v1': + return await self._datasets_get(args) + if method_id == 'apify.datasets_post.v1': + return await self._datasets_post(args) + if method_id == 'apify.keyValueStore_delete.v1': + return await self._keyValueStore_delete(args) + if method_id == 'apify.keyValueStore_get.v1': + return await self._keyValueStore_get(args) + if method_id == 'apify.keyValueStore_keys_get.v1': + return await self._keyValueStore_keys_get(args) + if method_id == 'apify.keyValueStore_put.v1': + return await self._keyValueStore_put(args) + if method_id == 'apify.keyValueStore_record_delete.v1': + return await self._keyValueStore_record_delete(args) + if method_id == 'apify.keyValueStore_record_get.v1': + return await self._keyValueStore_record_get(args) + if method_id == 'apify.keyValueStore_record_head.v1': + return await self._keyValueStore_record_head(args) + if method_id == 'apify.keyValueStore_record_put.v1': + return await self._keyValueStore_record_put(args) + if method_id == 'apify.keyValueStores_get.v1': + return await self._keyValueStores_get(args) + if method_id == 'apify.keyValueStores_post.v1': + return await self._keyValueStores_post(args) + if method_id == 'apify.log_get.v1': + return await self._log_get(args) + if method_id == 'apify.requestQueue_delete.v1': + return await self._requestQueue_delete(args) + if method_id == 'apify.requestQueue_get.v1': + return await self._requestQueue_get(args) + if method_id == 'apify.requestQueue_head_get.v1': + return await self._requestQueue_head_get(args) + if method_id == 'apify.requestQueue_head_lock_post.v1': + return await self._requestQueue_head_lock_post(args) + if method_id == 'apify.requestQueue_put.v1': + return await self._requestQueue_put(args) + if method_id == 'apify.requestQueue_request_delete.v1': + return await self._requestQueue_request_delete(args) + if method_id == 'apify.requestQueue_request_get.v1': + return await self._requestQueue_request_get(args) + if method_id == 'apify.requestQueue_request_lock_delete.v1': + return await self._requestQueue_request_lock_delete(args) + if method_id == 'apify.requestQueue_request_lock_put.v1': + return await self._requestQueue_request_lock_put(args) + if method_id == 'apify.requestQueue_request_put.v1': + return await self._requestQueue_request_put(args) + if method_id == 'apify.requestQueue_requests_batch_delete.v1': + return await self._requestQueue_requests_batch_delete(args) + if method_id == 'apify.requestQueue_requests_batch_post.v1': + return await self._requestQueue_requests_batch_post(args) + if method_id == 'apify.requestQueue_requests_get.v1': + return await self._requestQueue_requests_get(args) + if method_id == 'apify.requestQueue_requests_post.v1': + return await self._requestQueue_requests_post(args) + if method_id == 'apify.requestQueue_requests_unlock_post.v1': + return await self._requestQueue_requests_unlock_post(args) + if method_id == 'apify.requestQueues_get.v1': + return await self._requestQueues_get(args) + if method_id == 'apify.requestQueues_post.v1': + return await self._requestQueues_post(args) + if method_id == 'apify.schedule_delete.v1': + return await self._schedule_delete(args) + if method_id == 'apify.schedule_get.v1': + return await self._schedule_get(args) + if method_id == 'apify.schedule_log_get.v1': + return await self._schedule_log_get(args) + if method_id == 'apify.schedule_put.v1': + return await self._schedule_put(args) + if method_id == 'apify.schedules_get.v1': + return await self._schedules_get(args) + if method_id == 'apify.schedules_post.v1': + return await self._schedules_post(args) + if method_id == 'apify.store_get.v1': + return await self._store_get(args) + if method_id == 'apify.user_get.v1': + return await self._user_get(args) + if method_id == 'apify.users_me_get.v1': + return await self._users_me_get(args) + if method_id == 'apify.users_me_limits_get.v1': + return await self._users_me_limits_get(args) + if method_id == 'apify.users_me_limits_put.v1': + return await self._users_me_limits_put(args) + if method_id == 'apify.users_me_usage_monthly_get.v1': + return await self._users_me_usage_monthly_get(args) + if method_id == 'apify.webhookDispatch_get.v1': + return await self._webhookDispatch_get(args) + if method_id == 'apify.webhookDispatches_get.v1': + return await self._webhookDispatches_get(args) + if method_id == 'apify.webhook_delete.v1': + return await self._webhook_delete(args) + if method_id == 'apify.webhook_get.v1': + return await self._webhook_get(args) + if method_id == 'apify.webhook_put.v1': + return await self._webhook_put(args) + if method_id == 'apify.webhook_test_post.v1': + return await self._webhook_test_post(args) + if method_id == 'apify.webhook_webhookDispatches_get.v1': + return await self._webhook_webhookDispatches_get(args) + if method_id == 'apify.webhooks_get.v1': + return await self._webhooks_get(args) + if method_id == 'apify.webhooks_post.v1': + return await self._webhooks_post(args) + return json.dumps({"ok": False, "provider": PROVIDER_NAME, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + + def _path_params(self, args: Dict[str, Any], names: list[str]) -> Dict[str, str]: + result = {} + missing = [] + for name in names: + value = args.get(name) + if value is None or value == "": + missing.append(name) + continue + result[name] = quote(str(value), safe="~") + if missing: + raise ValueError(f"Missing required path params: {', '.join(missing)}") + return result + + def _query_params(self, args: Dict[str, Any], names: list[str]) -> Dict[str, Any]: + return {name: args[name] for name in names if args.get(name) is not None} + + def _content_type(self, default_content_types: list[str], args: Dict[str, Any]) -> str: + explicit = str(args.get("content_type", "")).strip() + if explicit: + return explicit + if "application/json" in default_content_types: + return "application/json" + if default_content_types: + return str(default_content_types[0]) + return "application/json" + + async def _request( + self, + *, + method_id: str, + http_method: str, + path: str, + path_params: Dict[str, str], + query: Dict[str, Any], + has_request_body: bool, + request_content_types: list[str], + args: Dict[str, Any], + ) -> str: + token = self._token() + headers_obj = args.get("headers") or {} + if not isinstance(headers_obj, dict): + return self._invalid_args(method_id, "headers must be an object when provided.") + headers = {k: str(v) for k, v in headers_obj.items()} + if token and "Authorization" not in headers: + headers["Authorization"] = f"Bearer {token}" + accept = str(args.get("accept", "")).strip() + if accept and "Accept" not in headers: + headers["Accept"] = accept + try: + timeout_seconds = float(args.get("timeout_seconds", _TIMEOUT)) + except (TypeError, ValueError) as e: + return self._invalid_args(method_id, f"Invalid timeout_seconds: {type(e).__name__}: {e}") + if timeout_seconds <= 0: + return self._invalid_args(method_id, "timeout_seconds must be > 0.") + url = _BASE_URL + path.format(**path_params) + request_kwargs: Dict[str, Any] = {"params": query, "headers": headers} + has_body = "body" in args + has_body_base64 = "body_base64" in args + if has_body and has_body_base64: + return self._invalid_args(method_id, "Provide either body or body_base64, not both.") + if not has_request_body and (has_body or has_body_base64): + return self._invalid_args(method_id, "This method does not accept a request body.") + if has_request_body: + content_type = self._content_type(request_content_types, args) + if "Content-Type" not in headers: + headers["Content-Type"] = content_type + if has_body_base64: + try: + request_kwargs["content"] = base64.b64decode(str(args.get("body_base64", ""))) + except (ValueError, TypeError) as e: + return self._invalid_args(method_id, f"Invalid body_base64: {type(e).__name__}: {e}") + else: + body = args.get("body") + if content_type.startswith("application/json"): + request_kwargs["json"] = body + elif isinstance(body, str): + request_kwargs["content"] = body.encode("utf-8") + elif body is not None: + request_kwargs["content"] = json.dumps(body, ensure_ascii=False).encode("utf-8") + try: + async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=bool(args.get("follow_redirects", False))) as client: + response = await client.request(http_method, url, **request_kwargs) + except httpx.TimeoutException: + return json.dumps({"ok": False, "provider": PROVIDER_NAME, "method_id": method_id, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError) as e: + logger.error("apify request failed method=%s", method_id, exc_info=e) + return json.dumps({"ok": False, "provider": PROVIDER_NAME, "method_id": method_id, "error_code": "HTTP_ERROR", "message": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + return self._format_response(method_id, response) + + def _format_response(self, method_id: str, response: httpx.Response) -> str: + if response.status_code >= 400: + return self._provider_error(method_id, response) + content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() + result: Dict[str, Any] = { + "status_code": response.status_code, + "content_type": content_type or None, + "url": str(response.request.url), + "headers": dict(response.headers), + } + if response.request.method == "HEAD": + result["body"] = None + elif content_type.startswith("application/json"): + try: + result["body"] = response.json() + except json.JSONDecodeError: + result["body"] = response.text + elif self._is_text_content_type(content_type): + result["body"] = response.text + elif not response.content: + result["body"] = "" + else: + result["body_base64"] = base64.b64encode(response.content).decode("ascii") + result["body_size"] = len(response.content) + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": result}, indent=2, ensure_ascii=False) + + def _provider_error(self, method_id: str, response: httpx.Response) -> str: + try: + detail: Any = response.json() + except json.JSONDecodeError: + detail = response.text[:1000] + logger.info("apify api error method=%s status=%s body=%s", method_id, response.status_code, response.text[:300]) + return json.dumps({"ok": False, "provider": PROVIDER_NAME, "method_id": method_id, "error_code": "PROVIDER_ERROR", "http_status": response.status_code, "detail": detail}, indent=2, ensure_ascii=False) + + def _invalid_args(self, method_id: str, message: str) -> str: + return json.dumps({"ok": False, "provider": PROVIDER_NAME, "method_id": method_id, "error_code": "INVALID_ARGS", "message": message}, indent=2, ensure_ascii=False) + + def _is_text_content_type(self, content_type: str) -> bool: + if not content_type: + return True + return content_type.startswith(_TEXT_RESPONSE_PREFIXES) + + async def _PostChargeRun(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['runId']) + except ValueError as e: + return self._invalid_args('apify.PostChargeRun.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.PostChargeRun.v1', + http_method='POST', + path='/v2/actor-runs/{runId}/charge', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _PostResurrectRun(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['runId']) + except ValueError as e: + return self._invalid_args('apify.PostResurrectRun.v1', str(e)) + query = self._query_params(args, ['build', 'memory', 'restartOnError', 'timeout']) + return await self._request( + method_id='apify.PostResurrectRun.v1', + http_method='POST', + path='/v2/actor-runs/{runId}/resurrect', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_build_abort_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId', 'buildId']) + except ValueError as e: + return self._invalid_args('apify.act_build_abort_post.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.act_build_abort_post.v1', + http_method='POST', + path='/v2/acts/{actorId}/builds/{buildId}/abort', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_build_default_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId']) + except ValueError as e: + return self._invalid_args('apify.act_build_default_get.v1', str(e)) + query = self._query_params(args, ['waitForFinish']) + return await self._request( + method_id='apify.act_build_default_get.v1', + http_method='GET', + path='/v2/acts/{actorId}/builds/default', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_build_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId', 'buildId']) + except ValueError as e: + return self._invalid_args('apify.act_build_get.v1', str(e)) + query = self._query_params(args, ['waitForFinish']) + return await self._request( + method_id='apify.act_build_get.v1', + http_method='GET', + path='/v2/acts/{actorId}/builds/{buildId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_builds_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId']) + except ValueError as e: + return self._invalid_args('apify.act_builds_get.v1', str(e)) + query = self._query_params(args, ['desc', 'limit', 'offset']) + return await self._request( + method_id='apify.act_builds_get.v1', + http_method='GET', + path='/v2/acts/{actorId}/builds', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_builds_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId']) + except ValueError as e: + return self._invalid_args('apify.act_builds_post.v1', str(e)) + query = self._query_params(args, ['betaPackages', 'tag', 'useCache', 'version', 'waitForFinish']) + return await self._request( + method_id='apify.act_builds_post.v1', + http_method='POST', + path='/v2/acts/{actorId}/builds', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_delete(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId']) + except ValueError as e: + return self._invalid_args('apify.act_delete.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.act_delete.v1', + http_method='DELETE', + path='/v2/acts/{actorId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId']) + except ValueError as e: + return self._invalid_args('apify.act_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.act_get.v1', + http_method='GET', + path='/v2/acts/{actorId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_openapi_json_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId', 'buildId']) + except ValueError as e: + return self._invalid_args('apify.act_openapi_json_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.act_openapi_json_get.v1', + http_method='GET', + path='/v2/acts/{actorId}/builds/{buildId}/openapi.json', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_put(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId']) + except ValueError as e: + return self._invalid_args('apify.act_put.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.act_put.v1', + http_method='PUT', + path='/v2/acts/{actorId}', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _act_runSyncGetDatasetItems_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId']) + except ValueError as e: + return self._invalid_args('apify.act_runSyncGetDatasetItems_get.v1', str(e)) + query = self._query_params(args, ['attachment', 'bom', 'build', 'clean', 'delimiter', 'desc', 'fields', 'flatten', 'format', 'limit', 'memory', 'offset', 'omit', 'restartOnError', 'simplified', 'skipEmpty', 'skipFailedPages', 'skipHeaderRow', 'skipHidden', 'timeout', 'unwind', 'webhooks', 'xmlRoot', 'xmlRow']) + return await self._request( + method_id='apify.act_runSyncGetDatasetItems_get.v1', + http_method='GET', + path='/v2/acts/{actorId}/run-sync-get-dataset-items', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_runSyncGetDatasetItems_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId']) + except ValueError as e: + return self._invalid_args('apify.act_runSyncGetDatasetItems_post.v1', str(e)) + query = self._query_params(args, ['attachment', 'bom', 'build', 'clean', 'delimiter', 'desc', 'fields', 'flatten', 'format', 'limit', 'memory', 'offset', 'omit', 'restartOnError', 'simplified', 'skipEmpty', 'skipFailedPages', 'skipHeaderRow', 'skipHidden', 'timeout', 'unwind', 'webhooks', 'xmlRoot', 'xmlRow']) + return await self._request( + method_id='apify.act_runSyncGetDatasetItems_post.v1', + http_method='POST', + path='/v2/acts/{actorId}/run-sync-get-dataset-items', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _act_runSync_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId']) + except ValueError as e: + return self._invalid_args('apify.act_runSync_get.v1', str(e)) + query = self._query_params(args, ['build', 'memory', 'outputRecordKey', 'restartOnError', 'timeout', 'webhooks']) + return await self._request( + method_id='apify.act_runSync_get.v1', + http_method='GET', + path='/v2/acts/{actorId}/run-sync', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_runSync_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId']) + except ValueError as e: + return self._invalid_args('apify.act_runSync_post.v1', str(e)) + query = self._query_params(args, ['build', 'memory', 'outputRecordKey', 'restartOnError', 'timeout', 'webhooks']) + return await self._request( + method_id='apify.act_runSync_post.v1', + http_method='POST', + path='/v2/acts/{actorId}/run-sync', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _act_run_abort_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId', 'runId']) + except ValueError as e: + return self._invalid_args('apify.act_run_abort_post.v1', str(e)) + query = self._query_params(args, ['gracefully']) + return await self._request( + method_id='apify.act_run_abort_post.v1', + http_method='POST', + path='/v2/acts/{actorId}/runs/{runId}/abort', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_run_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId', 'runId']) + except ValueError as e: + return self._invalid_args('apify.act_run_get.v1', str(e)) + query = self._query_params(args, ['waitForFinish']) + return await self._request( + method_id='apify.act_run_get.v1', + http_method='GET', + path='/v2/acts/{actorId}/runs/{runId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_run_metamorph_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId', 'runId']) + except ValueError as e: + return self._invalid_args('apify.act_run_metamorph_post.v1', str(e)) + query = self._query_params(args, ['build', 'targetActorId']) + return await self._request( + method_id='apify.act_run_metamorph_post.v1', + http_method='POST', + path='/v2/acts/{actorId}/runs/{runId}/metamorph', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_run_resurrect_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId', 'runId']) + except ValueError as e: + return self._invalid_args('apify.act_run_resurrect_post.v1', str(e)) + query = self._query_params(args, ['build', 'memory', 'restartOnError', 'timeout']) + return await self._request( + method_id='apify.act_run_resurrect_post.v1', + http_method='POST', + path='/v2/acts/{actorId}/runs/{runId}/resurrect', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_runs_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId']) + except ValueError as e: + return self._invalid_args('apify.act_runs_get.v1', str(e)) + query = self._query_params(args, ['desc', 'limit', 'offset', 'startedAfter', 'startedBefore', 'status']) + return await self._request( + method_id='apify.act_runs_get.v1', + http_method='GET', + path='/v2/acts/{actorId}/runs', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_runs_last_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId']) + except ValueError as e: + return self._invalid_args('apify.act_runs_last_get.v1', str(e)) + query = self._query_params(args, ['status']) + return await self._request( + method_id='apify.act_runs_last_get.v1', + http_method='GET', + path='/v2/acts/{actorId}/runs/last', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_runs_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId']) + except ValueError as e: + return self._invalid_args('apify.act_runs_post.v1', str(e)) + query = self._query_params(args, ['build', 'forcePermissionLevel', 'memory', 'restartOnError', 'timeout', 'waitForFinish', 'webhooks']) + return await self._request( + method_id='apify.act_runs_post.v1', + http_method='POST', + path='/v2/acts/{actorId}/runs', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _act_version_delete(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId', 'versionNumber']) + except ValueError as e: + return self._invalid_args('apify.act_version_delete.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.act_version_delete.v1', + http_method='DELETE', + path='/v2/acts/{actorId}/versions/{versionNumber}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_version_envVar_delete(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId', 'envVarName', 'versionNumber']) + except ValueError as e: + return self._invalid_args('apify.act_version_envVar_delete.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.act_version_envVar_delete.v1', + http_method='DELETE', + path='/v2/acts/{actorId}/versions/{versionNumber}/env-vars/{envVarName}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_version_envVar_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId', 'envVarName', 'versionNumber']) + except ValueError as e: + return self._invalid_args('apify.act_version_envVar_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.act_version_envVar_get.v1', + http_method='GET', + path='/v2/acts/{actorId}/versions/{versionNumber}/env-vars/{envVarName}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_version_envVar_put(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId', 'envVarName', 'versionNumber']) + except ValueError as e: + return self._invalid_args('apify.act_version_envVar_put.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.act_version_envVar_put.v1', + http_method='PUT', + path='/v2/acts/{actorId}/versions/{versionNumber}/env-vars/{envVarName}', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _act_version_envVars_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId', 'versionNumber']) + except ValueError as e: + return self._invalid_args('apify.act_version_envVars_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.act_version_envVars_get.v1', + http_method='GET', + path='/v2/acts/{actorId}/versions/{versionNumber}/env-vars', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_version_envVars_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId', 'versionNumber']) + except ValueError as e: + return self._invalid_args('apify.act_version_envVars_post.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.act_version_envVars_post.v1', + http_method='POST', + path='/v2/acts/{actorId}/versions/{versionNumber}/env-vars', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _act_version_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId', 'versionNumber']) + except ValueError as e: + return self._invalid_args('apify.act_version_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.act_version_get.v1', + http_method='GET', + path='/v2/acts/{actorId}/versions/{versionNumber}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_version_put(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId', 'versionNumber']) + except ValueError as e: + return self._invalid_args('apify.act_version_put.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.act_version_put.v1', + http_method='PUT', + path='/v2/acts/{actorId}/versions/{versionNumber}', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _act_versions_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId']) + except ValueError as e: + return self._invalid_args('apify.act_versions_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.act_versions_get.v1', + http_method='GET', + path='/v2/acts/{actorId}/versions', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _act_versions_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId']) + except ValueError as e: + return self._invalid_args('apify.act_versions_post.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.act_versions_post.v1', + http_method='POST', + path='/v2/acts/{actorId}/versions', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _act_webhooks_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorId']) + except ValueError as e: + return self._invalid_args('apify.act_webhooks_get.v1', str(e)) + query = self._query_params(args, ['desc', 'limit', 'offset']) + return await self._request( + method_id='apify.act_webhooks_get.v1', + http_method='GET', + path='/v2/acts/{actorId}/webhooks', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorBuild_abort_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['buildId']) + except ValueError as e: + return self._invalid_args('apify.actorBuild_abort_post.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.actorBuild_abort_post.v1', + http_method='POST', + path='/v2/actor-builds/{buildId}/abort', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorBuild_delete(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['buildId']) + except ValueError as e: + return self._invalid_args('apify.actorBuild_delete.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.actorBuild_delete.v1', + http_method='DELETE', + path='/v2/actor-builds/{buildId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorBuild_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['buildId']) + except ValueError as e: + return self._invalid_args('apify.actorBuild_get.v1', str(e)) + query = self._query_params(args, ['waitForFinish']) + return await self._request( + method_id='apify.actorBuild_get.v1', + http_method='GET', + path='/v2/actor-builds/{buildId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorBuild_log_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['buildId']) + except ValueError as e: + return self._invalid_args('apify.actorBuild_log_get.v1', str(e)) + query = self._query_params(args, ['download', 'stream']) + return await self._request( + method_id='apify.actorBuild_log_get.v1', + http_method='GET', + path='/v2/actor-builds/{buildId}/log', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorBuild_openapi_json_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['buildId']) + except ValueError as e: + return self._invalid_args('apify.actorBuild_openapi_json_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.actorBuild_openapi_json_get.v1', + http_method='GET', + path='/v2/actor-builds/{buildId}/openapi.json', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorBuilds_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.actorBuilds_get.v1', str(e)) + query = self._query_params(args, ['desc', 'limit', 'offset']) + return await self._request( + method_id='apify.actorBuilds_get.v1', + http_method='GET', + path='/v2/actor-builds', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorRun_abort_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['runId']) + except ValueError as e: + return self._invalid_args('apify.actorRun_abort_post.v1', str(e)) + query = self._query_params(args, ['gracefully']) + return await self._request( + method_id='apify.actorRun_abort_post.v1', + http_method='POST', + path='/v2/actor-runs/{runId}/abort', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorRun_delete(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['runId']) + except ValueError as e: + return self._invalid_args('apify.actorRun_delete.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.actorRun_delete.v1', + http_method='DELETE', + path='/v2/actor-runs/{runId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorRun_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['runId']) + except ValueError as e: + return self._invalid_args('apify.actorRun_get.v1', str(e)) + query = self._query_params(args, ['waitForFinish']) + return await self._request( + method_id='apify.actorRun_get.v1', + http_method='GET', + path='/v2/actor-runs/{runId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorRun_metamorph_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['runId']) + except ValueError as e: + return self._invalid_args('apify.actorRun_metamorph_post.v1', str(e)) + query = self._query_params(args, ['build', 'targetActorId']) + return await self._request( + method_id='apify.actorRun_metamorph_post.v1', + http_method='POST', + path='/v2/actor-runs/{runId}/metamorph', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorRun_put(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['runId']) + except ValueError as e: + return self._invalid_args('apify.actorRun_put.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.actorRun_put.v1', + http_method='PUT', + path='/v2/actor-runs/{runId}', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _actorRun_reboot_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['runId']) + except ValueError as e: + return self._invalid_args('apify.actorRun_reboot_post.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.actorRun_reboot_post.v1', + http_method='POST', + path='/v2/actor-runs/{runId}/reboot', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorRuns_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.actorRuns_get.v1', str(e)) + query = self._query_params(args, ['desc', 'limit', 'offset', 'startedAfter', 'startedBefore', 'status']) + return await self._request( + method_id='apify.actorRuns_get.v1', + http_method='GET', + path='/v2/actor-runs', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorTask_delete(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorTaskId']) + except ValueError as e: + return self._invalid_args('apify.actorTask_delete.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.actorTask_delete.v1', + http_method='DELETE', + path='/v2/actor-tasks/{actorTaskId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorTask_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorTaskId']) + except ValueError as e: + return self._invalid_args('apify.actorTask_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.actorTask_get.v1', + http_method='GET', + path='/v2/actor-tasks/{actorTaskId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorTask_input_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorTaskId']) + except ValueError as e: + return self._invalid_args('apify.actorTask_input_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.actorTask_input_get.v1', + http_method='GET', + path='/v2/actor-tasks/{actorTaskId}/input', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorTask_input_put(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorTaskId']) + except ValueError as e: + return self._invalid_args('apify.actorTask_input_put.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.actorTask_input_put.v1', + http_method='PUT', + path='/v2/actor-tasks/{actorTaskId}/input', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _actorTask_put(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorTaskId']) + except ValueError as e: + return self._invalid_args('apify.actorTask_put.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.actorTask_put.v1', + http_method='PUT', + path='/v2/actor-tasks/{actorTaskId}', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _actorTask_runSyncGetDatasetItems_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorTaskId']) + except ValueError as e: + return self._invalid_args('apify.actorTask_runSyncGetDatasetItems_get.v1', str(e)) + query = self._query_params(args, ['attachment', 'bom', 'build', 'clean', 'delimiter', 'desc', 'fields', 'flatten', 'format', 'limit', 'memory', 'offset', 'omit', 'simplified', 'skipEmpty', 'skipFailedPages', 'skipHeaderRow', 'skipHidden', 'timeout', 'unwind', 'webhooks', 'xmlRoot', 'xmlRow']) + return await self._request( + method_id='apify.actorTask_runSyncGetDatasetItems_get.v1', + http_method='GET', + path='/v2/actor-tasks/{actorTaskId}/run-sync-get-dataset-items', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorTask_runSyncGetDatasetItems_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorTaskId']) + except ValueError as e: + return self._invalid_args('apify.actorTask_runSyncGetDatasetItems_post.v1', str(e)) + query = self._query_params(args, ['attachment', 'bom', 'build', 'clean', 'delimiter', 'desc', 'fields', 'flatten', 'format', 'limit', 'memory', 'offset', 'omit', 'restartOnError', 'simplified', 'skipEmpty', 'skipFailedPages', 'skipHeaderRow', 'skipHidden', 'timeout', 'unwind', 'webhooks', 'xmlRoot', 'xmlRow']) + return await self._request( + method_id='apify.actorTask_runSyncGetDatasetItems_post.v1', + http_method='POST', + path='/v2/actor-tasks/{actorTaskId}/run-sync-get-dataset-items', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _actorTask_runSync_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorTaskId']) + except ValueError as e: + return self._invalid_args('apify.actorTask_runSync_get.v1', str(e)) + query = self._query_params(args, ['build', 'memory', 'outputRecordKey', 'timeout', 'webhooks']) + return await self._request( + method_id='apify.actorTask_runSync_get.v1', + http_method='GET', + path='/v2/actor-tasks/{actorTaskId}/run-sync', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorTask_runSync_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorTaskId']) + except ValueError as e: + return self._invalid_args('apify.actorTask_runSync_post.v1', str(e)) + query = self._query_params(args, ['build', 'memory', 'outputRecordKey', 'restartOnError', 'timeout', 'webhooks']) + return await self._request( + method_id='apify.actorTask_runSync_post.v1', + http_method='POST', + path='/v2/actor-tasks/{actorTaskId}/run-sync', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _actorTask_runs_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorTaskId']) + except ValueError as e: + return self._invalid_args('apify.actorTask_runs_get.v1', str(e)) + query = self._query_params(args, ['desc', 'limit', 'offset', 'status']) + return await self._request( + method_id='apify.actorTask_runs_get.v1', + http_method='GET', + path='/v2/actor-tasks/{actorTaskId}/runs', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorTask_runs_last_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorTaskId']) + except ValueError as e: + return self._invalid_args('apify.actorTask_runs_last_get.v1', str(e)) + query = self._query_params(args, ['status']) + return await self._request( + method_id='apify.actorTask_runs_last_get.v1', + http_method='GET', + path='/v2/actor-tasks/{actorTaskId}/runs/last', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorTask_runs_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorTaskId']) + except ValueError as e: + return self._invalid_args('apify.actorTask_runs_post.v1', str(e)) + query = self._query_params(args, ['build', 'memory', 'restartOnError', 'timeout', 'waitForFinish', 'webhooks']) + return await self._request( + method_id='apify.actorTask_runs_post.v1', + http_method='POST', + path='/v2/actor-tasks/{actorTaskId}/runs', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _actorTask_webhooks_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['actorTaskId']) + except ValueError as e: + return self._invalid_args('apify.actorTask_webhooks_get.v1', str(e)) + query = self._query_params(args, ['desc', 'limit', 'offset']) + return await self._request( + method_id='apify.actorTask_webhooks_get.v1', + http_method='GET', + path='/v2/actor-tasks/{actorTaskId}/webhooks', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorTasks_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.actorTasks_get.v1', str(e)) + query = self._query_params(args, ['desc', 'limit', 'offset']) + return await self._request( + method_id='apify.actorTasks_get.v1', + http_method='GET', + path='/v2/actor-tasks', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _actorTasks_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.actorTasks_post.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.actorTasks_post.v1', + http_method='POST', + path='/v2/actor-tasks', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _acts_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.acts_get.v1', str(e)) + query = self._query_params(args, ['desc', 'limit', 'my', 'offset', 'sortBy']) + return await self._request( + method_id='apify.acts_get.v1', + http_method='GET', + path='/v2/acts', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _acts_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.acts_post.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.acts_post.v1', + http_method='POST', + path='/v2/acts', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _dataset_delete(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.dataset_delete.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.dataset_delete.v1', + http_method='DELETE', + path='/v2/datasets/{datasetId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _dataset_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.dataset_get.v1', str(e)) + query = self._query_params(args, ['token']) + return await self._request( + method_id='apify.dataset_get.v1', + http_method='GET', + path='/v2/datasets/{datasetId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _dataset_items_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.dataset_items_get.v1', str(e)) + query = self._query_params(args, ['attachment', 'bom', 'clean', 'delimiter', 'fields', 'flatten', 'format', 'limit', 'omit', 'simplified', 'skipEmpty', 'skipFailedPages', 'skipHeaderRow', 'skipHidden', 'unwind', 'view', 'xmlRoot', 'xmlRow']) + return await self._request( + method_id='apify.dataset_items_get.v1', + http_method='GET', + path='/v2/datasets/{datasetId}/items', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _dataset_items_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.dataset_items_post.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.dataset_items_post.v1', + http_method='POST', + path='/v2/datasets/{datasetId}/items', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _dataset_put(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.dataset_put.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.dataset_put.v1', + http_method='PUT', + path='/v2/datasets/{datasetId}', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _dataset_statistics_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.dataset_statistics_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.dataset_statistics_get.v1', + http_method='GET', + path='/v2/datasets/{datasetId}/statistics', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _datasets_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.datasets_get.v1', str(e)) + query = self._query_params(args, ['ownership']) + return await self._request( + method_id='apify.datasets_get.v1', + http_method='GET', + path='/v2/datasets', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _datasets_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.datasets_post.v1', str(e)) + query = self._query_params(args, ['name']) + return await self._request( + method_id='apify.datasets_post.v1', + http_method='POST', + path='/v2/datasets', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _keyValueStore_delete(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.keyValueStore_delete.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.keyValueStore_delete.v1', + http_method='DELETE', + path='/v2/key-value-stores/{storeId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _keyValueStore_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.keyValueStore_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.keyValueStore_get.v1', + http_method='GET', + path='/v2/key-value-stores/{storeId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _keyValueStore_keys_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.keyValueStore_keys_get.v1', str(e)) + query = self._query_params(args, ['collection', 'exclusiveStartKey', 'limit', 'prefix']) + return await self._request( + method_id='apify.keyValueStore_keys_get.v1', + http_method='GET', + path='/v2/key-value-stores/{storeId}/keys', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _keyValueStore_put(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.keyValueStore_put.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.keyValueStore_put.v1', + http_method='PUT', + path='/v2/key-value-stores/{storeId}', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _keyValueStore_record_delete(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.keyValueStore_record_delete.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.keyValueStore_record_delete.v1', + http_method='DELETE', + path='/v2/key-value-stores/{storeId}/records/{recordKey}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _keyValueStore_record_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.keyValueStore_record_get.v1', str(e)) + query = self._query_params(args, ['attachment']) + return await self._request( + method_id='apify.keyValueStore_record_get.v1', + http_method='GET', + path='/v2/key-value-stores/{storeId}/records/{recordKey}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _keyValueStore_record_head(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.keyValueStore_record_head.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.keyValueStore_record_head.v1', + http_method='HEAD', + path='/v2/key-value-stores/{storeId}/records/{recordKey}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _keyValueStore_record_put(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.keyValueStore_record_put.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.keyValueStore_record_put.v1', + http_method='PUT', + path='/v2/key-value-stores/{storeId}/records/{recordKey}', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _keyValueStores_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.keyValueStores_get.v1', str(e)) + query = self._query_params(args, ['ownership']) + return await self._request( + method_id='apify.keyValueStores_get.v1', + http_method='GET', + path='/v2/key-value-stores', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _keyValueStores_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.keyValueStores_post.v1', str(e)) + query = self._query_params(args, ['name']) + return await self._request( + method_id='apify.keyValueStores_post.v1', + http_method='POST', + path='/v2/key-value-stores', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _log_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['buildOrRunId']) + except ValueError as e: + return self._invalid_args('apify.log_get.v1', str(e)) + query = self._query_params(args, ['download', 'raw', 'stream']) + return await self._request( + method_id='apify.log_get.v1', + http_method='GET', + path='/v2/logs/{buildOrRunId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _requestQueue_delete(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.requestQueue_delete.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.requestQueue_delete.v1', + http_method='DELETE', + path='/v2/request-queues/{queueId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _requestQueue_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.requestQueue_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.requestQueue_get.v1', + http_method='GET', + path='/v2/request-queues/{queueId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _requestQueue_head_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.requestQueue_head_get.v1', str(e)) + query = self._query_params(args, ['limit']) + return await self._request( + method_id='apify.requestQueue_head_get.v1', + http_method='GET', + path='/v2/request-queues/{queueId}/head', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _requestQueue_head_lock_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.requestQueue_head_lock_post.v1', str(e)) + query = self._query_params(args, ['limit']) + return await self._request( + method_id='apify.requestQueue_head_lock_post.v1', + http_method='POST', + path='/v2/request-queues/{queueId}/head/lock', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _requestQueue_put(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.requestQueue_put.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.requestQueue_put.v1', + http_method='PUT', + path='/v2/request-queues/{queueId}', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _requestQueue_request_delete(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.requestQueue_request_delete.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.requestQueue_request_delete.v1', + http_method='DELETE', + path='/v2/request-queues/{queueId}/requests/{requestId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _requestQueue_request_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.requestQueue_request_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.requestQueue_request_get.v1', + http_method='GET', + path='/v2/request-queues/{queueId}/requests/{requestId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _requestQueue_request_lock_delete(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.requestQueue_request_lock_delete.v1', str(e)) + query = self._query_params(args, ['forefront']) + return await self._request( + method_id='apify.requestQueue_request_lock_delete.v1', + http_method='DELETE', + path='/v2/request-queues/{queueId}/requests/{requestId}/lock', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _requestQueue_request_lock_put(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.requestQueue_request_lock_put.v1', str(e)) + query = self._query_params(args, ['forefront']) + return await self._request( + method_id='apify.requestQueue_request_lock_put.v1', + http_method='PUT', + path='/v2/request-queues/{queueId}/requests/{requestId}/lock', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _requestQueue_request_put(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.requestQueue_request_put.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.requestQueue_request_put.v1', + http_method='PUT', + path='/v2/request-queues/{queueId}/requests/{requestId}', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _requestQueue_requests_batch_delete(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.requestQueue_requests_batch_delete.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.requestQueue_requests_batch_delete.v1', + http_method='DELETE', + path='/v2/request-queues/{queueId}/requests/batch', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _requestQueue_requests_batch_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.requestQueue_requests_batch_post.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.requestQueue_requests_batch_post.v1', + http_method='POST', + path='/v2/request-queues/{queueId}/requests/batch', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _requestQueue_requests_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.requestQueue_requests_get.v1', str(e)) + query = self._query_params(args, ['exclusiveStartId', 'limit']) + return await self._request( + method_id='apify.requestQueue_requests_get.v1', + http_method='GET', + path='/v2/request-queues/{queueId}/requests', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _requestQueue_requests_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.requestQueue_requests_post.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.requestQueue_requests_post.v1', + http_method='POST', + path='/v2/request-queues/{queueId}/requests', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _requestQueue_requests_unlock_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.requestQueue_requests_unlock_post.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.requestQueue_requests_unlock_post.v1', + http_method='POST', + path='/v2/request-queues/{queueId}/requests/unlock', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _requestQueues_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.requestQueues_get.v1', str(e)) + query = self._query_params(args, ['ownership']) + return await self._request( + method_id='apify.requestQueues_get.v1', + http_method='GET', + path='/v2/request-queues', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _requestQueues_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.requestQueues_post.v1', str(e)) + query = self._query_params(args, ['name']) + return await self._request( + method_id='apify.requestQueues_post.v1', + http_method='POST', + path='/v2/request-queues', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _schedule_delete(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['scheduleId']) + except ValueError as e: + return self._invalid_args('apify.schedule_delete.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.schedule_delete.v1', + http_method='DELETE', + path='/v2/schedules/{scheduleId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _schedule_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['scheduleId']) + except ValueError as e: + return self._invalid_args('apify.schedule_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.schedule_get.v1', + http_method='GET', + path='/v2/schedules/{scheduleId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _schedule_log_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['scheduleId']) + except ValueError as e: + return self._invalid_args('apify.schedule_log_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.schedule_log_get.v1', + http_method='GET', + path='/v2/schedules/{scheduleId}/log', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _schedule_put(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['scheduleId']) + except ValueError as e: + return self._invalid_args('apify.schedule_put.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.schedule_put.v1', + http_method='PUT', + path='/v2/schedules/{scheduleId}', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _schedules_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.schedules_get.v1', str(e)) + query = self._query_params(args, ['desc', 'limit', 'offset']) + return await self._request( + method_id='apify.schedules_get.v1', + http_method='GET', + path='/v2/schedules', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _schedules_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.schedules_post.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.schedules_post.v1', + http_method='POST', + path='/v2/schedules', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _store_get(self, args: Dict[str, Any]) -> str: + try: + allowed_args = { + 'method_id', + 'allowsAgenticUsers', + 'category', + 'limit', + 'offset', + 'pricingModel', + 'search', + 'sortBy', + 'username', + } + unknown_args = sorted(k for k in args.keys() if k not in allowed_args) + if unknown_args: + return self._invalid_args( + 'apify.store_get.v1', + f"Unknown args: {', '.join(unknown_args)}. Allowed args: allowsAgenticUsers, category, limit, offset, pricingModel, search, sortBy, username.", + ) + path_params = self._path_params(args, []) + if not any(args.get(name) is not None for name in ['allowsAgenticUsers', 'category', 'pricingModel', 'search', 'username']): + return self._invalid_args( + 'apify.store_get.v1', + "At least one narrowing filter is required: allowsAgenticUsers, category, pricingModel, search, or username.", + ) + except ValueError as e: + return self._invalid_args('apify.store_get.v1', str(e)) + query = self._query_params(args, ['allowsAgenticUsers', 'category', 'limit', 'offset', 'pricingModel', 'search', 'sortBy', 'username']) + if query.get('limit') is None: + query['limit'] = 20 + return await self._request( + method_id='apify.store_get.v1', + http_method='GET', + path='/v2/store', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _user_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['userId']) + except ValueError as e: + return self._invalid_args('apify.user_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.user_get.v1', + http_method='GET', + path='/v2/users/{userId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _users_me_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.users_me_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.users_me_get.v1', + http_method='GET', + path='/v2/users/me', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _users_me_limits_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.users_me_limits_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.users_me_limits_get.v1', + http_method='GET', + path='/v2/users/me/limits', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _users_me_limits_put(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.users_me_limits_put.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.users_me_limits_put.v1', + http_method='PUT', + path='/v2/users/me/limits', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _users_me_usage_monthly_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.users_me_usage_monthly_get.v1', str(e)) + query = self._query_params(args, ['date']) + return await self._request( + method_id='apify.users_me_usage_monthly_get.v1', + http_method='GET', + path='/v2/users/me/usage/monthly', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _webhookDispatch_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['dispatchId']) + except ValueError as e: + return self._invalid_args('apify.webhookDispatch_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.webhookDispatch_get.v1', + http_method='GET', + path='/v2/webhook-dispatches/{dispatchId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _webhookDispatches_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.webhookDispatches_get.v1', str(e)) + query = self._query_params(args, ['desc', 'limit', 'offset']) + return await self._request( + method_id='apify.webhookDispatches_get.v1', + http_method='GET', + path='/v2/webhook-dispatches', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _webhook_delete(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['webhookId']) + except ValueError as e: + return self._invalid_args('apify.webhook_delete.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.webhook_delete.v1', + http_method='DELETE', + path='/v2/webhooks/{webhookId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _webhook_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['webhookId']) + except ValueError as e: + return self._invalid_args('apify.webhook_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.webhook_get.v1', + http_method='GET', + path='/v2/webhooks/{webhookId}', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _webhook_put(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['webhookId']) + except ValueError as e: + return self._invalid_args('apify.webhook_put.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.webhook_put.v1', + http_method='PUT', + path='/v2/webhooks/{webhookId}', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) + + async def _webhook_test_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['webhookId']) + except ValueError as e: + return self._invalid_args('apify.webhook_test_post.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.webhook_test_post.v1', + http_method='POST', + path='/v2/webhooks/{webhookId}/test', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _webhook_webhookDispatches_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, ['webhookId']) + except ValueError as e: + return self._invalid_args('apify.webhook_webhookDispatches_get.v1', str(e)) + query = self._query_params(args, []) + return await self._request( + method_id='apify.webhook_webhookDispatches_get.v1', + http_method='GET', + path='/v2/webhooks/{webhookId}/dispatches', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _webhooks_get(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.webhooks_get.v1', str(e)) + query = self._query_params(args, ['desc', 'limit', 'offset']) + return await self._request( + method_id='apify.webhooks_get.v1', + http_method='GET', + path='/v2/webhooks', + path_params=path_params, + query=query, + has_request_body=False, + request_content_types=[], + args=args, + ) + + async def _webhooks_post(self, args: Dict[str, Any]) -> str: + try: + path_params = self._path_params(args, []) + except ValueError as e: + return self._invalid_args('apify.webhooks_post.v1', str(e)) + query = self._query_params(args, ['desc', 'limit', 'offset']) + return await self._request( + method_id='apify.webhooks_post.v1', + http_method='POST', + path='/v2/webhooks', + path_params=path_params, + query=query, + has_request_body=True, + request_content_types=['application/json'], + args=args, + ) diff --git a/flexus_client_kit/integrations/fi_apollo.py b/flexus_client_kit/integrations/fi_apollo.py new file mode 100644 index 00000000..e70a10a8 --- /dev/null +++ b/flexus_client_kit/integrations/fi_apollo.py @@ -0,0 +1,124 @@ +import json +import logging +import os +from typing import Any, Dict, List + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("apollo") + +PROVIDER_NAME = "apollo" +METHOD_IDS = [ + "apollo.contacts.create.v1", + "apollo.organizations.bulk_enrich.v1", + "apollo.organizations.enrich.v1", + "apollo.people.enrich.v1", + "apollo.people.search.v1", + "apollo.sequences.contacts.add.v1", +] + +_BASE_URL = "https://api.apollo.io/v1" + + +class IntegrationApollo: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + key = os.environ.get("APOLLO_API_KEY", "") + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if key else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "apollo.organizations.enrich.v1": + return await self._organizations_enrich(args) + if method_id == "apollo.organizations.bulk_enrich.v1": + return await self._organizations_bulk_enrich(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _organizations_enrich(self, args: Dict[str, Any]) -> str: + key = os.environ.get("APOLLO_API_KEY", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "APOLLO_API_KEY env var not set"}, indent=2, ensure_ascii=False) + domain = str(args.get("domain", "")).strip() + if not domain: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "domain is required"}, indent=2, ensure_ascii=False) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_BASE_URL}/organizations/enrich", + params={"domain": domain, "api_key": key}, + ) + if resp.status_code == 422: + return json.dumps({"ok": False, "error_code": "NOT_FOUND", "message": f"Domain not found: {domain}"}, indent=2, ensure_ascii=False) + if resp.status_code != 200: + logger.info("apollo organizations_enrich error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + result = { + "ok": True, + "credit_note": "This call consumes API credits.", + "organization": data.get("organization"), + } + return f"apollo.organizations.enrich ok\n\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _organizations_bulk_enrich(self, args: Dict[str, Any]) -> str: + key = os.environ.get("APOLLO_API_KEY", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "APOLLO_API_KEY env var not set"}, indent=2, ensure_ascii=False) + domains: List[str] = args.get("domains", []) + if not domains: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "domains list is required"}, indent=2, ensure_ascii=False) + if len(domains) > 10: + return json.dumps({"ok": False, "error_code": "INVALID_ARG", "message": "domains list must not exceed 10 items"}, indent=2, ensure_ascii=False) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{_BASE_URL}/organizations/bulk_enrich", + json={"api_key": key, "domains": domains}, + ) + if resp.status_code != 200: + logger.info("apollo organizations_bulk_enrich error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + result = { + "ok": True, + "credit_note": "This call consumes API credits.", + "organizations": data.get("organizations", []), + } + return f"apollo.organizations.bulk_enrich ok\n\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_appstoreconnect.py b/flexus_client_kit/integrations/fi_appstoreconnect.py new file mode 100644 index 00000000..54411a7b --- /dev/null +++ b/flexus_client_kit/integrations/fi_appstoreconnect.py @@ -0,0 +1,154 @@ +import json +import logging +import os +import time +from typing import Any, Dict + +import httpx +import jwt + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("appstoreconnect") + +PROVIDER_NAME = "appstoreconnect" +METHOD_IDS = [ + "appstoreconnect.customer_reviews.list.v1", +] + +_BASE_URL = "https://api.appstoreconnect.apple.com/v1" + + +def _no_creds() -> str: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + + +class IntegrationAppstoreconnect: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + key_id = os.environ.get("APPSTORECONNECT_KEY_ID", "") + issuer_id = os.environ.get("APPSTORECONNECT_ISSUER_ID", "") + private_key = os.environ.get("APPSTORECONNECT_PRIVATE_KEY", "") + has_creds = bool(key_id and issuer_id and private_key) + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if has_creds else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "appstoreconnect.customer_reviews.list.v1": + return await self._customer_reviews_list(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + + def _ok(self, method_label: str, data: Any) -> str: + return f"appstoreconnect.{method_label} ok\n\n```json\n{json.dumps({'ok': True, 'result': data}, indent=2, ensure_ascii=False)}\n```" + + def _provider_error(self, status_code: int, detail: str) -> str: + logger.info("appstoreconnect provider error status=%s detail=%s", status_code, detail) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": status_code, "detail": detail}, indent=2, ensure_ascii=False) + + def _has_creds(self) -> bool: + return bool( + os.environ.get("APPSTORECONNECT_KEY_ID", "") + and os.environ.get("APPSTORECONNECT_ISSUER_ID", "") + and os.environ.get("APPSTORECONNECT_PRIVATE_KEY", "") + ) + + def _make_token(self) -> str: + key_id = os.environ.get("APPSTORECONNECT_KEY_ID", "") + issuer_id = os.environ.get("APPSTORECONNECT_ISSUER_ID", "") + private_key_raw = os.environ.get("APPSTORECONNECT_PRIVATE_KEY", "") + private_key = private_key_raw.replace("\\n", "\n") + now = int(time.time()) + payload = { + "iss": issuer_id, + "iat": now, + "exp": now + 1200, + "aud": "appstoreconnect-v1", + } + token = jwt.encode(payload, private_key, algorithm="ES256", headers={"kid": key_id}) + return token + + async def _customer_reviews_list(self, args: Dict[str, Any]) -> str: + if not self._has_creds(): + return _no_creds() + app_id = str(args.get("app_id", "")).strip() + if not app_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "app_id"}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 25)), 200) + sort = str(args.get("sort", "-createdDate")) + params: Dict[str, Any] = {"sort": sort, "limit": limit} + min_rating = args.get("min_rating") + if min_rating is not None: + params["filter[rating]"] = int(min_rating) + territory = args.get("territory") + if territory: + params["filter[territory]"] = str(territory) + try: + token = self._make_token() + except jwt.PyJWTError as e: + return json.dumps({"ok": False, "error_code": "JWT_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + headers = {"Authorization": f"Bearer {token}"} + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.get( + f"{_BASE_URL}/apps/{app_id}/customerReviews", + params=params, + headers=headers, + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code != 200: + return self._provider_error(resp.status_code, resp.text[:500]) + try: + data = resp.json() + except json.JSONDecodeError: + return self._provider_error(resp.status_code, "invalid json in response") + raw_items = data.get("data", []) + reviews = [] + for item in raw_items: + try: + attrs = item["attributes"] + reviews.append({ + "id": item["id"], + "title": attrs.get("title"), + "body": attrs.get("body"), + "rating": attrs.get("rating"), + "date": attrs.get("createdDate"), + "reviewer": attrs.get("reviewerNickname"), + }) + except KeyError: + continue + result = { + "reviews": reviews, + "total": len(reviews), + "links": data.get("links"), + } + return self._ok("customer_reviews.list.v1", result) diff --git a/flexus_client_kit/integrations/fi_bing_webmaster.py b/flexus_client_kit/integrations/fi_bing_webmaster.py new file mode 100644 index 00000000..909c7f32 --- /dev/null +++ b/flexus_client_kit/integrations/fi_bing_webmaster.py @@ -0,0 +1,102 @@ +import json +import logging +import os +from typing import Any, Dict +from urllib.parse import quote + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("bing_webmaster") + +PROVIDER_NAME = "bing_webmaster" +METHOD_IDS = [ + "bing_webmaster.get_page_query_stats.v1", + "bing_webmaster.get_page_stats.v1", + "bing_webmaster.get_rank_and_traffic_stats.v1", +] + +_BASE_URL = "https://api.webmaster.tools.bing.com/api/6.0" + + +class IntegrationBingWebmaster: + def __init__(self, rcx=None): + self.rcx = rcx + + def _get_api_key(self) -> str: + if self.rcx is not None: + return (self.rcx.external_auth.get("bing_webmaster") or {}).get("api_key", "") + return "" + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set BING_WEBMASTER_KEY env var. Obtain from https://www.bing.com/webmasters/api."}, indent=2, ensure_ascii=False) + + site_url = str(args.get("query", "")) + if not site_url: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.query must be the site URL (e.g. 'https://example.com/')."}, indent=2, ensure_ascii=False) + + encoded_site = quote(site_url, safe="") + params = {"apikey": api_key} + + if method_id == "bing_webmaster.get_page_stats.v1": + endpoint = f"/Sites/{encoded_site}/PageStats" + elif method_id == "bing_webmaster.get_page_query_stats.v1": + endpoint = f"/Sites/{encoded_site}/PageQueryStats" + elif method_id == "bing_webmaster.get_rank_and_traffic_stats.v1": + endpoint = f"/Sites/{encoded_site}/RankAndTrafficStats" + else: + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get( + _BASE_URL + endpoint, + params=params, + headers={"Accept": "application/json"}, + ) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + results = data.get("d", data.get("results", data.get("value", data))) + if not isinstance(results, list): + results = [results] + summary = f"Found {len(results)} result(s) from {PROVIDER_NAME} ({method_id})." + payload: Dict[str, Any] = {"ok": True, "results": results, "total": len(results)} + if args.get("include_raw"): + payload["raw"] = data + return summary + "\n\n```json\n" + json.dumps(payload, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_bombora.py b/flexus_client_kit/integrations/fi_bombora.py new file mode 100644 index 00000000..be3dcc9a --- /dev/null +++ b/flexus_client_kit/integrations/fi_bombora.py @@ -0,0 +1,245 @@ +import asyncio +import base64 +import json +import logging +import os +from typing import Any, Dict, List + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("bombora") + +PROVIDER_NAME = "bombora" +METHOD_IDS = [ + "bombora.companysurge.company_scores.get.v1", + "bombora.companysurge.topics.list.v1", +] + +_BASE_URL = "https://sentry.bombora.com" +_POLL_INTERVAL_S = 20 +_MAX_POLL_ATTEMPTS = 3 + + +class IntegrationBombora: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + client_id = os.environ.get("BOMBORA_CLIENT_ID", "") + client_secret = os.environ.get("BOMBORA_CLIENT_SECRET", "") + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if (client_id and client_secret) else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "bombora.companysurge.topics.list.v1": + return await self._topics_list(args) + if method_id == "bombora.companysurge.company_scores.get.v1": + return await self._company_scores_get(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _get_token(self, client: httpx.AsyncClient) -> str: + client_id = os.environ.get("BOMBORA_CLIENT_ID", "") + client_secret = os.environ.get("BOMBORA_CLIENT_SECRET", "") + raw = f"{client_id}:{client_secret}".encode() + return base64.b64encode(raw).decode() + + def _build_headers(self, token: str) -> Dict[str, str]: + return { + "Authorization": f"Basic {token}", + "Referer": "bombora.com", + } + + def _check_credentials(self) -> str: + if not os.environ.get("BOMBORA_CLIENT_ID", "") or not os.environ.get("BOMBORA_CLIENT_SECRET", ""): + return json.dumps({ + "ok": False, + "error_code": "NO_CREDENTIALS", + "message": "BOMBORA_CLIENT_ID and BOMBORA_CLIENT_SECRET env vars not set", + }, indent=2, ensure_ascii=False) + return "" + + async def _topics_list(self, args: Dict[str, Any]) -> str: + cred_err = self._check_credentials() + if cred_err: + return cred_err + limit = int(args.get("limit", 50)) + try: + async with httpx.AsyncClient(timeout=30) as client: + token = await self._get_token(client) + resp = await client.get( + f"{_BASE_URL}/v2/cmp/GetMyTopics", + headers=self._build_headers(token), + ) + if resp.status_code == 401: + return json.dumps({ + "ok": False, + "error_code": "AUTH_ERROR", + "message": "Invalid Bombora credentials. Check BOMBORA_CLIENT_ID and BOMBORA_CLIENT_SECRET.", + }, indent=2, ensure_ascii=False) + if resp.status_code == 403: + return json.dumps({ + "ok": False, + "error_code": "ENTITLEMENT_MISSING", + "provider": PROVIDER_NAME, + "message": "This provider requires a contract/plan entitlement. Contact Bombora sales.", + }, indent=2, ensure_ascii=False) + if resp.status_code != 200: + logger.info("bombora topics_list error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({ + "ok": False, + "error_code": "PROVIDER_ERROR", + "status": resp.status_code, + "detail": resp.text[:500], + }, indent=2, ensure_ascii=False) + data = resp.json() + topics: List[Dict[str, Any]] = (data.get("Topics") or [])[:limit] + result = { + "ok": True, + "provider": PROVIDER_NAME, + "total_returned": len(topics), + "limit": limit, + "topics": [ + {"id": t.get("Id"), "name": t.get("Name"), "description": t.get("Description")} + for t in topics + ], + } + return f"bombora.companysurge.topics.list ok\n\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _company_scores_get(self, args: Dict[str, Any]) -> str: + cred_err = self._check_credentials() + if cred_err: + return cred_err + domains = args.get("domains") or [] + if not domains: + return json.dumps({ + "ok": False, + "error_code": "MISSING_ARG", + "message": "domains is required (list of domain strings)", + }, indent=2, ensure_ascii=False) + topic_ids = args.get("topic_ids") or [] + body: Dict[str, Any] = { + "Domains": list(domains), + "OutputFormat": "json", + } + if topic_ids: + body["Topics"] = [int(t) for t in topic_ids] + try: + async with httpx.AsyncClient(timeout=30) as client: + token = await self._get_token(client) + headers = self._build_headers(token) + create_resp = await client.post( + f"{_BASE_URL}/v4/Surge/Create", + headers={**headers, "Content-Type": "application/json"}, + json=body, + ) + if create_resp.status_code == 401: + return json.dumps({ + "ok": False, + "error_code": "AUTH_ERROR", + "message": "Invalid Bombora credentials. Check BOMBORA_CLIENT_ID and BOMBORA_CLIENT_SECRET.", + }, indent=2, ensure_ascii=False) + if create_resp.status_code == 403: + return json.dumps({ + "ok": False, + "error_code": "ENTITLEMENT_MISSING", + "provider": PROVIDER_NAME, + "message": "This provider requires a contract/plan entitlement. Contact Bombora sales.", + }, indent=2, ensure_ascii=False) + if create_resp.status_code != 200: + logger.info("bombora surge_create error %s: %s", create_resp.status_code, create_resp.text[:200]) + return json.dumps({ + "ok": False, + "error_code": "PROVIDER_ERROR", + "status": create_resp.status_code, + "detail": create_resp.text[:500], + }, indent=2, ensure_ascii=False) + create_data = create_resp.json() + if not create_data.get("Success"): + return json.dumps({ + "ok": False, + "error_code": "REPORT_CREATE_FAILED", + "message": create_data.get("Message", ""), + }, indent=2, ensure_ascii=False) + job_id = str(create_data.get("Id", "")) + for _ in range(_MAX_POLL_ATTEMPTS): + await asyncio.sleep(_POLL_INTERVAL_S) + result_resp = await client.get( + f"{_BASE_URL}/v2/Surge/TryGetResult", + headers=headers, + params={"id": job_id}, + ) + if result_resp.status_code != 200: + logger.info("bombora surge_result error %s: %s", result_resp.status_code, result_resp.text[:200]) + break + content_type = result_resp.headers.get("content-type", "") + if "octet-stream" in content_type or "json" in content_type: + try: + report_data = result_resp.json() + except (json.JSONDecodeError, ValueError): + report_data = result_resp.text[:2000] + result = { + "ok": True, + "provider": PROVIDER_NAME, + "job_id": job_id, + "domains_queried": list(domains), + "results": report_data, + } + return f"bombora.companysurge.company_scores.get ok\n\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" + poll_data = result_resp.json() + if poll_data.get("Success") is True: + result = { + "ok": True, + "provider": PROVIDER_NAME, + "job_id": job_id, + "domains_queried": list(domains), + "results": poll_data, + } + return f"bombora.companysurge.company_scores.get ok\n\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" + return json.dumps({ + "ok": True, + "status": "processing", + "provider": PROVIDER_NAME, + "job_id": job_id, + "domains_queried": list(domains), + "message": ( + "Bombora report is still processing (typically takes 10-15 minutes). " + "Re-call with op=call, method_id=bombora.companysurge.company_scores.get.v1 " + "and job_id to retrieve results when ready." + ), + "check_url": f"{_BASE_URL}/v2/Surge/TryGetResult?id={job_id}", + }, indent=2, ensure_ascii=False) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_builtwith.py b/flexus_client_kit/integrations/fi_builtwith.py new file mode 100644 index 00000000..a88ef7e3 --- /dev/null +++ b/flexus_client_kit/integrations/fi_builtwith.py @@ -0,0 +1,153 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("builtwith") + +PROVIDER_NAME = "builtwith" +METHOD_IDS = [ + "builtwith.domain.api.v1", + "builtwith.domain.live.v1", +] + +_BASE_URL = "https://api.builtwith.com" + + +class IntegrationBuiltwith: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + key = os.environ.get("BUILTWITH_API_KEY", "") + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if key else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "builtwith.domain.api.v1": + return await self._domain_api(args) + if method_id == "builtwith.domain.live.v1": + return await self._domain_live(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _domain_api(self, args: Dict[str, Any]) -> str: + key = os.environ.get("BUILTWITH_API_KEY", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "BUILTWITH_API_KEY env var not set"}, indent=2, ensure_ascii=False) + domain = str(args.get("domain", "")).strip() + if not domain: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "domain is required"}, indent=2, ensure_ascii=False) + include_raw = bool(args.get("include_raw", False)) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_BASE_URL}/v21/api.json", + params={"KEY": key, "LOOKUP": domain}, + ) + if resp.status_code != 200: + logger.info("builtwith domain.api error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + results = data.get("Results") or [] + techs = [] + for result_item in results: + paths = (result_item.get("Result") or {}).get("Paths") or [] + for path in paths: + for tech in path.get("Technologies") or []: + name = tech.get("Name") + if name: + techs.append({ + "name": name, + "tag": tech.get("Tag"), + "categories": tech.get("Categories"), + }) + payload: Dict[str, Any] = { + "ok": True, + "provider": PROVIDER_NAME, + "method_id": "builtwith.domain.api.v1", + "domain": domain, + "tech_count": len(techs), + "technologies": techs, + } + if include_raw: + payload["raw"] = data + return f"builtwith.domain.api ok\n\n```json\n{json.dumps(payload, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _domain_live(self, args: Dict[str, Any]) -> str: + key = os.environ.get("BUILTWITH_API_KEY", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "BUILTWITH_API_KEY env var not set"}, indent=2, ensure_ascii=False) + domain = str(args.get("domain", "")).strip() + if not domain: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "domain is required"}, indent=2, ensure_ascii=False) + include_raw = bool(args.get("include_raw", False)) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_BASE_URL}/dlive/api.json", + params={"KEY": key, "LOOKUP": domain}, + ) + if resp.status_code != 200: + logger.info("builtwith domain.live error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + results = data.get("Results") or [] + techs = [] + for result_item in results: + live = (result_item.get("Result") or {}).get("Live") or [] + for tech in live: + name = tech.get("Name") + if name: + techs.append({ + "name": name, + "tag": tech.get("Tag"), + "categories": tech.get("Categories"), + }) + payload: Dict[str, Any] = { + "ok": True, + "provider": PROVIDER_NAME, + "method_id": "builtwith.domain.live.v1", + "domain": domain, + "tech_count": len(techs), + "technologies": techs, + } + if include_raw: + payload["raw"] = data + return f"builtwith.domain.live ok\n\n```json\n{json.dumps(payload, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_calendly.py b/flexus_client_kit/integrations/fi_calendly.py new file mode 100644 index 00000000..eb039718 --- /dev/null +++ b/flexus_client_kit/integrations/fi_calendly.py @@ -0,0 +1,130 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("calendly") + +PROVIDER_NAME = "calendly" +METHOD_IDS = [ + "calendly.scheduled_events.list.v1", + "calendly.scheduled_events.invitees.list.v1", +] + +_BASE_URL = "https://api.calendly.com" + + +class IntegrationCalendly: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + key = os.environ.get("CALENDLY_ACCESS_TOKEN", "") + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if key else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "calendly.scheduled_events.list.v1": + return await self._scheduled_events_list(args) + if method_id == "calendly.scheduled_events.invitees.list.v1": + return await self._scheduled_events_invitees_list(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _get_user_uri(self, client: httpx.AsyncClient, token: str) -> str: + resp = await client.get( + f"{_BASE_URL}/users/me", + headers={"Authorization": f"Bearer {token}"}, + ) + resp.raise_for_status() + return resp.json()["resource"]["uri"] + + async def _scheduled_events_list(self, args: Dict[str, Any]) -> str: + token = os.environ.get("CALENDLY_ACCESS_TOKEN", "") + if not token: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "CALENDLY_ACCESS_TOKEN env var not set"}, indent=2, ensure_ascii=False) + count = int(args.get("count", 20)) + min_start_time = args.get("min_start_time") + max_start_time = args.get("max_start_time") + status = args.get("status") + try: + async with httpx.AsyncClient(timeout=30) as client: + user_uri = await self._get_user_uri(client, token) + params: Dict[str, Any] = {"user": user_uri, "count": min(count, 100)} + if min_start_time: + params["min_start_time"] = min_start_time + if max_start_time: + params["max_start_time"] = max_start_time + if status: + params["status"] = status + resp = await client.get( + f"{_BASE_URL}/scheduled_events", + headers={"Authorization": f"Bearer {token}"}, + params=params, + ) + if resp.status_code != 200: + logger.info("calendly scheduled_events.list error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + return f"calendly.scheduled_events.list ok\n\n```json\n{json.dumps({'ok': True, 'collection': data.get('collection', []), 'pagination': data.get('pagination', {})}, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _scheduled_events_invitees_list(self, args: Dict[str, Any]) -> str: + token = os.environ.get("CALENDLY_ACCESS_TOKEN", "") + if not token: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "CALENDLY_ACCESS_TOKEN env var not set"}, indent=2, ensure_ascii=False) + event_uuid = str(args.get("event_uuid", "")).strip() + if not event_uuid: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "event_uuid is required"}, indent=2, ensure_ascii=False) + count = int(args.get("count", 20)) + status = args.get("status") + try: + async with httpx.AsyncClient(timeout=30) as client: + params: Dict[str, Any] = {"count": min(count, 100)} + if status: + params["status"] = status + resp = await client.get( + f"{_BASE_URL}/scheduled_events/{event_uuid}/invitees", + headers={"Authorization": f"Bearer {token}"}, + params=params, + ) + if resp.status_code != 200: + logger.info("calendly scheduled_events.invitees.list error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + return f"calendly.scheduled_events.invitees.list ok\n\n```json\n{json.dumps({'ok': True, 'collection': data.get('collection', []), 'pagination': data.get('pagination', {})}, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_capterra.py b/flexus_client_kit/integrations/fi_capterra.py new file mode 100644 index 00000000..f04b3eb7 --- /dev/null +++ b/flexus_client_kit/integrations/fi_capterra.py @@ -0,0 +1,178 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("capterra") + +PROVIDER_NAME = "capterra" +METHOD_IDS = [ + "capterra.products.list.v1", + "capterra.reviews.list.v1", + "capterra.categories.list.v1", +] + +_BASE_URL = "https://www.capterra.com/api/v1" + +_PARTNER_NOTE = ( + "Capterra (Gartner Digital Markets) does not expose a public product/review REST API. " + "Access requires a Gartner Digital Markets partner agreement. " + "Contact https://digital-markets.gartner.com to request API access." +) + + +class IntegrationCapterra: + def __init__(self, rcx=None): + self.rcx = rcx + + def _get_api_key(self) -> str: + if self.rcx is not None: + return (self.rcx.external_auth.get("capterra") or {}).get("api_key", "") + return "" + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}\n" + f"note: {_PARTNER_NOTE}" + ) + if op == "status": + api_key = self._get_api_key() + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if api_key else "auth_missing", + "method_count": len(METHOD_IDS), + "note": _PARTNER_NOTE, + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "capterra.products.list.v1": + return await self._products_list(args) + if method_id == "capterra.reviews.list.v1": + return await self._reviews_list(args) + if method_id == "capterra.categories.list.v1": + return await self._categories_list(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _products_list(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": f"Set CAPTERRA_API_KEY env var. {_PARTNER_NOTE}"}, indent=2, ensure_ascii=False) + query = str(args.get("query", "")).strip() + limit = min(int(args.get("limit", 25)), 100) + cursor = args.get("cursor", None) + include_raw = bool(args.get("include_raw", False)) + + params: Dict[str, Any] = {"limit": limit, "apiKey": api_key} + if query: + params["q"] = query + if cursor is not None: + params["offset"] = int(cursor) + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(_BASE_URL + "/products", params=params, headers={"Accept": "application/json"}) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300], "note": _PARTNER_NOTE}, indent=2, ensure_ascii=False) + data = r.json() + items = data.get("data", data.get("results", data.get("products", []))) + total = data.get("total", len(items)) + result: Dict[str, Any] = {"ok": True, "results": items, "total": total} + if include_raw: + result["raw"] = data + summary = f"Found {len(items)} product(s) from {PROVIDER_NAME} (total={total})." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}", "note": _PARTNER_NOTE}, indent=2, ensure_ascii=False) + + async def _reviews_list(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": f"Set CAPTERRA_API_KEY env var. {_PARTNER_NOTE}"}, indent=2, ensure_ascii=False) + query = str(args.get("query", "")).strip() + limit = min(int(args.get("limit", 25)), 100) + cursor = args.get("cursor", None) + include_raw = bool(args.get("include_raw", False)) + + params: Dict[str, Any] = {"limit": limit, "apiKey": api_key} + if query: + params["q"] = query + if cursor is not None: + params["offset"] = int(cursor) + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(_BASE_URL + "/reviews", params=params, headers={"Accept": "application/json"}) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300], "note": _PARTNER_NOTE}, indent=2, ensure_ascii=False) + data = r.json() + items = data.get("data", data.get("results", data.get("reviews", []))) + total = data.get("total", len(items)) + result: Dict[str, Any] = {"ok": True, "results": items, "total": total} + if include_raw: + result["raw"] = data + summary = f"Found {len(items)} review(s) from {PROVIDER_NAME} (total={total})." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}", "note": _PARTNER_NOTE}, indent=2, ensure_ascii=False) + + async def _categories_list(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": f"Set CAPTERRA_API_KEY env var. {_PARTNER_NOTE}"}, indent=2, ensure_ascii=False) + query = str(args.get("query", "")).strip() + limit = min(int(args.get("limit", 25)), 100) + include_raw = bool(args.get("include_raw", False)) + + params: Dict[str, Any] = {"limit": limit, "apiKey": api_key} + if query: + params["q"] = query + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(_BASE_URL + "/categories", params=params, headers={"Accept": "application/json"}) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300], "note": _PARTNER_NOTE}, indent=2, ensure_ascii=False) + data = r.json() + items = data.get("data", data.get("results", data.get("categories", []))) + total = data.get("total", len(items)) + result: Dict[str, Any] = {"ok": True, "results": items, "total": total} + if include_raw: + result["raw"] = data + summary = f"Found {len(items)} categorie(s) from {PROVIDER_NAME} (total={total})." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}", "note": _PARTNER_NOTE}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_chargebee.py b/flexus_client_kit/integrations/fi_chargebee.py new file mode 100644 index 00000000..0e310e2c --- /dev/null +++ b/flexus_client_kit/integrations/fi_chargebee.py @@ -0,0 +1,235 @@ +import json +import logging +import os +from typing import Any, Dict, List + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("chargebee") + +PROVIDER_NAME = "chargebee" +METHOD_IDS = [ + "chargebee.invoices.list.v1", + "chargebee.subscriptions.list.v1", +] + +_TIMEOUT = 30.0 + + +def _base_url() -> str: + site = os.environ.get("CHARGEBEE_SITE", "") + return f"https://{site}.chargebee.com/api/v2" if site else "" + + +def _check_credentials() -> tuple[str, str]: + api_key = os.environ.get("CHARGEBEE_API_KEY", "").strip() + site = os.environ.get("CHARGEBEE_SITE", "").strip() + return api_key, site + + +class IntegrationChargebee: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + api_key, site = _check_credentials() + status = "available" if (api_key and site) else "no_credentials" + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": status, + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + if method_id == "chargebee.subscriptions.list.v1": + return await self._subscriptions_list(call_args) + if method_id == "chargebee.invoices.list.v1": + return await self._invoices_list(call_args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + def _no_credentials(self) -> str: + return json.dumps({ + "ok": False, + "error_code": "NO_CREDENTIALS", + "provider": PROVIDER_NAME, + "message": "CHARGEBEE_API_KEY or CHARGEBEE_SITE env var not set", + }, indent=2, ensure_ascii=False) + + def _handle_error_status(self, status: int, text: str) -> str | None: + if status == 401: + return json.dumps({ + "ok": False, + "error_code": "AUTH_ERROR", + "provider": PROVIDER_NAME, + "message": "CHARGEBEE_API_KEY invalid or expired", + }, indent=2, ensure_ascii=False) + if status >= 400: + logger.info("chargebee error %s: %s", status, text[:200]) + return json.dumps({ + "ok": False, + "error_code": "PROVIDER_ERROR", + "provider": PROVIDER_NAME, + "status": status, + "detail": text[:500], + }, indent=2, ensure_ascii=False) + return None + + def _normalize_subscription(self, entry: Dict[str, Any]) -> Dict[str, Any]: + sub = entry.get("subscription") or {} + items = sub.get("subscription_items") or [] + plan_id = None + amount = sub.get("total_dues") + if items: + first = items[0] + plan_id = first.get("item_price_id") or first.get("item_id") + if amount is None: + amount = sum(i.get("amount", 0) for i in items) + return { + "id": sub.get("id"), + "customer_id": sub.get("customer_id"), + "plan_id": plan_id, + "status": sub.get("status"), + "current_term_start": sub.get("current_term_start"), + "current_term_end": sub.get("current_term_end"), + "amount": amount, + "currency_code": sub.get("currency_code"), + } + + def _normalize_invoice(self, entry: Dict[str, Any]) -> Dict[str, Any]: + inv = entry.get("invoice") or entry + return { + "id": inv.get("id"), + "subscription_id": inv.get("subscription_id"), + "customer_id": inv.get("customer_id"), + "status": inv.get("status"), + "amount_paid": inv.get("amount_paid"), + "amount_due": inv.get("amount_due"), + "currency_code": inv.get("currency_code"), + "date": inv.get("date"), + "due_date": inv.get("due_date"), + } + + async def _subscriptions_list(self, args: Dict[str, Any]) -> str: + api_key, site = _check_credentials() + if not api_key or not site: + return self._no_credentials() + + limit = min(max(int(args.get("limit", 20)), 1), 100) + offset = str(args.get("offset", "")).strip() or None + status = str(args.get("status", "")).strip() or None + plan_id = str(args.get("plan_id", "")).strip() or None + customer_id = str(args.get("customer_id", "")).strip() or None + + params: Dict[str, Any] = {"limit": limit} + if offset: + params["offset"] = offset + if status: + params["status[is]"] = status + if plan_id: + params["plan_id[is]"] = plan_id + if customer_id: + params["customer_id[is]"] = customer_id + + try: + base = _base_url() + if not base: + return self._no_credentials() + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{base}/subscriptions", + auth=(api_key, ""), + params=params, + ) + err = self._handle_error_status(resp.status_code, resp.text) + if err: + return err + data = resp.json() + raw_list = data.get("list", []) + next_offset = data.get("next_offset") + normalized: List[Dict[str, Any]] = [self._normalize_subscription(e) for e in raw_list] + out = {"ok": True, "data": normalized, "next_offset": next_offset} + return json.dumps(out, indent=2, ensure_ascii=False) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + logger.info("chargebee HTTP error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "detail": str(e)}, indent=2, ensure_ascii=False) + except KeyError as e: + logger.info("chargebee KeyError: %s", e) + return json.dumps({"ok": False, "error_code": "PARSE_ERROR", "provider": PROVIDER_NAME, "detail": str(e)}, indent=2, ensure_ascii=False) + except ValueError as e: + logger.info("chargebee ValueError: %s", e) + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "provider": PROVIDER_NAME, "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _invoices_list(self, args: Dict[str, Any]) -> str: + api_key, site = _check_credentials() + if not api_key or not site: + return self._no_credentials() + + limit = min(max(int(args.get("limit", 20)), 1), 100) + offset = str(args.get("offset", "")).strip() or None + status = str(args.get("status", "")).strip() or None + subscription_id = str(args.get("subscription_id", "")).strip() or None + customer_id = str(args.get("customer_id", "")).strip() or None + + params: Dict[str, Any] = {"limit": limit} + if offset: + params["offset"] = offset + if status: + params["status[is]"] = status + if subscription_id: + params["subscription_id[is]"] = subscription_id + if customer_id: + params["customer_id[is]"] = customer_id + + try: + base = _base_url() + if not base: + return self._no_credentials() + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{base}/invoices", + auth=(api_key, ""), + params=params, + ) + err = self._handle_error_status(resp.status_code, resp.text) + if err: + return err + data = resp.json() + raw_list = data.get("list", []) + next_offset = data.get("next_offset") + normalized: List[Dict[str, Any]] = [self._normalize_invoice(e) for e in raw_list] + out = {"ok": True, "data": normalized, "next_offset": next_offset} + return json.dumps(out, indent=2, ensure_ascii=False) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + logger.info("chargebee HTTP error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "detail": str(e)}, indent=2, ensure_ascii=False) + except KeyError as e: + logger.info("chargebee KeyError: %s", e) + return json.dumps({"ok": False, "error_code": "PARSE_ERROR", "provider": PROVIDER_NAME, "detail": str(e)}, indent=2, ensure_ascii=False) + except ValueError as e: + logger.info("chargebee ValueError: %s", e) + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "provider": PROVIDER_NAME, "detail": str(e)}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_cint.py b/flexus_client_kit/integrations/fi_cint.py new file mode 100644 index 00000000..3e7c3b34 --- /dev/null +++ b/flexus_client_kit/integrations/fi_cint.py @@ -0,0 +1,436 @@ +import json +import logging +import os +from typing import Any, Dict, Optional + +import httpx + +from flexus_client_kit import ckit_cloudtool + + +logger = logging.getLogger("cint") + +PROVIDER_NAME = "cint" +METHOD_IDS = [ + "cint.accounts.list.v1", + "cint.accounts.users.list.v1", + "cint.accounts.service_clients.list.v1", + "cint.accounts.user_business_units.list.v1", + "cint.projects.create.v1", + "cint.projects.get.v1", + "cint.projects.feasibility.get.v1", + "cint.target_groups.create.v1", + "cint.target_groups.list.v1", + "cint.target_groups.get.v1", + "cint.target_groups.details.get.v1", + "cint.target_groups.quota_distribution.get.v1", + "cint.target_groups.exclusions.list.v1", + "cint.target_groups.price.get.v1", + "cint.target_groups.price_prediction.get.v1", + "cint.target_groups.supplier_quota_distribution.get.v1", + "cint.target_groups.supplier_quota_distribution_draft.get.v1", + "cint.fielding.launch.v1", + "cint.fielding.job_status.get.v1", +] + +_BASE_URL = "https://api.cint.com/v1" +_TIMEOUT = 30.0 + +# Cint Exchange Demand API uses an enterprise-issued API key. +# Required values: +# - CINT_API_KEY: bearer token issued by Cint for the account. +# - CINT_API_VERSION: optional header override if Flexus must pin a newer reviewed version. +# Runtime IDs such as account_id, project_id, target_group_id, business_unit_id, and profile_id +# are passed per call for now because different Cint accounts can expose different organizational +# structures and pricing models. +CINT_SETUP_SCHEMA: list[dict[str, Any]] = [] + + +class IntegrationCint: + def __init__(self, rcx=None) -> None: + self.rcx = rcx + + def _api_key(self) -> str: + try: + return str(os.environ.get("CINT_API_KEY", "")).strip() + except (TypeError, ValueError): + return "" + + def _api_version(self) -> str: + try: + return str(os.environ.get("CINT_API_VERSION", "2025-12-18")).strip() or "2025-12-18" + except (TypeError, ValueError): + return "2025-12-18" + + def _headers(self) -> Dict[str, str]: + return { + "Authorization": f"Bearer {self._api_key()}", + "Cint-API-Version": self._api_version(), + "Content-Type": "application/json", + } + + def _status(self) -> str: + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": "ready" if self._api_key() else "missing_credentials", + "method_count": len(METHOD_IDS), + "auth_type": "api_key", + "required_env": ["CINT_API_KEY"], + "optional_env": ["CINT_API_VERSION"], + "products": [ + "Accounts and users", + "Projects", + "Target Groups", + "Draft feasibility", + "Quota Distribution", + "Supplier Distribution", + "Pricing and Price Prediction", + "Fielding Jobs", + ], + }, + indent=2, + ensure_ascii=False, + ) + + def _help(self) -> str: + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}\n" + "notes:\n" + "- Cint is an enterprise sample marketplace; API access must be approved by Cint.\n" + "- account_id is required on all demand-side flows, and business_unit_id is required on pricing flows.\n" + "- launch returns an async fielding job that should be polled until completion.\n" + "- some pricing methods require Private Exchange to be enabled on the account.\n" + ) + + def _error(self, method_id: str, code: str, message: str, **extra: Any) -> str: + payload: Dict[str, Any] = { + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": code, + "message": message, + } + payload.update(extra) + return json.dumps(payload, indent=2, ensure_ascii=False) + + def _result(self, method_id: str, result: Any) -> str: + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": result}, indent=2, ensure_ascii=False) + + def _require_str(self, method_id: str, args: Dict[str, Any], key: str) -> str: + value = str(args.get(key, "")).strip() + if not value: + raise ValueError(f"{key} is required for {method_id}.") + return value + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Optional[Dict[str, Any]], + ) -> str: + try: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return self._help() + if op == "status": + return self._status() + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return self._error(method_id, "METHOD_UNKNOWN", "Unknown Cint method.") + if not self._api_key(): + return self._error(method_id, "AUTH_MISSING", "Set CINT_API_KEY in the runtime environment.") + return await self._dispatch(method_id, call_args) + except (TypeError, ValueError) as e: + logger.error("cint called_by_model failed", exc_info=e) + return self._error("cint.runtime", "RUNTIME_ERROR", f"{type(e).__name__}: {e}") + + async def _request( + self, + method_id: str, + http_method: str, + path: str, + *, + body: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + idempotency_key: str = "", + ) -> str: + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + url = _BASE_URL + path + headers = self._headers() + if idempotency_key: + headers["Idempotency-Key"] = idempotency_key + if http_method == "GET": + response = await client.get(url, headers=headers, params=params) + elif http_method == "POST": + response = await client.post(url, headers=headers, params=params, json=body) + else: + return self._error(method_id, "UNSUPPORTED_HTTP_METHOD", f"Unsupported HTTP method {http_method}.") + except httpx.TimeoutException: + return self._error(method_id, "TIMEOUT", "Cint request timed out.") + except httpx.HTTPError as e: + logger.error("cint request failed", exc_info=e) + return self._error(method_id, "HTTP_ERROR", f"{type(e).__name__}: {e}") + + if response.status_code >= 400: + detail: Any = response.text[:1000] + try: + detail = response.json() + except json.JSONDecodeError: + pass + logger.info("cint provider error method=%s status=%s body=%s", method_id, response.status_code, response.text[:300]) + return self._error(method_id, "PROVIDER_ERROR", "Cint returned an error.", http_status=response.status_code, detail=detail) + + result: Dict[str, Any] | str = {} + if response.text.strip(): + try: + result = response.json() + except json.JSONDecodeError: + result = response.text + location = response.headers.get("location", "") + if location and isinstance(result, dict): + result["job_status_url"] = location + return self._result(method_id, result) + + def _project_body(self, method_id: str, args: Dict[str, Any]) -> Dict[str, Any]: + name = self._require_str(method_id, args, "name") + body: Dict[str, Any] = {"name": name} + for key in [ + "target_completes", + "country_code", + "language_code", + "category", + "business_unit_id", + "project_manager_id", + "study_type", + "industry_code", + ]: + value = args.get(key) + if value not in (None, ""): + body[key] = value + return body + + def _target_group_body(self, method_id: str, args: Dict[str, Any]) -> Dict[str, Any]: + body: Dict[str, Any] = {} + for key in [ + "name", + "business_unit_id", + "project_manager_id", + "study_type_code", + "industry_code", + "country_code", + "language_code", + "cost_per_interview", + "estimated_incidence_rate", + "expected_incidence_rate", + "estimated_length_of_interview", + "expected_length_of_interview_minutes", + "target_completes", + "fielding_start_date", + "fielding_end_date", + "survey_url", + "fielding_specification", + "filling_goal", + "profile", + "quota_sets", + "profile_criteria", + "supply_allocations", + ]: + value = args.get(key) + if value not in (None, ""): + body[key] = value + if not body: + raise ValueError(f"At least one target group field is required for {method_id}.") + return body + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + try: + if method_id == "cint.accounts.list.v1": + return await self._request(method_id, "GET", "/accounts") + + account_id = self._require_str(method_id, args, "account_id") + if method_id == "cint.accounts.users.list.v1": + return await self._request(method_id, "GET", f"/accounts/{account_id}/users") + if method_id == "cint.accounts.service_clients.list.v1": + return await self._request(method_id, "GET", f"/accounts/{account_id}/service-clients") + if method_id == "cint.accounts.user_business_units.list.v1": + user_id = self._require_str(method_id, args, "user_id") + return await self._request(method_id, "GET", f"/accounts/{account_id}/users/{user_id}/business-units") + if method_id == "cint.projects.create.v1": + return await self._request( + method_id, + "POST", + f"/demand/accounts/{account_id}/projects", + body=self._project_body(method_id, args), + idempotency_key=str(args.get("idempotency_key", "")).strip(), + ) + if method_id == "cint.projects.get.v1": + project_id = self._require_str(method_id, args, "project_id") + return await self._request(method_id, "GET", f"/demand/accounts/{account_id}/projects/{project_id}") + if method_id == "cint.projects.feasibility.get.v1": + project_id = str(args.get("project_id", "")).strip() + if project_id: + return await self._request(method_id, "GET", f"/demand/accounts/{account_id}/projects/{project_id}/feasibility") + feasibility_body: Dict[str, Any] = {} + for key in [ + "country_code", + "language_code", + "target_completes", + "profile_criteria", + "quota_sets", + "business_unit_id", + "project_manager_id", + "study_type_code", + "industry_code", + "fielding_specification", + "cost_per_interview", + "expected_incidence_rate", + "expected_length_of_interview_minutes", + "filling_goal", + "profile", + ]: + value = args.get(key) + if value not in (None, ""): + feasibility_body[key] = value + return await self._request( + method_id, + "POST", + f"/demand/accounts/{account_id}/target-groups/calculate-feasibility", + body=feasibility_body, + idempotency_key=str(args.get("idempotency_key", "")).strip(), + ) + if method_id == "cint.target_groups.create.v1": + project_id = self._require_str(method_id, args, "project_id") + return await self._request( + method_id, + "POST", + f"/demand/accounts/{account_id}/projects/{project_id}/target-groups", + body=self._target_group_body(method_id, args), + idempotency_key=str(args.get("idempotency_key", "")).strip(), + ) + if method_id == "cint.target_groups.list.v1": + project_id = self._require_str(method_id, args, "project_id") + params: Dict[str, Any] = {} + for key in ["name", "language_code", "country_code", "page", "page_size", "status"]: + value = args.get(key) + if value not in (None, ""): + params[key] = value + return await self._request(method_id, "GET", f"/demand/accounts/{account_id}/projects/{project_id}/target-groups", params=params) + if method_id == "cint.target_groups.get.v1": + project_id = self._require_str(method_id, args, "project_id") + target_group_id = self._require_str(method_id, args, "target_group_id") + return await self._request(method_id, "GET", f"/demand/accounts/{account_id}/projects/{project_id}/target-groups/{target_group_id}") + if method_id == "cint.target_groups.details.get.v1": + project_id = self._require_str(method_id, args, "project_id") + target_group_id = self._require_str(method_id, args, "target_group_id") + return await self._request(method_id, "GET", f"/demand/accounts/{account_id}/projects/{project_id}/target-groups/{target_group_id}/details") + if method_id == "cint.target_groups.quota_distribution.get.v1": + project_id = self._require_str(method_id, args, "project_id") + target_group_id = self._require_str(method_id, args, "target_group_id") + return await self._request( + method_id, + "GET", + f"/demand/accounts/{account_id}/projects/{project_id}/target-groups/{target_group_id}/quota-distribution", + ) + if method_id == "cint.target_groups.exclusions.list.v1": + project_id = self._require_str(method_id, args, "project_id") + return await self._request(method_id, "GET", f"/demand/accounts/{account_id}/projects/{project_id}/exclusions") + if method_id == "cint.target_groups.price.get.v1": + business_unit_id = self._require_str(method_id, args, "business_unit_id") + target_group_id = self._require_str(method_id, args, "target_group_id") + return await self._request( + method_id, + "GET", + f"/demand/accounts/{account_id}/business-units/{business_unit_id}/target-groups/{target_group_id}/price", + ) + if method_id == "cint.target_groups.price_prediction.get.v1": + business_unit_id = self._require_str(method_id, args, "business_unit_id") + price_prediction_body: Dict[str, Any] = {} + for key in [ + "study_type_code", + "industry_code", + "fielding_specification", + "expected_incidence_rate", + "expected_length_of_interview_minutes", + "filling_goal", + "profile", + "pricing_model", + ]: + value = args.get(key) + if value not in (None, ""): + price_prediction_body[key] = value + return await self._request( + method_id, + "POST", + f"/demand/accounts/{account_id}/business-units/{business_unit_id}/generate-price-prediction", + body=price_prediction_body, + idempotency_key=str(args.get("idempotency_key", "")).strip(), + ) + if method_id == "cint.target_groups.supplier_quota_distribution.get.v1": + project_id = self._require_str(method_id, args, "project_id") + target_group_id = self._require_str(method_id, args, "target_group_id") + profile_id = self._require_str(method_id, args, "profile_id") + return await self._request( + method_id, + "GET", + f"/demand/accounts/{account_id}/projects/{project_id}/target-groups/{target_group_id}/profiles/{profile_id}/supplier-quota-distribution", + ) + if method_id == "cint.target_groups.supplier_quota_distribution_draft.get.v1": + draft_supplier_distribution_body: Dict[str, Any] = {} + for key in [ + "business_unit_id", + "project_manager_id", + "study_type_code", + "industry_code", + "fielding_specification", + "expected_incidence_rate", + "expected_length_of_interview_minutes", + "filling_goal", + "profile", + ]: + value = args.get(key) + if value not in (None, ""): + draft_supplier_distribution_body[key] = value + return await self._request( + method_id, + "POST", + f"/demand/accounts/{account_id}/supplier-quota-distribution/draft", + body=draft_supplier_distribution_body, + idempotency_key=str(args.get("idempotency_key", "")).strip(), + ) + if method_id == "cint.fielding.launch.v1": + project_id = self._require_str(method_id, args, "project_id") + target_group_id = self._require_str(method_id, args, "target_group_id") + body: Dict[str, Any] = {} + end_fielding_date = args.get("end_fielding_date") + if end_fielding_date not in (None, ""): + body["end_fielding_date"] = end_fielding_date + return await self._request( + method_id, + "POST", + f"/demand/accounts/{account_id}/projects/{project_id}/target-groups/{target_group_id}/fielding-run-jobs/launch-from-draft", + body=body or None, + idempotency_key=str(args.get("idempotency_key", "")).strip(), + ) + if method_id == "cint.fielding.job_status.get.v1": + project_id = self._require_str(method_id, args, "project_id") + target_group_id = self._require_str(method_id, args, "target_group_id") + return await self._request( + method_id, + "GET", + f"/demand/accounts/{account_id}/projects/{project_id}/target-groups/{target_group_id}/fielding-run-jobs/launch-from-draft", + ) + except ValueError as e: + return self._error(method_id, "INVALID_ARGS", str(e)) + return self._error(method_id, "METHOD_UNIMPLEMENTED", "Method is declared but not implemented.") diff --git a/flexus_client_kit/integrations/fi_clearbit.py b/flexus_client_kit/integrations/fi_clearbit.py new file mode 100644 index 00000000..91565264 --- /dev/null +++ b/flexus_client_kit/integrations/fi_clearbit.py @@ -0,0 +1,108 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("clearbit") + +PROVIDER_NAME = "clearbit" +METHOD_IDS = [ + "clearbit.company.enrich.v1", +] + +_BASE_URL = "https://company.clearbit.com/v2" + + +class IntegrationClearbit: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + key = os.environ.get("CLEARBIT_API_KEY", "") + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if key else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "clearbit.company.enrich.v1": + return await self._company_enrich(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _company_enrich(self, args: Dict[str, Any]) -> str: + key = os.environ.get("CLEARBIT_API_KEY", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "CLEARBIT_API_KEY env var not set"}, indent=2, ensure_ascii=False) + domain = str(args.get("domain", "")).strip() + if not domain: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "domain is required"}, indent=2, ensure_ascii=False) + include_raw = bool(args.get("include_raw", False)) + try: + async with httpx.AsyncClient(timeout=30, auth=(key, "")) as client: + resp = await client.get( + f"{_BASE_URL}/companies/find", + params={"domain": domain}, + ) + if resp.status_code == 202: + return json.dumps({ + "ok": True, + "status": "pending", + "message": "Clearbit is fetching data asynchronously. Retry shortly.", + "domain": domain, + }, indent=2, ensure_ascii=False) + if resp.status_code != 200: + logger.info("clearbit company_enrich error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + result: Dict[str, Any] = { + "ok": True, + "credit_note": "This call consumes API credits.", + "domain": data.get("domain"), + "name": data.get("name"), + "description": data.get("description"), + "industry": data.get("category", {}).get("industry") if data.get("category") else None, + "employees": data.get("metrics", {}).get("employees") if data.get("metrics") else None, + "revenue": data.get("metrics", {}).get("estimatedAnnualRevenue") if data.get("metrics") else None, + "tech": data.get("tech", []), + "location": data.get("location"), + "linkedin_handle": data.get("linkedin", {}).get("handle") if data.get("linkedin") else None, + "twitter_handle": data.get("twitter", {}).get("handle") if data.get("twitter") else None, + "founded_year": data.get("foundedYear"), + "logo": data.get("logo"), + "url": data.get("url"), + "tags": data.get("tags", []), + } + if include_raw: + result["raw"] = data + return f"clearbit.company.enrich ok\n\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_coresignal.py b/flexus_client_kit/integrations/fi_coresignal.py new file mode 100644 index 00000000..3bc99c9a --- /dev/null +++ b/flexus_client_kit/integrations/fi_coresignal.py @@ -0,0 +1,141 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("coresignal") + +PROVIDER_NAME = "coresignal" +METHOD_IDS = [ + "coresignal.jobs.posts.v1", + "coresignal.companies.profile.v1", +] + +_BASE_URL = "https://api.coresignal.com/cdapi/v1" + + +class IntegrationCoresignal: + def __init__(self, rcx=None): + self.rcx = rcx + + def _get_api_key(self) -> str: + if self.rcx is not None: + return (self.rcx.external_auth.get("coresignal") or {}).get("api_key", "") + return "" + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set CORESIGNAL_API_KEY env var."}, indent=2, ensure_ascii=False) + + if method_id == "coresignal.jobs.posts.v1": + return await self._jobs_posts(args, api_key) + if method_id == "coresignal.companies.profile.v1": + return await self._companies_profile(args, api_key) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + def _headers(self, api_key: str) -> Dict[str, str]: + return { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + async def _jobs_posts(self, args: Dict[str, Any], api_key: str) -> str: + query = str(args.get("query", "")).strip() + limit = int(args.get("limit", 20)) + body: Dict[str, Any] = {"size": limit} + if query: + body["query"] = {"bool": {"must": [{"match": {"title": query}}]}} + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.post(_BASE_URL + "/job_posting/search/filter", json=body, headers=self._headers(api_key)) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + results = data if isinstance(data, list) else data.get("data", data.get("results", [])) + include_raw = bool(args.get("include_raw")) + out: Dict[str, Any] = {"ok": True, "results": results if include_raw else [ + { + "title": j.get("title"), + "company_name": j.get("company_name"), + "location": j.get("location"), + "date_posted": j.get("date_posted") or j.get("created"), + "url": j.get("url"), + } + for j in (results if isinstance(results, list) else []) + ]} + summary = f"Found {len(results) if isinstance(results, list) else 1} job posting(s) from {PROVIDER_NAME}." + return summary + "\n\n```json\n" + json.dumps(out, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _companies_profile(self, args: Dict[str, Any], api_key: str) -> str: + company_name = str(args.get("company_name", args.get("query", ""))).strip() + if not company_name: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "args.company_name required."}, indent=2, ensure_ascii=False) + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get( + _BASE_URL + "/linkedin/company/search/filter", + params={"name": company_name}, + headers=self._headers(api_key), + ) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + results = data if isinstance(data, list) else data.get("data", data.get("results", data)) + include_raw = bool(args.get("include_raw")) + out: Dict[str, Any] = {"ok": True, "results": results if include_raw else [ + { + "name": c.get("name"), + "website": c.get("website"), + "industry": c.get("industry"), + "employee_count": c.get("employee_count"), + "founded": c.get("founded"), + "description": str(c.get("description", ""))[:300], + } + for c in (results if isinstance(results, list) else [results]) + ]} + summary = f"Found company profile(s) from {PROVIDER_NAME}." + return summary + "\n\n```json\n" + json.dumps(out, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_crossbeam.py b/flexus_client_kit/integrations/fi_crossbeam.py new file mode 100644 index 00000000..16dea15e --- /dev/null +++ b/flexus_client_kit/integrations/fi_crossbeam.py @@ -0,0 +1,438 @@ +import json +import logging +import os +from typing import Any, Dict, List + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("crossbeam") + +PROVIDER_NAME = "crossbeam" +API_BASE = "https://api.crossbeam.com/v0.1" +METHOD_IDS = [ + "crossbeam.partners.list.v1", + "crossbeam.account_mapping.overlaps.list.v1", + "crossbeam.exports.records.get.v1", +] + +_AUTH_REQUIRED_OVERLAPS = json.dumps( + { + "ok": False, + "error_code": "AUTH_REQUIRED", + "provider": PROVIDER_NAME, + "method_id": "crossbeam.account_mapping.overlaps.list.v1", + "message": "Account mapping overlaps endpoint structure is not fully documented. Crossbeam REST API requires Supernode plan. Check developers.crossbeam.com for current API spec.", + }, + indent=2, + ensure_ascii=False, +) + +_AUTH_REQUIRED_EXPORTS = json.dumps( + { + "ok": False, + "error_code": "AUTH_REQUIRED", + "provider": PROVIDER_NAME, + "method_id": "crossbeam.exports.records.get.v1", + "message": "Exports records endpoint structure is not fully documented. Crossbeam REST API requires Supernode plan. Check developers.crossbeam.com for current API spec.", + }, + indent=2, + ensure_ascii=False, +) + + +class IntegrationCrossbeam: + def _headers(self, api_key: str) -> Dict[str, str]: + return { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + def _check_credentials(self) -> str: + key = os.environ.get("CROSSBEAM_API_KEY", "") + if not key: + return json.dumps( + { + "ok": False, + "error_code": "NO_CREDENTIALS", + "provider": PROVIDER_NAME, + "message": "CROSSBEAM_API_KEY env var not set", + }, + indent=2, + ensure_ascii=False, + ) + return "" + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + key = os.environ.get("CROSSBEAM_API_KEY", "") + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if key else "no_credentials", + "method_count": len(METHOD_IDS), + }, + indent=2, + ensure_ascii=False, + ) + if op == "list_methods": + return json.dumps( + {"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, + indent=2, + ensure_ascii=False, + ) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "crossbeam.partners.list.v1": + return await self._partners_list(args) + if method_id == "crossbeam.account_mapping.overlaps.list.v1": + return await self._overlaps_list(args) + if method_id == "crossbeam.exports.records.get.v1": + return await self._exports_records_get(args) + return json.dumps( + {"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + + async def _partners_list(self, args: Dict[str, Any]) -> str: + cred_err = self._check_credentials() + if cred_err: + return cred_err + limit = int(args.get("limit", 25)) + limit = min(max(limit, 1), 100) + page_cursor = str(args.get("page_cursor", "")).strip() + params: Dict[str, Any] = {"limit": limit} + if page_cursor: + params["page_cursor"] = page_cursor + key = os.environ.get("CROSSBEAM_API_KEY", "") + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.get( + f"{API_BASE}/partners", + headers=self._headers(key), + params=params, + ) + if resp.status_code == 401: + return json.dumps( + { + "ok": False, + "error_code": "AUTH_ERROR", + "provider": PROVIDER_NAME, + "message": "Invalid CROSSBEAM_API_KEY. Check credentials.", + }, + indent=2, + ensure_ascii=False, + ) + if resp.status_code == 403: + return json.dumps( + { + "ok": False, + "error_code": "ENTITLEMENT_MISSING", + "provider": PROVIDER_NAME, + "message": "Crossbeam REST API requires Supernode plan.", + }, + indent=2, + ensure_ascii=False, + ) + if resp.status_code != 200: + logger.info("crossbeam partners_list error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps( + { + "ok": False, + "error_code": "PROVIDER_ERROR", + "provider": PROVIDER_NAME, + "status": resp.status_code, + "detail": resp.text[:500], + }, + indent=2, + ensure_ascii=False, + ) + try: + data = resp.json() + except json.JSONDecodeError as e: + logger.info("crossbeam partners_list JSON decode error: %s", e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + items_raw = data.get("items") or data.get("partners") or data.get("data") or [] + pagination = data.get("pagination") or {} + next_cursor = pagination.get("next_page_cursor") or pagination.get("next_cursor") or "" + normalized: List[Dict[str, Any]] = [] + for it in items_raw: + normalized.append({ + "id": it.get("id"), + "name": it.get("name"), + "account_count": it.get("account_count"), + "status": it.get("status"), + "created_at": it.get("created_at"), + }) + result = { + "ok": True, + "provider": PROVIDER_NAME, + "items": normalized, + "next_page_cursor": next_cursor if next_cursor else None, + } + return json.dumps(result, indent=2, ensure_ascii=False) + except httpx.TimeoutException as e: + logger.info("crossbeam partners_list timeout: %s", e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("crossbeam partners_list HTTP error: %s", e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "detail": str(e)}, + indent=2, + ensure_ascii=False, + ) + + async def _overlaps_list(self, args: Dict[str, Any]) -> str: + cred_err = self._check_credentials() + if cred_err: + return cred_err + partner_id = str(args.get("partner_id", "")).strip() + if not partner_id: + return json.dumps( + { + "ok": False, + "error_code": "MISSING_ARG", + "provider": PROVIDER_NAME, + "message": "partner_id is required", + }, + indent=2, + ensure_ascii=False, + ) + limit = int(args.get("limit", 25)) + limit = min(max(limit, 1), 100) + page_cursor = str(args.get("page_cursor", "")).strip() + population_id = str(args.get("population_id", "")).strip() + params: Dict[str, Any] = {"limit": limit, "partner_id": partner_id} + if page_cursor: + params["page_cursor"] = page_cursor + if population_id: + params["population_id"] = population_id + key = os.environ.get("CROSSBEAM_API_KEY", "") + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.get( + f"{API_BASE}/account-mapping/overlaps", + headers=self._headers(key), + params=params, + ) + if resp.status_code == 404: + return _AUTH_REQUIRED_OVERLAPS + if resp.status_code == 401: + return json.dumps( + { + "ok": False, + "error_code": "AUTH_ERROR", + "provider": PROVIDER_NAME, + "message": "Invalid CROSSBEAM_API_KEY.", + }, + indent=2, + ensure_ascii=False, + ) + if resp.status_code == 403: + return json.dumps( + { + "ok": False, + "error_code": "ENTITLEMENT_MISSING", + "provider": PROVIDER_NAME, + "message": "Account mapping requires Supernode plan.", + }, + indent=2, + ensure_ascii=False, + ) + if resp.status_code != 200: + logger.info("crossbeam overlaps_list error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps( + { + "ok": False, + "error_code": "PROVIDER_ERROR", + "provider": PROVIDER_NAME, + "status": resp.status_code, + "detail": resp.text[:500], + }, + indent=2, + ensure_ascii=False, + ) + try: + data = resp.json() + except json.JSONDecodeError as e: + logger.info("crossbeam overlaps_list JSON decode error: %s", e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + items_raw = data.get("items") or data.get("overlaps") or data.get("data") or [] + pagination = data.get("pagination") or {} + next_cursor = pagination.get("next_page_cursor") or pagination.get("next_cursor") or "" + normalized: List[Dict[str, Any]] = [] + for it in items_raw: + normalized.append({ + "account_name": it.get("account_name") or it.get("name"), + "your_crm_id": it.get("your_crm_id") or it.get("crm_id"), + "partner_crm_id": it.get("partner_crm_id") or it.get("partner_id"), + "overlap_type": it.get("overlap_type") or it.get("type"), + "partner_name": it.get("partner_name") or it.get("partner"), + }) + result = { + "ok": True, + "provider": PROVIDER_NAME, + "items": normalized, + "next_page_cursor": next_cursor if next_cursor else None, + } + return json.dumps(result, indent=2, ensure_ascii=False) + except httpx.TimeoutException as e: + logger.info("crossbeam overlaps_list timeout: %s", e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("crossbeam overlaps_list HTTP error: %s", e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "detail": str(e)}, + indent=2, + ensure_ascii=False, + ) + + async def _exports_records_get(self, args: Dict[str, Any]) -> str: + cred_err = self._check_credentials() + if cred_err: + return cred_err + export_id = str(args.get("export_id", "")).strip() + if not export_id: + return json.dumps( + { + "ok": False, + "error_code": "MISSING_ARG", + "provider": PROVIDER_NAME, + "message": "export_id is required", + }, + indent=2, + ensure_ascii=False, + ) + limit = int(args.get("limit", 50)) + limit = min(max(limit, 1), 100) + page_cursor = str(args.get("page_cursor", "")).strip() + params: Dict[str, Any] = {"limit": limit} + if page_cursor: + params["page_cursor"] = page_cursor + key = os.environ.get("CROSSBEAM_API_KEY", "") + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.get( + f"{API_BASE}/exports/{export_id}/records", + headers=self._headers(key), + params=params, + ) + if resp.status_code == 404: + return _AUTH_REQUIRED_EXPORTS + if resp.status_code == 401: + return json.dumps( + { + "ok": False, + "error_code": "AUTH_ERROR", + "provider": PROVIDER_NAME, + "message": "Invalid CROSSBEAM_API_KEY.", + }, + indent=2, + ensure_ascii=False, + ) + if resp.status_code == 403: + return json.dumps( + { + "ok": False, + "error_code": "ENTITLEMENT_MISSING", + "provider": PROVIDER_NAME, + "message": "Exports require Supernode plan.", + }, + indent=2, + ensure_ascii=False, + ) + if resp.status_code != 200: + logger.info("crossbeam exports_records_get error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps( + { + "ok": False, + "error_code": "PROVIDER_ERROR", + "provider": PROVIDER_NAME, + "status": resp.status_code, + "detail": resp.text[:500], + }, + indent=2, + ensure_ascii=False, + ) + try: + data = resp.json() + except json.JSONDecodeError as e: + logger.info("crossbeam exports_records_get JSON decode error: %s", e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + records_raw = data.get("records") or data.get("items") or data.get("data") or [] + if isinstance(records_raw, dict): + records_raw = list(records_raw.values()) if records_raw else [] + records = list(records_raw)[:limit] + pagination = data.get("pagination") or {} + next_cursor = pagination.get("next_page_cursor") or pagination.get("next_cursor") or "" + result = { + "ok": True, + "provider": PROVIDER_NAME, + "export_id": export_id, + "records": records, + "next_page_cursor": next_cursor if next_cursor else None, + } + return json.dumps(result, indent=2, ensure_ascii=False) + except httpx.TimeoutException as e: + logger.info("crossbeam exports_records_get timeout: %s", e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("crossbeam exports_records_get HTTP error: %s", e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "detail": str(e)}, + indent=2, + ensure_ascii=False, + ) diff --git a/flexus_client_kit/integrations/fi_crunchbase.py b/flexus_client_kit/integrations/fi_crunchbase.py new file mode 100644 index 00000000..e69bb132 --- /dev/null +++ b/flexus_client_kit/integrations/fi_crunchbase.py @@ -0,0 +1,184 @@ +import json +import logging +import os +from typing import Any, Dict, List + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("crunchbase") + +PROVIDER_NAME = "crunchbase" +METHOD_IDS = [ + "crunchbase.organizations.lookup.v1", + "crunchbase.organizations.search.v1", +] + +_BASE_URL = "https://api.crunchbase.com/api/v4" + +_DEFAULT_LOOKUP_FIELDS = [ + "short_description", + "num_employees_enum", + "revenue_range", + "funding_total", + "last_funding_type", + "founded_on", + "location_identifiers", +] + +_DEFAULT_SEARCH_FIELDS = [ + "identifier", + "short_description", + "num_employees_enum", + "funding_total", +] + + +class IntegrationCrunchbase: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + key = os.environ.get("CRUNCHBASE_API_KEY", "") + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if key else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "crunchbase.organizations.lookup.v1": + return await self._organizations_lookup(args) + if method_id == "crunchbase.organizations.search.v1": + return await self._organizations_search(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _organizations_lookup(self, args: Dict[str, Any]) -> str: + key = os.environ.get("CRUNCHBASE_API_KEY", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "CRUNCHBASE_API_KEY env var not set"}, indent=2, ensure_ascii=False) + permalink = str(args.get("permalink", "")).strip() + if not permalink: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "permalink is required"}, indent=2, ensure_ascii=False) + field_ids: List[str] = args.get("field_ids") or _DEFAULT_LOOKUP_FIELDS + params = { + "user_key": key, + "field_ids": ",".join(field_ids), + } + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_BASE_URL}/entities/organizations/{permalink}", + params=params, + ) + if resp.status_code == 404: + return json.dumps({"ok": False, "error_code": "NOT_FOUND", "permalink": permalink}, indent=2, ensure_ascii=False) + if resp.status_code != 200: + logger.info("crunchbase lookup error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + properties = data.get("properties", {}) + return f"crunchbase.organizations.lookup ok\n\n```json\n{json.dumps({'ok': True, 'permalink': permalink, 'properties': properties}, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _organizations_search(self, args: Dict[str, Any]) -> str: + key = os.environ.get("CRUNCHBASE_API_KEY", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "CRUNCHBASE_API_KEY env var not set"}, indent=2, ensure_ascii=False) + limit = int(args.get("limit") or 10) + predicates = [ + { + "type": "predicate", + "field_id": "facet_ids", + "operator_id": "includes", + "values": ["company"], + }, + ] + name = str(args.get("name", "")).strip() + if name: + predicates.append({ + "type": "predicate", + "field_id": "identifier", + "operator_id": "contains", + "values": [name], + }) + location = str(args.get("location", "")).strip() + if location: + predicates.append({ + "type": "predicate", + "field_id": "location_identifiers", + "operator_id": "includes", + "values": [location], + }) + num_employees_min = args.get("num_employees_min") + if num_employees_min is not None: + predicates.append({ + "type": "predicate", + "field_id": "num_employees_enum", + "operator_id": "gte", + "values": [num_employees_min], + }) + num_employees_max = args.get("num_employees_max") + if num_employees_max is not None: + predicates.append({ + "type": "predicate", + "field_id": "num_employees_enum", + "operator_id": "lte", + "values": [num_employees_max], + }) + funding_total_min = args.get("funding_total_min") + if funding_total_min is not None: + predicates.append({ + "type": "predicate", + "field_id": "funding_total", + "operator_id": "gte", + "values": [funding_total_min], + }) + body = { + "field_ids": _DEFAULT_SEARCH_FIELDS, + "query": predicates, + "limit": limit, + } + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{_BASE_URL}/searches/organizations", + params={"user_key": key}, + json=body, + ) + if resp.status_code != 200: + logger.info("crunchbase search error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + entities = [e.get("properties", {}) for e in data.get("entities", [])] + return f"crunchbase.organizations.search ok\n\n```json\n{json.dumps({'ok': True, 'count': len(entities), 'results': entities}, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_datadog.py b/flexus_client_kit/integrations/fi_datadog.py new file mode 100644 index 00000000..c0992714 --- /dev/null +++ b/flexus_client_kit/integrations/fi_datadog.py @@ -0,0 +1,187 @@ +import json +import logging +import os +from typing import Any, Dict, List + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("datadog") + +PROVIDER_NAME = "datadog" +METHOD_IDS = [ + "datadog.metrics.timeseries.query.v1", + "datadog.metrics.query.v1", +] + + +def _base_url() -> str: + site = os.environ.get("DD_SITE", "datadoghq.com") + return f"https://api.{site}/api/v1" + + +class IntegrationDatadog: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help\n" + "op=status\n" + "op=list_methods\n" + "op=call(args={method_id: ...})\n" + f"known_method_ids={len(METHOD_IDS)}" + ) + if op == "status": + api_key = os.environ.get("DD_API_KEY", "") + app_key = os.environ.get("DD_APP_KEY", "") + status = "available" if (api_key and app_key) else "no_credentials" + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": status, + "method_count": len(METHOD_IDS), + }, + indent=2, + ensure_ascii=False, + ) + if op == "list_methods": + return json.dumps( + {"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, + indent=2, + ensure_ascii=False, + ) + if op != "call": + return "Error: unknown op." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required." + if method_id not in METHOD_IDS: + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, call_args: Dict[str, Any]) -> str: + if method_id in ("datadog.metrics.timeseries.query.v1", "datadog.metrics.query.v1"): + return await self._timeseries_query(call_args) + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + + async def _timeseries_query(self, call_args: Dict[str, Any]) -> str: + query = str(call_args.get("query", "")).strip() + if not query: + return "Error: query required." + from_ts = call_args.get("from_ts") + to_ts = call_args.get("to_ts") + if from_ts is None or to_ts is None: + return "Error: from_ts and to_ts required." + try: + from_ts = int(from_ts) + to_ts = int(to_ts) + except (TypeError, ValueError): + return "Error: from_ts and to_ts must be integers." + api_key = os.environ.get("DD_API_KEY", "") + app_key = os.environ.get("DD_APP_KEY", "") + if not api_key or not app_key: + return json.dumps( + {"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + url = f"{_base_url()}/query" + params = {"from": from_ts, "to": to_ts, "query": query} + headers = { + "DD-API-KEY": api_key, + "DD-APPLICATION-KEY": app_key, + } + try: + async with httpx.AsyncClient() as client: + response = await client.get( + url, params=params, headers=headers, timeout=30.0 + ) + response.raise_for_status() + except httpx.TimeoutException as e: + logger.info("Datadog timeout query=%s: %s", query, e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "query": query}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPStatusError as e: + logger.info( + "Datadog HTTP error query=%s status=%s: %s", + query, + e.response.status_code, + e, + ) + return json.dumps( + { + "ok": False, + "error_code": "HTTP_ERROR", + "query": query, + "status_code": e.response.status_code, + }, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("Datadog HTTP error query=%s: %s", query, e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "query": query}, + indent=2, + ensure_ascii=False, + ) + try: + payload = response.json() + except ValueError as e: + logger.info("Datadog JSON decode error query=%s: %s", query, e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "query": query}, + indent=2, + ensure_ascii=False, + ) + status = payload.get("status", "") + if status != "ok": + err_msg = payload.get("error", "unknown") + logger.info("Datadog query error query=%s status=%s: %s", query, status, err_msg) + return json.dumps( + { + "ok": False, + "error_code": "QUERY_ERROR", + "query": query, + "status": status, + "error": err_msg, + }, + indent=2, + ensure_ascii=False, + ) + series_raw = payload.get("series") or [] + normalized: List[Dict[str, Any]] = [] + for s in series_raw: + metric = s.get("metric", "") + scope = s.get("scope", "") + pointlist = s.get("pointlist") or [] + unit = s.get("unit") + points = [{"ts": p[0], "value": p[1]} for p in pointlist if len(p) >= 2] + normalized.append( + {"metric": metric, "scope": scope, "points": points, "unit": unit} + ) + return json.dumps( + {"ok": True, "query": query, "series": normalized}, + indent=2, + ensure_ascii=False, + ) diff --git a/flexus_client_kit/integrations/fi_dataforseo.py b/flexus_client_kit/integrations/fi_dataforseo.py new file mode 100644 index 00000000..4e810aff --- /dev/null +++ b/flexus_client_kit/integrations/fi_dataforseo.py @@ -0,0 +1,108 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("dataforseo") + +PROVIDER_NAME = "dataforseo" +METHOD_IDS = [ + "dataforseo.trends.demography.live.v1", + "dataforseo.trends.explore.live.v1", + "dataforseo.trends.merged_data.live.v1", + "dataforseo.trends.subregion_interests.live.v1", +] + +_BASE_URL = "https://api.dataforseo.com/v3" + +_METHOD_ENDPOINTS = { + "dataforseo.trends.explore.live.v1": "/dataforseo_trends/explore/live", + "dataforseo.trends.subregion_interests.live.v1": "/dataforseo_trends/subregion_interests/live", + "dataforseo.trends.demography.live.v1": "/dataforseo_trends/demography/live", + "dataforseo.trends.merged_data.live.v1": "/dataforseo_trends/merged_data/live", +} + + +class IntegrationDataforseo: + # XXX: requires multiple credentials (DATAFORSEO_LOGIN + DATAFORSEO_PASSWORD). + # manual auth (single api_key field) does not cover this provider. + # currently reads from env vars as a fallback. + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + login = os.environ.get("DATAFORSEO_LOGIN", "") + password = os.environ.get("DATAFORSEO_PASSWORD", "") + if not login or not password: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set DATAFORSEO_LOGIN and DATAFORSEO_PASSWORD env vars."}, indent=2, ensure_ascii=False) + + query = args.get("query", "") + if isinstance(query, str): + keywords = [query] if query else [] + else: + keywords = list(query) + + geo = args.get("geo") or {} + location_name = geo.get("country", "United States") if geo else "United States" + start_date = args.get("start_date") + + body: Dict[str, Any] = {"keywords": keywords, "location_name": location_name} + if start_date and method_id == "dataforseo.trends.explore.live.v1": + body["date_from"] = start_date + + endpoint = _METHOD_ENDPOINTS[method_id] + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.post( + _BASE_URL + endpoint, + json=[body], + auth=(login, password), + headers={"Content-Type": "application/json"}, + ) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + tasks = data.get("tasks", []) + results = [] + for task in tasks: + task_results = (task.get("result") or []) + results.extend(task_results) + summary = f"Found {len(results)} result(s) from {PROVIDER_NAME} ({method_id})." + payload: Dict[str, Any] = {"ok": True, "results": results, "total": len(results)} + if args.get("include_raw"): + payload["raw"] = data + return summary + "\n\n```json\n" + json.dumps(payload, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_delighted.py b/flexus_client_kit/integrations/fi_delighted.py new file mode 100644 index 00000000..a201d2c4 --- /dev/null +++ b/flexus_client_kit/integrations/fi_delighted.py @@ -0,0 +1,176 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("delighted") + +PROVIDER_NAME = "delighted" +API_BASE = "https://api.delighted.com/v1" +METHOD_IDS = [ + "delighted.metrics.get.v1", +] +_TIMEOUT = 30.0 + + +class IntegrationDelighted: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + api_key = os.environ.get("DELIGHTED_API_KEY", "") + if os.environ.get("NO_CREDENTIALS"): + status = "no_credentials" + else: + status = "available" if api_key else "no_credentials" + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": status, + "method_count": len(METHOD_IDS), + }, + indent=2, + ensure_ascii=False, + ) + if op == "list_methods": + return json.dumps( + {"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, + indent=2, + ensure_ascii=False, + ) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + if method_id == "delighted.metrics.get.v1": + return await self._metrics_get(call_args) + return json.dumps( + {"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + + async def _metrics_get(self, call_args: Dict[str, Any]) -> str: + api_key = os.environ.get("DELIGHTED_API_KEY", "") + if not api_key or os.environ.get("NO_CREDENTIALS"): + return json.dumps( + { + "ok": False, + "error_code": "NO_CREDENTIALS", + "provider": PROVIDER_NAME, + "message": "DELIGHTED_API_KEY env var not set", + }, + indent=2, + ensure_ascii=False, + ) + params: Dict[str, Any] = {} + since = call_args.get("since") + if since is not None: + params["since"] = since + until = call_args.get("until") + if until is not None: + params["until"] = until + trend = str(call_args.get("trend", "")).strip() + if trend: + params["trend"] = trend + token = str(call_args.get("token", "")).strip() + if token: + params["token"] = token + channel = str(call_args.get("channel", "")).strip() + if channel: + params["channel"] = channel + url = f"{API_BASE}/metrics.json" + headers = {"Content-Type": "application/json"} + try: + async with httpx.AsyncClient( + auth=httpx.BasicAuth(username=api_key, password=""), + timeout=_TIMEOUT, + ) as client: + response = await client.get(url, params=params, headers=headers) + response.raise_for_status() + except httpx.TimeoutException as e: + logger.info("Delighted metrics timeout: %s", e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPStatusError as e: + logger.info("Delighted HTTP error status=%s: %s", e.response.status_code, e) + return json.dumps( + { + "ok": False, + "error_code": "HTTP_ERROR", + "provider": PROVIDER_NAME, + "status_code": e.response.status_code, + }, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("Delighted HTTP error: %s", e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + payload = response.json() + except json.JSONDecodeError as e: + logger.info("Delighted JSON decode error: %s", e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + normalized = self._normalize_metrics(payload) + except (KeyError, ValueError) as e: + logger.info("Delighted response parse error: %s", e) + return json.dumps( + {"ok": False, "error_code": "UNEXPECTED_RESPONSE", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + return json.dumps( + {"ok": True, "data": normalized}, + indent=2, + ensure_ascii=False, + ) + + def _normalize_metrics(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return { + "nps": payload.get("nps"), + "promoter_count": payload.get("promoter_count"), + "passive_count": payload.get("passive_count"), + "detractor_count": payload.get("detractor_count"), + "survey_request_count": payload.get("survey_request_count"), + "responded": payload.get("responded") or payload.get("response_count"), + "percent_promoters": payload.get("percent_promoters") or payload.get("promoter_percent"), + "percent_passives": payload.get("percent_passives") or payload.get("passive_percent"), + "percent_detractors": payload.get("percent_detractors") or payload.get("detractor_percent"), + "average_score": payload.get("average_score"), + } diff --git a/flexus_client_kit/integrations/fi_docusign.py b/flexus_client_kit/integrations/fi_docusign.py new file mode 100644 index 00000000..9b5b213d --- /dev/null +++ b/flexus_client_kit/integrations/fi_docusign.py @@ -0,0 +1,391 @@ +import json +import logging +import os +from typing import Any, Dict, List + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("docusign") + +PROVIDER_NAME = "docusign" +METHOD_IDS = [ + "docusign.envelopes.create.v1", + "docusign.envelopes.get.v1", + "docusign.envelopes.list_status_changes.v1", +] +_DEFAULT_BASE_URL = "https://demo.docusign.net/restapi/v2.1" + + +def _base_url() -> str: + return os.environ.get("DOCUSIGN_BASE_URL", "").strip() or _DEFAULT_BASE_URL + + +def _api_base() -> str: + account_id = os.environ.get("DOCUSIGN_ACCOUNT_ID", "").strip() + base = _base_url() + return f"{base}/accounts/{account_id}" + + +def _check_credentials() -> bool: + token = os.environ.get("DOCUSIGN_ACCESS_TOKEN", "").strip() + account_id = os.environ.get("DOCUSIGN_ACCOUNT_ID", "").strip() + return bool(token and account_id) + + +def _headers() -> Dict[str, str]: + token = os.environ.get("DOCUSIGN_ACCESS_TOKEN", "").strip() + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + +def _normalize_envelope_create(p: Dict[str, Any]) -> Dict[str, Any]: + return { + "envelope_id": p.get("envelopeId"), + "status": p.get("status"), + "created_date_time": p.get("createdDateTime"), + "email_subject": p.get("emailSubject"), + } + + +def _normalize_envelope_get(p: Dict[str, Any]) -> Dict[str, Any]: + return { + "envelope_id": p.get("envelopeId"), + "status": p.get("status"), + "email_subject": p.get("emailSubject"), + "created_date_time": p.get("createdDateTime"), + "sent_date_time": p.get("sentDateTime"), + "completed_date_time": p.get("completedDateTime"), + "declined_date_time": p.get("declinedDateTime"), + } + + +def _normalize_envelope_list_item(p: Dict[str, Any]) -> Dict[str, Any]: + return { + "envelope_id": p.get("envelopeId"), + "status": p.get("status"), + "email_subject": p.get("emailSubject"), + "created_date_time": p.get("createdDateTime"), + "sent_date_time": p.get("sentDateTime"), + "completed_date_time": p.get("completedDateTime"), + } + + +class IntegrationDocusign: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help\n" + "op=status\n" + "op=list_methods\n" + "op=call(args={method_id: ...})\n" + f"known_method_ids={len(METHOD_IDS)}" + ) + if op == "status": + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if _check_credentials() else "no_credentials", + "method_count": len(METHOD_IDS), + }, + indent=2, + ensure_ascii=False, + ) + if op == "list_methods": + return json.dumps( + {"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, + indent=2, + ensure_ascii=False, + ) + if op != "call": + return "Error: unknown op." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required." + if method_id not in METHOD_IDS: + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, call_args: Dict[str, Any]) -> str: + if method_id == "docusign.envelopes.create.v1": + return await self._envelopes_create(call_args) + if method_id == "docusign.envelopes.get.v1": + return await self._envelopes_get(call_args) + if method_id == "docusign.envelopes.list_status_changes.v1": + return await self._envelopes_list_status_changes(call_args) + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + + async def _envelopes_create(self, call_args: Dict[str, Any]) -> str: + if not _check_credentials(): + return json.dumps( + {"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + template_id = str(call_args.get("template_id", "")).strip() + email_subject = str(call_args.get("email_subject", "")).strip() + recipients = call_args.get("recipients") + if not template_id: + return json.dumps( + {"ok": False, "error_code": "MISSING_ARGS", "message": "template_id required"}, + indent=2, + ensure_ascii=False, + ) + if not email_subject: + return json.dumps( + {"ok": False, "error_code": "MISSING_ARGS", "message": "email_subject required"}, + indent=2, + ensure_ascii=False, + ) + if not isinstance(recipients, list) or not recipients: + return json.dumps( + {"ok": False, "error_code": "MISSING_ARGS", "message": "recipients (list with name+email) required"}, + indent=2, + ensure_ascii=False, + ) + status = str(call_args.get("status", "sent")).strip() or "sent" + if status not in ("sent", "created"): + status = "sent" + template_roles: List[Dict[str, Any]] = [] + for r in recipients: + if not isinstance(r, dict): + continue + email = str(r.get("email", "")).strip() + name = str(r.get("name", "")).strip() + if not email: + continue + template_roles.append({ + "email": email, + "name": name or email, + "roleName": "signer", + }) + if not template_roles: + return json.dumps( + {"ok": False, "error_code": "MISSING_ARGS", "message": "recipients must have at least one with email"}, + indent=2, + ensure_ascii=False, + ) + body: Dict[str, Any] = { + "status": status, + "emailSubject": email_subject, + "templateId": template_id, + "templateRoles": template_roles, + } + url = f"{_api_base()}/envelopes" + try: + async with httpx.AsyncClient() as client: + response = await client.post( + url, + json=body, + headers=_headers(), + timeout=30.0, + ) + response.raise_for_status() + except httpx.TimeoutException as e: + logger.info("DocuSign envelopes.create timeout: %s", e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPStatusError as e: + logger.info("DocuSign envelopes.create HTTP status=%s: %s", e.response.status_code, e) + return json.dumps( + { + "ok": False, + "error_code": "HTTP_ERROR", + "provider": PROVIDER_NAME, + "status_code": e.response.status_code, + }, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("DocuSign envelopes.create HTTP error: %s", e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + payload = response.json() + except ValueError as e: + logger.info("DocuSign envelopes.create JSON decode: %s", e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + result = _normalize_envelope_create(payload) + return json.dumps({"ok": True, **result}, indent=2, ensure_ascii=False) + + async def _envelopes_get(self, call_args: Dict[str, Any]) -> str: + if not _check_credentials(): + return json.dumps( + {"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + envelope_id = str(call_args.get("envelope_id", "")).strip() + if not envelope_id: + return json.dumps( + {"ok": False, "error_code": "MISSING_ARGS", "message": "envelope_id required"}, + indent=2, + ensure_ascii=False, + ) + url = f"{_api_base()}/envelopes/{envelope_id}" + try: + async with httpx.AsyncClient() as client: + response = await client.get( + url, + headers=_headers(), + timeout=30.0, + ) + response.raise_for_status() + except httpx.TimeoutException as e: + logger.info("DocuSign envelopes.get timeout envelope_id=%s: %s", envelope_id, e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPStatusError as e: + logger.info("DocuSign envelopes.get HTTP status=%s: %s", e.response.status_code, e) + return json.dumps( + { + "ok": False, + "error_code": "HTTP_ERROR", + "provider": PROVIDER_NAME, + "status_code": e.response.status_code, + }, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("DocuSign envelopes.get HTTP error: %s", e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + payload = response.json() + except ValueError as e: + logger.info("DocuSign envelopes.get JSON decode: %s", e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + result = _normalize_envelope_get(payload) + return json.dumps({"ok": True, **result}, indent=2, ensure_ascii=False) + + async def _envelopes_list_status_changes(self, call_args: Dict[str, Any]) -> str: + if not _check_credentials(): + return json.dumps( + {"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + from_date = str(call_args.get("from_date", "")).strip() + if not from_date: + return json.dumps( + {"ok": False, "error_code": "MISSING_ARGS", "message": "from_date (ISO8601) required"}, + indent=2, + ensure_ascii=False, + ) + to_date = str(call_args.get("to_date", "")).strip() or None + status = str(call_args.get("status", "")).strip() or None + count_raw = call_args.get("count", 20) + try: + count = int(count_raw) if count_raw is not None else 20 + except (TypeError, ValueError): + count = 20 + if count < 1: + count = 20 + if count > 100: + count = 100 + params: Dict[str, Any] = { + "from_date": from_date, + "count": count, + } + if to_date: + params["to_date"] = to_date + if status: + params["status"] = status + url = f"{_api_base()}/envelopes" + try: + async with httpx.AsyncClient() as client: + response = await client.get( + url, + params=params, + headers=_headers(), + timeout=30.0, + ) + response.raise_for_status() + except httpx.TimeoutException as e: + logger.info("DocuSign envelopes.list_status_changes timeout: %s", e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPStatusError as e: + logger.info("DocuSign envelopes.list_status_changes HTTP status=%s: %s", e.response.status_code, e) + return json.dumps( + { + "ok": False, + "error_code": "HTTP_ERROR", + "provider": PROVIDER_NAME, + "status_code": e.response.status_code, + }, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("DocuSign envelopes.list_status_changes HTTP error: %s", e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + payload = response.json() + except ValueError as e: + logger.info("DocuSign envelopes.list_status_changes JSON decode: %s", e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + envelopes_raw = payload.get("envelopes") or [] + except (KeyError, ValueError) as e: + logger.info("DocuSign envelopes.list_status_changes response structure: %s", e) + return json.dumps( + {"ok": False, "error_code": "UNEXPECTED_RESPONSE", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + envelopes = [_normalize_envelope_list_item(e) for e in envelopes_raw if isinstance(e, dict)] + return json.dumps({"ok": True, "envelopes": envelopes}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_dovetail.py b/flexus_client_kit/integrations/fi_dovetail.py new file mode 100644 index 00000000..8664a7e6 --- /dev/null +++ b/flexus_client_kit/integrations/fi_dovetail.py @@ -0,0 +1,207 @@ +import json +import logging +import os +from typing import Any, Dict, List + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("dovetail") + +PROVIDER_NAME = "dovetail" +METHOD_IDS = [ + "dovetail.insights.export.markdown.v1", + "dovetail.projects.export.zip.v1", +] + +_BASE_URL = "https://dovetail.com/api/v1" + + +class IntegrationDovetail: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + key = os.environ.get("DOVETAIL_API_KEY", "") + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if key else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "dovetail.insights.export.markdown.v1": + return await self._insights_export_markdown(args) + if method_id == "dovetail.projects.export.zip.v1": + return await self._projects_export_zip(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _insights_export_markdown(self, args: Dict[str, Any]) -> str: + project_id = str(args.get("project_id", "")).strip() + if not project_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "detail": "project_id is required"}, indent=2, ensure_ascii=False) + tag_ids: List[str] = [str(t) for t in (args.get("tag_ids") or []) if t] + token = os.environ.get("DOVETAIL_API_KEY", "") + if not token: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "detail": "DOVETAIL_API_KEY env var not set"}, indent=2, ensure_ascii=False) + + # Dovetail API constraint: only one of project_id, tag_id, or highlight_id allowed per request. + # When tag_ids provided, filter by tag and apply project_id check client-side. + # When no tag_ids, filter by project_id directly. + params: Dict[str, Any] = {"page[limit]": 100} + filter_mode: str + if tag_ids: + filter_mode = "tag_id" + if len(tag_ids) == 1: + params["filter[tag_id]"] = tag_ids[0] + else: + params["filter[tag_id][]"] = tag_ids + else: + filter_mode = "project_id" + params["filter[project_id]"] = project_id + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + resp = await client.get( + f"{_BASE_URL}/highlights", + headers=self._headers(token), + params=params, + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + if resp.status_code != 200: + try: + detail = resp.json() + except json.JSONDecodeError: + detail = resp.text + return self._provider_error(resp.status_code, detail) + + try: + body = resp.json() + except json.JSONDecodeError as e: + return json.dumps({"ok": False, "error_code": "PARSE_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + highlights = body.get("data", []) + page_info = body.get("page", {}) + + md_lines = [f"# Dovetail Highlights Export\n\nproject_id: `{project_id}`\n"] + if tag_ids: + md_lines.append(f"tag_ids: {', '.join(f'`{t}`' for t in tag_ids)}\n") + md_lines.append(f"count: {len(highlights)} (total_workspace: {page_info.get('total_count', '?')})\n\n---\n") + + for h in highlights: + text = h.get("text") or "(no text)" + tags = ", ".join(t["title"] for t in h.get("tags", [])) + md_lines.append(f"## Highlight `{h['id']}`\n") + md_lines.append(f"**Created:** {h.get('created_at', '')} ") + if tags: + md_lines.append(f"**Tags:** {tags} ") + md_lines.append(f"\n{text}\n\n---\n") + + markdown = "\n".join(md_lines) + api_note = ( + "Dovetail public API v1 does not expose a list-insights-by-project endpoint. " + "This result contains highlights (tagged data points) filtered by " + + ("tag_id" if filter_mode == "tag_id" else "project_id") + + ". To export a specific insight document as markdown, use GET /v1/insights/{insight_id}/export/markdown." + ) + return self._ok("insights.export.markdown", { + "project_id": project_id, + "filter_mode": filter_mode, + "highlight_count": len(highlights), + "has_more": page_info.get("has_more", False), + "api_note": api_note, + "markdown": markdown, + }) + + async def _projects_export_zip(self, args: Dict[str, Any]) -> str: + project_id = str(args.get("project_id", "")).strip() + if not project_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "detail": "project_id is required"}, indent=2, ensure_ascii=False) + token = os.environ.get("DOVETAIL_API_KEY", "") + if not token: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "detail": "DOVETAIL_API_KEY env var not set"}, indent=2, ensure_ascii=False) + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + resp = await client.get( + f"{_BASE_URL}/projects/{project_id}", + headers=self._headers(token), + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + if resp.status_code != 200: + try: + detail = resp.json() + except json.JSONDecodeError: + detail = resp.text + return self._provider_error(resp.status_code, detail) + + try: + body = resp.json() + except json.JSONDecodeError as e: + return json.dumps({"ok": False, "error_code": "PARSE_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + project = body.get("data", {}) + api_note = ( + "Dovetail public API v1 does not expose a ZIP export endpoint. " + "ZIP export (JSON/JSONL data, insights, tags, field groups) is available via the Dovetail UI: " + "open the project → Settings → Export → Download as ZIP." + ) + return self._ok("projects.export.zip", { + "project_id": project_id, + "project_title": project.get("title"), + "project_created_at": project.get("created_at"), + "project_deleted": project.get("deleted"), + "api_note": api_note, + }) + + def _headers(self, token: str) -> Dict[str, str]: + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + def _ok(self, method_label: str, data: Any) -> str: + return ( + f"dovetail.{method_label} ok\n\n" + f"```json\n{json.dumps({'ok': True, 'result': data}, indent=2, ensure_ascii=False)}\n```" + ) + + def _provider_error(self, status_code: int, detail: Any) -> str: + logger.info("dovetail provider error status=%s detail=%s", status_code, detail) + return json.dumps({ + "ok": False, + "error_code": "PROVIDER_ERROR", + "status": status_code, + "detail": detail, + }, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_dynata.py b/flexus_client_kit/integrations/fi_dynata.py new file mode 100644 index 00000000..df5cd4aa --- /dev/null +++ b/flexus_client_kit/integrations/fi_dynata.py @@ -0,0 +1,205 @@ +import json +import logging +import os +from typing import Any, Dict, Optional + +import httpx + +from flexus_client_kit import ckit_cloudtool + + +logger = logging.getLogger("dynata") + +PROVIDER_NAME = "dynata" +METHOD_IDS = [ + "dynata.demand.projects.create.v1", + "dynata.demand.projects.get.v1", + "dynata.demand.quota_cells.launch.v1", + "dynata.rex.respondents.upsert.v1", +] + +_TIMEOUT = 30.0 + +# Dynata exposes more than one API family. +# Demand API values: +# - DYNATA_DEMAND_API_KEY +# - DYNATA_DEMAND_BASE_URL +# REX values: +# - DYNATA_REX_ACCESS_KEY +# - DYNATA_REX_SECRET_KEY +# - DYNATA_REX_BASE_URL +# The base URLs are provided by Dynata during onboarding, so this integration does not invent them. +DYNATA_SETUP_SCHEMA: list[dict[str, Any]] = [] + + +class IntegrationDynata: + def __init__(self, rcx=None) -> None: + self.rcx = rcx + + def _demand_api_key(self) -> str: + return str(os.environ.get("DYNATA_DEMAND_API_KEY", "")).strip() + + def _demand_base_url(self) -> str: + return str(os.environ.get("DYNATA_DEMAND_BASE_URL", "")).strip().rstrip("/") + + def _rex_access_key(self) -> str: + return str(os.environ.get("DYNATA_REX_ACCESS_KEY", "")).strip() + + def _rex_secret_key(self) -> str: + return str(os.environ.get("DYNATA_REX_SECRET_KEY", "")).strip() + + def _rex_base_url(self) -> str: + return str(os.environ.get("DYNATA_REX_BASE_URL", "")).strip().rstrip("/") + + def _status(self) -> str: + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": "ready" if (self._demand_api_key() and self._demand_base_url()) or (self._rex_access_key() and self._rex_secret_key() and self._rex_base_url()) else "partial_or_missing_credentials", + "method_count": len(METHOD_IDS), + "demand_ready": bool(self._demand_api_key() and self._demand_base_url()), + "rex_ready": bool(self._rex_access_key() and self._rex_secret_key() and self._rex_base_url()), + "required_env": [ + "DYNATA_DEMAND_API_KEY", + "DYNATA_DEMAND_BASE_URL", + "DYNATA_REX_ACCESS_KEY", + "DYNATA_REX_SECRET_KEY", + "DYNATA_REX_BASE_URL", + ], + "products": ["Demand API", "Respondent Exchange (REX)"], + "message": "Dynata issues different credentials for Demand and REX flows; configure whichever family you plan to use.", + }, + indent=2, + ensure_ascii=False, + ) + + def _help(self) -> str: + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}\n" + "notes:\n" + "- Dynata Demand and REX are separate API families with separate credentials.\n" + "- Base URLs come from Dynata onboarding, so Flexus stores them as runtime config.\n" + "- This file exposes only the documented high-value recruitment flows with stable naming.\n" + ) + + def _error(self, method_id: str, code: str, message: str, **extra: Any) -> str: + payload: Dict[str, Any] = { + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": code, + "message": message, + } + payload.update(extra) + return json.dumps(payload, indent=2, ensure_ascii=False) + + def _result(self, method_id: str, result: Any) -> str: + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": result}, indent=2, ensure_ascii=False) + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Optional[Dict[str, Any]], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return self._help() + if op == "status": + return self._status() + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return self._error(method_id, "METHOD_UNKNOWN", "Unknown Dynata method.") + return await self._dispatch(method_id, call_args) + + async def _demand_request(self, method_id: str, http_method: str, path: str, *, body: Optional[Dict[str, Any]] = None) -> str: + if not self._demand_api_key() or not self._demand_base_url(): + return self._error(method_id, "AUTH_MISSING", "Set DYNATA_DEMAND_API_KEY and DYNATA_DEMAND_BASE_URL in the runtime environment.") + headers = { + "Authorization": self._demand_api_key(), + "Content-Type": "application/json", + } + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + url = self._demand_base_url() + path + if http_method == "GET": + response = await client.get(url, headers=headers) + elif http_method == "POST": + response = await client.post(url, headers=headers, json=body) + else: + return self._error(method_id, "UNSUPPORTED_HTTP_METHOD", f"Unsupported HTTP method {http_method}.") + except httpx.TimeoutException: + return self._error(method_id, "TIMEOUT", "Dynata Demand request timed out.") + except httpx.HTTPError as e: + logger.error("dynata demand request failed", exc_info=e) + return self._error(method_id, "HTTP_ERROR", f"{type(e).__name__}: {e}") + if response.status_code >= 400: + return self._error(method_id, "PROVIDER_ERROR", "Dynata Demand returned an error.", http_status=response.status_code, detail=response.text[:1000]) + if not response.text.strip(): + return self._result(method_id, {}) + try: + return self._result(method_id, response.json()) + except json.JSONDecodeError: + return self._result(method_id, response.text) + + async def _rex_request(self, method_id: str, path: str, body: Dict[str, Any]) -> str: + if not self._rex_access_key() or not self._rex_secret_key() or not self._rex_base_url(): + return self._error(method_id, "AUTH_MISSING", "Set DYNATA_REX_ACCESS_KEY, DYNATA_REX_SECRET_KEY, and DYNATA_REX_BASE_URL in the runtime environment.") + headers = { + "access_key": self._rex_access_key(), + "secret_key": self._rex_secret_key(), + "Content-Type": "application/json", + } + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.put(self._rex_base_url() + path, headers=headers, json=body) + except httpx.TimeoutException: + return self._error(method_id, "TIMEOUT", "Dynata REX request timed out.") + except httpx.HTTPError as e: + logger.error("dynata rex request failed", exc_info=e) + return self._error(method_id, "HTTP_ERROR", f"{type(e).__name__}: {e}") + if response.status_code >= 400: + return self._error(method_id, "PROVIDER_ERROR", "Dynata REX returned an error.", http_status=response.status_code, detail=response.text[:1000]) + if not response.text.strip(): + return self._result(method_id, {}) + try: + return self._result(method_id, response.json()) + except json.JSONDecodeError: + return self._result(method_id, response.text) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "dynata.demand.projects.create.v1": + return await self._demand_request(method_id, "POST", "/projects", body=args) + if method_id == "dynata.demand.projects.get.v1": + project_id = str(args.get("project_id", "")).strip() + if not project_id: + return self._error(method_id, "INVALID_ARGS", "project_id is required.") + return await self._demand_request(method_id, "GET", f"/projects/{project_id}") + if method_id == "dynata.demand.quota_cells.launch.v1": + quota_cell_id = str(args.get("quota_cell_id", "")).strip() + if not quota_cell_id: + return self._error(method_id, "INVALID_ARGS", "quota_cell_id is required.") + body = {} + project_id = str(args.get("project_id", "")).strip() + if project_id: + body["project_id"] = project_id + return await self._demand_request(method_id, "POST", f"/quota-cells/{quota_cell_id}/launch", body=body) + if method_id == "dynata.rex.respondents.upsert.v1": + respondent_id = str(args.get("respondent_id", "")).strip() + country = str(args.get("country", "")).strip() + language = str(args.get("language", "")).strip() + if not respondent_id or not country or not language: + return self._error(method_id, "INVALID_ARGS", "respondent_id, country, and language are required.") + body = dict(args) + return await self._rex_request(method_id, "/put-respondent", body=body) + return self._error(method_id, "METHOD_UNIMPLEMENTED", "Method is declared but not implemented.") diff --git a/flexus_client_kit/integrations/fi_ebay.py b/flexus_client_kit/integrations/fi_ebay.py new file mode 100644 index 00000000..c26dbf2e --- /dev/null +++ b/flexus_client_kit/integrations/fi_ebay.py @@ -0,0 +1,202 @@ +import base64 +import json +import logging +import os +import time +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("ebay") + +PROVIDER_NAME = "ebay" +METHOD_IDS = [ + "ebay.browse.get_items.v1", + "ebay.browse.search.v1", + "ebay.marketplace_insights.item_sales_search.v1", +] + +_BASE_URL = "https://api.ebay.com" +_TOKEN_URL = "https://api.ebay.com/identity/v1/oauth2/token" +_SCOPE = "https://api.ebay.com/oauth/api_scope" + +_token_cache: list = ["", 0.0] # [access_token, expiry_timestamp] + + +def _auth_missing() -> str: + missing = [v for v in ("EBAY_APP_ID", "EBAY_CERT_ID") if not os.environ.get(v)] + if not missing: + return "" + return json.dumps({ + "ok": False, + "error_code": "AUTH_MISSING", + "message": f"Set env vars: {', '.join(missing)}", + }, indent=2, ensure_ascii=False) + + +async def _get_access_token() -> str: + if _token_cache[0] and time.time() < _token_cache[1] - 30: + return _token_cache[0] + app_id = os.environ.get("EBAY_APP_ID", "") + cert_id = os.environ.get("EBAY_CERT_ID", "") + credentials = base64.b64encode(f"{app_id}:{cert_id}".encode()).decode() + async with httpx.AsyncClient(timeout=15.0) as client: + r = await client.post( + _TOKEN_URL, + data={"grant_type": "client_credentials", "scope": _SCOPE}, + headers={ + "Authorization": f"Basic {credentials}", + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + r.raise_for_status() + resp = r.json() + _token_cache[0] = resp["access_token"] + _token_cache[1] = time.time() + resp.get("expires_in", 7200) + return _token_cache[0] + + +class IntegrationEbay: + # XXX: requires multiple credentials (EBAY_APP_ID + EBAY_CERT_ID). + # manual auth (single api_key field) does not cover this provider. + # currently reads from env vars as a fallback. + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + missing = [v for v in ("EBAY_APP_ID", "EBAY_CERT_ID") if not os.environ.get(v)] + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available", + "method_count": len(METHOD_IDS), + "auth": ("missing: " + ", ".join(missing)) if missing else "configured", + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if auth_err := _auth_missing(): + return auth_err + if method_id == "ebay.browse.search.v1": + return await self._browse_search(args) + if method_id == "ebay.browse.get_items.v1": + return await self._browse_get_item(args) + if method_id == "ebay.marketplace_insights.item_sales_search.v1": + return await self._marketplace_insights_sales(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _browse_search(self, args: Dict[str, Any]) -> str: + query = str(args.get("query", "")).strip() + limit = min(int(args.get("limit", 10)), 50) + try: + token = await _get_access_token() + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get( + _BASE_URL + "/buy/browse/v1/item_summary/search", + params={"q": query, "limit": limit, "sort": "bestMatch", "fieldgroups": "EXTENDED"}, + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + ) + if r.status_code >= 400: + logger.info("ebay HTTP %s: %s", r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + items = data.get("itemSummaries", []) + compact = [ + { + "itemId": it.get("itemId", ""), + "title": it.get("title", ""), + "price": (it.get("price") or {}).get("value", ""), + "currency": (it.get("price") or {}).get("currency", ""), + "condition": it.get("condition", ""), + "buyingOptions": it.get("buyingOptions", []), + } + for it in items + ] + result = {"ok": True, "query": query, "total": data.get("total", len(items)), "results": items if args.get("include_raw") else compact} + return f"Found {len(items)} item(s) from {PROVIDER_NAME}.\n\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _browse_get_item(self, args: Dict[str, Any]) -> str: + item_id = str(args.get("item_id", "")).strip() + try: + token = await _get_access_token() + if item_id: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get( + _BASE_URL + f"/buy/browse/v1/item/{item_id}", + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + ) + if r.status_code >= 400: + logger.info("ebay HTTP %s: %s", r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + return f"Found 1 result(s) from {PROVIDER_NAME}.\n\n```json\n{json.dumps({'ok': True, 'result': data}, indent=2, ensure_ascii=False)}\n```" + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.item_id or args.query required"}, indent=2, ensure_ascii=False) + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get( + _BASE_URL + "/buy/browse/v1/item_summary/search", + params={"q": query, "limit": 1, "sort": "bestMatch"}, + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + ) + if r.status_code >= 400: + logger.info("ebay HTTP %s: %s", r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + items = data.get("itemSummaries", []) + return f"Found {len(items)} result(s) from {PROVIDER_NAME}.\n\n```json\n{json.dumps({'ok': True, 'results': items}, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _marketplace_insights_sales(self, args: Dict[str, Any]) -> str: + query = str(args.get("query", "")).strip() + limit = min(int(args.get("limit", 10)), 50) + try: + token = await _get_access_token() + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get( + _BASE_URL + "/buy/marketplace-insights/v1_beta/item_sales/search", + params={"q": query, "limit": limit}, + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + ) + if r.status_code == 403: + return json.dumps({"ok": False, "error_code": "AUTH_REQUIRED", "provider": PROVIDER_NAME, "message": "Marketplace Insights API requires special scope approval from eBay."}, indent=2, ensure_ascii=False) + if r.status_code >= 400: + logger.info("ebay HTTP %s: %s", r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + items = data.get("itemSales", data.get("itemSummaries", [])) + result = {"ok": True, "query": query, "total": data.get("total", len(items)), "results": items if args.get("include_raw") else items[:limit]} + return f"Found {len(items)} sold item(s) from {PROVIDER_NAME}.\n\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_event_registry.py b/flexus_client_kit/integrations/fi_event_registry.py new file mode 100644 index 00000000..3f12af18 --- /dev/null +++ b/flexus_client_kit/integrations/fi_event_registry.py @@ -0,0 +1,163 @@ +import json +import logging +import os +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional +import re + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("event_registry") + +PROVIDER_NAME = "event_registry" +METHOD_IDS = [ + "event_registry.article.get_articles.v1", + "event_registry.event.get_events.v1", +] + +_BASE_URL = "https://eventregistry.org/api/v1" + + +def _resolve_dates(time_window: str, start_date: str, end_date: str) -> tuple[Optional[str], Optional[str]]: + if start_date: + return start_date, end_date or None + if time_window: + m = re.match(r"last_(\d+)d", time_window) + if m: + days = int(m.group(1)) + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + return start.strftime("%Y-%m-%d"), now.strftime("%Y-%m-%d") + return None, None + + +class IntegrationEventRegistry: + def __init__(self, rcx=None): + self.rcx = rcx + + def _get_api_key(self) -> str: + if self.rcx is not None: + return (self.rcx.external_auth.get("event_registry") or {}).get("api_key", "") + return "" + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + api_key = self._get_api_key() + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available" if api_key else "auth_missing", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "event_registry.article.get_articles.v1": + return await self._get_articles(args) + if method_id == "event_registry.event.get_events.v1": + return await self._get_events(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _get_articles(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set EVENT_REGISTRY_KEY env var."}, indent=2, ensure_ascii=False) + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "query is required"}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 20)), 100) + time_window = str(args.get("time_window", "")) + start_date = str(args.get("start_date", "")) + end_date = str(args.get("end_date", "")) + include_raw = bool(args.get("include_raw", False)) + + sd, ed = _resolve_dates(time_window, start_date, end_date) + body: Dict[str, Any] = { + "action": "getArticles", + "keyword": query, + "articlesCount": limit, + "articlesSortBy": "date", + "resultType": "articles", + "apiKey": api_key, + "lang": "eng", + } + if sd: + body["dateStart"] = sd + if ed: + body["dateEnd"] = ed + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.post(_BASE_URL + "/article/getArticles", json=body, headers={"Accept": "application/json"}) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + articles = data.get("articles", {}).get("results", []) + total = data.get("articles", {}).get("totalResults", len(articles)) + result: Dict[str, Any] = {"ok": True, "results": articles, "total": total} + if include_raw: + result["raw"] = data + summary = f"Found {len(articles)} article(s) from {PROVIDER_NAME}." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _get_events(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set EVENT_REGISTRY_KEY env var."}, indent=2, ensure_ascii=False) + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "query is required"}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 20)), 50) + include_raw = bool(args.get("include_raw", False)) + + body: Dict[str, Any] = { + "action": "getEvents", + "keyword": query, + "eventsCount": limit, + "eventsSortBy": "date", + "resultType": "events", + "apiKey": api_key, + } + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.post(_BASE_URL + "/event/getEvents", json=body, headers={"Accept": "application/json"}) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + events = data.get("events", {}).get("results", []) + total = data.get("events", {}).get("totalResults", len(events)) + result: Dict[str, Any] = {"ok": True, "results": events, "total": total} + if include_raw: + result["raw"] = data + summary = f"Found {len(events)} event(s) from {PROVIDER_NAME}." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_fireflies.py b/flexus_client_kit/integrations/fi_fireflies.py new file mode 100644 index 00000000..fd8b568e --- /dev/null +++ b/flexus_client_kit/integrations/fi_fireflies.py @@ -0,0 +1,127 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("fireflies") + +PROVIDER_NAME = "fireflies" +METHOD_IDS = [ + "fireflies.transcript.get.v1", +] + +_BASE_URL = "https://api.fireflies.ai/graphql" + +_TRANSCRIPT_QUERY = """ +query Transcript($transcriptId: String!) { + transcript(id: $transcriptId) { + id + title + date + duration + sentences { + index + speaker_name + text + start_time + end_time + } + } +} +""".strip() + + +class IntegrationFireflies: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + key = os.environ.get("FIREFLIES_API_KEY", "") + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if key else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "fireflies.transcript.get.v1": + return await self._transcript_get(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + + def _headers(self, key: str) -> Dict[str, str]: + return { + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + } + + def _ok(self, method_label: str, data: Any) -> str: + return ( + f"fireflies.{method_label} ok\n\n" + f"```json\n{json.dumps({'ok': True, 'result': data}, indent=2, ensure_ascii=False)}\n```" + ) + + def _provider_error(self, status_code: int, detail: str) -> str: + logger.info("fireflies provider error status=%s detail=%s", status_code, detail) + return json.dumps({ + "ok": False, + "error_code": "PROVIDER_ERROR", + "status": status_code, + "detail": detail, + }, indent=2, ensure_ascii=False) + + async def _transcript_get(self, args: Dict[str, Any]) -> str: + key = os.environ.get("FIREFLIES_API_KEY", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + transcript_id = str(args.get("transcript_id") or args.get("meeting_id") or "").strip() + if not transcript_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "detail": "transcript_id or meeting_id required"}, indent=2, ensure_ascii=False) + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.post( + _BASE_URL, + headers=self._headers(key), + json={"query": _TRANSCRIPT_QUERY, "variables": {"transcriptId": transcript_id}}, + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code != 200: + return self._provider_error(resp.status_code, resp.text[:500]) + try: + data = resp.json() + except json.JSONDecodeError: + return self._provider_error(resp.status_code, "invalid json in response") + if "errors" in data: + errors = data["errors"] + logger.info("fireflies graphql errors transcript_id=%s errors=%s", transcript_id, errors) + return json.dumps({"ok": False, "error_code": "GRAPHQL_ERROR", "errors": errors}, indent=2, ensure_ascii=False) + transcript = (data.get("data") or {}).get("transcript") + return self._ok("transcript.get.v1", transcript) diff --git a/flexus_client_kit/integrations/fi_g2.py b/flexus_client_kit/integrations/fi_g2.py new file mode 100644 index 00000000..a5ab0672 --- /dev/null +++ b/flexus_client_kit/integrations/fi_g2.py @@ -0,0 +1,190 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("g2") + +PROVIDER_NAME = "g2" +METHOD_IDS = [ + "g2.vendors.list.v1", + "g2.reviews.list.v1", + "g2.categories.benchmark.v1", +] + +_BASE_URL = "https://data.g2.com/api/v1" + + +class IntegrationG2: + def __init__(self, rcx=None): + self.rcx = rcx + + def _get_api_key(self) -> str: + if self.rcx is not None: + return (self.rcx.external_auth.get("g2") or {}).get("api_key", "") + return "" + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + api_key = self._get_api_key() + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available" if api_key else "auth_missing", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "g2.vendors.list.v1": + return await self._vendors_list(args) + if method_id == "g2.reviews.list.v1": + return await self._reviews_list(args) + if method_id == "g2.categories.benchmark.v1": + return await self._categories_benchmark(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + def _g2_headers(self, api_key: str) -> Dict[str, str]: + return { + "Authorization": f"Token token={api_key}", + "Content-Type": "application/vnd.api+json", + "Accept": "application/vnd.api+json", + } + + async def _vendors_list(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set G2_API_KEY env var. Generate token at https://my.g2.com/developers or https://www.g2.com/static/integrations"}, indent=2, ensure_ascii=False) + query = str(args.get("query", "")).strip() + limit = min(int(args.get("limit", 10)), 100) + cursor = args.get("cursor", None) + include_raw = bool(args.get("include_raw", False)) + + params: Dict[str, Any] = { + "page[size]": limit, + "page[number]": int(cursor) if cursor is not None else 1, + } + if query: + params["filter[name]"] = query + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(_BASE_URL + "/products", params=params, headers=self._g2_headers(api_key)) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + items = data.get("data", []) + meta = data.get("meta", {}) + total = meta.get("record_count", len(items)) + result: Dict[str, Any] = {"ok": True, "results": items, "total": total} + links = data.get("links", {}) + if links.get("next"): + result["next_cursor"] = (int(cursor) if cursor else 1) + 1 + if include_raw: + result["raw"] = data + summary = f"Found {len(items)} product(s) from {PROVIDER_NAME} (total={total})." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _reviews_list(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set G2_API_KEY env var. Generate token at https://my.g2.com/developers or https://www.g2.com/static/integrations"}, indent=2, ensure_ascii=False) + query = str(args.get("query", "")).strip() + limit = min(int(args.get("limit", 10)), 100) + cursor = args.get("cursor", None) + include_raw = bool(args.get("include_raw", False)) + + params: Dict[str, Any] = { + "page[size]": limit, + "page[number]": int(cursor) if cursor is not None else 1, + } + if query: + params["filter[product_name]"] = query + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(_BASE_URL + "/survey-responses", params=params, headers=self._g2_headers(api_key)) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + items = data.get("data", []) + meta = data.get("meta", {}) + total = meta.get("record_count", len(items)) + result: Dict[str, Any] = {"ok": True, "results": items, "total": total} + links = data.get("links", {}) + if links.get("next"): + result["next_cursor"] = (int(cursor) if cursor else 1) + 1 + if include_raw: + result["raw"] = data + summary = f"Found {len(items)} review(s) from {PROVIDER_NAME} (total={total})." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _categories_benchmark(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set G2_API_KEY env var. Generate token at https://my.g2.com/developers or https://www.g2.com/static/integrations"}, indent=2, ensure_ascii=False) + query = str(args.get("query", "")).strip() + limit = min(int(args.get("limit", 10)), 100) + cursor = args.get("cursor", None) + include_raw = bool(args.get("include_raw", False)) + + params: Dict[str, Any] = { + "page[size]": limit, + "page[number]": int(cursor) if cursor is not None else 1, + } + if query: + params["filter[name]"] = query + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(_BASE_URL + "/categories", params=params, headers=self._g2_headers(api_key)) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + items = data.get("data", []) + meta = data.get("meta", {}) + total = meta.get("record_count", len(items)) + result: Dict[str, Any] = {"ok": True, "results": items, "total": total} + links = data.get("links", {}) + if links.get("next"): + result["next_cursor"] = (int(cursor) if cursor else 1) + 1 + if include_raw: + result["raw"] = data + summary = f"Found {len(items)} categorie(s) from {PROVIDER_NAME} (total={total})." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_ga4.py b/flexus_client_kit/integrations/fi_ga4.py new file mode 100644 index 00000000..4772f086 --- /dev/null +++ b/flexus_client_kit/integrations/fi_ga4.py @@ -0,0 +1,146 @@ +import json +import logging +import os +import time +from typing import Any, Dict + +import httpx +import jwt + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("ga4") + +PROVIDER_NAME = "ga4" +METHOD_IDS = [ + "ga4.properties.run_report.v1", +] + +API_BASE = "https://analyticsdata.googleapis.com/v1beta" + + +class IntegrationGa4: + async def called_by_model(self, toolcall, model_produced_args): + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nmethods: {', '.join(METHOD_IDS)}" + if op == "status": + key = os.environ.get("GOOGLE_ANALYTICS_SERVICE_ACCOUNT_JSON", "") + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available" if key else "no_credentials", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _get_access_token(self): + sa_json = os.environ.get("GOOGLE_ANALYTICS_SERVICE_ACCOUNT_JSON", "") + if not sa_json: + raise ValueError("GOOGLE_ANALYTICS_SERVICE_ACCOUNT_JSON is not set") + sa = json.loads(sa_json) + client_email = sa["client_email"] + private_key = sa["private_key"] + token_uri = sa["token_uri"] + now = int(time.time()) + payload = { + "iss": client_email, + "sub": client_email, + "aud": token_uri, + "iat": now, + "exp": now + 3600, + "scope": "https://www.googleapis.com/auth/analytics.readonly", + } + token = jwt.encode(payload, private_key, algorithm="RS256") + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post(token_uri, data={ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": token, + }) + resp.raise_for_status() + return resp.json()["access_token"] + + async def _dispatch(self, method_id, call_args): + if method_id == "ga4.properties.run_report.v1": + return await self._run_report(call_args) + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _run_report(self, call_args): + property_id = call_args.get("property_id") or os.environ.get("GA4_PROPERTY_ID", "") + if not property_id: + return json.dumps({"ok": False, "error_code": "MISSING_PROPERTY_ID", "error": "property_id required (or set GA4_PROPERTY_ID)"}, indent=2, ensure_ascii=False) + + date_ranges = call_args.get("date_ranges") + if not date_ranges: + return json.dumps({"ok": False, "error_code": "MISSING_DATE_RANGES", "error": "date_ranges required"}, indent=2, ensure_ascii=False) + + dimensions = call_args.get("dimensions") or [] + metrics = call_args.get("metrics") or [] + dimension_filter = call_args.get("dimension_filter") + limit = int(call_args.get("limit") or 100) + + body: Dict[str, Any] = { + "dateRanges": date_ranges, + "dimensions": [{"name": d} for d in dimensions], + "metrics": [{"name": m} for m in metrics], + "limit": limit, + } + if dimension_filter: + body["dimensionFilter"] = dimension_filter + + try: + access_token = await self._get_access_token() + except json.JSONDecodeError as e: + return json.dumps({"ok": False, "error_code": "SA_JSON_INVALID", "error": str(e)}, indent=2, ensure_ascii=False) + except KeyError as e: + return json.dumps({"ok": False, "error_code": "SA_JSON_MISSING_FIELD", "error": str(e)}, indent=2, ensure_ascii=False) + except ValueError as e: + return json.dumps({"ok": False, "error_code": "CONFIG_ERROR", "error": str(e)}, indent=2, ensure_ascii=False) + except jwt.PyJWTError as e: + return json.dumps({"ok": False, "error_code": "JWT_ERROR", "error": str(e)}, indent=2, ensure_ascii=False) + except httpx.HTTPStatusError as e: + logger.info("ga4 token exchange failed: %s %s", e.response.status_code, e.response.text) + return json.dumps({"ok": False, "error_code": "TOKEN_EXCHANGE_FAILED", "status": e.response.status_code, "error": e.response.text}, indent=2, ensure_ascii=False) + except httpx.TimeoutException as e: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "error": str(e)}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "error": str(e)}, indent=2, ensure_ascii=False) + + url = f"{API_BASE}/{property_id}:runReport" + headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"} + + try: + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post(url, json=body, headers=headers) + resp.raise_for_status() + data = resp.json() + except httpx.HTTPStatusError as e: + logger.info("ga4 run_report failed: %s %s", e.response.status_code, e.response.text) + return json.dumps({"ok": False, "error_code": "API_ERROR", "status": e.response.status_code, "error": e.response.text}, indent=2, ensure_ascii=False) + except httpx.TimeoutException as e: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "error": str(e)}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "error": str(e)}, indent=2, ensure_ascii=False) + + dim_headers = [h["name"] for h in data.get("dimensionHeaders", [])] + met_headers = [h["name"] for h in data.get("metricHeaders", [])] + rows = [] + for row in data.get("rows", []): + dim_values = [v["value"] for v in row.get("dimensionValues", [])] + met_values = [v["value"] for v in row.get("metricValues", [])] + rows.append({ + "dimensions": dict(zip(dim_headers, dim_values)), + "metrics": dict(zip(met_headers, met_values)), + }) + + return json.dumps({ + "ok": True, + "row_count": data.get("rowCount", len(rows)), + "rows": rows, + }, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_gdelt.py b/flexus_client_kit/integrations/fi_gdelt.py new file mode 100644 index 00000000..12319ada --- /dev/null +++ b/flexus_client_kit/integrations/fi_gdelt.py @@ -0,0 +1,148 @@ +import json +import logging +import os +import re +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("gdelt") + +PROVIDER_NAME = "gdelt" +METHOD_IDS = [ + "gdelt.doc.search.v1", + "gdelt.events.search.v1", +] + +_DOC_URL = "https://api.gdeltproject.org/api/v2/doc/doc" +_EVENTS_URL = "https://api.gdeltproject.org/api/v2/events/search" + + +def _resolve_dates( + time_window: str, + start_date: str, + end_date: str, +) -> tuple[Optional[str], Optional[str]]: + if start_date: + sd = start_date.replace("-", "") + "000000" + ed = (end_date.replace("-", "") + "235959") if end_date else None + return sd, ed + if time_window: + m = re.match(r"last_(\d+)d", time_window) + if m: + days = int(m.group(1)) + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + return start.strftime("%Y%m%d%H%M%S"), now.strftime("%Y%m%d%H%M%S") + return None, None + + +class IntegrationGdelt: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "gdelt.doc.search.v1": + return await self._doc_search(args) + if method_id == "gdelt.events.search.v1": + return await self._events_search(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _doc_search(self, args: Dict[str, Any]) -> str: + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "query is required"}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 25)), 250) + time_window = str(args.get("time_window", "")) + start_date = str(args.get("start_date", "")) + end_date = str(args.get("end_date", "")) + include_raw = bool(args.get("include_raw", False)) + + params: Dict[str, Any] = { + "query": query + " sourcelang:english", + "mode": "artlist", + "format": "json", + "maxrecords": limit, + "sort": "DateDesc", + } + start_dt, end_dt = _resolve_dates(time_window, start_date, end_date) + if start_dt: + params["startdatetime"] = start_dt + if end_dt: + params["enddatetime"] = end_dt + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(_DOC_URL, params=params, headers={"Accept": "application/json"}) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + articles = data.get("articles", []) + result: Dict[str, Any] = {"ok": True, "results": articles, "total": len(articles)} + if include_raw: + result["raw"] = data + summary = f"Found {len(articles)} article(s) from {PROVIDER_NAME}." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _events_search(self, args: Dict[str, Any]) -> str: + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "query is required"}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 25)), 250) + include_raw = bool(args.get("include_raw", False)) + + params: Dict[str, Any] = { + "query": query, + "format": "json", + "maxrecords": limit, + } + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(_EVENTS_URL, params=params, headers={"Accept": "application/json"}) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + events = data.get("events", data.get("results", [])) + result: Dict[str, Any] = {"ok": True, "results": events, "total": len(events)} + if include_raw: + result["raw"] = data + summary = f"Found {len(events)} event(s) from {PROVIDER_NAME}." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_gdrive.py b/flexus_client_kit/integrations/fi_gdrive.py new file mode 100644 index 00000000..8218e57a --- /dev/null +++ b/flexus_client_kit/integrations/fi_gdrive.py @@ -0,0 +1,172 @@ +import asyncio +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("gdrive") + +PROVIDER_NAME = "gdrive" +API_BASE = "https://www.googleapis.com/drive/v3" +METHOD_IDS = [ + "gdrive.files.export.v1", +] + +_SCOPE = "https://www.googleapis.com/auth/drive.readonly" + + +def _get_token() -> str: + sa_json = os.environ.get("GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON", "") + if not sa_json: + return "" + from google.oauth2 import service_account + import google.auth.transport.requests + info = json.loads(sa_json) + creds = service_account.Credentials.from_service_account_info( + info, scopes=[_SCOPE] + ) + request = google.auth.transport.requests.Request() + creds.refresh(request) + return creds.token + + +class IntegrationGdrive: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help\n" + "op=status\n" + "op=list_methods\n" + "op=call(args={method_id: ...})\n" + f"known_method_ids={len(METHOD_IDS)}" + ) + if op == "status": + sa_json = os.environ.get("GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON", "") + status = "available" if sa_json else "no_credentials" + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": status, + "method_count": len(METHOD_IDS), + }, + indent=2, + ensure_ascii=False, + ) + if op == "list_methods": + return json.dumps( + {"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, + indent=2, + ensure_ascii=False, + ) + if op != "call": + return "Error: unknown op." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required." + if method_id not in METHOD_IDS: + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, call_args: Dict[str, Any]) -> str: + if method_id == "gdrive.files.export.v1": + return await self._files_export(call_args) + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + + async def _files_export(self, call_args: Dict[str, Any]) -> str: + file_id = str(call_args.get("file_id", "")).strip() + if not file_id: + return "Error: file_id required." + mime_type = str(call_args.get("mime_type", "text/plain")).strip() or "text/plain" + sa_json = os.environ.get("GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON", "") + if not sa_json: + return json.dumps( + {"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + token = await asyncio.to_thread(_get_token) + if not token: + return json.dumps( + {"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + url = f"{API_BASE}/files/{file_id}/export" + params = {"mimeType": mime_type} + headers = {"Authorization": f"Bearer {token}"} + try: + async with httpx.AsyncClient() as client: + response = await client.get( + url, params=params, headers=headers, timeout=60.0 + ) + response.raise_for_status() + except httpx.TimeoutException as e: + logger.info("GDrive export timeout file_id=%s: %s", file_id, e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "file_id": file_id}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPStatusError as e: + logger.info( + "GDrive export HTTP error file_id=%s status=%s: %s", + file_id, + e.response.status_code, + e, + ) + return json.dumps( + { + "ok": False, + "error_code": "HTTP_ERROR", + "file_id": file_id, + "status_code": e.response.status_code, + }, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("GDrive export HTTP error file_id=%s: %s", file_id, e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "file_id": file_id}, + indent=2, + ensure_ascii=False, + ) + content_bytes = response.content + content_length = len(content_bytes) + try: + content_str = content_bytes.decode("utf-8") + content_preview = content_str[:2000] + except UnicodeDecodeError: + content_preview = "[binary content]" + return json.dumps( + { + "ok": True, + "file_id": file_id, + "mime_type": mime_type, + "content_length": content_length, + "content_preview": content_preview, + }, + indent=2, + ensure_ascii=False, + ) diff --git a/flexus_client_kit/integrations/fi_github.py b/flexus_client_kit/integrations/fi_github.py deleted file mode 100644 index 051fbae6..00000000 --- a/flexus_client_kit/integrations/fi_github.py +++ /dev/null @@ -1,79 +0,0 @@ -import os -import asyncio -import logging -from typing import Dict, List - -from flexus_client_kit import ckit_bot_exec, ckit_cloudtool, ckit_client - - -logger = logging.getLogger("fi_github") - -TIMEOUT_S = 15.0 - - -GITHUB_TOOL = ckit_cloudtool.CloudTool( - strict=False, - name="github", - description=( - "Interact with GitHub via the gh CLI. Provide full list of args as a JSON array , e.g ['issue', 'create', '--title', 'My title']" - ), - parameters={ - "type": "object", - "properties": { - "args": { - "type": "array", - "items": {"type": "string"}, - "description": "gh cli args list, e.g. ['issue', 'view', '5']" - }, - }, - "required": ["args"] - }, -) - - -class IntegrationGitHub: - def __init__(self, fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext, allowed_write_commands: List[List[str]] = []): - self.fclient = fclient - self.rcx = rcx - self.allowed_write_commands = allowed_write_commands - - def is_read_only_command(self, args: List[str]) -> bool: - if not args or args[0] in {"search", "status", "help", "--help", "-h", "version", "--version"}: - return True - READ_VERBS = {"view", "list", "status", "search", "browse", "show", "diff", "item-list", "field-list", "files"} - return len(args) >= 2 and args[1] in READ_VERBS - - def _is_allowed_write_command(self, args: List[str]) -> bool: - return any(len(args) >= len(a) and args[:len(a)] == a for a in self.allowed_write_commands) - - async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, List[str]]) -> str: - if not (args := model_produced_args.get("args")): - return "Error: no args param found!" - if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args): - return "Error: args must be a list of str!" - - github_auth = self.rcx.external_auth.get("github") or {} - token = (github_auth.get("token") or {}).get("access_token", "") - - if (not self.is_read_only_command(args) and not self._is_allowed_write_command(args) and not toolcall.confirmed_by_human): - raise ckit_cloudtool.NeedsConfirmation( - confirm_setup_key="github_write", - confirm_command=f"gh {' '.join(args)}", - confirm_explanation=f"This command will modify GitHub: gh {' '.join(args)}", - ) - - env = os.environ.copy() - env["GITHUB_TOKEN"] = token - proc = await asyncio.create_subprocess_exec( - *["gh"] + args, - stdin=asyncio.subprocess.DEVNULL, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=env, - ) - try: - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=TIMEOUT_S) - except asyncio.TimeoutError: - proc.kill() - return "Timeout after %d seconds" % TIMEOUT_S - return stdout.decode() or "NO OUTPUT" if proc.returncode == 0 else f"Error: {stderr.decode() or stdout.decode()}" diff --git a/flexus_client_kit/integrations/fi_glassdoor.py b/flexus_client_kit/integrations/fi_glassdoor.py new file mode 100644 index 00000000..54a11638 --- /dev/null +++ b/flexus_client_kit/integrations/fi_glassdoor.py @@ -0,0 +1,34 @@ +import json +from typing import Any, Dict + +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = 'glassdoor' +METHOD_IDS = [ + "glassdoor.provider.search.v1", +] + + +class IntegrationGlassdoor: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help\n" + "op=status\n" + "op=list_methods\n" + "op=call(args={method_id: ...})\n" + f"known_method_ids={len(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + method_id = str((args.get("args") or {}).get("method_id", "")).strip() + return json.dumps({"ok": False, "error_code": "INTEGRATION_UNAVAILABLE", "provider": PROVIDER_NAME, "method_id": method_id, "message": "We don't have this integration, but we do have a frog and it can catch insects =)"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_gnews.py b/flexus_client_kit/integrations/fi_gnews.py new file mode 100644 index 00000000..f55aa5d4 --- /dev/null +++ b/flexus_client_kit/integrations/fi_gnews.py @@ -0,0 +1,165 @@ +import json +import logging +import os +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional +import re + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("gnews") + +PROVIDER_NAME = "gnews" +METHOD_IDS = [ + "gnews.search.v1", + "gnews.top_headlines.v1", +] + +_BASE_URL = "https://gnews.io/api/v4" + + +def _resolve_dates(time_window: str, start_date: str, end_date: str) -> tuple[Optional[str], Optional[str]]: + if start_date: + return start_date, end_date or None + if time_window: + m = re.match(r"last_(\d+)d", time_window) + if m: + days = int(m.group(1)) + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + return start.strftime("%Y-%m-%dT%H:%M:%SZ"), now.strftime("%Y-%m-%dT%H:%M:%SZ") + return None, None + + +class IntegrationGnews: + def __init__(self, rcx=None): + self.rcx = rcx + + def _get_api_key(self) -> str: + if self.rcx is not None: + return (self.rcx.external_auth.get("gnews") or {}).get("api_key", "") + return "" + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + api_key = self._get_api_key() + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available" if api_key else "auth_missing", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "gnews.search.v1": + return await self._search(args) + if method_id == "gnews.top_headlines.v1": + return await self._top_headlines(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _search(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set GNEWS_KEY env var."}, indent=2, ensure_ascii=False) + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "query is required"}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 10)), 10) + geo = args.get("geo") or {} + time_window = str(args.get("time_window", "")) + start_date = str(args.get("start_date", "")) + end_date = str(args.get("end_date", "")) + include_raw = bool(args.get("include_raw", False)) + + params: Dict[str, Any] = { + "q": query, + "token": api_key, + "lang": "en", + "max": limit, + } + country = geo.get("country", "") if isinstance(geo, dict) else "" + if country: + params["country"] = country.lower() + sd, ed = _resolve_dates(time_window, start_date, end_date) + if sd: + params["from"] = sd + if ed: + params["to"] = ed + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(_BASE_URL + "/search", params=params, headers={"Accept": "application/json"}) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + articles = data.get("articles", []) + total = data.get("totalArticles", len(articles)) + result: Dict[str, Any] = {"ok": True, "results": articles, "total": total} + if include_raw: + result["raw"] = data + summary = f"Found {len(articles)} article(s) from {PROVIDER_NAME} (total={total})." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _top_headlines(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set GNEWS_KEY env var."}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 10)), 10) + geo = args.get("geo") or {} + query = str(args.get("query", "")).strip() + include_raw = bool(args.get("include_raw", False)) + + params: Dict[str, Any] = { + "token": api_key, + "lang": "en", + "max": limit, + } + if query: + params["q"] = query + country = geo.get("country", "") if isinstance(geo, dict) else "" + if country: + params["country"] = country.lower() + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(_BASE_URL + "/top-headlines", params=params, headers={"Accept": "application/json"}) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + articles = data.get("articles", []) + total = data.get("totalArticles", len(articles)) + result: Dict[str, Any] = {"ok": True, "results": articles, "total": total} + if include_raw: + result["raw"] = data + summary = f"Found {len(articles)} headline(s) from {PROVIDER_NAME} (total={total})." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_gong.py b/flexus_client_kit/integrations/fi_gong.py new file mode 100644 index 00000000..dd8107ad --- /dev/null +++ b/flexus_client_kit/integrations/fi_gong.py @@ -0,0 +1,129 @@ +import base64 +import json +import logging +import os +from typing import Any, Dict, List + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("gong") + +PROVIDER_NAME = "gong" +METHOD_IDS = [ + "gong.calls.list.v1", + "gong.calls.transcript.get.v1", +] + +_BASE_URL = "https://api.gong.io/v2" + + +class IntegrationGong: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + access_key = os.environ.get("GONG_ACCESS_KEY", "") + access_key_secret = os.environ.get("GONG_ACCESS_KEY_SECRET", "") + has_creds = bool(access_key and access_key_secret) + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if has_creds else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + def _auth_header(self) -> str: + access_key = os.environ.get("GONG_ACCESS_KEY", "") + access_key_secret = os.environ.get("GONG_ACCESS_KEY_SECRET", "") + credentials = base64.b64encode(f"{access_key}:{access_key_secret}".encode()).decode() + return f"Basic {credentials}" + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "gong.calls.list.v1": + return await self._calls_list(args) + if method_id == "gong.calls.transcript.get.v1": + return await self._calls_transcript_get(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _calls_list(self, args: Dict[str, Any]) -> str: + access_key = os.environ.get("GONG_ACCESS_KEY", "") + access_key_secret = os.environ.get("GONG_ACCESS_KEY_SECRET", "") + if not (access_key and access_key_secret): + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "GONG_ACCESS_KEY and GONG_ACCESS_KEY_SECRET env vars required"}, indent=2, ensure_ascii=False) + from_date_time = args.get("from_date_time") + to_date_time = args.get("to_date_time") + cursor = args.get("cursor") + try: + params: Dict[str, Any] = {} + if from_date_time: + params["fromDateTime"] = from_date_time + if to_date_time: + params["toDateTime"] = to_date_time + if cursor: + params["cursor"] = cursor + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_BASE_URL}/calls", + headers={"Authorization": self._auth_header()}, + params=params, + ) + if resp.status_code != 200: + logger.info("gong calls.list error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + return f"gong.calls.list ok\n\n```json\n{json.dumps({'ok': True, 'calls': data.get('calls', []), 'records': data.get('records', {})}, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _calls_transcript_get(self, args: Dict[str, Any]) -> str: + access_key = os.environ.get("GONG_ACCESS_KEY", "") + access_key_secret = os.environ.get("GONG_ACCESS_KEY_SECRET", "") + if not (access_key and access_key_secret): + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "GONG_ACCESS_KEY and GONG_ACCESS_KEY_SECRET env vars required"}, indent=2, ensure_ascii=False) + call_ids = args.get("call_ids") + if not call_ids or not isinstance(call_ids, list): + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "call_ids (list of str) is required"}, indent=2, ensure_ascii=False) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{_BASE_URL}/calls/transcript", + headers={ + "Authorization": self._auth_header(), + "Content-Type": "application/json", + }, + json={"filter": {"callIds": call_ids}}, + ) + if resp.status_code != 200: + logger.info("gong calls.transcript.get error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + return f"gong.calls.transcript.get ok\n\n```json\n{json.dumps({'ok': True, 'callTranscripts': data.get('callTranscripts', [])}, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_google_ads.py b/flexus_client_kit/integrations/fi_google_ads.py new file mode 100644 index 00000000..48c1f5e6 --- /dev/null +++ b/flexus_client_kit/integrations/fi_google_ads.py @@ -0,0 +1,54 @@ +import json +from typing import Any, Dict + +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = "google_ads" +METHOD_IDS = [ + "google_ads.ad_group_ad.create.v1", + "google_ads.asset.create.v1", + "google_ads.campaigns.mutate.v1", + "google_ads.googleads.search_stream.v1", + "google_ads.keyword_planner.generate_forecast_metrics.v1", + "google_ads.keyword_planner.generate_historical_metrics.v1", + "google_ads.keyword_planner.generate_keyword_ideas.v1", + "google_ads.keywordplan.generate_historical_metrics.v1", + "google_ads.search_stream.query.v1", +] + + +class IntegrationGoogleAds: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + return json.dumps({ + "ok": False, + "error_code": "AUTH_REQUIRED", + "provider": PROVIDER_NAME, + "message": "Connect your Google Ads account via the integrations panel. Google Ads API requires OAuth2 user authorization and a developer token.", + }, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_google_calendar.py b/flexus_client_kit/integrations/fi_google_calendar.py index e270e26d..0d21a0c2 100644 --- a/flexus_client_kit/integrations/fi_google_calendar.py +++ b/flexus_client_kit/integrations/fi_google_calendar.py @@ -1,167 +1,65 @@ import json import logging -import time -from typing import Dict, Any, Optional - -import google.oauth2.credentials -import googleapiclient.discovery -import langchain_google_community.calendar.toolkit +from typing import Any, Dict from flexus_client_kit import ckit_cloudtool -from flexus_client_kit import ckit_client -from flexus_client_kit.integrations import langchain_adapter logger = logging.getLogger("google_calendar") -REQUIRED_SCOPES = ["https://www.googleapis.com/auth/calendar"] - - -GOOGLE_CALENDAR_TOOL = ckit_cloudtool.CloudTool( - strict=False, - name="google_calendar", - description="Access Google Calendar to create, search, update, move, and delete events. Call with op=\"help\" to see all available ops.", - parameters={ - "type": "object", - "properties": { - "op": {"type": "string", "description": "Operation name: help, status, or any calendar op"}, - "args": {"type": "object"}, - }, - "required": ["op"] +PROVIDER_NAME = "google_calendar" +METHOD_IDS = [ + "google_calendar.events.insert.v1", + "google_calendar.events.list.v1", +] + +_AUTH_REQUIRED = json.dumps( + { + "ok": False, + "error_code": "AUTH_REQUIRED", + "provider": PROVIDER_NAME, + "message": ( + "Google Calendar requires per-user OAuth authentication. " + "Connect your Google account via the Flexus integrations settings first." + ), }, + indent=2, + ensure_ascii=False, ) class IntegrationGoogleCalendar: - def __init__( - self, - fclient: ckit_client.FlexusClient, - rcx, - ): - self.fclient = fclient - self.rcx = rcx - self._last_access_token = None - self.tools = [] - self.tool_map = {} - - async def _ensure_tools_initialized(self) -> bool: - google_auth = self.rcx.external_auth.get("google") or {} - token_obj = google_auth.get("token") or {} - access_token = token_obj.get("access_token", "") - if not access_token: - self.tools = [] - self.tool_map = {} - self._last_access_token = None - return False - if access_token == self._last_access_token and self.tools: - return True - creds = google.oauth2.credentials.Credentials(token=access_token) - service = googleapiclient.discovery.build("calendar", "v3", credentials=creds) - - toolkit = langchain_google_community.calendar.toolkit.CalendarToolkit(api_resource=service) - self.tools = toolkit.get_tools() - self.tool_map = {t.name: t for t in self.tools} - self._last_access_token = access_token - - logger.info("Initialized %d calendar tools: %s", len(self.tools), list(self.tool_map.keys())) - return True - async def called_by_model( self, toolcall: ckit_cloudtool.FCloudtoolCall, - model_produced_args: Optional[Dict[str, Any]] + model_produced_args: Dict[str, Any], ) -> str: - if not model_produced_args: - return self._all_commands_help() - - op = model_produced_args.get("op", "") - if not op: - return self._all_commands_help() - - args, args_error = ckit_cloudtool.sanitize_args(model_produced_args) - if args_error: - return args_error - - op_lower = op.lower() - print_help = op_lower == "help" or op_lower == "status+help" - print_status = op_lower == "status" or op_lower == "status+help" - - authenticated = await self._ensure_tools_initialized() - - if print_status: - status_msg = await self._status(authenticated) - if print_help and authenticated: - return status_msg + "\n\n" + self._all_commands_help() - return status_msg - - if print_help: - if authenticated: - return self._all_commands_help() - return await self._status(authenticated) - - if not authenticated: - return await self._status(authenticated) - - if op not in self.tool_map: - return self._all_commands_help() - - if op == "search_events" and "calendars_info" in args: - try: - if isinstance(args["calendars_info"], str): - cal_str = args["calendars_info"].strip() - if cal_str and cal_str[0] in '[{': - calendars_info = json.loads(cal_str) - else: - calendars_info = [cal_str] if cal_str else [] - else: - calendars_info = args["calendars_info"] - if calendars_info and (isinstance(calendars_info[0], str) or not calendars_info[0].get("timeZone")): - get_cal_result, is_auth_error = await langchain_adapter.run_langchain_tool(self.tool_map["get_calendars_info"], {}) - if is_auth_error: - result, is_auth_error = get_cal_result, is_auth_error - else: - all_calendars = json.loads(get_cal_result) - requested_ids = set(calendars_info if isinstance(calendars_info[0], str) else [cal["id"] for cal in calendars_info]) - matched_calendars = [cal for cal in all_calendars if cal["id"] in requested_ids or "primary" in requested_ids] - args["calendars_info"] = json.dumps(matched_calendars) - logger.info("Fetched full calendar info for %d calendars", len(matched_calendars)) - result, is_auth_error = await langchain_adapter.run_langchain_tool(self.tool_map[op], args) - else: - result, is_auth_error = await langchain_adapter.run_langchain_tool(self.tool_map[op], args) - except Exception as e: - logger.warning("Error preprocessing search_events args: %s", e) - result, is_auth_error = await langchain_adapter.run_langchain_tool(self.tool_map[op], args) - else: - result, is_auth_error = await langchain_adapter.run_langchain_tool(self.tool_map[op], args) - - if is_auth_error: - self._last_access_token = None - return "❌ Authentication error. Please reconnect Google in workspace settings.\n\nThen retry." - return result - - def _all_commands_help(self) -> str: - if not self.tools: - return "❌ No tools loaded" - - return ( - "Google Calendar - All Available Operations:\n" + - langchain_adapter.format_tools_help(self.tools) + - "To execute an operation:\n" + - ' google_calendar({"op": "get_calendars_info"})\n' + - ' google_calendar({"op": "create_calendar_event", "args": {...}})' - ) - - async def _status(self, authenticated: bool) -> str: - r = f"Google Calendar integration status:\n" - r += f" Authenticated: {'✅ Yes' if authenticated else '❌ No'}\n" - r += f" User: {self.rcx.persona.owner_fuser_id}\n" - r += f" Workspace: {self.rcx.persona.ws_id}\n" - - if authenticated and await self._ensure_tools_initialized(): - r += f" Tools loaded: {len(self.tools)}\n" - r += f" Available ops: {', '.join(self.tool_map.keys())}\n" - elif not authenticated: - r += "\n❌ Not authenticated. Please connect Google in workspace settings.\n" - - return r - - + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}\n" + "note: Requires Google OAuth per-user authentication." + ) + if op == "status": + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": "auth_required", + "method_count": len(METHOD_IDS), + "message": "Connect your Google account to use Google Calendar.", + }, + indent=2, + ensure_ascii=False, + ) + if op == "list_methods": + return json.dumps( + {"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, + indent=2, + ensure_ascii=False, + ) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + return _AUTH_REQUIRED diff --git a/flexus_client_kit/integrations/fi_google_play.py b/flexus_client_kit/integrations/fi_google_play.py new file mode 100644 index 00000000..3f69905b --- /dev/null +++ b/flexus_client_kit/integrations/fi_google_play.py @@ -0,0 +1,162 @@ +import datetime +import json +import logging +import os +import time +from typing import Any, Dict, List + +import httpx +import jwt + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("google_play") + +PROVIDER_NAME = "google_play" +METHOD_IDS = [ + "google_play.reviews.list.v1", +] + +_BASE_URL = "https://androidpublisher.googleapis.com/androidpublisher/v3" +_TOKEN_URL = "https://oauth2.googleapis.com/token" +_SCOPE = "https://www.googleapis.com/auth/androidpublisher" +_TIMEOUT = 30.0 + + +class IntegrationGooglePlay: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + if method_id == "google_play.reviews.list.v1": + return await self._reviews_list(call_args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _get_access_token(self, client: httpx.AsyncClient) -> str: + sa_json_str = os.environ.get("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON", "") + if not sa_json_str: + raise ValueError( + "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON env var not set. " + "It should contain the full JSON content of a Google service account key file " + "(download from GCP Console > IAM > Service Accounts > Keys). " + "The service account must have Google Play Developer API access with androidpublisher scope." + ) + sa = json.loads(sa_json_str) + client_email = sa["client_email"] + private_key = sa["private_key"].replace("\\n", "\n") + token_uri = sa.get("token_uri", _TOKEN_URL) + now = int(time.time()) + payload = { + "iss": client_email, + "scope": _SCOPE, + "aud": token_uri, + "iat": now, + "exp": now + 3600, + } + signed = jwt.encode(payload, private_key, algorithm="RS256") + resp = await client.post( + token_uri, + data={ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": signed, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=_TIMEOUT, + ) + resp.raise_for_status() + return resp.json()["access_token"] + + async def _reviews_list(self, args: Dict[str, Any]) -> str: + package_name = str(args.get("package_name", "")).strip() + if not package_name: + package_name = os.environ.get("GOOGLE_PLAY_PACKAGE_NAME", "").strip() + if not package_name: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "args.package_name required (or set GOOGLE_PLAY_PACKAGE_NAME env var)."}, indent=2, ensure_ascii=False) + max_results = min(int(args.get("max_results", 50)), 100) + start_index = int(args.get("start_index", 0)) + translation_language = str(args.get("translation_language", "")).strip() + params: Dict[str, Any] = {"maxResults": max_results, "startIndex": start_index} + if translation_language: + params["translationLanguage"] = translation_language + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + access_token = await self._get_access_token(client) + headers = {"Authorization": f"Bearer {access_token}"} + url = f"{_BASE_URL}/applications/{package_name}/reviews" + r = await client.get(url, params=params, headers=headers) + if r.status_code == 401: + logger.info("%s auth error for package %s: %s", PROVIDER_NAME, package_name, r.text[:200]) + return json.dumps({"ok": False, "error_code": "AUTH_ERROR", "message": "Google Play authentication failed. Check GOOGLE_PLAY_SERVICE_ACCOUNT_JSON and service account permissions."}, indent=2, ensure_ascii=False) + if r.status_code == 403: + logger.info("%s permission denied for package %s: %s", PROVIDER_NAME, package_name, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PERMISSION_DENIED", "message": f"Service account does not have permission to access reviews for {package_name}."}, indent=2, ensure_ascii=False) + if r.status_code == 404: + return json.dumps({"ok": False, "error_code": "NOT_FOUND", "message": f"App {package_name} not found in Google Play."}, indent=2, ensure_ascii=False) + if r.status_code >= 400: + logger.info("%s HTTP %s for package %s: %s", PROVIDER_NAME, r.status_code, package_name, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + raw_reviews = data.get("reviews", []) + page_info = data.get("pageInfo", {}) + total = page_info.get("totalResults", len(raw_reviews)) + reviews: List[Dict[str, Any]] = [] + for rev in raw_reviews: + user_comment: Dict[str, Any] = {} + for c in rev.get("comments", []): + if "userComment" in c: + user_comment = c["userComment"] + break + seconds = int(user_comment.get("lastModified", {}).get("seconds", 0) or 0) + date_str = ( + datetime.datetime.fromtimestamp(seconds, tz=datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + if seconds else "" + ) + reviews.append({ + "review_id": rev.get("reviewId"), + "author": rev.get("authorName"), + "text": user_comment.get("text"), + "rating": user_comment.get("starRating"), + "date": date_str, + }) + out: Dict[str, Any] = { + "ok": True, + "package_name": package_name, + "total": total, + "count": len(reviews), + "reviews": reviews, + } + summary = f"Retrieved {len(reviews)} reviews for {package_name} (total reported: {total})." + return summary + "\n\n```json\n" + json.dumps(out, indent=2, ensure_ascii=False) + "\n```" + except ValueError as e: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": str(e)}, indent=2, ensure_ascii=False) + except json.JSONDecodeError as e: + return json.dumps({"ok": False, "error_code": "CONFIG_ERROR", "message": f"Invalid GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: {e}"}, indent=2, ensure_ascii=False) + except KeyError as e: + return json.dumps({"ok": False, "error_code": "CONFIG_ERROR", "message": f"Missing field in service account JSON: {e}"}, indent=2, ensure_ascii=False) + except jwt.PyJWTError as e: + return json.dumps({"ok": False, "error_code": "AUTH_ERROR", "message": f"JWT signing failed: {e}"}, indent=2, ensure_ascii=False) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_google_search_console.py b/flexus_client_kit/integrations/fi_google_search_console.py new file mode 100644 index 00000000..7a8898d8 --- /dev/null +++ b/flexus_client_kit/integrations/fi_google_search_console.py @@ -0,0 +1,46 @@ +import json +from typing import Any, Dict + +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = "google_search_console" +METHOD_IDS = [ + "google_search_console.searchanalytics.query.v1", +] + + +class IntegrationGoogleSearchConsole: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + return json.dumps({ + "ok": False, + "error_code": "AUTH_REQUIRED", + "provider": PROVIDER_NAME, + "message": "Connect your Google account via the integrations panel. Google Search Console uses OAuth2 and requires user authorization.", + }, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_google_sheets.py b/flexus_client_kit/integrations/fi_google_sheets.py deleted file mode 100644 index 4c5c2550..00000000 --- a/flexus_client_kit/integrations/fi_google_sheets.py +++ /dev/null @@ -1,140 +0,0 @@ -from __future__ import annotations -import logging -import time -from typing import Dict, Any, Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from flexus_client_kit import ckit_bot_exec - -import google.oauth2.credentials -import googleapiclient.discovery -import langchain_google_community.sheets.toolkit - -from flexus_client_kit import ckit_cloudtool -from flexus_client_kit import ckit_client -from flexus_client_kit.integrations import langchain_adapter - -logger = logging.getLogger("google_sheets") - -REQUIRED_SCOPES = ["https://www.googleapis.com/auth/spreadsheets"] - - -GOOGLE_SHEETS_TOOL = ckit_cloudtool.CloudTool( - strict=False, - name="google_sheets", - description="Access Google Sheets to read, write, create, update, append, clear, and batch update data. Call with op=\"help\" to see all available ops.", - parameters={ - "type": "object", - "properties": { - "op": {"type": "string", "description": "Operation name: help, status, or any sheets op"}, - "args": {"type": "object"}, - }, - "required": ["op"] - }, -) - - -class IntegrationGoogleSheets: - def __init__( - self, - fclient: ckit_client.FlexusClient, - rcx: ckit_bot_exec.RobotContext, - ): - self.fclient = fclient - self.rcx = rcx - self._last_access_token = None - self.tools = [] - self.tool_map = {} - - async def _ensure_tools_initialized(self) -> bool: - google_auth = self.rcx.external_auth.get("google") or {} - token_obj = google_auth.get("token") or {} - access_token = token_obj.get("access_token", "") - if not access_token: - self.tools = [] - self.tool_map = {} - self._last_access_token = None - return False - if access_token == self._last_access_token and self.tools: - return True - creds = google.oauth2.credentials.Credentials(token=access_token) - service = googleapiclient.discovery.build("sheets", "v4", credentials=creds) - - toolkit = langchain_google_community.sheets.toolkit.SheetsToolkit(api_resource=service) - self.tools = toolkit.get_tools() - self.tool_map = {t.name: t for t in self.tools} - self._last_access_token = access_token - - logger.info("Initialized %d sheets tools: %s", len(self.tools), list(self.tool_map.keys())) - return True - - async def called_by_model( - self, - toolcall: ckit_cloudtool.FCloudtoolCall, - model_produced_args: Optional[Dict[str, Any]] - ) -> str: - if not model_produced_args: - return self._all_commands_help() - - op = model_produced_args.get("op", "") - if not op: - return self._all_commands_help() - - args, args_error = ckit_cloudtool.sanitize_args(model_produced_args) - if args_error: - return args_error - - op_lower = op.lower() - print_help = op_lower == "help" or op_lower == "status+help" - print_status = op_lower == "status" or op_lower == "status+help" - - authenticated = await self._ensure_tools_initialized() - - if print_status: - status_msg = await self._status(authenticated) - if print_help and authenticated: - return status_msg + "\n\n" + self._all_commands_help() - return status_msg - - if print_help: - if authenticated: - return self._all_commands_help() - return await self._status(authenticated) - - if not authenticated: - return await self._status(authenticated) - - if op not in self.tool_map: - return self._all_commands_help() - - result, is_auth_error = await langchain_adapter.run_langchain_tool(self.tool_map[op], args) - if is_auth_error: - self._last_access_token = None - return "❌ Authentication error. Please reconnect Google in workspace settings.\n\nThen retry." - return result - - def _all_commands_help(self) -> str: - if not self.tools: - return "❌ No tools loaded" - - return ( - "Google Sheets - All Available Operations:\n" + - langchain_adapter.format_tools_help(self.tools) + - "To execute an operation:\n" + - ' google_sheets({"op": "sheets_read_data", "args": {"spreadsheet_id": "...", "range": "Sheet1!A1:D10"}})\n' + - ' google_sheets({"op": "sheets_update_values", "args": {...}})' - ) - - async def _status(self, authenticated: bool) -> str: - r = f"Google Sheets integration status:\n" - r += f" Authenticated: {'✅ Yes' if authenticated else '❌ No'}\n" - r += f" User: {self.rcx.persona.owner_fuser_id}\n" - r += f" Workspace: {self.rcx.persona.ws_id}\n" - - if authenticated and await self._ensure_tools_initialized(): - r += f" Tools loaded: {len(self.tools)}\n" - r += f" Available ops: {', '.join(self.tool_map.keys())}\n" - elif not authenticated: - r += "\n❌ Not authenticated. Please connect Google in workspace settings.\n" - - return r diff --git a/flexus_client_kit/integrations/fi_google_shopping.py b/flexus_client_kit/integrations/fi_google_shopping.py new file mode 100644 index 00000000..0fca5665 --- /dev/null +++ b/flexus_client_kit/integrations/fi_google_shopping.py @@ -0,0 +1,54 @@ +import json +from typing import Any, Dict + +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = "google_shopping" +METHOD_IDS = [ + "google_shopping.reports.search_topic_trends.v1", +] + + +class IntegrationGoogleShopping: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}\n" + "note: requires Google Merchant Center OAuth connection" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "google_shopping.reports.search_topic_trends.v1": + return json.dumps({ + "ok": False, + "error_code": "AUTH_REQUIRED", + "provider": PROVIDER_NAME, + "method_id": method_id, + "message": ( + "Google Merchant Center Insights API requires OAuth with a Google account " + "that has Merchant Center access. Connect your account via the integrations panel. " + "This API provides search topic trend data — what consumers search for on Google Shopping." + ), + }, indent=2, ensure_ascii=False) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_grafana.py b/flexus_client_kit/integrations/fi_grafana.py new file mode 100644 index 00000000..d9f44a87 --- /dev/null +++ b/flexus_client_kit/integrations/fi_grafana.py @@ -0,0 +1,200 @@ +import json +import logging +import os +from typing import Any, Dict, List + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("grafana") + +PROVIDER_NAME = "grafana" +METHOD_IDS = [ + "grafana.alerts.list.v1", +] + + +class IntegrationGrafana: + def _check_credentials(self) -> str: + token = os.environ.get("GRAFANA_API_TOKEN", "") + base_url = os.environ.get("GRAFANA_BASE_URL", "") + if not token or not base_url: + return json.dumps( + { + "ok": False, + "error_code": "NO_CREDENTIALS", + "provider": PROVIDER_NAME, + "message": "GRAFANA_API_TOKEN and GRAFANA_BASE_URL env vars required", + }, + indent=2, + ensure_ascii=False, + ) + return "" + + def _base_url(self) -> str: + return os.environ.get("GRAFANA_BASE_URL", "").rstrip("/") + + def _headers(self) -> Dict[str, str]: + token = os.environ.get("GRAFANA_API_TOKEN", "") + return { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + } + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help\n" + "op=status\n" + "op=list_methods\n" + "op=call(args={method_id: ...})\n" + f"known_method_ids={len(METHOD_IDS)}" + ) + if op == "status": + err = self._check_credentials() + if err: + return err + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": "available", + "method_count": len(METHOD_IDS), + }, + indent=2, + ensure_ascii=False, + ) + if op == "list_methods": + return json.dumps( + {"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, + indent=2, + ensure_ascii=False, + ) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, call_args: Dict[str, Any]) -> str: + if method_id == "grafana.alerts.list.v1": + return await self._alerts_list(call_args) + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + + async def _alerts_list(self, call_args: Dict[str, Any]) -> str: + err = self._check_credentials() + if err: + return err + active = call_args.get("active", True) + if active is None: + active = True + silenced = call_args.get("silenced", False) + if silenced is None: + silenced = False + inhibited = call_args.get("inhibited", False) + if inhibited is None: + inhibited = False + filter_list = call_args.get("filter") + if filter_list is None: + filter_list = [] + elif isinstance(filter_list, str): + filter_list = [filter_list] if filter_list.strip() else [] + limit = call_args.get("limit", 50) + try: + limit = min(max(int(limit), 1), 500) + except (TypeError, ValueError): + limit = 50 + base = self._base_url() + url = f"{base}/api/alertmanager/grafana/api/v2/alerts" + params: Dict[str, Any] = { + "active": str(active).lower(), + "silenced": str(silenced).lower(), + "inhibited": str(inhibited).lower(), + } + for f in filter_list: + if isinstance(f, str) and f.strip(): + params.setdefault("filter", []).append(f) + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.get(url, headers=self._headers(), params=params) + if resp.status_code != 200: + logger.info("Grafana alerts.list error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps( + { + "ok": False, + "error_code": "PROVIDER_ERROR", + "status": resp.status_code, + "detail": resp.text[:500], + }, + indent=2, + ensure_ascii=False, + ) + raw = resp.json() + except httpx.TimeoutException as e: + logger.info("Grafana timeout alerts.list: %s", e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("Grafana HTTP error alerts.list: %s", e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, + indent=2, + ensure_ascii=False, + ) + if not isinstance(raw, list): + logger.info("Grafana alerts.list unexpected response type: %s", type(raw)) + return json.dumps( + {"ok": False, "error_code": "UNEXPECTED_RESPONSE", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + normalized: List[Dict[str, Any]] = [] + for item in raw[:limit]: + try: + labels = item.get("labels") or {} + annotations = item.get("annotations") or {} + status = item.get("status") or {} + name = labels.get("alertname", labels.get("__alert_rule_uid__", "")) + state = status.get("state", "") + summary = annotations.get("summary", annotations.get("description", "")) + starts_at = item.get("startsAt", "") + ends_at = item.get("endsAt", "") + normalized.append({ + "name": name, + "state": state, + "labels": labels, + "summary": summary, + "starts_at": starts_at, + "ends_at": ends_at, + }) + except (KeyError, ValueError) as e: + logger.info("Grafana alerts.list item parse error: %s", e) + continue + return json.dumps( + {"ok": True, "alerts": normalized, "count": len(normalized)}, + indent=2, + ensure_ascii=False, + ) diff --git a/flexus_client_kit/integrations/fi_hasdata.py b/flexus_client_kit/integrations/fi_hasdata.py new file mode 100644 index 00000000..3e45fcd3 --- /dev/null +++ b/flexus_client_kit/integrations/fi_hasdata.py @@ -0,0 +1,158 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("hasdata") + +PROVIDER_NAME = "hasdata" +METHOD_IDS = [ + "hasdata.indeed.jobs.v1", + "hasdata.glassdoor.jobs.v1", +] + +_BASE_URL = "https://api.hasdata.com" + + +class IntegrationHasdata: + def __init__(self, rcx=None): + self.rcx = rcx + + def _get_api_key(self) -> str: + if self.rcx is not None: + return (self.rcx.external_auth.get("hasdata") or {}).get("api_key", "") + return "" + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set HASDATA_API_KEY env var."}, indent=2, ensure_ascii=False) + + if method_id == "hasdata.indeed.jobs.v1": + return await self._indeed_jobs(args, api_key) + if method_id == "hasdata.glassdoor.jobs.v1": + return await self._glassdoor_jobs(args, api_key) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + def _headers(self, api_key: str) -> Dict[str, str]: + return { + "x-api-key": api_key, + "Content-Type": "application/json", + } + + async def _indeed_jobs(self, args: Dict[str, Any], api_key: str) -> str: + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "args.query required."}, indent=2, ensure_ascii=False) + + geo = args.get("geo") or {} + if isinstance(geo, str): + geo = {"country": geo} + location = str(geo.get("city", geo.get("country", "United States"))) + limit = int(args.get("limit", 20)) + + params: Dict[str, Any] = { + "keyword": query, + "location": location, + "num": limit, + } + try: + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(_BASE_URL + "/scrape/indeed/jobs", params=params, headers=self._headers(api_key)) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + results = data.get("jobs", data.get("results", data.get("data", []))) + include_raw = bool(args.get("include_raw")) + out: Dict[str, Any] = {"ok": True, "source": "indeed", "results": results if include_raw else [ + { + "title": j.get("title"), + "company": j.get("company"), + "location": j.get("location"), + "salary": j.get("salary"), + "date_posted": j.get("date") or j.get("datePosted"), + "url": j.get("url") or j.get("link"), + } + for j in (results if isinstance(results, list) else []) + ]} + summary = f"Found {len(results) if isinstance(results, list) else 1} Indeed job(s) from {PROVIDER_NAME}." + return summary + "\n\n```json\n" + json.dumps(out, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _glassdoor_jobs(self, args: Dict[str, Any], api_key: str) -> str: + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "args.query required."}, indent=2, ensure_ascii=False) + + geo = args.get("geo") or {} + if isinstance(geo, str): + geo = {"country": geo} + location = str(geo.get("city", geo.get("country", "United States"))) + limit = int(args.get("limit", 20)) + + params: Dict[str, Any] = { + "keyword": query, + "location": location, + "num": limit, + } + try: + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.get(_BASE_URL + "/scrape/glassdoor/jobs", params=params, headers=self._headers(api_key)) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + results = data.get("jobs", data.get("results", data.get("data", []))) + include_raw = bool(args.get("include_raw")) + out: Dict[str, Any] = {"ok": True, "source": "glassdoor", "results": results if include_raw else [ + { + "title": j.get("title"), + "company": j.get("company") or j.get("employerName"), + "location": j.get("location"), + "salary": j.get("salary") or j.get("salaryRange"), + "date_posted": j.get("date") or j.get("datePosted"), + "url": j.get("url") or j.get("link"), + } + for j in (results if isinstance(results, list) else []) + ]} + summary = f"Found {len(results) if isinstance(results, list) else 1} Glassdoor job(s) from {PROVIDER_NAME}." + return summary + "\n\n```json\n" + json.dumps(out, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_instagram.py b/flexus_client_kit/integrations/fi_instagram.py new file mode 100644 index 00000000..bb494f56 --- /dev/null +++ b/flexus_client_kit/integrations/fi_instagram.py @@ -0,0 +1,48 @@ +import json +from typing import Any, Dict + +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = "instagram" +METHOD_IDS = [ + "instagram.hashtag.recent_media.v1", + "instagram.hashtag.search.v1", + "instagram.hashtag.top_media.v1", +] + + +class IntegrationInstagram: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + return json.dumps({ + "ok": False, + "error_code": "AUTH_REQUIRED", + "provider": PROVIDER_NAME, + "message": "Connect your Instagram account via the integrations panel. Instagram Graph API requires OAuth2 via Facebook and a Business or Creator account.", + }, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_jira.py b/flexus_client_kit/integrations/fi_jira.py deleted file mode 100644 index 01be05b4..00000000 --- a/flexus_client_kit/integrations/fi_jira.py +++ /dev/null @@ -1,252 +0,0 @@ -from __future__ import annotations -import aiohttp -import json -import logging -import time -from typing import Dict, Any, Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from flexus_client_kit import ckit_bot_exec - -import langchain_community.agent_toolkits.jira.toolkit -import langchain_community.utilities.jira -from atlassian import Jira, Confluence - -from flexus_client_kit import ckit_cloudtool -from flexus_client_kit import ckit_client -from flexus_client_kit.integrations import langchain_adapter - -logger = logging.getLogger("jira") - -REQUIRED_SCOPES = [ - "read:jira-work", - "write:jira-work", - "read:project:jira", - "write:issue:jira", - "read:jql:jira", - "offline_access", -] - - -JIRA_TOOL = ckit_cloudtool.CloudTool( - strict=False, - name="jira", - description="Access Jira to search issues (JQL), get projects, create issues, and other Jira API operations. Call with op=\"help\" to see all available ops.", - parameters={ - "type": "object", - "properties": { - "op": {"type": "string", "description": "Operation name: help, status, or any jira op"}, - "args": {"type": "object"}, - }, - "required": ["op"] - }, -) - -JIRA_SETUP_SCHEMA = [ - { - "bs_name": "jira_instance_url", - "bs_type": "string_short", - "bs_default": "", - "bs_group": "Jira", - "bs_order": 1, - "bs_importance": 1, - "bs_description": "Jira instance URL (e.g., https://yourcompany.atlassian.net)", - }, -] - - -class IntegrationJira: - def __init__( - self, - fclient: ckit_client.FlexusClient, - rcx: ckit_bot_exec.RobotContext, - jira_instance_url: str, - ): - self.fclient = fclient - self.rcx = rcx - self.jira_instance_url = jira_instance_url - self._last_access_token = None - self.tools = [] - self.tool_map = {} - - async def _get_accessible_resources(self) -> list[dict]: - atlassian_auth = self.rcx.external_auth.get("atlassian") or {} - token_obj = atlassian_auth.get("token") or {} - access_token = token_obj.get("access_token", "") - if not access_token: - return [] - - try: - async with aiohttp.ClientSession() as session: - async with session.get( - "https://api.atlassian.com/oauth/token/accessible-resources", - headers={"Authorization": f"Bearer {access_token}"} - ) as resp: - if resp.status == 200: - return await resp.json() - else: - logger.warning("Failed to get accessible resources: %d", resp.status) - return [] - except Exception as e: - logger.warning("Error getting accessible resources: %s", e) - return [] - - async def _ensure_tools_initialized(self) -> bool: - atlassian_auth = self.rcx.external_auth.get("atlassian") or {} - token_obj = atlassian_auth.get("token") or {} - access_token = token_obj.get("access_token", "") - if not access_token: - self.tools = [] - self.tool_map = {} - return False - if access_token == self._last_access_token and self.tools: - return True - - accessible = await self._get_accessible_resources() - logger.info("Accessible Atlassian sites: %s", accessible) - - cloud_id = None - instance_url = self.jira_instance_url - if accessible: - cloud_id = accessible[0].get('id') - if not instance_url: - instance_url = accessible[0].get('url', '') - logger.info("Using cloud ID: %s for OAuth API calls", cloud_id) - - if cloud_id: - api_url = f"https://api.atlassian.com/ex/jira/{cloud_id}" - logger.info("Using OAuth API URL: %s", api_url) - else: - api_url = instance_url - logger.warning("No cloud ID found, using configured URL: %s", api_url) - - jira_client = Jira( - url=api_url, - token=access_token, - cloud=True, - ) - confluence_client = Confluence( - url=api_url, - token=access_token, - cloud=True, - ) - - wrapper = langchain_community.utilities.jira.JiraAPIWrapper( - jira_instance_url=instance_url, - jira_api_token="dummy", - jira_username="dummy", - jira_cloud=True, - ) - wrapper.jira = jira_client - wrapper.confluence = confluence_client - - toolkit = langchain_community.agent_toolkits.jira.toolkit.JiraToolkit.from_jira_api_wrapper(wrapper) - self.tools = toolkit.get_tools() - self.tool_map = {t.name: t for t in self.tools} - self._last_access_token = access_token - - logger.info("Initialized %d jira tools: %s", len(self.tools), list(self.tool_map.keys())) - return True - - async def called_by_model( - self, - toolcall: ckit_cloudtool.FCloudtoolCall, - model_produced_args: Optional[Dict[str, Any]] - ) -> str: - if not model_produced_args: - return self._all_commands_help() - - op = model_produced_args.get("op", "") - if not op: - return self._all_commands_help() - - args, args_error = ckit_cloudtool.sanitize_args(model_produced_args) - if args_error: - return args_error - - logger.info("Jira called: op=%s, args=%s", op, args) - op_lower = op.lower() - print_help = op_lower == "help" or op_lower == "status+help" - print_status = op_lower == "status" or op_lower == "status+help" - - authenticated = await self._ensure_tools_initialized() - - if print_status: - status_msg = await self._status(authenticated) - if print_help and authenticated: - return status_msg + "\n\n" + self._all_commands_help() - return status_msg - - if print_help: - if authenticated: - return self._all_commands_help() - return await self._status(authenticated) - - if not authenticated: - return await self._status(authenticated) - - if op not in self.tool_map: - return self._all_commands_help() - - if op == "get_projects": - tool_input = "" - elif op == "jql_query" and "query" in args: - tool_input = args["query"] - elif op == "create_issue" and isinstance(args, dict): - tool_input = json.dumps(args) - elif op == "catch_all_jira_api" and isinstance(args, dict): - tool_input = json.dumps(args) - elif op == "create_confluence_page" and isinstance(args, dict): - tool_input = json.dumps(args) - else: - tool_input = json.dumps(args) if isinstance(args, dict) else str(args) - - logger.info("Calling Jira tool: op=%s, tool_input=%s", op, tool_input) - - if op == "get_projects": - try: - raw_projects = self.tool_map[op].api_wrapper.jira.projects() - logger.info("Raw projects from Jira API: %s", raw_projects) - except Exception as e: - logger.warning("Error calling raw projects API: %s", e, exc_info=True) - - result, is_auth_error = await langchain_adapter.run_langchain_tool(self.tool_map[op], tool_input) - logger.info("Jira tool result: op=%s, result_length=%d, result_preview=%s", op, len(result), result[:500]) - if is_auth_error: - self._last_access_token = None - return "❌ Authentication error. Please reconnect Atlassian in workspace settings.\n\nThen retry." - return result - - def _all_commands_help(self) -> str: - if not self.tools: - return "❌ No tools loaded" - - return ( - "Jira - All Available Operations:\n" + - langchain_adapter.format_tools_help(self.tools) + - "To execute an operation:\n" + - ' jira({"op": "get_projects"})\n' + - ' jira({"op": "jql_query", "args": {"query": "project = TEST AND status = Open"}})\n' + - ' jira({"op": "create_issue", "args": {"summary": "Bug fix", "description": "Details", "issuetype": {"name": "Task"}, "priority": {"name": "High"}}})' - ) - - async def _status(self, authenticated: bool) -> str: - r = f"Jira integration status:\n" - r += f" Authenticated: {'✅ Yes' if authenticated else '❌ No'}\n" - r += f" User: {self.rcx.persona.owner_fuser_id}\n" - r += f" Workspace: {self.rcx.persona.ws_id}\n" - r += f" Configured Instance URL: {self.jira_instance_url}\n" - - if authenticated and await self._ensure_tools_initialized(): - accessible = await self._get_accessible_resources() - if accessible: - r += f"\n Accessible Atlassian sites ({len(accessible)}):\n" - for site in accessible: - r += f" - {site.get('name', 'Unknown')}: {site.get('url', 'N/A')}\n" - - r += f"\n Tools loaded: {len(self.tools)}\n" - r += f" Available ops: {', '.join(self.tool_map.keys())}\n" - elif not authenticated: - r += "\n❌ Not authenticated. Please connect Atlassian in workspace settings.\n" - - return r diff --git a/flexus_client_kit/integrations/fi_launchdarkly.py b/flexus_client_kit/integrations/fi_launchdarkly.py new file mode 100644 index 00000000..7270d1b2 --- /dev/null +++ b/flexus_client_kit/integrations/fi_launchdarkly.py @@ -0,0 +1,117 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +logger = logging.getLogger("launchdarkly") + +PROVIDER_NAME = "launchdarkly" +METHOD_IDS = [ + "launchdarkly.flags.get.v1", + "launchdarkly.flags.patch.v1", +] + +_BASE_URL = "https://app.launchdarkly.com/api/v2" + + +class IntegrationLaunchdarkly: + async def called_by_model(self, toolcall, model_produced_args): + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return (f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nmethods: {', '.join(METHOD_IDS)}") + if op == "status": + key = os.environ.get("LAUNCHDARKLY_API_KEY", "") + proj = os.environ.get("LAUNCHDARKLY_PROJECT_KEY", "") + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available" if (key and proj) else "no_credentials", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + def _headers(self): + key = os.environ.get("LAUNCHDARKLY_API_KEY", "") + return {"Authorization": key, "Content-Type": "application/json"} + + def _project_key(self): + return os.environ.get("LAUNCHDARKLY_PROJECT_KEY", "default") + + def _client(self): + return httpx.AsyncClient(base_url=_BASE_URL, headers=self._headers(), timeout=30) + + async def _dispatch(self, method_id, call_args): + if method_id == "launchdarkly.flags.get.v1": + return await self._flags_get(call_args) + if method_id == "launchdarkly.flags.patch.v1": + return await self._flags_patch(call_args) + + async def _flags_get(self, call_args): + flag_key = str(call_args.get("flag_key", "")).strip() + if not flag_key: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "flag_key"}, indent=2, ensure_ascii=False) + + env = str(call_args.get("env", "")).strip() + project_key = self._project_key() + + params: Dict[str, Any] = {} + if env: + params["env"] = env + + async with self._client() as client: + resp = await client.get(f"/flags/{project_key}/{flag_key}", params=params) + + if resp.status_code != 200: + logger.info("launchdarkly flags get failed: status=%d body=%s", resp.status_code, resp.text[:500]) + return json.dumps({"ok": False, "error_code": "API_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + + data = resp.json() + environments = data.get("environments") or {} + + on_value = None + if env and env in environments: + on_value = environments[env].get("on") + elif environments: + first_env = next(iter(environments.values())) + on_value = first_env.get("on") + + return json.dumps({ + "ok": True, + "key": data.get("key"), + "name": data.get("name"), + "kind": data.get("kind"), + "on": on_value, + "variations": data.get("variations"), + "environments": environments, + "tags": data.get("tags"), + "created_date": data.get("creationDate"), + }, indent=2, ensure_ascii=False) + + async def _flags_patch(self, call_args): + flag_key = str(call_args.get("flag_key", "")).strip() + if not flag_key: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "flag_key"}, indent=2, ensure_ascii=False) + + patch_operations = call_args.get("patch_operations") + if not patch_operations or not isinstance(patch_operations, list): + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "patch_operations"}, indent=2, ensure_ascii=False) + + project_key = self._project_key() + + async with self._client() as client: + resp = await client.patch(f"/flags/{project_key}/{flag_key}", json=patch_operations) + + if resp.status_code not in (200, 201): + logger.info("launchdarkly flags patch failed: status=%d body=%s", resp.status_code, resp.text[:500]) + return json.dumps({"ok": False, "error_code": "API_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + + data = resp.json() + return json.dumps({"ok": True, "key": data.get("key"), "name": data.get("name")}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_levelsfyi.py b/flexus_client_kit/integrations/fi_levelsfyi.py new file mode 100644 index 00000000..e9c0ead2 --- /dev/null +++ b/flexus_client_kit/integrations/fi_levelsfyi.py @@ -0,0 +1,56 @@ +import json +from typing import Any, Dict + +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = "levelsfyi" +METHOD_IDS = [ + "levelsfyi.compensation.benchmark.v1", +] + + +class IntegrationLevelsfyi: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}\n" + "note: Levels.fyi does not provide a public API. Compensation data is available only via their website." + ) + if op == "status": + return json.dumps({ + "ok": False, + "provider": PROVIDER_NAME, + "status": "unavailable", + "error_code": "NO_PUBLIC_API", + "message": "Levels.fyi does not offer a public API. Compensation benchmark data is accessible only through their website at https://www.levels.fyi. Consider using alternative sources such as Glassdoor, LinkedIn Salary, or Bureau of Labor Statistics for compensation data.", + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return json.dumps({ + "ok": False, + "error_code": "NO_PUBLIC_API", + "provider": PROVIDER_NAME, + "method_id": method_id, + "message": ( + "Levels.fyi does not provide a public API for compensation data. " + "Data is only available on their website at https://www.levels.fyi. " + "Alternatives: Glassdoor Salary API (via HasData scraping), LinkedIn Salary, " + "BLS Occupational Employment Statistics (public), or Payscale." + ), + }, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_linkedin.py b/flexus_client_kit/integrations/fi_linkedin.py index be57db08..eeba96cf 100644 --- a/flexus_client_kit/integrations/fi_linkedin.py +++ b/flexus_client_kit/integrations/fi_linkedin.py @@ -1,583 +1,332 @@ -import asyncio +import json import logging -import time -from dataclasses import dataclass -from typing import Dict, Any, Optional, List +from typing import Any, Dict, Optional import httpx -from flexus_client_kit import ckit_client from flexus_client_kit import ckit_cloudtool -from flexus_client_kit import ckit_bot_exec logger = logging.getLogger("linkedin") +PROVIDER_NAME = "linkedin" +METHOD_IDS = [ + "linkedin.auth.userinfo.get.v1", + "linkedin.posts.create_text.v1", + "linkedin.posts.create_article.v1", + "linkedin.assets.register_image_upload.v1", + "linkedin.assets.register_video_upload.v1", + "linkedin.posts.create_image.v1", + "linkedin.posts.create_video.v1", +] -AD_ACCOUNT_ID = "513489554" - -API_BASE = "https://api.linkedin.com" -API_VERSION = "202509" - +_BASE_URL = "https://api.linkedin.com" +_TIMEOUT = 30.0 LINKEDIN_TOOL = ckit_cloudtool.CloudTool( strict=False, name="linkedin", - description="Interact with LinkedIn Ads API, call with op=\"help\" to print usage, call with op=\"status+help\" to see both status and help in one call", + description="LinkedIn open permissions: OIDC userinfo and member posting.", parameters={ "type": "object", "properties": { - "op": {"type": "string", "description": "Start with 'help' for usage"}, + "op": {"type": "string", "description": "Use help, status, list_methods, or call."}, "args": {"type": "object"}, }, - "required": [] + "required": [], }, ) - -HELP = """ -Help: - -linkedin(op="status") - Shows current LinkedIn Ads account status, lists all campaign groups with their campaigns. - -linkedin(op="list_campaign_groups") - Lists all campaign groups for ad account. - -linkedin(op="list_campaigns", args={"campaign_group_id": "123456", "status": "ACTIVE"}) - Lists campaigns. Optional filters: campaign_group_id, status (ACTIVE, PAUSED, ARCHIVED, etc). - -linkedin(op="create_campaign_group", args={ - "name": "Q1 2024 Campaigns", - "total_budget": 1000.0, - "currency": "USD", - "status": "ACTIVE" -}) - Creates a new campaign group with specified budget. - -linkedin(op="create_campaign", args={ - "campaign_group_id": "123456", - "name": "Brand Awareness Campaign", - "objective": "BRAND_AWARENESS", - "daily_budget": 50.0, - "currency": "USD", - "status": "PAUSED" -}) - Creates a campaign in a campaign group. - Valid objectives: BRAND_AWARENESS, WEBSITE_VISITS, ENGAGEMENT, VIDEO_VIEWS, LEAD_GENERATION, WEBSITE_CONVERSIONS, JOB_APPLICANTS - -linkedin(op="get_campaign", args={"campaign_id": "123456"}) - Gets details for a specific campaign. - -linkedin(op="update_campaign", args={"campaign_id": "123456", "status": "ACTIVE", "daily_budget": 100.0}) - Updates campaign settings. Optional: status, daily_budget, name. - -linkedin(op="get_analytics", args={"campaign_id": "123456", "days": 30}) - Gets analytics for a campaign. Default: last 30 days. -""" - -LINKEDIN_SETUP_SCHEMA = [ - { - "bs_name": "ad_account_id", - "bs_type": "string_short", - "bs_default": "", - "bs_group": "LinkedIn", - "bs_importance": 1, - "bs_description": "LinkedIn Ads Account ID", - }, -] - - -@dataclass -class Budget: - amount: str - currency_code: str - - -@dataclass -class CampaignGroup: - id: str - name: str - status: str - total_budget: Optional[Budget] = None - - -@dataclass -class Campaign: - id: str - name: str - status: str - objective_type: str - daily_budget: Budget - campaign_group_id: Optional[str] = None - - -@dataclass -class Analytics: - impressions: int - clicks: int - cost: float +# Open-permissions LinkedIn does not need per-bot setup fields. +# Credentials are not pasted into this file or bot setup. +# They must already exist in the platform auth provider named "linkedin". +# Required provider-side values are: +# - client_id: LinkedIn app Client ID from developer.linkedin.com +# - client_secret: LinkedIn app Client Secret from developer.linkedin.com +# - redirect_uri: the Flexus OAuth callback URL configured in the platform auth layer +# - scopes: at minimum openid, profile, email, w_member_social +# After user OAuth completes, Flexus must store the connected account under +# rcx.external_auth["linkedin"], with token.access_token populated. +# Optional refresh flow data, if available in the auth layer, also belongs there. +LINKEDIN_SETUP_SCHEMA = [] class IntegrationLinkedIn: - def __init__( - self, - fclient: ckit_client.FlexusClient, - rcx: ckit_bot_exec.RobotContext, - ad_account_id: str, - ): - self.fclient = fclient + def __init__(self, rcx=None): self.rcx = rcx - self.access_token = "" - self.ad_account_id = ad_account_id or AD_ACCOUNT_ID - self.problems = [] - self._campaign_groups_cache = None - self._campaigns_cache = None - self._last_access_token = None - - async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Optional[Dict[str, Any]]) -> str: - if not model_produced_args: - return HELP - - linkedin_auth = self.rcx.external_auth.get("linkedin") or {} - token_obj = linkedin_auth.get("token") or {} - self.access_token = token_obj.get("access_token", "") - self._last_access_token = self.access_token - if not self.access_token: - return "❌ LinkedIn not connected. Please connect LinkedIn in workspace settings.\n\nTry linkedin(op='help') for usage." + def _auth(self) -> Dict[str, Any]: + # This integration only reads already-connected OAuth data. + # Where to paste values: + # - app-level keys such as client_id/client_secret belong in the platform auth provider "linkedin" + # - user-level access tokens belong in the connected external auth record for provider "linkedin" + # Expected token shape: + # { + # "token": { + # "access_token": "...", + # "refresh_token": "...", # optional, if your auth layer supports refresh + # } + # } + # Legacy fallback oauth_token is still accepted to keep the read path simple. + return (self.rcx.external_auth.get("linkedin") or {}) if self.rcx else {} + + def _access_token(self) -> str: + auth = self._auth() + token_obj = auth.get("token") or {} + return str(token_obj.get("access_token", "") or auth.get("oauth_token", "")).strip() + + def _status(self) -> str: + access_token = self._access_token() + return json.dumps({ + "ok": bool(access_token), + "provider": PROVIDER_NAME, + "status": "ready" if access_token else "missing_credentials", + "method_count": len(METHOD_IDS), + "auth_provider": "linkedin", + "products": [ + "Sign in with LinkedIn using OpenID Connect", + "Share on LinkedIn", + ], + "scopes_expected": ["openid", "profile", "email", "w_member_social"], + "has_access_token": bool(access_token), + }, indent=2, ensure_ascii=False) + + def _help(self) -> str: + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}\n" + "notes:\n" + "- auth.userinfo.get returns OIDC userinfo from /v2/userinfo\n" + "- register_*_upload returns uploadUrl + asset for a later binary upload step\n" + "- create_image/create_video require an already uploaded asset URN\n" + ) - self.headers = { - "Authorization": f"Bearer {self.access_token}", - "Content-Type": "application/json", + def _headers(self, *, has_body: bool) -> Dict[str, str]: + access_token = self._access_token() + headers = { + "Authorization": f"Bearer {access_token}", "X-Restli-Protocol-Version": "2.0.0", - "LinkedIn-Version": API_VERSION, } - - op = model_produced_args.get("op", "") - args, args_error = ckit_cloudtool.sanitize_args(model_produced_args) - if args_error: - return args_error - - print_help = False - print_status = False - r = "" - - if not op or "help" in op: - print_help = True - if not op or "status" in op: - print_status = True - - if print_status: - r += f"LinkedIn Ads Account: {self.ad_account_id}\n" - - await self._refresh_cache() - if self._campaign_groups_cache: - r += f"Campaign Groups ({len(self._campaign_groups_cache)}):\n" - for group in self._campaign_groups_cache: - r += f" 📁 {group.name} (ID: {group.id}, Status: {group.status})" - if group.total_budget: - r += f" - Budget: {group.total_budget.amount} {group.total_budget.currency_code}" - r += "\n" - - group_campaigns = [c for c in (self._campaigns_cache or []) if c.campaign_group_id == group.id] - if group_campaigns: - for campaign in group_campaigns: - r += f" 📊 {campaign.name} (ID: {campaign.id})" - r += f" Objective: {campaign.objective_type}, Daily Budget: {campaign.daily_budget.amount} {campaign.daily_budget.currency_code}\n" - else: - r += f" (no campaigns)\n" - else: - r += "No campaign groups found or failed to fetch.\n" - - if self.problems: - r += "\nProblems:\n" - for problem in self.problems: - r += f" {problem}\n" - r += "\n" - - if print_help: - r += HELP - - elif print_status: + if has_body: + headers["Content-Type"] = "application/json" + return headers + + def _auth_missing(self, method_id: str) -> str: + return json.dumps({ + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": "AUTH_MISSING", + "message": "Connect LinkedIn in workspace settings and ensure an access token is present.", + }, indent=2, ensure_ascii=False) + + def _invalid_args(self, method_id: str, message: str) -> str: + return json.dumps({ + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": "INVALID_ARGS", + "message": message, + }, indent=2, ensure_ascii=False) + + def _result(self, method_id: str, result: Any) -> str: + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "method_id": method_id, + "result": result, + }, indent=2, ensure_ascii=False) + + def _provider_error(self, method_id: str, status_code: int, body: str) -> str: + detail: Any = body[:500] + try: + detail = json.loads(body) + except json.JSONDecodeError: pass - - elif op == "list_campaign_groups": - result = await self._list_campaign_groups() - if result: - r += f"Found {len(result)} campaign groups:\n" - for group in result: - r += f" {group.name} (ID: {group.id}, Status: {group.status})" - if group.total_budget: - r += f" - Budget: {group.total_budget.amount} {group.total_budget.currency_code}" - else: - r += "No campaign groups found.\n" - - elif op == "list_campaigns": - campaign_group_id = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "campaign_group_id", None) - status_filter = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "status", None) - result = await self._list_campaigns(status_filter=status_filter) - if result: - campaigns = result - if campaign_group_id: - campaigns = [c for c in campaigns if c.campaign_group_id == campaign_group_id] - r += f"Found {len(campaigns)} campaigns:\n" - for campaign in campaigns: - r += f" {campaign.name} (ID: {campaign.id})" - r += f" Status: {campaign.status}, Objective: {campaign.objective_type}" - r += f" Daily Budget: {campaign.daily_budget.amount} {campaign.daily_budget.currency_code}\n" - else: - r += "No campaigns found.\n" - - elif op == "create_campaign_group": - name = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "name", "") - total_budget = float(ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "total_budget", "1000.0")) - currency = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "currency", "USD") - status = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "status", "ACTIVE") - - if not name: - return "ERROR: name parameter required for create_campaign_group\n" - - result = await self._create_campaign_group(name, total_budget, currency, status) - if result: - self._campaign_groups_cache = None - r += f"✅ Campaign group created: {result.name} (ID: {result.id})\n" - else: - r += "❌ Failed to create campaign group. Check logs for details.\n" - - elif op == "create_campaign": - campaign_group_id = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "campaign_group_id", "") - name = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "name", "") - objective = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "objective", "BRAND_AWARENESS") - daily_budget = float(ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "daily_budget", "10.0")) - currency = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "currency", "USD") - status = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "status", "PAUSED") - - if not campaign_group_id or not name: - return "ERROR: campaign_group_id and name parameters required for create_campaign\n" - - result = await self._create_campaign(campaign_group_id, name, objective, daily_budget, currency, status) - if result: - self._campaigns_cache = None - r += f"✅ Campaign created: {result.name} (ID: {result.id})\n" - r += f" Status: {result.status}, Objective: {result.objective_type}" - r += f" Daily Budget: {result.daily_budget.amount} {result.daily_budget.currency_code}\n" - else: - r += "❌ Failed to create campaign. Check logs for details.\n" - - elif op == "get_campaign": - campaign_id = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "campaign_id", "") - if not campaign_id: - return "ERROR: campaign_id parameter required for get_campaign\n" - - result = await self._get_campaign(campaign_id) - if result: - r += f"Campaign: {result.name} (ID: {result.id})\n" - r += f" Status: {result.status}\n" - r += f" Objective: {result.objective_type}\n" - r += f" Daily Budget: {result.daily_budget.amount} {result.daily_budget.currency_code}\n" - else: - r += f"❌ Failed to get campaign {campaign_id}\n" - - elif op == "get_analytics": - campaign_id = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "campaign_id", "") - days = int(ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "days", "30")) - - if not campaign_id: - return "ERROR: campaign_id parameter required for get_analytics\n" - - result = await self._get_campaign_analytics(campaign_id, days) - if result: - r += f"Analytics for campaign {campaign_id} (last {days} days):\n" - r += f" Impressions: {result.impressions:,}\n" - r += f" Clicks: {result.clicks:,}\n" - r += f" Cost: ${result.cost:.2f}\n" - if result.impressions > 0: - ctr = (result.clicks / result.impressions) * 100 - r += f" CTR: {ctr:.2f}%\n" - if result.clicks > 0: - cpc = result.cost / result.clicks - r += f" CPC: ${cpc:.2f}\n" - else: - r += f"❌ Failed to get analytics for campaign {campaign_id}\n" - - else: - r += f"Unknown operation {op!r}, try \"help\"\n\n" - - return r - - async def _refresh_cache(self): - """Refresh campaign groups and campaigns cache""" - self._campaign_groups_cache = await self._list_campaign_groups() - self._campaigns_cache = await self._list_campaigns() - - async def _list_campaign_groups(self) -> Optional[List[CampaignGroup]]: - url = f"{API_BASE}/rest/adAccounts/{self.ad_account_id}/adCampaignGroups?q=search" - logger.info(f"Listing campaign groups for account: {self.ad_account_id}") + logger.info("linkedin api error method=%s status=%s body=%s", method_id, status_code, body[:300]) + return json.dumps({ + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": "PROVIDER_ERROR", + "http_status": status_code, + "detail": detail, + }, indent=2, ensure_ascii=False) + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Optional[Dict[str, Any]], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return self._help() + if op == "status": + return self._status() + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + if not self._access_token(): + return self._auth_missing(method_id) + return await self._dispatch(method_id, call_args) + + async def _request( + self, + method_id: str, + http_method: str, + path: str, + *, + body: Optional[Dict[str, Any]] = None, + ) -> str: + url = _BASE_URL + path try: - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=self.headers, timeout=30.0) - if response.status_code == 200: - data = response.json() - elements = data.get("elements", []) - groups = [] - for elem in elements: - budget_data = elem.get("totalBudget") - groups.append(CampaignGroup( - id=elem["id"], - name=elem["name"], - status=elem["status"], - total_budget=Budget( - amount=budget_data["amount"], - currency_code=budget_data["currencyCode"], - ) if budget_data else None, - )) - return groups + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + if http_method == "GET": + response = await client.get(url, headers=self._headers(has_body=False)) + elif http_method == "POST": + response = await client.post(url, headers=self._headers(has_body=True), json=body) else: - logger.error(f"Failed to list campaign groups: {response.status_code} - {response.text}") - self.problems.append(f"Failed to list campaign groups: {response.status_code}") - return None - except Exception as e: - logger.exception("Exception listing campaign groups") - self.problems.append(f"Exception listing campaign groups: {e}") - return None - - async def _list_campaigns(self, status_filter: Optional[str] = None) -> Optional[List[Campaign]]: - params = {"q": "search"} - if status_filter: - params["search"] = f"(status:(values:List({status_filter})))" - url = f"{API_BASE}/rest/adAccounts/{self.ad_account_id}/adCampaigns" - logger.info(f"Listing campaigns for account: {self.ad_account_id}") + return json.dumps({"ok": False, "error_code": "UNSUPPORTED_HTTP_METHOD"}, indent=2, ensure_ascii=False) + except httpx.TimeoutException: + return json.dumps({"ok": False, "provider": PROVIDER_NAME, "method_id": method_id, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError) as e: + logger.error("linkedin request failed", exc_info=e) + return json.dumps({ + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": "HTTP_ERROR", + "message": f"{type(e).__name__}: {e}", + }, indent=2, ensure_ascii=False) + + if response.status_code >= 400: + return self._provider_error(method_id, response.status_code, response.text) + + if not response.text.strip(): + return self._result(method_id, {}) try: - async with httpx.AsyncClient() as client: - response = await client.get(url, params=params, headers=self.headers, timeout=30.0) - if response.status_code == 200: - data = response.json() - elements = data.get("elements", []) - campaigns = [] - for elem in elements: - budget_data = elem["dailyBudget"] - campaign_group_urn = elem.get("campaignGroup", "") - campaign_group_id = campaign_group_urn.split(":")[-1] if campaign_group_urn else None - campaigns.append(Campaign( - id=elem["id"], - name=elem["name"], - status=elem["status"], - objective_type=elem["objectiveType"], - daily_budget=Budget( - amount=budget_data["amount"], - currency_code=budget_data["currencyCode"], - ), - campaign_group_id=campaign_group_id, - )) - return campaigns - else: - logger.error(f"Failed to list campaigns: {response.status_code} - {response.text}") - self.problems.append(f"Failed to list campaigns: {response.status_code}") - return None - except Exception as e: - logger.exception("Exception listing campaigns") - self.problems.append(f"Exception listing campaigns: {e}") - return None - - async def _create_campaign_group( - self, - name: str, - total_budget_amount: float, - total_budget_currency: str, - status: str, - ) -> Optional[CampaignGroup]: - account_urn = f"urn:li:sponsoredAccount:{self.ad_account_id}" - start_time = int(time.time() * 1000) - end_time = start_time + (30 * 86400 * 1000) - payload = { - "account": account_urn, - "name": name, - "status": status, - "runSchedule": { - "start": start_time, - "end": end_time, - }, - "totalBudget": { - "amount": str(total_budget_amount), - "currencyCode": total_budget_currency, - }, + return self._result(method_id, response.json()) + except json.JSONDecodeError: + return self._result(method_id, response.text) + + def _build_share_payload(self, args: Dict[str, Any], media_category: str) -> Dict[str, Any]: + author = str(args.get("author", "")).strip() + text = str(args.get("text", "") or args.get("commentary", "")).strip() + visibility = str(args.get("visibility", "PUBLIC")).strip().upper() + if not author: + raise ValueError("author is required and must be a Person URN such as urn:li:person:123.") + if not text: + raise ValueError("text is required.") + + share_content: Dict[str, Any] = { + "shareCommentary": {"text": text}, + "shareMediaCategory": media_category, } - url = f"{API_BASE}/rest/adAccounts/{self.ad_account_id}/adCampaignGroups" - logger.info(f"Creating campaign group: {name}") - try: - async with httpx.AsyncClient() as client: - response = await client.post(url, json=payload, headers=self.headers, timeout=30.0) - if response.status_code == 201: - campaign_group_id = response.headers["x-restli-id"] - return CampaignGroup( - id=campaign_group_id, - name=name, - status=status, - total_budget=Budget( - amount=str(total_budget_amount), - currency_code=total_budget_currency, - ), - ) - else: - logger.error(f"Failed to create campaign group: {response.status_code} - {response.text}") - self.problems.append(f"Failed to create campaign group: {response.status_code}") - return None - except Exception as e: - logger.exception("Exception creating campaign group") - self.problems.append(f"Exception creating campaign group: {e}") - return None - - async def _create_campaign( - self, - campaign_group_id: str, - name: str, - objective_type: str, - daily_budget_amount: float, - daily_budget_currency: str, - status: str, - ) -> Optional[Campaign]: - valid_objectives = ["BRAND_AWARENESS", "WEBSITE_VISITS", "ENGAGEMENT", "VIDEO_VIEWS", "LEAD_GENERATION", "WEBSITE_CONVERSIONS", "JOB_APPLICANTS"] - if objective_type not in valid_objectives: - logger.error(f"Invalid objective_type: {objective_type}") - self.problems.append(f"Invalid objective_type: {objective_type}. Valid: {', '.join(valid_objectives)}") - return None - - account_urn = f"urn:li:sponsoredAccount:{self.ad_account_id}" - campaign_group_urn = f"urn:li:sponsoredCampaignGroup:{campaign_group_id}" - payload = { - "account": account_urn, - "campaignGroup": campaign_group_urn, - "name": name, - "type": "SPONSORED_UPDATES", - "objectiveType": objective_type, - "status": status, - "dailyBudget": { - "amount": str(daily_budget_amount), - "currencyCode": daily_budget_currency, + if media_category == "ARTICLE": + original_url = str(args.get("original_url", "") or args.get("url", "")).strip() + if not original_url: + raise ValueError("original_url is required for article posts.") + share_content["media"] = [{ + "status": "READY", + "originalUrl": original_url, + **({"title": {"text": str(args.get("title", "")).strip()}} if str(args.get("title", "")).strip() else {}), + **({"description": {"text": str(args.get("description", "")).strip()}} if str(args.get("description", "")).strip() else {}), + }] + if media_category in {"IMAGE", "VIDEO"}: + asset = str(args.get("asset", "")).strip() + if not asset: + raise ValueError("asset is required for image/video posts. Register and upload media first.") + share_content["media"] = [{ + "status": "READY", + "media": asset, + **({"title": {"text": str(args.get("title", "")).strip()}} if str(args.get("title", "")).strip() else {}), + **({"description": {"text": str(args.get("description", "")).strip()}} if str(args.get("description", "")).strip() else {}), + }] + + return { + "author": author, + "lifecycleState": "PUBLISHED", + "specificContent": { + "com.linkedin.ugc.ShareContent": share_content, }, - "unitCost": { - "amount": "10.0", - "currencyCode": daily_budget_currency, + "visibility": { + "com.linkedin.ugc.MemberNetworkVisibility": visibility, }, - "costType": "CPM", - "offsiteDeliveryEnabled": False, - "locale": {"country": "US", "language": "en"}, - "runSchedule": { - "start": int(time.time() * 1000), - }, - "politicalIntent": "NOT_POLITICAL", } - url = f"{API_BASE}/rest/adAccounts/{self.ad_account_id}/adCampaigns" - logger.info(f"Creating campaign: {name}") - try: - async with httpx.AsyncClient() as client: - response = await client.post(url, json=payload, headers=self.headers, timeout=30.0) - if response.status_code == 201: - logger.info(f"Campaign created - headers: {dict(response.headers)}") - campaign_id = response.headers["x-restli-id"] - return Campaign( - id=campaign_id, - name=name, - status=status, - objective_type=objective_type, - daily_budget=Budget( - amount=str(daily_budget_amount), - currency_code=daily_budget_currency, - ), - campaign_group_id=campaign_group_id, - ) - else: - logger.error(f"Failed to create campaign: {response.status_code} - {response.text}") - self.problems.append(f"Failed to create campaign: {response.status_code}") - return None - except Exception as e: - logger.exception("Exception creating campaign") - self.problems.append(f"Exception creating campaign: {e}") - return None - - async def _get_campaign(self, campaign_id: str) -> Optional[Campaign]: - url = f"{API_BASE}/rest/adAccounts/{self.ad_account_id}/adCampaigns/{campaign_id}" - logger.info(f"Fetching campaign: {campaign_id}") - try: - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=self.headers, timeout=30.0) - if response.status_code == 200: - data = response.json() - budget_data = data["dailyBudget"] - campaign_group_urn = data.get("campaignGroup", "") - campaign_group_id = campaign_group_urn.split(":")[-1] if campaign_group_urn else None - return Campaign( - id=data["id"], - name=data["name"], - status=data["status"], - objective_type=data["objectiveType"], - daily_budget=Budget( - amount=budget_data["amount"], - currency_code=budget_data["currencyCode"], - ), - campaign_group_id=campaign_group_id, - ) - else: - logger.error(f"Failed to get campaign: {response.status_code} - {response.text}") - self.problems.append(f"Failed to get campaign: {response.status_code}") - return None - except Exception as e: - logger.exception("Exception fetching campaign") - self.problems.append(f"Exception fetching campaign: {e}") - return None - - async def _get_campaign_analytics( - self, - campaign_id: str, - days: int = 30, - ) -> Optional[Analytics]: - from datetime import datetime, timedelta - - end_date = datetime.utcnow() - start_date = end_date - timedelta(days=days) - date_range = ( - f"(start:(year:{start_date.year},month:{start_date.month},day:{start_date.day})," - f"end:(year:{end_date.year},month:{end_date.month},day:{end_date.day}))" - ) - campaign_urn = f"urn%3Ali%3AsponsoredCampaign%3A{campaign_id}" + def _build_register_upload_payload(self, args: Dict[str, Any], recipe_suffix: str) -> Dict[str, Any]: + owner = str(args.get("owner", "")).strip() + if not owner: + raise ValueError("owner is required and must be a Person URN such as urn:li:person:123.") + return { + "registerUploadRequest": { + "recipes": [f"urn:li:digitalmediaRecipe:feedshare-{recipe_suffix}"], + "owner": owner, + "serviceRelationships": [ + { + "relationshipType": "OWNER", + "identifier": "urn:li:userGeneratedContent", + } + ], + } + } - url = ( - f"{API_BASE}/rest/adAnalytics?" - f"q=analytics&" - f"pivot=CAMPAIGN&" - f"campaigns=List({campaign_urn})&" - f"timeGranularity=DAILY&" - f"dateRange={date_range}&" - f"fields=impressions,clicks,costInLocalCurrency" - ) - logger.info(f"Fetching analytics for campaign: {campaign_id}") + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: try: - async with httpx.AsyncClient(timeout=30.0) as client: - headers = {k: v for k, v in self.headers.items() if k != "Content-Type"} - request = httpx.Request("GET", url, headers=headers) - response = await client.send(request) - if response.status_code == 200: - data = response.json() - elements = data.get("elements", []) - if not elements: - return Analytics(impressions=0, clicks=0, cost=0.0) - - total_impressions = sum(e.get("impressions", 0) for e in elements) - total_clicks = sum(e.get("clicks", 0) for e in elements) - total_cost = sum(float(e.get("costInLocalCurrency", 0) or 0) for e in elements) - - return Analytics( - impressions=total_impressions, - clicks=total_clicks, - cost=total_cost, - ) - else: - logger.error(f"Failed to get analytics: {response.status_code} - {response.text}") - self.problems.append(f"Failed to get analytics: {response.status_code}") - return None - except Exception as e: - logger.exception("Exception fetching analytics") - self.problems.append(f"Exception fetching analytics: {e}") - return None + if method_id == "linkedin.auth.userinfo.get.v1": + return await self._request(method_id, "GET", "/v2/userinfo") + if method_id == "linkedin.assets.register_image_upload.v1": + return await self._request( + method_id, + "POST", + "/v2/assets?action=registerUpload", + body=self._build_register_upload_payload(args, "image"), + ) + if method_id == "linkedin.assets.register_video_upload.v1": + return await self._request( + method_id, + "POST", + "/v2/assets?action=registerUpload", + body=self._build_register_upload_payload(args, "video"), + ) + if method_id == "linkedin.posts.create_text.v1": + return await self._request( + method_id, + "POST", + "/v2/ugcPosts", + body=self._build_share_payload(args, "NONE"), + ) + if method_id == "linkedin.posts.create_article.v1": + return await self._request( + method_id, + "POST", + "/v2/ugcPosts", + body=self._build_share_payload(args, "ARTICLE"), + ) + if method_id == "linkedin.posts.create_image.v1": + return await self._request( + method_id, + "POST", + "/v2/ugcPosts", + body=self._build_share_payload(args, "IMAGE"), + ) + if method_id == "linkedin.posts.create_video.v1": + return await self._request( + method_id, + "POST", + "/v2/ugcPosts", + body=self._build_share_payload(args, "VIDEO"), + ) + except ValueError as e: + return self._invalid_args(method_id, str(e)) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_linkedin_b2b.py b/flexus_client_kit/integrations/fi_linkedin_b2b.py new file mode 100644 index 00000000..0fcc7530 --- /dev/null +++ b/flexus_client_kit/integrations/fi_linkedin_b2b.py @@ -0,0 +1,1175 @@ +import json +import logging +from typing import Any, Dict, Optional +from urllib.parse import quote + +import httpx + +from flexus_client_kit import ckit_cloudtool + + +logger = logging.getLogger("linkedin_b2b") + +PROVIDER_NAME = "linkedin_b2b" +METHOD_IDS = [ + "linkedin_b2b.organizations.get.v1", + "linkedin_b2b.organizations.search.v1", + "linkedin_b2b.organization_acls.member_organizations.list.v1", + "linkedin_b2b.organization_acls.organization_members.list.v1", + "linkedin_b2b.organization_posts.create.v1", + "linkedin_b2b.organization_posts.get.v1", + "linkedin_b2b.organization_posts.list.v1", + "linkedin_b2b.organization_posts.delete.v1", + "linkedin_b2b.comments.create.v1", + "linkedin_b2b.comments.get.v1", + "linkedin_b2b.comments.list.v1", + "linkedin_b2b.comments.update.v1", + "linkedin_b2b.comments.delete.v1", + "linkedin_b2b.reactions.create.v1", + "linkedin_b2b.reactions.list.v1", + "linkedin_b2b.reactions.delete.v1", + "linkedin_b2b.social_metadata.get.v1", + "linkedin_b2b.followers.get.v1", + "linkedin_b2b.followers.stats.get.v1", + "linkedin_b2b.page_analytics.get.v1", + "linkedin_b2b.share_statistics.get.v1", + "linkedin_b2b.video_analytics.get.v1", + "linkedin_b2b.member_profile_analytics.get.v1", + "linkedin_b2b.member_post_analytics.get.v1", + "linkedin_b2b.mentions.people.search.v1", + "linkedin_b2b.notifications.social_actions.list.v1", + "linkedin_b2b.ad_accounts.get.v1", + "linkedin_b2b.ad_accounts.list.v1", + "linkedin_b2b.ad_account_users.get.v1", + "linkedin_b2b.ad_account_users.list.v1", + "linkedin_b2b.ad_account_users.create.v1", + "linkedin_b2b.ad_account_users.update.v1", + "linkedin_b2b.ad_account_users.delete.v1", + "linkedin_b2b.ad_campaign_groups.create.v1", + "linkedin_b2b.ad_campaign_groups.get.v1", + "linkedin_b2b.ad_campaign_groups.list.v1", + "linkedin_b2b.ad_campaign_groups.update.v1", + "linkedin_b2b.ad_campaigns.create.v1", + "linkedin_b2b.ad_campaigns.get.v1", + "linkedin_b2b.ad_campaigns.list.v1", + "linkedin_b2b.ad_campaigns.update.v1", + "linkedin_b2b.creatives.create.v1", + "linkedin_b2b.creatives.get.v1", + "linkedin_b2b.creatives.list.v1", + "linkedin_b2b.ad_analytics.get.v1", + "linkedin_b2b.ad_analytics.query.v1", + "linkedin_b2b.audience_counts.get.v1", + "linkedin_b2b.targeting_facets.list.v1", + "linkedin_b2b.targeting_entities.list.v1", + "linkedin_b2b.lead_forms.get.v1", + "linkedin_b2b.lead_forms.list.v1", + "linkedin_b2b.lead_forms.create.v1", + "linkedin_b2b.lead_forms.update.v1", + "linkedin_b2b.events.create.v1", + "linkedin_b2b.events.get.v1", + "linkedin_b2b.events.update.v1", + "linkedin_b2b.events.list_by_organizer.v1", + "linkedin_b2b.events.list_leadgen_by_organizer.v1", + "linkedin_b2b.events.register_background_upload.v1", + "linkedin_b2b.lead_sync.forms.get.v1", + "linkedin_b2b.lead_sync.forms.list.v1", + "linkedin_b2b.lead_sync.responses.get.v1", + "linkedin_b2b.lead_sync.responses.list.v1", + "linkedin_b2b.lead_sync.notifications.create.v1", + "linkedin_b2b.lead_sync.notifications.get.v1", + "linkedin_b2b.lead_sync.notifications.delete.v1", + "linkedin_b2b.conversions.create.v1", + "linkedin_b2b.conversions.get.v1", + "linkedin_b2b.conversions.list.v1", + "linkedin_b2b.conversions.associate_campaigns.v1", + "linkedin_b2b.conversion_events.upload.v1", + "linkedin_b2b.dmp_segments.create.v1", + "linkedin_b2b.dmp_segments.get.v1", + "linkedin_b2b.dmp_segments.list.v1", + "linkedin_b2b.dmp_segments.update.v1", + "linkedin_b2b.dmp_segment_users.upload.v1", + "linkedin_b2b.dmp_segment_companies.upload.v1", + "linkedin_b2b.dmp_segment_destinations.list.v1", + "linkedin_b2b.dmp_segment_list_uploads.get.v1", + "linkedin_b2b.ad_segments.list.v1", + "linkedin_b2b.website_retargeting.list.v1", + "linkedin_b2b.predictive_audiences.list.v1", + "linkedin_b2b.audience_insights.query.v1", + "linkedin_b2b.media_planning.forecast_reach.v1", + "linkedin_b2b.media_planning.forecast_impressions.v1", + "linkedin_b2b.media_planning.forecast_leads.v1", + "linkedin_b2b.account_intelligence.get.v1", +] + +_BASE_URL = "https://api.linkedin.com" +_TIMEOUT = 30.0 + +LINKEDIN_B2B_TOOL = ckit_cloudtool.CloudTool( + strict=False, + name="linkedin_b2b", + description="LinkedIn non-partner B2B APIs: Community Management, Ads, Leads, Events, Conversions, and qualified private marketing surfaces.", + parameters={ + "type": "object", + "properties": { + "op": {"type": "string", "description": "Use help, status, list_methods, or call."}, + "args": {"type": "object"}, + }, + "required": [], + }, +) + +# B2B LinkedIn shares the same OAuth provider as open LinkedIn: "linkedin". +# App-level keys are not pasted into this module. They belong in the platform auth provider: +# - client_id: LinkedIn app Client ID from developer.linkedin.com +# - client_secret: LinkedIn app Client Secret from developer.linkedin.com +# - redirect_uri: the Flexus OAuth callback URL configured in the platform auth layer +# - scopes: the reviewed LinkedIn Marketing / Community / Lead Sync scopes approved for this app +# After OAuth completes, Flexus must store token.access_token in rcx.external_auth["linkedin"]. +# The setup schema below is only for default runtime IDs that the bot can reuse on calls. +LINKEDIN_B2B_SETUP_SCHEMA = [ + { + "bs_name": "ad_account_id", + "bs_type": "string_short", + "bs_default": "", + "bs_group": "LinkedIn B2B", + "bs_order": 10, + "bs_importance": 1, + "bs_description": ( + "Default LinkedIn Ads account ID used by ad and lead methods. " + "Value: the numeric ad account ID, for example 123456789. " + "Get it from LinkedIn Campaign Manager or from the adAccounts API after auth works. " + "Paste it into this setup field in the bot/persona setup, not into OAuth secrets." + ), + }, + { + "bs_name": "organization_id", + "bs_type": "string_short", + "bs_default": "", + "bs_group": "LinkedIn B2B", + "bs_order": 20, + "bs_importance": 1, + "bs_description": ( + "Default LinkedIn organization ID used by page, org, post, follower, and analytics methods. " + "Value: the numeric organization ID, for example 987654321. " + "Get it from the LinkedIn Page admin URL, organization lookup calls, or your onboarding data. " + "Paste it into this setup field in the bot/persona setup." + ), + }, + { + "bs_name": "linkedin_api_version", + "bs_type": "string_short", + "bs_default": "202509", + "bs_group": "LinkedIn B2B", + "bs_order": 30, + "bs_importance": 1, + "bs_description": ( + "LinkedIn Marketing API version header in YYYYMM format. " + "Value: a version string like 202509. " + "Get it from the LinkedIn Marketing API versioning docs and keep it aligned with the approved API surface. " + "Paste it into this setup field only if you need to override the default shipped version." + ), + }, +] + + +class IntegrationLinkedinB2B: + def __init__( + self, + rcx=None, + ad_account_id: str = "", + organization_id: str = "", + linkedin_api_version: str = "202509", + ): + self.rcx = rcx + self.ad_account_id = str(ad_account_id or "").strip() + self.organization_id = str(organization_id or "").strip() + self.linkedin_api_version = str(linkedin_api_version or "202509").strip() + + def _auth(self) -> Dict[str, Any]: + # Same auth storage contract as fi_linkedin.py: + # - provider name in Flexus auth layer: "linkedin" + # - connected account payload location: rcx.external_auth["linkedin"] + # - required value for runtime calls: token.access_token + # This module does not own client_id/client_secret storage; our responsibility starts once + # the platform auth layer already exposes the connected account here. + return (self.rcx.external_auth.get("linkedin") or {}) if self.rcx else {} + + def _access_token(self) -> str: + auth = self._auth() + token_obj = auth.get("token") or {} + return str(token_obj.get("access_token", "") or auth.get("oauth_token", "")).strip() + + def _status(self) -> str: + access_token = self._access_token() + return json.dumps({ + "ok": bool(access_token), + "provider": PROVIDER_NAME, + "status": "ready" if access_token else "missing_credentials", + "method_count": len(METHOD_IDS), + "auth_provider": "linkedin", + "default_ad_account_id": self.ad_account_id, + "default_organization_id": self.organization_id, + "linkedin_api_version": self.linkedin_api_version, + "products": [ + "Community Management API", + "Advertising API", + "Events Management API", + "Lead Sync API", + "Conversions API", + "Matched Audiences API", + "Audience Insights API", + "Media Planning API", + "Company Intelligence API", + ], + "has_access_token": bool(access_token), + }, indent=2, ensure_ascii=False) + + def _help(self) -> str: + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}\n" + "notes:\n" + "- This integration assumes official LinkedIn reviewed or qualified B2B access.\n" + "- Many create/update methods accept args.body for full pass-through provider payloads.\n" + "- Where LinkedIn docs expose only query-style resources, method ids normalize that shape into one call.\n" + ) + + def _headers(self, *, has_body: bool, restli_method: str = "") -> Dict[str, str]: + access_token = self._access_token() + headers = { + "Authorization": f"Bearer {access_token}", + "Linkedin-Version": self.linkedin_api_version, + "X-Restli-Protocol-Version": "2.0.0", + } + if has_body: + headers["Content-Type"] = "application/json" + if restli_method: + headers["X-RestLi-Method"] = restli_method + return headers + + def _auth_missing(self, method_id: str) -> str: + return json.dumps({ + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": "AUTH_MISSING", + "message": "Connect LinkedIn in workspace settings and ensure an access token is present.", + }, indent=2, ensure_ascii=False) + + def _invalid_args(self, method_id: str, message: str) -> str: + return json.dumps({ + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": "INVALID_ARGS", + "message": message, + }, indent=2, ensure_ascii=False) + + def _docs_gap(self, method_id: str, message: str) -> str: + return json.dumps({ + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": "OFFICIAL_DOCS_GAP", + "message": message, + }, indent=2, ensure_ascii=False) + + def _result(self, method_id: str, result: Any) -> str: + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "method_id": method_id, + "result": result, + }, indent=2, ensure_ascii=False) + + def _provider_error(self, method_id: str, status_code: int, body: str) -> str: + detail: Any = body[:500] + try: + detail = json.loads(body) + except json.JSONDecodeError: + pass + logger.info("linkedin_b2b api error method=%s status=%s body=%s", method_id, status_code, body[:300]) + return json.dumps({ + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": "PROVIDER_ERROR", + "http_status": status_code, + "detail": detail, + }, indent=2, ensure_ascii=False) + + def _enc(self, value: Any) -> str: + return quote(str(value), safe="") + + def _query(self, params: Dict[str, Any]) -> str: + parts = [] + for key, value in params.items(): + if value is None or value == "": + continue + if isinstance(value, bool): + value = "true" if value else "false" + parts.append(f"{key}={self._enc(value)}") + return "&".join(parts) + + def _with_query(self, path: str, params: Dict[str, Any]) -> str: + query = self._query(params) + return f"{path}?{query}" if query else path + + async def _request( + self, + method_id: str, + http_method: str, + path: str, + *, + body: Optional[Dict[str, Any]] = None, + restli_method: str = "", + ) -> str: + url = _BASE_URL + path + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + if http_method == "GET": + response = await client.get(url, headers=self._headers(has_body=False, restli_method=restli_method)) + elif http_method == "POST": + response = await client.post(url, headers=self._headers(has_body=True, restli_method=restli_method), json=body) + elif http_method == "PUT": + response = await client.put(url, headers=self._headers(has_body=True, restli_method=restli_method), json=body) + elif http_method == "DELETE": + response = await client.delete(url, headers=self._headers(has_body=False, restli_method=restli_method)) + else: + return json.dumps({"ok": False, "error_code": "UNSUPPORTED_HTTP_METHOD"}, indent=2, ensure_ascii=False) + except httpx.TimeoutException: + return json.dumps({"ok": False, "provider": PROVIDER_NAME, "method_id": method_id, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError) as e: + logger.error("linkedin_b2b request failed", exc_info=e) + return json.dumps({ + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": "HTTP_ERROR", + "message": f"{type(e).__name__}: {e}", + }, indent=2, ensure_ascii=False) + + if response.status_code >= 400: + return self._provider_error(method_id, response.status_code, response.text) + + if response.status_code == 204 or not response.text.strip(): + return self._result(method_id, {"status": "success"}) + try: + payload: Any = response.json() + except json.JSONDecodeError: + payload = response.text + response_id = response.headers.get("x-restli-id") or response.headers.get("x-linkedin-id") + if response_id: + return self._result(method_id, {"id": response_id, "payload": payload}) + return self._result(method_id, payload) + + def _body(self, args: Dict[str, Any], required: bool = False) -> Dict[str, Any]: + body = args.get("body") + if isinstance(body, dict): + return body + if required: + raise ValueError("body is required and must be an object for this method.") + return {} + + def _patch_body(self, args: Dict[str, Any]) -> Dict[str, Any]: + body = args.get("body") + if isinstance(body, dict): + return body + patch_set = args.get("set") + if isinstance(patch_set, dict) and patch_set: + return {"patch": {"$set": patch_set}} + raise ValueError("Provide body as a Rest.li patch object or set as a non-empty object.") + + def _pick(self, args: Dict[str, Any], key: str, default: str = "") -> str: + return str(args.get(key, default)).strip() + + def _organization_id(self, args: Dict[str, Any], required: bool = False) -> str: + value = self._pick(args, "organization_id", self.organization_id) + if required and not value: + raise ValueError("organization_id is required.") + return value + + def _organization_urn(self, args: Dict[str, Any], required: bool = False) -> str: + if self._pick(args, "organization_urn"): + return self._pick(args, "organization_urn") + organization_id = self._organization_id(args, required=required) + return f"urn:li:organization:{organization_id}" if organization_id else "" + + def _ad_account_id(self, args: Dict[str, Any], required: bool = False) -> str: + value = self._pick(args, "ad_account_id", self.ad_account_id) + if required and not value: + raise ValueError("ad_account_id is required.") + return value + + def _ad_account_urn(self, args: Dict[str, Any], required: bool = False) -> str: + if self._pick(args, "ad_account_urn"): + return self._pick(args, "ad_account_urn") + ad_account_id = self._ad_account_id(args, required=required) + return f"urn:li:sponsoredAccount:{ad_account_id}" if ad_account_id else "" + + def _campaign_urn(self, args: Dict[str, Any], required: bool = False) -> str: + if self._pick(args, "campaign_urn"): + return self._pick(args, "campaign_urn") + campaign_id = self._pick(args, "campaign_id") + if required and not campaign_id: + raise ValueError("campaign_id or campaign_urn is required.") + return f"urn:li:sponsoredCampaign:{campaign_id}" if campaign_id else "" + + def _person_urn(self, args: Dict[str, Any], required: bool = False) -> str: + person_urn = self._pick(args, "person_urn") or self._pick(args, "role_assignee") or self._pick(args, "user_urn") + if required and not person_urn: + raise ValueError("person_urn is required.") + return person_urn + + def _social_target_urn(self, args: Dict[str, Any], required: bool = False) -> str: + target_urn = self._pick(args, "target_urn") or self._pick(args, "entity_urn") or self._pick(args, "post_urn") + if required and not target_urn: + raise ValueError("target_urn is required.") + return target_urn + + def _comment_id(self, args: Dict[str, Any], required: bool = False) -> str: + comment_id = self._pick(args, "comment_id") + if required and not comment_id: + raise ValueError("comment_id is required.") + return comment_id + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Optional[Dict[str, Any]], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return self._help() + if op == "status": + return self._status() + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + if not self._access_token(): + return self._auth_missing(method_id) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: # noqa: C901 + try: + if method_id == "linkedin_b2b.organizations.get.v1": + organization_id = self._organization_id(args, required=True) + return await self._request(method_id, "GET", f"/rest/organizations/{organization_id}") + if method_id == "linkedin_b2b.organizations.search.v1": + if self._pick(args, "vanity_name"): + return await self._request( + method_id, + "GET", + self._with_query("/rest/organizations", {"q": "vanityName", "vanityName": self._pick(args, "vanity_name")}), + ) + if isinstance(args.get("ids"), list) and args.get("ids"): + ids = ",".join(str(x) for x in args.get("ids", [])) + return await self._request(method_id, "GET", self._with_query("/rest/organizationsLookup", {"ids": f"List({ids})"})) + raise ValueError("Provide vanity_name or ids for organization lookup.") + if method_id == "linkedin_b2b.organization_acls.member_organizations.list.v1": + person_urn = self._person_urn(args, required=True) + return await self._request( + method_id, + "GET", + self._with_query("/rest/organizationAcls", { + "q": "roleAssignee", + "roleAssignee": person_urn, + "start": self._pick(args, "start"), + "count": self._pick(args, "count"), + "state": self._pick(args, "state"), + "role": self._pick(args, "role"), + }), + ) + if method_id == "linkedin_b2b.organization_acls.organization_members.list.v1": + organization_urn = self._organization_urn(args, required=True) + return await self._request( + method_id, + "GET", + self._with_query("/rest/organizationAcls", { + "q": "organization", + "organization": organization_urn, + "start": self._pick(args, "start"), + "count": self._pick(args, "count"), + "state": self._pick(args, "state"), + "role": self._pick(args, "role"), + }), + ) + if method_id == "linkedin_b2b.organization_posts.create.v1": + return await self._request(method_id, "POST", "/rest/posts", body=self._body(args, required=True)) + if method_id == "linkedin_b2b.organization_posts.get.v1": + post_urn = self._social_target_urn(args, required=True) + return await self._request( + method_id, + "GET", + self._with_query(f"/rest/posts/{self._enc(post_urn)}", {"viewContext": self._pick(args, "viewContext")}), + ) + if method_id == "linkedin_b2b.organization_posts.list.v1": + author = self._pick(args, "author") or self._organization_urn(args, required=True) + return await self._request( + method_id, + "GET", + self._with_query("/rest/posts", { + "q": "author", + "author": author, + "viewContext": self._pick(args, "viewContext"), + "sortBy": self._pick(args, "sortBy"), + "start": self._pick(args, "start"), + "count": self._pick(args, "count"), + }), + ) + if method_id == "linkedin_b2b.organization_posts.delete.v1": + post_urn = self._social_target_urn(args, required=True) + return await self._request(method_id, "DELETE", f"/rest/posts/{self._enc(post_urn)}") + if method_id == "linkedin_b2b.comments.create.v1": + target_urn = self._social_target_urn(args, required=True) + return await self._request(method_id, "POST", f"/rest/socialActions/{self._enc(target_urn)}/comments", body=self._body(args, required=True)) + if method_id == "linkedin_b2b.comments.get.v1": + target_urn = self._social_target_urn(args, required=True) + comment_id = self._comment_id(args, required=True) + return await self._request(method_id, "GET", f"/rest/socialActions/{self._enc(target_urn)}/comments/{comment_id}") + if method_id == "linkedin_b2b.comments.list.v1": + target_urn = self._social_target_urn(args, required=True) + return await self._request( + method_id, + "GET", + self._with_query(f"/rest/socialActions/{self._enc(target_urn)}/comments", { + "start": self._pick(args, "start"), + "count": self._pick(args, "count"), + }), + ) + if method_id == "linkedin_b2b.comments.update.v1": + target_urn = self._social_target_urn(args, required=True) + comment_id = self._comment_id(args, required=True) + actor = self._pick(args, "actor") + if not actor: + raise ValueError("actor is required for comment update.") + return await self._request( + method_id, + "POST", + self._with_query(f"/rest/socialActions/{self._enc(target_urn)}/comments/{comment_id}", {"actor": actor}), + body=self._body(args, required=True), + ) + if method_id == "linkedin_b2b.comments.delete.v1": + target_urn = self._social_target_urn(args, required=True) + comment_id = self._comment_id(args, required=True) + actor = self._pick(args, "actor") + if not actor: + raise ValueError("actor is required for comment delete.") + return await self._request( + method_id, + "DELETE", + self._with_query(f"/rest/socialActions/{self._enc(target_urn)}/comments/{comment_id}", {"actor": actor}), + ) + if method_id == "linkedin_b2b.reactions.create.v1": + actor = self._pick(args, "actor") + if not actor: + raise ValueError("actor is required for reaction create.") + return await self._request( + method_id, + "POST", + self._with_query("/rest/reactions", {"actor": actor}), + body=self._body(args, required=True), + ) + if method_id == "linkedin_b2b.reactions.list.v1": + entity_urn = self._social_target_urn(args, required=True) + return await self._request( + method_id, + "GET", + self._with_query(f"/rest/reactions/(entity:{self._enc(entity_urn)})", { + "q": "entity", + "sort": self._pick(args, "sort"), + "start": self._pick(args, "start"), + "count": self._pick(args, "count"), + }), + ) + if method_id == "linkedin_b2b.reactions.delete.v1": + actor = self._pick(args, "actor") + entity_urn = self._social_target_urn(args, required=True) + if not actor: + raise ValueError("actor is required for reaction delete.") + return await self._request(method_id, "DELETE", f"/rest/reactions/(actor:{self._enc(actor)},entity:{self._enc(entity_urn)})") + if method_id == "linkedin_b2b.social_metadata.get.v1": + entity_urn = self._social_target_urn(args, required=True) + return await self._request(method_id, "GET", f"/rest/socialMetadata/{self._enc(entity_urn)}") + if method_id == "linkedin_b2b.followers.get.v1": + organization_urn = self._organization_urn(args, required=True) + return await self._request( + method_id, + "GET", + self._with_query(f"/rest/networkSizes/{self._enc(organization_urn)}", {"edgeType": "COMPANY_FOLLOWED_BY_MEMBER"}), + ) + if method_id == "linkedin_b2b.followers.stats.get.v1": + organization_urn = self._organization_urn(args, required=True) + return await self._request( + method_id, + "GET", + self._with_query("/rest/organizationalEntityFollowerStatistics", { + "q": "organizationalEntity", + "organizationalEntity": organization_urn, + "timeIntervals": self._pick(args, "timeIntervals"), + }), + ) + if method_id == "linkedin_b2b.page_analytics.get.v1": + brand_urn = self._pick(args, "brand_urn") + if brand_urn: + return await self._request( + method_id, + "GET", + self._with_query("/rest/brandPageStatistics", {"q": "brand", "brand": brand_urn, "timeIntervals": self._pick(args, "timeIntervals")}), + ) + organization_urn = self._organization_urn(args, required=True) + return await self._request( + method_id, + "GET", + self._with_query("/rest/organizationPageStatistics", { + "q": "organization", + "organization": organization_urn, + "timeIntervals": self._pick(args, "timeIntervals"), + }), + ) + if method_id == "linkedin_b2b.share_statistics.get.v1": + organization_urn = self._organization_urn(args, required=True) + return await self._request( + method_id, + "GET", + self._with_query("/rest/organizationalEntityShareStatistics", { + "q": "organizationalEntity", + "organizationalEntity": organization_urn, + "timeIntervals": self._pick(args, "timeIntervals"), + "shares": self._pick(args, "shares"), + "ugcPosts": self._pick(args, "ugcPosts"), + }), + ) + if method_id == "linkedin_b2b.video_analytics.get.v1": + entity_urn = self._social_target_urn(args, required=True) + return await self._request( + method_id, + "GET", + self._with_query("/rest/videoAnalytics", { + "q": "entity", + "entity": entity_urn, + "type": self._pick(args, "type", "VIDEO_VIEW"), + "aggregation": self._pick(args, "aggregation"), + "timeRange": self._pick(args, "timeRange"), + }), + ) + if method_id == "linkedin_b2b.member_profile_analytics.get.v1": + if self._pick(args, "dateRange"): + return await self._request( + method_id, + "GET", + self._with_query("/rest/memberFollowersCount", {"q": "dateRange", "dateRange": self._pick(args, "dateRange")}), + ) + return await self._request(method_id, "GET", self._with_query("/rest/memberFollowersCount", {"q": "me"})) + if method_id == "linkedin_b2b.member_post_analytics.get.v1": + if self._pick(args, "entity"): + return await self._request( + method_id, + "GET", + self._with_query("/rest/memberCreatorPostAnalytics", { + "q": "entity", + "entity": self._pick(args, "entity"), + "queryType": self._pick(args, "queryType"), + "aggregation": self._pick(args, "aggregation"), + "dateRange": self._pick(args, "dateRange"), + }), + ) + return await self._request( + method_id, + "GET", + self._with_query("/rest/memberCreatorPostAnalytics", { + "q": "me", + "queryType": self._pick(args, "queryType"), + "aggregation": self._pick(args, "aggregation"), + "dateRange": self._pick(args, "dateRange"), + }), + ) + if method_id == "linkedin_b2b.mentions.people.search.v1": + organization_urn = self._organization_urn(args, required=True) + vanity_url = self._pick(args, "vanity_url") + if vanity_url: + return await self._request( + method_id, + "GET", + self._with_query("/rest/vanityUrl", { + "q": "vanityUrlAsOrganization", + "vanityUrl": vanity_url, + "organization": organization_urn, + }), + ) + keywords = self._pick(args, "keywords") + if not keywords: + raise ValueError("keywords is required unless vanity_url is provided.") + return await self._request( + method_id, + "GET", + self._with_query("/rest/peopleTypeahead", { + "q": "organizationFollowers", + "keywords": keywords, + "organization": organization_urn, + }), + ) + if method_id == "linkedin_b2b.notifications.social_actions.list.v1": + organization_urn = self._organization_urn(args, required=True) + return await self._request( + method_id, + "GET", + self._with_query("/rest/organizationalEntityNotifications", { + "q": "criteria", + "organizationalEntity": organization_urn, + "actions": self._pick(args, "actions"), + "sourcePost": self._pick(args, "sourcePost"), + "timeRange.start": self._pick(args, "timeRange.start"), + "timeRange.end": self._pick(args, "timeRange.end"), + }), + ) + if method_id == "linkedin_b2b.ad_accounts.get.v1": + ad_account_id = self._ad_account_id(args, required=True) + return await self._request(method_id, "GET", f"/rest/adAccounts/{ad_account_id}") + if method_id == "linkedin_b2b.ad_accounts.list.v1": + return await self._request( + method_id, + "GET", + self._with_query("/rest/adAccounts", { + "q": "search", + "search": self._pick(args, "search"), + "pageSize": self._pick(args, "pageSize"), + "pageToken": self._pick(args, "pageToken"), + }), + ) + if method_id == "linkedin_b2b.ad_account_users.get.v1": + account_urn = self._ad_account_urn(args, required=True) + user_urn = self._person_urn(args, required=True) + return await self._request(method_id, "GET", f"/rest/adAccountUsers/(account:{self._enc(account_urn)},user:{self._enc(user_urn)})") + if method_id == "linkedin_b2b.ad_account_users.list.v1": + account_urn = self._ad_account_urn(args) + if account_urn: + return await self._request( + method_id, + "GET", + self._with_query("/rest/adAccountUsers", {"q": "accounts", "accounts": account_urn}), + ) + return await self._request(method_id, "GET", self._with_query("/rest/adAccountUsers", {"q": "authenticatedUser"})) + if method_id == "linkedin_b2b.ad_account_users.create.v1": + account_urn = self._ad_account_urn(args, required=True) + user_urn = self._person_urn(args, required=True) + body = self._body(args) or {"account": account_urn, "user": user_urn, "role": self._pick(args, "role")} + if not body.get("role"): + raise ValueError("role is required for ad account user create.") + return await self._request(method_id, "PUT", f"/rest/adAccountUsers/(account:{self._enc(account_urn)},user:{self._enc(user_urn)})", body=body) + if method_id == "linkedin_b2b.ad_account_users.update.v1": + account_urn = self._ad_account_urn(args, required=True) + user_urn = self._person_urn(args, required=True) + return await self._request( + method_id, + "POST", + f"/rest/adAccountUsers/(account:{self._enc(account_urn)},user:{self._enc(user_urn)})", + body=self._patch_body(args), + restli_method="PARTIAL_UPDATE", + ) + if method_id == "linkedin_b2b.ad_account_users.delete.v1": + account_urn = self._ad_account_urn(args, required=True) + user_urn = self._person_urn(args, required=True) + return await self._request(method_id, "DELETE", f"/rest/adAccountUsers/(account:{self._enc(account_urn)},user:{self._enc(user_urn)})") + if method_id == "linkedin_b2b.ad_campaign_groups.create.v1": + ad_account_id = self._ad_account_id(args, required=True) + return await self._request(method_id, "POST", f"/rest/adAccounts/{ad_account_id}/adCampaignGroups", body=self._body(args, required=True)) + if method_id == "linkedin_b2b.ad_campaign_groups.get.v1": + ad_account_id = self._ad_account_id(args, required=True) + campaign_group_id = self._pick(args, "campaign_group_id") + if not campaign_group_id: + raise ValueError("campaign_group_id is required.") + return await self._request(method_id, "GET", f"/rest/adAccounts/{ad_account_id}/adCampaignGroups/{campaign_group_id}") + if method_id == "linkedin_b2b.ad_campaign_groups.list.v1": + ad_account_id = self._ad_account_id(args, required=True) + return await self._request( + method_id, + "GET", + self._with_query(f"/rest/adAccounts/{ad_account_id}/adCampaignGroups", { + "q": "search", + "search": self._pick(args, "search"), + "pageSize": self._pick(args, "pageSize"), + "pageToken": self._pick(args, "pageToken"), + }), + ) + if method_id == "linkedin_b2b.ad_campaign_groups.update.v1": + ad_account_id = self._ad_account_id(args, required=True) + campaign_group_id = self._pick(args, "campaign_group_id") + if not campaign_group_id: + raise ValueError("campaign_group_id is required.") + return await self._request( + method_id, + "POST", + f"/rest/adAccounts/{ad_account_id}/adCampaignGroups/{campaign_group_id}", + body=self._patch_body(args), + restli_method="PARTIAL_UPDATE", + ) + if method_id == "linkedin_b2b.ad_campaigns.create.v1": + ad_account_id = self._ad_account_id(args, required=True) + return await self._request(method_id, "POST", f"/rest/adAccounts/{ad_account_id}/adCampaigns", body=self._body(args, required=True)) + if method_id == "linkedin_b2b.ad_campaigns.get.v1": + ad_account_id = self._ad_account_id(args, required=True) + campaign_id = self._pick(args, "campaign_id") + if not campaign_id: + raise ValueError("campaign_id is required.") + return await self._request(method_id, "GET", f"/rest/adAccounts/{ad_account_id}/adCampaigns/{campaign_id}") + if method_id == "linkedin_b2b.ad_campaigns.list.v1": + ad_account_id = self._ad_account_id(args, required=True) + return await self._request( + method_id, + "GET", + self._with_query(f"/rest/adAccounts/{ad_account_id}/adCampaigns", { + "q": "search", + "search": self._pick(args, "search"), + "pageSize": self._pick(args, "pageSize"), + "pageToken": self._pick(args, "pageToken"), + }), + ) + if method_id == "linkedin_b2b.ad_campaigns.update.v1": + ad_account_id = self._ad_account_id(args, required=True) + campaign_id = self._pick(args, "campaign_id") + if not campaign_id: + raise ValueError("campaign_id is required.") + return await self._request( + method_id, + "POST", + f"/rest/adAccounts/{ad_account_id}/adCampaigns/{campaign_id}", + body=self._patch_body(args), + restli_method="PARTIAL_UPDATE", + ) + if method_id == "linkedin_b2b.creatives.create.v1": + ad_account_id = self._ad_account_id(args, required=True) + action = self._pick(args, "action") + path = f"/rest/adAccounts/{ad_account_id}/creatives?action={self._enc(action)}" if action else f"/rest/adAccounts/{ad_account_id}/creatives" + return await self._request(method_id, "POST", path, body=self._body(args, required=True)) + if method_id == "linkedin_b2b.creatives.get.v1": + ad_account_id = self._ad_account_id(args, required=True) + creative_urn = self._pick(args, "creative_urn") or self._pick(args, "creative_id") + if not creative_urn: + raise ValueError("creative_urn or creative_id is required.") + return await self._request(method_id, "GET", f"/rest/adAccounts/{ad_account_id}/creatives/{self._enc(creative_urn)}") + if method_id == "linkedin_b2b.creatives.list.v1": + ad_account_id = self._ad_account_id(args, required=True) + return await self._request( + method_id, + "GET", + self._with_query(f"/rest/adAccounts/{ad_account_id}/creatives", { + "q": "criteria", + "campaigns": self._pick(args, "campaigns"), + "contentReferences": self._pick(args, "contentReferences"), + "creatives": self._pick(args, "creatives"), + "intendedStatuses": self._pick(args, "intendedStatuses"), + "isTestAccount": self._pick(args, "isTestAccount"), + "leadgenCreativeCallToActionDestinations": self._pick(args, "leadgenCreativeCallToActionDestinations"), + "pageSize": self._pick(args, "pageSize"), + "pageToken": self._pick(args, "pageToken"), + }), + ) + if method_id in {"linkedin_b2b.ad_analytics.get.v1", "linkedin_b2b.ad_analytics.query.v1"}: + return await self._request( + method_id, + "GET", + self._with_query("/rest/adAnalytics", { + "q": self._pick(args, "q", "analytics"), + "pivot": self._pick(args, "pivot"), + "pivots": self._pick(args, "pivots"), + "timeGranularity": self._pick(args, "timeGranularity"), + "dateRange": self._pick(args, "dateRange"), + "campaigns": self._pick(args, "campaigns"), + "accounts": self._pick(args, "accounts"), + "campaignGroups": self._pick(args, "campaignGroups"), + "creatives": self._pick(args, "creatives"), + "shares": self._pick(args, "shares"), + "fields": self._pick(args, "fields"), + "account": self._pick(args, "account"), + }), + ) + if method_id == "linkedin_b2b.audience_counts.get.v1": + targeting_criteria = self._pick(args, "targetingCriteria") + if not targeting_criteria: + raise ValueError("targetingCriteria is required.") + return await self._request( + method_id, + "GET", + self._with_query("/rest/audienceCounts", {"q": "targetingCriteriaV2", "targetingCriteria": targeting_criteria}), + ) + if method_id == "linkedin_b2b.targeting_facets.list.v1": + return await self._request(method_id, "GET", "/rest/adTargetingFacets") + if method_id == "linkedin_b2b.targeting_entities.list.v1": + query_kind = self._pick(args, "query_kind", "adTargetingFacet") + params = {"q": query_kind} + if query_kind == "adTargetingFacet": + params["facet"] = self._pick(args, "facet") + elif query_kind == "typeahead": + params["facet"] = self._pick(args, "facet") + params["query"] = self._pick(args, "query") + elif query_kind == "urns": + params["urns"] = self._pick(args, "urns") + else: + raise ValueError("query_kind must be adTargetingFacet, typeahead, or urns.") + params["locale"] = self._pick(args, "locale") + params["queryVersion"] = self._pick(args, "queryVersion") + return await self._request(method_id, "GET", self._with_query("/rest/adTargetingEntities", params)) + if method_id == "linkedin_b2b.lead_forms.get.v1": + lead_form_id = self._pick(args, "lead_form_id") + if not lead_form_id: + raise ValueError("lead_form_id is required.") + return await self._request(method_id, "GET", f"/rest/leadForms/{lead_form_id}") + if method_id == "linkedin_b2b.lead_forms.list.v1": + owner = self._pick(args, "owner") or self._ad_account_urn(args) or self._organization_urn(args) + if not owner: + raise ValueError("owner or ad_account_id or organization_id is required.") + return await self._request( + method_id, + "GET", + self._with_query("/rest/leadForms", { + "q": "owner", + "owner": owner, + "start": self._pick(args, "start"), + "count": self._pick(args, "count"), + }), + ) + if method_id == "linkedin_b2b.lead_forms.create.v1": + return await self._request(method_id, "POST", "/rest/leadForms", body=self._body(args, required=True)) + if method_id == "linkedin_b2b.lead_forms.update.v1": + lead_form_id = self._pick(args, "lead_form_id") + if not lead_form_id: + raise ValueError("lead_form_id is required.") + return await self._request( + method_id, + "POST", + f"/rest/leadForms/{lead_form_id}", + body=self._patch_body(args), + restli_method="PARTIAL_UPDATE", + ) + if method_id == "linkedin_b2b.events.create.v1": + return await self._request(method_id, "POST", "/rest/events", body=self._body(args, required=True)) + if method_id == "linkedin_b2b.events.get.v1": + event_id = self._pick(args, "event_id") + if not event_id: + raise ValueError("event_id is required.") + return await self._request(method_id, "GET", f"/rest/events/{event_id}") + if method_id == "linkedin_b2b.events.update.v1": + event_id = self._pick(args, "event_id") + if not event_id: + raise ValueError("event_id is required.") + return await self._request( + method_id, + "POST", + f"/rest/events/{event_id}", + body=self._patch_body(args), + restli_method="PARTIAL_UPDATE", + ) + if method_id == "linkedin_b2b.events.list_by_organizer.v1": + organizer = self._pick(args, "organizer") or self._organization_urn(args) + if not organizer: + raise ValueError("organizer or organization_id is required.") + return await self._request( + method_id, + "GET", + self._with_query("/rest/events", { + "q": "eventsByOrganizer", + "organizer": organizer, + "start": self._pick(args, "start"), + "count": self._pick(args, "count"), + "excludeCancelled": self._pick(args, "excludeCancelled"), + "timeBasedFilter": self._pick(args, "timeBasedFilter"), + "entryCriteria": self._pick(args, "entryCriteria"), + "sortOrder": self._pick(args, "sortOrder"), + }), + ) + if method_id == "linkedin_b2b.events.list_leadgen_by_organizer.v1": + organizer = self._pick(args, "organizer") or self._organization_urn(args, required=True) + return await self._request( + method_id, + "GET", + self._with_query("/rest/events", { + "q": "organizerLeadGenFormEnabledEvents", + "organizer": organizer, + "start": self._pick(args, "start"), + "count": self._pick(args, "count"), + }), + ) + if method_id == "linkedin_b2b.events.register_background_upload.v1": + return await self._request(method_id, "POST", "/rest/assets?action=registerUpload", body=self._body(args, required=True)) + if method_id == "linkedin_b2b.lead_sync.forms.get.v1": + lead_form_id = self._pick(args, "lead_form_id") + if not lead_form_id: + raise ValueError("lead_form_id is required.") + return await self._request(method_id, "GET", f"/rest/leadForms/{lead_form_id}") + if method_id == "linkedin_b2b.lead_sync.forms.list.v1": + owner = self._pick(args, "owner") or self._ad_account_urn(args) or self._organization_urn(args) + if not owner: + raise ValueError("owner or ad_account_id or organization_id is required.") + return await self._request(method_id, "GET", self._with_query("/rest/leadForms", {"q": "owner", "owner": owner, "start": self._pick(args, "start"), "count": self._pick(args, "count")})) + if method_id == "linkedin_b2b.lead_sync.responses.get.v1": + lead_id = self._pick(args, "lead_id") + if not lead_id: + raise ValueError("lead_id is required.") + return await self._request(method_id, "GET", f"/rest/leadFormResponses/{lead_id}") + if method_id == "linkedin_b2b.lead_sync.responses.list.v1": + owner = self._pick(args, "owner") or self._ad_account_urn(args) or self._organization_urn(args) + if not owner: + raise ValueError("owner or ad_account_id or organization_id is required.") + lead_type = self._pick(args, "leadType") + if not lead_type: + raise ValueError("leadType is required.") + return await self._request( + method_id, + "GET", + self._with_query("/rest/leadFormResponses", { + "q": "owner", + "owner": owner, + "leadType": f"(leadType:{lead_type})", + "versionedLeadGenFormUrn": self._pick(args, "versionedLeadGenFormUrn"), + "associatedEntity": self._pick(args, "associatedEntity"), + "submittedAtTimeRange": self._pick(args, "submittedAtTimeRange"), + "limitedToTestLeads": self._pick(args, "limitedToTestLeads"), + "start": self._pick(args, "start"), + "count": self._pick(args, "count"), + }), + ) + if method_id == "linkedin_b2b.lead_sync.notifications.create.v1": + return await self._request(method_id, "POST", "/rest/leadNotifications", body=self._body(args, required=True)) + if method_id == "linkedin_b2b.lead_sync.notifications.get.v1": + notification_id = self._pick(args, "notification_id") + if not notification_id: + raise ValueError("notification_id is required.") + return await self._request(method_id, "GET", f"/rest/leadNotifications/{notification_id}") + if method_id == "linkedin_b2b.lead_sync.notifications.delete.v1": + notification_id = self._pick(args, "notification_id") + if not notification_id: + raise ValueError("notification_id is required.") + return await self._request(method_id, "DELETE", f"/rest/leadNotifications/{notification_id}") + if method_id == "linkedin_b2b.conversions.create.v1": + auto_association_type = self._pick(args, "autoAssociationType") + path = f"/rest/conversions?autoAssociationType={self._enc(auto_association_type)}" if auto_association_type else "/rest/conversions" + return await self._request(method_id, "POST", path, body=self._body(args, required=True)) + if method_id == "linkedin_b2b.conversions.get.v1": + conversion_id = self._pick(args, "conversion_id") + if not conversion_id: + raise ValueError("conversion_id is required.") + account = self._pick(args, "account") or self._ad_account_urn(args, required=True) + return await self._request(method_id, "GET", self._with_query(f"/rest/conversions/{conversion_id}", {"account": account})) + if method_id == "linkedin_b2b.conversions.list.v1": + account = self._pick(args, "account") or self._ad_account_urn(args, required=True) + return await self._request(method_id, "GET", self._with_query("/rest/conversions", {"q": "account", "account": account})) + if method_id == "linkedin_b2b.conversions.associate_campaigns.v1": + if isinstance(args.get("ids"), list) and args.get("ids"): + ids = ",".join(str(x) for x in args.get("ids", [])) + body = self._body(args) + return await self._request( + method_id, + "PUT", + self._with_query("/rest/campaignConversions", {"ids": f"List({ids})"}), + body=body, + restli_method="BATCH_UPDATE", + ) + campaign_urn = self._campaign_urn(args, required=True) + conversion_urn = self._pick(args, "conversion_urn") + if not conversion_urn: + raise ValueError("conversion_urn is required.") + return await self._request( + method_id, + "PUT", + f"/rest/campaignConversions/(campaign:{self._enc(campaign_urn)},conversion:{self._enc(conversion_urn)})", + body=self._body(args), + ) + if method_id == "linkedin_b2b.conversion_events.upload.v1": + body = self._body(args, required=True) + restli_method = "BATCH_CREATE" if isinstance(body.get("elements"), list) else "" + return await self._request(method_id, "POST", "/rest/conversionEvents", body=body, restli_method=restli_method) + if method_id == "linkedin_b2b.dmp_segments.create.v1": + return await self._request(method_id, "POST", "/rest/dmpSegments", body=self._body(args, required=True)) + if method_id == "linkedin_b2b.dmp_segments.get.v1": + dmp_segment_id = self._pick(args, "dmp_segment_id") + if not dmp_segment_id: + raise ValueError("dmp_segment_id is required.") + return await self._request(method_id, "GET", f"/rest/dmpSegments/{dmp_segment_id}") + if method_id == "linkedin_b2b.dmp_segments.list.v1": + account = self._ad_account_urn(args, required=True) + return await self._request(method_id, "GET", self._with_query("/rest/dmpSegments", {"q": "account", "account": account})) + if method_id == "linkedin_b2b.dmp_segments.update.v1": + dmp_segment_id = self._pick(args, "dmp_segment_id") + if not dmp_segment_id: + raise ValueError("dmp_segment_id is required.") + return await self._request( + method_id, + "POST", + f"/rest/dmpSegments/{dmp_segment_id}", + body=self._patch_body(args), + restli_method="PARTIAL_UPDATE", + ) + if method_id == "linkedin_b2b.dmp_segment_users.upload.v1": + dmp_segment_id = self._pick(args, "dmp_segment_id") + if not dmp_segment_id: + raise ValueError("dmp_segment_id is required.") + body = self._body(args, required=True) + restli_method = "BATCH_CREATE" if isinstance(body.get("elements"), list) else "" + return await self._request(method_id, "POST", f"/rest/dmpSegments/{dmp_segment_id}/users", body=body, restli_method=restli_method) + if method_id == "linkedin_b2b.dmp_segment_companies.upload.v1": + dmp_segment_id = self._pick(args, "dmp_segment_id") + if not dmp_segment_id: + raise ValueError("dmp_segment_id is required.") + body = self._body(args, required=True) + restli_method = "BATCH_CREATE" if isinstance(body.get("elements"), list) else "" + return await self._request(method_id, "POST", f"/rest/dmpSegments/{dmp_segment_id}/companies", body=body, restli_method=restli_method) + if method_id == "linkedin_b2b.dmp_segment_destinations.list.v1": + dmp_segment_id = self._pick(args, "dmp_segment_id") + if not dmp_segment_id: + raise ValueError("dmp_segment_id is required.") + return await self._request(method_id, "GET", f"/rest/dmpSegments/{dmp_segment_id}/destinations") + if method_id == "linkedin_b2b.dmp_segment_list_uploads.get.v1": + dmp_segment_id = self._pick(args, "dmp_segment_id") + list_upload_id = self._pick(args, "list_upload_id") + if not dmp_segment_id or not list_upload_id: + raise ValueError("dmp_segment_id and list_upload_id are required.") + return await self._request(method_id, "GET", f"/rest/dmpSegments/{dmp_segment_id}/listUploads/{list_upload_id}") + if method_id == "linkedin_b2b.ad_segments.list.v1": + account = self._ad_account_urn(args, required=True) + return await self._request(method_id, "GET", self._with_query("/rest/adSegments", {"q": "accounts", "accounts": f"List({account})", "start": self._pick(args, "start"), "count": self._pick(args, "count")})) + if method_id == "linkedin_b2b.website_retargeting.list.v1": + account = self._ad_account_urn(args, required=True) + return await self._request(method_id, "GET", self._with_query("/rest/adPageSets", {"q": "account", "account": account})) + if method_id == "linkedin_b2b.predictive_audiences.list.v1": + dmp_segment_id = self._pick(args, "dmp_segment_id") + predictive_audience_id = self._pick(args, "predictive_audience_id") + if not dmp_segment_id: + raise ValueError("dmp_segment_id is required.") + if predictive_audience_id: + return await self._request(method_id, "GET", f"/rest/dmpSegments/{dmp_segment_id}/businessObjectiveBasedAudiences/{predictive_audience_id}") + return self._docs_gap(method_id, "Official docs confirm get-by-id on businessObjectiveBasedAudiences. A collection list endpoint was not confidently documented.") + if method_id == "linkedin_b2b.audience_insights.query.v1": + return await self._request(method_id, "POST", "/rest/targetingAudienceInsights?action=audienceInsights", body=self._body(args, required=True)) + if method_id == "linkedin_b2b.media_planning.forecast_reach.v1": + return await self._request(method_id, "POST", "/rest/mediaPlanning?action=forecastReaches", body=self._body(args, required=True)) + if method_id == "linkedin_b2b.media_planning.forecast_impressions.v1": + return await self._request(method_id, "POST", "/rest/mediaPlanning?action=forecastImpressions", body=self._body(args, required=True)) + if method_id == "linkedin_b2b.media_planning.forecast_leads.v1": + return await self._request(method_id, "POST", "/rest/mediaPlanning?action=forecastLeads", body=self._body(args, required=True)) + if method_id == "linkedin_b2b.account_intelligence.get.v1": + account = self._pick(args, "account") or self._ad_account_urn(args, required=True) + return await self._request( + method_id, + "GET", + self._with_query("/rest/accountIntelligence", { + "q": "account", + "account": account, + "start": self._pick(args, "start"), + "count": self._pick(args, "count"), + "filterCriteria": self._pick(args, "filterCriteria"), + }), + ) + except ValueError as e: + return self._invalid_args(method_id, str(e)) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_linkedin_jobs.py b/flexus_client_kit/integrations/fi_linkedin_jobs.py new file mode 100644 index 00000000..23c430b3 --- /dev/null +++ b/flexus_client_kit/integrations/fi_linkedin_jobs.py @@ -0,0 +1,34 @@ +import json +from typing import Any, Dict + +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = 'linkedin_jobs' +METHOD_IDS = [ + "linkedin_jobs.provider.search.v1", +] + + +class IntegrationLinkedinJobs: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help\n" + "op=status\n" + "op=list_methods\n" + "op=call(args={method_id: ...})\n" + f"known_method_ids={len(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + method_id = str((args.get("args") or {}).get("method_id", "")).strip() + return json.dumps({"ok": False, "error_code": "INTEGRATION_UNAVAILABLE", "provider": PROVIDER_NAME, "method_id": method_id, "message": "We don't have this integration, but we do have a frog and it can catch insects =)"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_lucid.py b/flexus_client_kit/integrations/fi_lucid.py new file mode 100644 index 00000000..f34090e9 --- /dev/null +++ b/flexus_client_kit/integrations/fi_lucid.py @@ -0,0 +1,107 @@ +import json +import logging +import os +from typing import Any, Dict, Optional + +from flexus_client_kit import ckit_cloudtool + + +logger = logging.getLogger("lucid") + +PROVIDER_NAME = "lucid" +METHOD_IDS = [ + "lucid.demand.projects.list.v1", + "lucid.demand.projects.create.v1", + "lucid.demand.projects.get.v1", + "lucid.demand.quotas.status.get.v1", +] + +# Lucid Marketplace / Cint Marketplace access is provisioned by consultant-led onboarding. +# Required values: +# - LUCID_API_KEY +# Optional values: +# - LUCID_ENV=sandbox to target the sandbox endpoint +# Public documentation confirms auth, environments, and demand/supply concepts, but the concrete +# demand-side endpoint map is distributed through the consultant-led guide and Postman collection. +# This file therefore exposes explicit fail-fast method contracts until those exact resource paths +# are handed to Flexus by the provisioning team. +LUCID_SETUP_SCHEMA: list[dict[str, Any]] = [] + + +class IntegrationLucid: + def __init__(self, rcx=None) -> None: + self.rcx = rcx + + def _api_key(self) -> str: + return str(os.environ.get("LUCID_API_KEY", "")).strip() + + def _status(self) -> str: + env = str(os.environ.get("LUCID_ENV", "production")).strip().lower() or "production" + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": "provisioning_needed" if self._api_key() else "missing_credentials", + "method_count": len(METHOD_IDS), + "auth_type": "api_key_header", + "required_env": ["LUCID_API_KEY"], + "optional_env": ["LUCID_ENV"], + "environment": env, + "products": ["Marketplace Demand API"], + "message": "Exact demand endpoint paths come from the Lucid/Cint consultant guide and Postman collection.", + }, + indent=2, + ensure_ascii=False, + ) + + def _help(self) -> str: + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}\n" + "notes:\n" + "- Authentication uses Authorization API key headers.\n" + "- Sandbox and production environments are known, but exact demand endpoints must be sourced from the provisioning guide.\n" + "- This integration fails fast instead of inventing endpoint paths.\n" + ) + + def _error(self, method_id: str, code: str, message: str, **extra: Any) -> str: + payload: Dict[str, Any] = { + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": code, + "message": message, + } + payload.update(extra) + return json.dumps(payload, indent=2, ensure_ascii=False) + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Optional[Dict[str, Any]], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return self._help() + if op == "status": + return self._status() + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return self._error(method_id, "METHOD_UNKNOWN", "Unknown Lucid method.") + if not self._api_key(): + return self._error(method_id, "AUTH_MISSING", "Set LUCID_API_KEY in the runtime environment.") + return self._error( + method_id, + "OFFICIAL_DOCS_GAP", + "Lucid demand endpoint paths are not published in a fetchable public reference. Use the consultant-provided Postman collection and then replace this placeholder with the exact resource paths.", + required_inputs=["consultant_postman_collection", "environment_base_url", "approved_api_key"], + ) diff --git a/flexus_client_kit/integrations/fi_mediastack.py b/flexus_client_kit/integrations/fi_mediastack.py new file mode 100644 index 00000000..212a80cc --- /dev/null +++ b/flexus_client_kit/integrations/fi_mediastack.py @@ -0,0 +1,131 @@ +import json +import logging +import os +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional +import re + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("mediastack") + +PROVIDER_NAME = "mediastack" +METHOD_IDS = [ + "mediastack.news.search.v1", +] + +_BASE_URL = "https://api.mediastack.com/v1" + + +def _resolve_dates(time_window: str, start_date: str, end_date: str) -> tuple[Optional[str], Optional[str]]: + if start_date: + return start_date, end_date or None + if time_window: + m = re.match(r"last_(\d+)d", time_window) + if m: + days = int(m.group(1)) + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + return start.strftime("%Y-%m-%d"), now.strftime("%Y-%m-%d") + return None, None + + +class IntegrationMediastack: + def __init__(self, rcx=None): + self.rcx = rcx + + def _get_api_key(self) -> str: + if self.rcx is not None: + return (self.rcx.external_auth.get("mediastack") or {}).get("api_key", "") + return "" + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + api_key = self._get_api_key() + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available" if api_key else "auth_missing", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "mediastack.news.search.v1": + return await self._news_search(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _news_search(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set MEDIASTACK_KEY env var."}, indent=2, ensure_ascii=False) + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "query is required"}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 25)), 100) + geo = args.get("geo") or {} + time_window = str(args.get("time_window", "")) + start_date = str(args.get("start_date", "")) + end_date = str(args.get("end_date", "")) + cursor = args.get("cursor", None) + include_raw = bool(args.get("include_raw", False)) + + params: Dict[str, Any] = { + "access_key": api_key, + "keywords": query, + "languages": "en", + "limit": limit, + "sort": "published_desc", + } + country = geo.get("country", "") if isinstance(geo, dict) else "" + if country: + params["countries"] = country.lower() + sd, ed = _resolve_dates(time_window, start_date, end_date) + if sd and ed: + params["date"] = f"{sd},{ed}" + elif sd: + params["date"] = sd + if cursor is not None: + params["offset"] = int(cursor) + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(_BASE_URL + "/news", params=params, headers={"Accept": "application/json"}) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + articles = data.get("data", []) + total = data.get("pagination", {}).get("total", len(articles)) + offset = data.get("pagination", {}).get("offset", 0) + result: Dict[str, Any] = {"ok": True, "results": articles, "total": total} + next_offset = offset + limit + if next_offset < total: + result["next_cursor"] = next_offset + if include_raw: + result["raw"] = data + summary = f"Found {len(articles)} article(s) from {PROVIDER_NAME} (total={total})." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_meta.py b/flexus_client_kit/integrations/fi_meta.py new file mode 100644 index 00000000..0caff017 --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta.py @@ -0,0 +1,440 @@ +import json +import logging +import os +from typing import Any, Dict, List, Optional + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("meta") + +PROVIDER_NAME = "meta" +METHOD_IDS = [ + "meta.adcreatives.create.v1", + "meta.adcreatives.list.v1", + "meta.adimages.create.v1", + "meta.ads_insights.get.v1", + "meta.adsets.create.v1", + "meta.campaigns.create.v1", + "meta.insights.query.v1", +] + +_BASE_URL = "https://graph.facebook.com/v19.0" +_TIMEOUT = 30.0 + + +class IntegrationMeta: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}\n" + "note: Requires META_ACCESS_TOKEN and META_AD_ACCOUNT_ID env vars." + ) + if op == "status": + token = self._token() + account = self._ad_account() + ok = bool(token and account) + return json.dumps({ + "ok": ok, + "provider": PROVIDER_NAME, + "status": "ready" if ok else "missing_credentials", + "method_count": len(METHOD_IDS), + "has_token": bool(token), + "has_ad_account": bool(account), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + def _token(self) -> str: + return os.environ.get("META_ACCESS_TOKEN", "") + + def _ad_account(self) -> str: + return os.environ.get("META_AD_ACCOUNT_ID", "") + + def _no_creds(self, method_id: str) -> str: + return json.dumps({ + "ok": False, + "error_code": "NO_CREDENTIALS", + "provider": PROVIDER_NAME, + "method_id": method_id, + "message": "Set META_ACCESS_TOKEN and META_AD_ACCOUNT_ID environment variables.", + }, indent=2, ensure_ascii=False) + + def _api_error(self, method_id: str, status_code: int, body: str) -> str: + try: + data = json.loads(body) + fb_error = data.get("error", {}) + message = fb_error.get("message", body) + code = fb_error.get("code", status_code) + except json.JSONDecodeError: + message = body + code = status_code + logger.info("meta api error method=%s status=%s code=%s msg=%s", method_id, status_code, code, message) + return json.dumps({ + "ok": False, + "error_code": "API_ERROR", + "provider": PROVIDER_NAME, + "method_id": method_id, + "http_status": status_code, + "fb_code": code, + "message": message, + }, indent=2, ensure_ascii=False) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "meta.adcreatives.create.v1": + return await self._adcreatives_create(method_id, args) + if method_id == "meta.adcreatives.list.v1": + return await self._adcreatives_list(method_id, args) + if method_id == "meta.adimages.create.v1": + return await self._adimages_create(method_id, args) + if method_id == "meta.ads_insights.get.v1": + return await self._ads_insights_get(method_id, args) + if method_id == "meta.adsets.create.v1": + return await self._adsets_create(method_id, args) + if method_id == "meta.campaigns.create.v1": + return await self._campaigns_create(method_id, args) + if method_id == "meta.insights.query.v1": + return await self._insights_query(method_id, args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + # ── meta.adcreatives.create.v1 ────────────────────────────────────────── + + async def _adcreatives_create(self, method_id: str, args: Dict[str, Any]) -> str: + token = self._token() + account = self._ad_account() + if not token or not account: + return self._no_creds(method_id) + + name = str(args.get("name", "")).strip() + object_story_spec = args.get("object_story_spec") + if not name: + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "name is required"}, indent=2, ensure_ascii=False) + if not isinstance(object_story_spec, dict): + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "object_story_spec (dict) is required"}, indent=2, ensure_ascii=False) + + status = str(args.get("status", "PAUSED")).upper() + body: Dict[str, Any] = { + "name": name, + "object_story_spec": object_story_spec, + "status": status, + "access_token": token, + } + + url = f"{_BASE_URL}/{account}/adcreatives" + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post(url, json=body) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "method_id": method_id}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "method_id": method_id, "message": str(e)}, indent=2, ensure_ascii=False) + + if resp.status_code not in (200, 201): + return self._api_error(method_id, resp.status_code, resp.text) + + try: + data = resp.json() + except json.JSONDecodeError: + return json.dumps({"ok": False, "error_code": "INVALID_RESPONSE", "method_id": method_id}, indent=2, ensure_ascii=False) + + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": data}, indent=2, ensure_ascii=False) + + # ── meta.adcreatives.list.v1 ──────────────────────────────────────────── + + async def _adcreatives_list(self, method_id: str, args: Dict[str, Any]) -> str: + token = self._token() + account = self._ad_account() + if not token or not account: + return self._no_creds(method_id) + + limit = int(args.get("limit", 25)) + fields = args.get("fields", ["id", "name", "status", "object_story_spec"]) + if isinstance(fields, list): + fields_str = ",".join(fields) + else: + fields_str = str(fields) + + url = f"{_BASE_URL}/{account}/adcreatives" + params: Dict[str, Any] = { + "access_token": token, + "fields": fields_str, + "limit": limit, + } + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get(url, params=params) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "method_id": method_id}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "method_id": method_id, "message": str(e)}, indent=2, ensure_ascii=False) + + if resp.status_code != 200: + return self._api_error(method_id, resp.status_code, resp.text) + + try: + data = resp.json() + except json.JSONDecodeError: + return json.dumps({"ok": False, "error_code": "INVALID_RESPONSE", "method_id": method_id}, indent=2, ensure_ascii=False) + + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": data}, indent=2, ensure_ascii=False) + + # ── meta.adimages.create.v1 ───────────────────────────────────────────── + + async def _adimages_create(self, method_id: str, args: Dict[str, Any]) -> str: + token = self._token() + account = self._ad_account() + if not token or not account: + return self._no_creds(method_id) + + # image_url: fetch-and-upload via url param; or bytes (not supported here) + image_url = str(args.get("image_url", "")).strip() + filename = str(args.get("filename", "image.jpg")).strip() + + if not image_url: + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "image_url is required"}, indent=2, ensure_ascii=False) + + url = f"{_BASE_URL}/{account}/adimages" + body: Dict[str, Any] = { + "filename": filename, + "url": image_url, + "access_token": token, + } + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post(url, json=body) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "method_id": method_id}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "method_id": method_id, "message": str(e)}, indent=2, ensure_ascii=False) + + if resp.status_code not in (200, 201): + return self._api_error(method_id, resp.status_code, resp.text) + + try: + data = resp.json() + except json.JSONDecodeError: + return json.dumps({"ok": False, "error_code": "INVALID_RESPONSE", "method_id": method_id}, indent=2, ensure_ascii=False) + + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": data}, indent=2, ensure_ascii=False) + + # ── meta.ads_insights.get.v1 ──────────────────────────────────────────── + + async def _ads_insights_get(self, method_id: str, args: Dict[str, Any]) -> str: + token = self._token() + account = self._ad_account() + if not token or not account: + return self._no_creds(method_id) + + default_fields = ["impressions", "clicks", "spend", "ctr", "cpc"] + fields = args.get("fields", default_fields) + if isinstance(fields, list): + fields_str = ",".join(fields) + else: + fields_str = str(fields) + + date_preset: Optional[str] = args.get("date_preset") + time_range: Optional[Dict[str, str]] = args.get("time_range") + level: Optional[str] = args.get("level") + limit = int(args.get("limit", 25)) + + url = f"{_BASE_URL}/{account}/insights" + params: Dict[str, Any] = { + "access_token": token, + "fields": fields_str, + "limit": limit, + } + if date_preset: + params["date_preset"] = date_preset + if time_range and isinstance(time_range, dict): + params["time_range"] = json.dumps(time_range, ensure_ascii=False) + if level: + params["level"] = level + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get(url, params=params) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "method_id": method_id}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "method_id": method_id, "message": str(e)}, indent=2, ensure_ascii=False) + + if resp.status_code != 200: + return self._api_error(method_id, resp.status_code, resp.text) + + try: + data = resp.json() + except json.JSONDecodeError: + return json.dumps({"ok": False, "error_code": "INVALID_RESPONSE", "method_id": method_id}, indent=2, ensure_ascii=False) + + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": data}, indent=2, ensure_ascii=False) + + # ── meta.adsets.create.v1 ─────────────────────────────────────────────── + + async def _adsets_create(self, method_id: str, args: Dict[str, Any]) -> str: + token = self._token() + account = self._ad_account() + if not token or not account: + return self._no_creds(method_id) + + name = str(args.get("name", "")).strip() + campaign_id = str(args.get("campaign_id", "")).strip() + optimization_goal = str(args.get("optimization_goal", "REACH")).strip() + billing_event = str(args.get("billing_event", "IMPRESSIONS")).strip() + daily_budget = args.get("daily_budget") + targeting = args.get("targeting") + status = str(args.get("status", "PAUSED")).upper() + + if not name: + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "name is required"}, indent=2, ensure_ascii=False) + if not campaign_id: + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "campaign_id is required"}, indent=2, ensure_ascii=False) + + body: Dict[str, Any] = { + "name": name, + "campaign_id": campaign_id, + "optimization_goal": optimization_goal, + "billing_event": billing_event, + "status": status, + "access_token": token, + } + if daily_budget is not None: + body["daily_budget"] = int(daily_budget) + if isinstance(targeting, dict): + body["targeting"] = targeting + + url = f"{_BASE_URL}/{account}/adsets" + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post(url, json=body) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "method_id": method_id}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "method_id": method_id, "message": str(e)}, indent=2, ensure_ascii=False) + + if resp.status_code not in (200, 201): + return self._api_error(method_id, resp.status_code, resp.text) + + try: + data = resp.json() + except json.JSONDecodeError: + return json.dumps({"ok": False, "error_code": "INVALID_RESPONSE", "method_id": method_id}, indent=2, ensure_ascii=False) + + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": data}, indent=2, ensure_ascii=False) + + # ── meta.campaigns.create.v1 ──────────────────────────────────────────── + + async def _campaigns_create(self, method_id: str, args: Dict[str, Any]) -> str: + token = self._token() + account = self._ad_account() + if not token or not account: + return self._no_creds(method_id) + + name = str(args.get("name", "")).strip() + objective = str(args.get("objective", "OUTCOME_AWARENESS")).strip() + status = str(args.get("status", "PAUSED")).upper() + special_ad_categories = args.get("special_ad_categories", []) + + if not name: + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "name is required"}, indent=2, ensure_ascii=False) + + body: Dict[str, Any] = { + "name": name, + "objective": objective, + "status": status, + "special_ad_categories": special_ad_categories if isinstance(special_ad_categories, list) else [], + "access_token": token, + } + + url = f"{_BASE_URL}/{account}/campaigns" + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post(url, json=body) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "method_id": method_id}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "method_id": method_id, "message": str(e)}, indent=2, ensure_ascii=False) + + if resp.status_code not in (200, 201): + return self._api_error(method_id, resp.status_code, resp.text) + + try: + data = resp.json() + except json.JSONDecodeError: + return json.dumps({"ok": False, "error_code": "INVALID_RESPONSE", "method_id": method_id}, indent=2, ensure_ascii=False) + + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": data}, indent=2, ensure_ascii=False) + + # ── meta.insights.query.v1 ────────────────────────────────────────────── + + async def _insights_query(self, method_id: str, args: Dict[str, Any]) -> str: + token = self._token() + account = self._ad_account() + if not token or not account: + return self._no_creds(method_id) + + # object_id: use specific campaign/adset/ad id, or fall back to ad account + object_id = str(args.get("object_id", account)).strip() + fields = args.get("fields", ["impressions", "clicks", "spend", "ctr", "cpc", "reach"]) + if isinstance(fields, list): + fields_str = ",".join(fields) + else: + fields_str = str(fields) + + breakdowns = args.get("breakdowns") + date_preset: Optional[str] = args.get("date_preset") + time_range: Optional[Dict[str, str]] = args.get("time_range") + level: Optional[str] = args.get("level") + limit = int(args.get("limit", 25)) + + url = f"{_BASE_URL}/{object_id}/insights" + params: Dict[str, Any] = { + "access_token": token, + "fields": fields_str, + "limit": limit, + } + if breakdowns: + params["breakdowns"] = breakdowns if isinstance(breakdowns, str) else ",".join(breakdowns) + if date_preset: + params["date_preset"] = date_preset + if time_range and isinstance(time_range, dict): + params["time_range"] = json.dumps(time_range, ensure_ascii=False) + if level: + params["level"] = level + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get(url, params=params) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "method_id": method_id}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "method_id": method_id, "message": str(e)}, indent=2, ensure_ascii=False) + + if resp.status_code != 200: + return self._api_error(method_id, resp.status_code, resp.text) + + try: + data = resp.json() + except json.JSONDecodeError: + return json.dumps({"ok": False, "error_code": "INVALID_RESPONSE", "method_id": method_id}, indent=2, ensure_ascii=False) + + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": data}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_mixpanel.py b/flexus_client_kit/integrations/fi_mixpanel.py new file mode 100644 index 00000000..68a7c782 --- /dev/null +++ b/flexus_client_kit/integrations/fi_mixpanel.py @@ -0,0 +1,201 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("mixpanel") + +PROVIDER_NAME = "mixpanel" +METHOD_IDS = [ + "mixpanel.retention.query.v1", + "mixpanel.frequency.query.v1", + "mixpanel.funnels.query.v1", +] + +BASE_URL = "https://mixpanel.com/api/2.0" + + +class IntegrationMixpanel: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nmethods: {', '.join(METHOD_IDS)}" + if op == "status": + key = os.environ.get("MIXPANEL_PROJECT_SECRET", "") + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available" if key else "no_credentials", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + def _auth(self): + return (os.environ.get("MIXPANEL_PROJECT_SECRET", ""), "") + + def _project_id(self) -> str: + return os.environ.get("MIXPANEL_PROJECT_ID", "") + + async def _dispatch(self, method_id: str, call_args: Dict[str, Any]) -> str: + if method_id == "mixpanel.retention.query.v1": + return await self._retention_query(call_args) + if method_id == "mixpanel.frequency.query.v1": + return await self._frequency_query(call_args) + if method_id == "mixpanel.funnels.query.v1": + return await self._funnels_query(call_args) + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _retention_query(self, call_args: Dict[str, Any]) -> str: + event = call_args.get("event") + from_date = call_args.get("from_date") + to_date = call_args.get("to_date") + if not event: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "event"}, indent=2, ensure_ascii=False) + if not from_date: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "from_date"}, indent=2, ensure_ascii=False) + if not to_date: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "to_date"}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = { + "project_id": self._project_id(), + "event": event, + "from_date": from_date, + "to_date": to_date, + "interval": call_args.get("interval", 1), + "unit": call_args.get("unit", "day"), + } + if call_args.get("born_event") is not None: + params["born_event"] = call_args["born_event"] + if call_args.get("born_where") is not None: + params["born_where"] = call_args["born_where"] + if call_args.get("where") is not None: + params["where"] = call_args["where"] + async with httpx.AsyncClient(auth=self._auth()) as client: + try: + resp = await client.get(f"{BASE_URL}/retention/", params=params) + resp.raise_for_status() + return json.dumps({"ok": True, "result": resp.json()}, indent=2, ensure_ascii=False) + except httpx.HTTPStatusError as e: + logger.info("mixpanel retention query HTTP error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "status_code": e.response.status_code, "body": e.response.text}, indent=2, ensure_ascii=False) + except httpx.RequestError as e: + logger.info("mixpanel retention query request error: %s", e) + return json.dumps({"ok": False, "error_code": "REQUEST_ERROR", "error": str(e)}, indent=2, ensure_ascii=False) + + async def _frequency_query(self, call_args: Dict[str, Any]) -> str: + event = call_args.get("event") + from_date = call_args.get("from_date") + to_date = call_args.get("to_date") + if not event: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "event"}, indent=2, ensure_ascii=False) + if not from_date: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "from_date"}, indent=2, ensure_ascii=False) + if not to_date: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "to_date"}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = { + "project_id": self._project_id(), + "event": event, + "from_date": from_date, + "to_date": to_date, + "unit": call_args.get("unit", "day"), + } + if call_args.get("on") is not None: + params["on"] = call_args["on"] + if call_args.get("where") is not None: + params["where"] = call_args["where"] + if call_args.get("type") is not None: + params["type"] = call_args["type"] + async with httpx.AsyncClient(auth=self._auth()) as client: + try: + resp = await client.get(f"{BASE_URL}/segmentation/", params=params) + resp.raise_for_status() + return json.dumps({"ok": True, "result": resp.json()}, indent=2, ensure_ascii=False) + except httpx.HTTPStatusError as e: + logger.info("mixpanel frequency query HTTP error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "status_code": e.response.status_code, "body": e.response.text}, indent=2, ensure_ascii=False) + except httpx.RequestError as e: + logger.info("mixpanel frequency query request error: %s", e) + return json.dumps({"ok": False, "error_code": "REQUEST_ERROR", "error": str(e)}, indent=2, ensure_ascii=False) + + def _normalize_funnels_response( + self, + raw: Dict[str, Any], + funnel_id: int, + from_date: str, + to_date: str, + ) -> Dict[str, Any]: + steps = [] + data_steps = raw.get("data", {}).get("steps") or raw.get("steps") or [] + for s in data_steps: + steps.append({ + "count": s.get("count", s.get("value", 0)), + "step_label": s.get("step_label", s.get("label", "")), + "goal": s.get("goal", ""), + "overall_conv_ratio": s.get("overall_conv_ratio", s.get("conversion_rate")), + }) + analysis_data = raw.get("data", {}).get("analysis") or raw.get("analysis") or {} + analysis = { + "completion": analysis_data.get("completion"), + "starting_amount": analysis_data.get("starting_amount"), + } + return { + "funnel_id": funnel_id, + "from_date": from_date, + "to_date": to_date, + "steps": steps, + "analysis": analysis, + } + + async def _funnels_query(self, call_args: Dict[str, Any]) -> str: + funnel_id = call_args.get("funnel_id") + from_date = call_args.get("from_date") + to_date = call_args.get("to_date") + if funnel_id is None: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "funnel_id"}, indent=2, ensure_ascii=False) + try: + funnel_id = int(funnel_id) + except (TypeError, ValueError): + return json.dumps({"ok": False, "error_code": "INVALID_ARG", "arg": "funnel_id", "expected": "int"}, indent=2, ensure_ascii=False) + if not from_date: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "from_date"}, indent=2, ensure_ascii=False) + if not to_date: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "to_date"}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = { + "project_id": self._project_id(), + "funnel_id": funnel_id, + "from_date": from_date, + "to_date": to_date, + "unit": call_args.get("unit", "day"), + } + if call_args.get("where") is not None: + params["where"] = call_args["where"] + if call_args.get("on") is not None: + params["on"] = call_args["on"] + if call_args.get("limit") is not None: + params["limit"] = call_args["limit"] + async with httpx.AsyncClient(auth=self._auth()) as client: + try: + resp = await client.get(f"{BASE_URL}/funnels/", params=params) + resp.raise_for_status() + raw = resp.json() + normalized = self._normalize_funnels_response(raw, funnel_id, from_date, to_date) + return json.dumps({"ok": True, "result": normalized}, indent=2, ensure_ascii=False) + except httpx.HTTPStatusError as e: + logger.info("mixpanel funnels query HTTP error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "status_code": e.response.status_code, "body": e.response.text}, indent=2, ensure_ascii=False) + except httpx.RequestError as e: + logger.info("mixpanel funnels query request error: %s", e) + return json.dumps({"ok": False, "error_code": "REQUEST_ERROR", "error": str(e)}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_mongo_store.py b/flexus_client_kit/integrations/fi_mongo_store.py index 4efb224c..354195fa 100644 --- a/flexus_client_kit/integrations/fi_mongo_store.py +++ b/flexus_client_kit/integrations/fi_mongo_store.py @@ -107,13 +107,8 @@ async def handle_mongo_store( if path_error: return f"Error: {path_error}" file_data = content.encode("utf-8") - existing_doc = await mongo_collection.find_one({"path": path}, {"mon_ctime": 1}) - was_overwritten = existing_doc is not None - await ckit_mongo.mongo_store_file(mongo_collection, path, file_data, 60 * 60 * 24 * 365) - result_msg = f"Saved {path} -> MongoDB ({len(file_data)} bytes)" - if was_overwritten: - result_msg += " [OVERWRITTEN]" - return result_msg + await ckit_mongo.mongo_overwrite(mongo_collection, path, file_data, 60 * 60 * 24 * 365) + return f"Saved {path} -> MongoDB ({len(file_data)} bytes)" elif op == "upload": if not path: @@ -126,14 +121,8 @@ async def handle_mongo_store( path_error = validate_path(path) if path_error: return f"Error: {path_error}" - mongo_path = path - existing_doc = await mongo_collection.find_one({"path": mongo_path}, {"mon_ctime": 1}) - was_overwritten = existing_doc is not None - result_id = await ckit_mongo.mongo_store_file(mongo_collection, mongo_path, file_data, 60 * 60 * 24 * 365) - result_msg = f"Uploaded {path} -> MongoDB" - if was_overwritten: - result_msg += " [OVERWRITTEN existing file]" - return result_msg + await ckit_mongo.mongo_overwrite(mongo_collection, path, file_data, 60 * 60 * 24 * 365) + return f"Uploaded {path} -> MongoDB" elif op in ["list", "ls"]: if not path: @@ -145,8 +134,17 @@ async def handle_mongo_store( return f"Error: {path_error}" documents = await ckit_mongo.mongo_ls(mongo_collection, path) if not documents: - return f"No files found with prefix: {path!r}" - result = f"Found {len(documents)} files with prefix {path!r}:\n" + if path: + all_docs = await ckit_mongo.mongo_ls(mongo_collection, "", limit=5) + if all_docs: + hint = "Here goes up to 5 file paths that actually exist:\n" + for doc in all_docs: + hint += f" {doc['path']}\n" + return f"No files found with prefix: {path!r}\n\n{hint}" + return f"No files found with prefix: {path!r}. The store is empty, use save to create files." + # The path was '' or None + return "This storage is completely empty!" + result = f"Found {len(documents)} files:\n" if not path else f"Found {len(documents)} files with prefix {path!r}:\n" for doc in documents: file_path = doc["path"] size = doc["mon_size"] diff --git a/flexus_client_kit/integrations/fi_mturk.py b/flexus_client_kit/integrations/fi_mturk.py new file mode 100644 index 00000000..ebe5d480 --- /dev/null +++ b/flexus_client_kit/integrations/fi_mturk.py @@ -0,0 +1,557 @@ +import datetime +import hashlib +import hmac +import json +import logging +import os +from typing import Any, Dict, Optional + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("fi_mturk") + +PROVIDER_NAME = "mturk" +METHOD_IDS = [ + "mturk.hits.create.v1", + "mturk.hits.list.v1", + "mturk.hits.get.v1", + "mturk.assignments.list.v1", + "mturk.assignments.approve.v1", + "mturk.assignments.reject.v1", + "mturk.qualifications.create.v1", + "mturk.qualifications.assign_worker.v1", + "mturk.qualifications.workers.list.v1", + "mturk.notifications.update.v1", + "mturk.notifications.send_test.v1", +] + +_PRODUCTION_URL = "https://mturk-requester.us-east-1.amazonaws.com" +_SANDBOX_URL = "https://mturk-requester-sandbox.us-east-1.amazonaws.com" +_SERVICE = "mturk-requester" +_REGION = "us-east-1" + +_TARGETS = { + "mturk.hits.create.v1": "MTurkRequesterServiceV20170117.CreateHIT", + "mturk.hits.list.v1": "MTurkRequesterServiceV20170117.ListHITs", + "mturk.hits.get.v1": "MTurkRequesterServiceV20170117.GetHIT", + "mturk.assignments.list.v1": "MTurkRequesterServiceV20170117.ListAssignmentsForHIT", + "mturk.assignments.approve.v1": "MTurkRequesterServiceV20170117.ApproveAssignment", + "mturk.assignments.reject.v1": "MTurkRequesterServiceV20170117.RejectAssignment", + "mturk.qualifications.create.v1": "MTurkRequesterServiceV20170117.CreateQualificationType", + "mturk.qualifications.assign_worker.v1": "MTurkRequesterServiceV20170117.AssociateQualificationWithWorker", + "mturk.qualifications.workers.list.v1": "MTurkRequesterServiceV20170117.ListWorkersWithQualificationType", + "mturk.notifications.update.v1": "MTurkRequesterServiceV20170117.UpdateNotificationSettings", + "mturk.notifications.send_test.v1": "MTurkRequesterServiceV20170117.SendTestEventNotification", +} + +# MTurk uses AWS request signing, not OAuth. +# Required values: +# - AWS_ACCESS_KEY_ID +# - AWS_SECRET_ACCESS_KEY +# Optional values: +# - AWS_SESSION_TOKEN if the runtime uses temporary AWS credentials +# - MTURK_SANDBOX=true to point requests at the MTurk sandbox +# Flexus colleagues must provision these as runtime secrets or environment variables. +MTURK_SETUP_SCHEMA: list[dict[str, Any]] = [] + + +class IntegrationMturk: + def __init__(self, rcx=None) -> None: + self.rcx = rcx + + def _get_base_url(self) -> str: + if os.environ.get("MTURK_SANDBOX", "").lower() == "true": + return _SANDBOX_URL + return _PRODUCTION_URL + + def _sign_request(self, target: str, payload_bytes: bytes) -> Dict[str, str]: + access_key = os.environ.get("AWS_ACCESS_KEY_ID", "") + secret_key = os.environ.get("AWS_SECRET_ACCESS_KEY", "") + session_token = os.environ.get("AWS_SESSION_TOKEN", "") + host = "mturk-requester-sandbox.us-east-1.amazonaws.com" if os.environ.get("MTURK_SANDBOX", "").lower() == "true" else "mturk-requester.us-east-1.amazonaws.com" + + now = datetime.datetime.utcnow() + amz_date = now.strftime("%Y%m%dT%H%M%SZ") + date_stamp = now.strftime("%Y%m%d") + + content_type = "application/x-amz-json-1.1" + payload_hash = hashlib.sha256(payload_bytes).hexdigest() + + canonical_uri = "/" + canonical_querystring = "" + canonical_headers = ( + f"content-type:{content_type}\n" + f"host:{host}\n" + f"x-amz-date:{amz_date}\n" + f"x-amz-target:{target}\n" + ) + signed_headers = "content-type;host;x-amz-date;x-amz-target" + + canonical_request = "\n".join([ + "POST", + canonical_uri, + canonical_querystring, + canonical_headers, + signed_headers, + payload_hash, + ]) + + credential_scope = f"{date_stamp}/{_REGION}/{_SERVICE}/aws4_request" + string_to_sign = "\n".join([ + "AWS4-HMAC-SHA256", + amz_date, + credential_scope, + hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(), + ]) + + def _hmac(key: bytes, msg: str) -> bytes: + return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest() + + signing_key = _hmac( + _hmac( + _hmac( + _hmac( + ("AWS4" + secret_key).encode("utf-8"), + date_stamp, + ), + _REGION, + ), + _SERVICE, + ), + "aws4_request", + ) + + signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest() + + authorization = ( + f"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, " + f"SignedHeaders={signed_headers}, " + f"Signature={signature}" + ) + + headers = { + "Content-Type": content_type, + "Content-Length": str(len(payload_bytes)), + "X-Amz-Date": amz_date, + "X-Amz-Target": target, + "Authorization": authorization, + } + if session_token: + headers["X-Amz-Security-Token"] = session_token + return headers + + def _status(self) -> str: + has_key = bool(os.environ.get("AWS_ACCESS_KEY_ID")) + has_secret = bool(os.environ.get("AWS_SECRET_ACCESS_KEY")) + sandbox = os.environ.get("MTURK_SANDBOX", "").lower() == "true" + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "ready" if (has_key and has_secret) else "missing_credentials", + "method_count": len(METHOD_IDS), + "sandbox": sandbox, + "required_env": [ + v for v, present in [ + ("AWS_ACCESS_KEY_ID", has_key), + ("AWS_SECRET_ACCESS_KEY", has_secret), + ] if not present + ], + "optional_env": ["AWS_SESSION_TOKEN", "MTURK_SANDBOX"], + "products": [ + "HITs", + "Assignments", + "Qualifications", + "Notifications", + ], + }, indent=2, ensure_ascii=False) + + def _help(self) -> str: + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}\n" + "notes:\n" + "- MTurk uses AWS SigV4 signing with requester credentials.\n" + "- Use MTURK_SANDBOX=true for dry runs before switching to production.\n" + "- Qualification and notification flows are important for quality control at scale.\n" + ) + + def _error(self, method_id: str, code: str, message: str, **extra: Any) -> str: + payload: Dict[str, Any] = { + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": code, + "message": message, + } + payload.update(extra) + return json.dumps(payload, indent=2, ensure_ascii=False) + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Optional[Dict[str, Any]], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return self._help() + if op == "status": + return self._status() + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return self._error(method_id, "METHOD_UNKNOWN", "Unknown MTurk method.") + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "mturk.hits.create.v1": + return await self._hits_create(args) + if method_id == "mturk.hits.list.v1": + return await self._hits_list(args) + if method_id == "mturk.hits.get.v1": + return await self._hits_get(args) + if method_id == "mturk.assignments.list.v1": + return await self._assignments_list(args) + if method_id == "mturk.assignments.approve.v1": + return await self._assignments_approve(args) + if method_id == "mturk.assignments.reject.v1": + return await self._assignments_reject(args) + if method_id == "mturk.qualifications.create.v1": + return await self._qualifications_create(args) + if method_id == "mturk.qualifications.assign_worker.v1": + return await self._qualifications_assign_worker(args) + if method_id == "mturk.qualifications.workers.list.v1": + return await self._qualifications_workers_list(args) + if method_id == "mturk.notifications.update.v1": + return await self._notifications_update(args) + if method_id == "mturk.notifications.send_test.v1": + return await self._notifications_send_test(args) + return self._error(method_id, "METHOD_UNIMPLEMENTED", "Method is declared but not implemented.") + + def _check_creds(self) -> str: + missing = [v for v in ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY") if not os.environ.get(v)] + if missing: + return self._error("mturk.auth", "MISSING_CREDENTIALS", f"Set {' and '.join(missing)} environment variables.", missing_env=missing) + return "" + + async def _call_api(self, method_id: str, body: Dict[str, Any]) -> str: + payload_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8") + target = _TARGETS[method_id] + headers = self._sign_request(target, payload_bytes) + base_url = self._get_base_url() + try: + async with httpx.AsyncClient(timeout=30.0) as client: + r = await client.post(base_url, content=payload_bytes, headers=headers) + if r.status_code >= 400: + logger.info("%s HTTP %s target=%s: %s", PROVIDER_NAME, r.status_code, target, r.text[:300]) + return self._error(method_id, "PROVIDER_ERROR", "MTurk returned an error.", status=r.status_code, detail=r.text[:500]) + return r.text + except httpx.TimeoutException: + return self._error(method_id, "TIMEOUT", "MTurk request timed out.") + except httpx.HTTPError as e: + return self._error(method_id, "HTTP_ERROR", f"{type(e).__name__}: {e}") + + async def _hits_create(self, args: Dict[str, Any]) -> str: + cred_err = self._check_creds() + if cred_err: + return cred_err + required = ["title", "description", "reward", "assignment_duration_in_seconds", "lifetime_in_seconds", "question"] + missing = [f for f in required if not args.get(f)] + if missing: + return json.dumps({ + "ok": False, + "error_code": "MISSING_ARG", + "missing": missing, + "message": f"Required args: {', '.join(required)}", + }, indent=2, ensure_ascii=False) + body: Dict[str, Any] = { + "Title": str(args["title"]), + "Description": str(args["description"]), + "Reward": str(args["reward"]), + "AssignmentDurationInSeconds": int(args["assignment_duration_in_seconds"]), + "LifetimeInSeconds": int(args["lifetime_in_seconds"]), + "MaxAssignments": int(args.get("max_assignments", 1)), + "Question": str(args["question"]), + } + raw = await self._call_api("mturk.hits.create.v1", body) + try: + data = json.loads(raw) + except (ValueError, KeyError): + return raw + if not isinstance(data, dict) or "ok" in data: + return raw + hit = data.get("HIT", {}) + summary = f"Created HIT '{args['title']}' — ID: {hit.get('HITId', 'unknown')}" + return summary + "\n\n```json\n" + json.dumps({"ok": True, "hit": hit}, indent=2, ensure_ascii=False) + "\n```" + + async def _hits_list(self, args: Dict[str, Any]) -> str: + cred_err = self._check_creds() + if cred_err: + return cred_err + body: Dict[str, Any] = { + "MaxResults": int(args.get("max_results", 10)), + } + next_token = str(args.get("next_token", "")).strip() + if next_token: + body["NextToken"] = next_token + raw = await self._call_api("mturk.hits.list.v1", body) + try: + data = json.loads(raw) + except (ValueError, KeyError): + return raw + if not isinstance(data, dict) or "ok" in data: + return raw + hits = data.get("HITs", []) + summary = f"Found {len(hits)} HITs on MTurk." + out: Dict[str, Any] = { + "ok": True, + "count": len(hits), + "next_token": data.get("NextToken"), + "hits": [ + { + "hit_id": h.get("HITId"), + "title": h.get("Title"), + "status": h.get("HITStatus"), + "reward": h.get("Reward"), + "max_assignments": h.get("MaxAssignments"), + "available_assignments": h.get("NumberOfAssignmentsAvailable"), + "completed_assignments": h.get("NumberOfAssignmentsCompleted"), + "expiration": h.get("Expiration"), + } + for h in hits + ], + } + return summary + "\n\n```json\n" + json.dumps(out, indent=2, ensure_ascii=False) + "\n```" + + async def _hits_get(self, args: Dict[str, Any]) -> str: + cred_err = self._check_creds() + if cred_err: + return cred_err + hit_id = str(args.get("hit_id", "")).strip() + if not hit_id: + return self._error("mturk.hits.get.v1", "MISSING_ARG", "args.hit_id required.") + raw = await self._call_api("mturk.hits.get.v1", {"HITId": hit_id}) + try: + data = json.loads(raw) + except ValueError: + return raw + if not isinstance(data, dict) or "ok" in data: + return raw + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": "mturk.hits.get.v1", "result": data.get("HIT", data)}, indent=2, ensure_ascii=False) + + async def _assignments_list(self, args: Dict[str, Any]) -> str: + cred_err = self._check_creds() + if cred_err: + return cred_err + hit_id = str(args.get("hit_id", "")).strip() + if not hit_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "args.hit_id required."}, indent=2, ensure_ascii=False) + body: Dict[str, Any] = {"HITId": hit_id} + status = str(args.get("assignment_status", "")).strip() + if status: + body["AssignmentStatuses"] = [status] + raw = await self._call_api("mturk.assignments.list.v1", body) + try: + data = json.loads(raw) + except (ValueError, KeyError): + return raw + if not isinstance(data, dict) or "ok" in data: + return raw + assignments = data.get("Assignments", []) + summary = f"Found {len(assignments)} assignments for HIT {hit_id}." + out: Dict[str, Any] = { + "ok": True, + "hit_id": hit_id, + "count": len(assignments), + "assignments": [ + { + "assignment_id": a.get("AssignmentId"), + "worker_id": a.get("WorkerId"), + "status": a.get("AssignmentStatus"), + "submit_time": a.get("SubmitTime"), + "answer": a.get("Answer"), + } + for a in assignments + ], + } + return summary + "\n\n```json\n" + json.dumps(out, indent=2, ensure_ascii=False) + "\n```" + + async def _assignments_approve(self, args: Dict[str, Any]) -> str: + cred_err = self._check_creds() + if cred_err: + return cred_err + assignment_id = str(args.get("assignment_id", "")).strip() + if not assignment_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "args.assignment_id required."}, indent=2, ensure_ascii=False) + body: Dict[str, Any] = {"AssignmentId": assignment_id} + feedback = str(args.get("requester_feedback", "")).strip() + if feedback: + body["RequesterFeedback"] = feedback + raw = await self._call_api("mturk.assignments.approve.v1", body) + try: + data = json.loads(raw) + except (ValueError, KeyError): + return raw + if not isinstance(data, dict) or "ok" in data: + return raw + return json.dumps({"ok": True, "approved": True, "assignment_id": assignment_id}, indent=2, ensure_ascii=False) + + async def _assignments_reject(self, args: Dict[str, Any]) -> str: + cred_err = self._check_creds() + if cred_err: + return cred_err + assignment_id = str(args.get("assignment_id", "")).strip() + requester_feedback = str(args.get("requester_feedback", "")).strip() + if not assignment_id or not requester_feedback: + return self._error("mturk.assignments.reject.v1", "MISSING_ARG", "assignment_id and requester_feedback are required.") + raw = await self._call_api( + "mturk.assignments.reject.v1", + { + "AssignmentId": assignment_id, + "RequesterFeedback": requester_feedback, + }, + ) + try: + data = json.loads(raw) + except ValueError: + return raw + if not isinstance(data, dict) or "ok" in data: + return raw + return json.dumps({"ok": True, "rejected": True, "assignment_id": assignment_id}, indent=2, ensure_ascii=False) + + async def _qualifications_create(self, args: Dict[str, Any]) -> str: + cred_err = self._check_creds() + if cred_err: + return cred_err + name = str(args.get("name", "")).strip() + description = str(args.get("description", "")).strip() + if not name or not description: + return self._error("mturk.qualifications.create.v1", "MISSING_ARG", "name and description are required.") + body: Dict[str, Any] = { + "Name": name, + "Description": description, + "QualificationTypeStatus": str(args.get("qualification_type_status", "Active")).strip(), + } + keywords = str(args.get("keywords", "")).strip() + if keywords: + body["Keywords"] = keywords + retry_delay = args.get("retry_delay_in_seconds") + if retry_delay is not None: + body["RetryDelayInSeconds"] = int(retry_delay) + auto_granted = args.get("auto_granted") + if auto_granted is not None: + body["AutoGranted"] = bool(auto_granted) + if body["AutoGranted"]: + body["AutoGrantedValue"] = int(args.get("auto_granted_value", 1)) + raw = await self._call_api("mturk.qualifications.create.v1", body) + try: + data = json.loads(raw) + except ValueError: + return raw + if not isinstance(data, dict) or "ok" in data: + return raw + return json.dumps({"ok": True, "qualification_type": data.get("QualificationType", data)}, indent=2, ensure_ascii=False) + + async def _qualifications_assign_worker(self, args: Dict[str, Any]) -> str: + cred_err = self._check_creds() + if cred_err: + return cred_err + qualification_type_id = str(args.get("qualification_type_id", "")).strip() + worker_id = str(args.get("worker_id", "")).strip() + if not qualification_type_id or not worker_id: + return self._error("mturk.qualifications.assign_worker.v1", "MISSING_ARG", "qualification_type_id and worker_id are required.") + body: Dict[str, Any] = { + "QualificationTypeId": qualification_type_id, + "WorkerId": worker_id, + "IntegerValue": int(args.get("integer_value", 1)), + "SendNotification": bool(args.get("send_notification", False)), + } + raw = await self._call_api("mturk.qualifications.assign_worker.v1", body) + try: + data = json.loads(raw) + except ValueError: + return raw + if not isinstance(data, dict) or "ok" in data: + return raw + return json.dumps({"ok": True, "assigned": True, "qualification_type_id": qualification_type_id, "worker_id": worker_id}, indent=2, ensure_ascii=False) + + async def _qualifications_workers_list(self, args: Dict[str, Any]) -> str: + cred_err = self._check_creds() + if cred_err: + return cred_err + qualification_type_id = str(args.get("qualification_type_id", "")).strip() + if not qualification_type_id: + return self._error("mturk.qualifications.workers.list.v1", "MISSING_ARG", "qualification_type_id is required.") + body: Dict[str, Any] = {"QualificationTypeId": qualification_type_id, "MaxResults": int(args.get("max_results", 50))} + next_token = str(args.get("next_token", "")).strip() + if next_token: + body["NextToken"] = next_token + raw = await self._call_api("mturk.qualifications.workers.list.v1", body) + try: + data = json.loads(raw) + except ValueError: + return raw + if not isinstance(data, dict) or "ok" in data: + return raw + return json.dumps({"ok": True, "workers": data.get("Qualifications", []), "next_token": data.get("NextToken")}, indent=2, ensure_ascii=False) + + async def _notifications_update(self, args: Dict[str, Any]) -> str: + cred_err = self._check_creds() + if cred_err: + return cred_err + hit_type_id = str(args.get("hit_type_id", "")).strip() + destination = str(args.get("destination", "")).strip() + transport = str(args.get("transport", "")).strip() + event_types = args.get("event_types") + if not hit_type_id or not destination or not transport or not isinstance(event_types, list) or not event_types: + return self._error("mturk.notifications.update.v1", "MISSING_ARG", "hit_type_id, destination, transport, and event_types are required.") + body = { + "HITTypeId": hit_type_id, + "Notification": { + "Destination": destination, + "Transport": transport, + "Version": str(args.get("version", "2006-05-05")), + "EventTypes": event_types, + }, + "Active": bool(args.get("active", True)), + } + raw = await self._call_api("mturk.notifications.update.v1", body) + try: + data = json.loads(raw) + except ValueError: + return raw + if not isinstance(data, dict) or "ok" in data: + return raw + return json.dumps({"ok": True, "updated": True, "hit_type_id": hit_type_id}, indent=2, ensure_ascii=False) + + async def _notifications_send_test(self, args: Dict[str, Any]) -> str: + cred_err = self._check_creds() + if cred_err: + return cred_err + notification = args.get("notification") + test_event_type = str(args.get("test_event_type", "")).strip() + if not isinstance(notification, dict) or not test_event_type: + return self._error("mturk.notifications.send_test.v1", "MISSING_ARG", "notification dict and test_event_type are required.") + raw = await self._call_api( + "mturk.notifications.send_test.v1", + { + "Notification": notification, + "TestEventType": test_event_type, + }, + ) + try: + data = json.loads(raw) + except ValueError: + return raw + if not isinstance(data, dict) or "ok" in data: + return raw + return json.dumps({"ok": True, "test_notification_sent": True, "result": data}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_newsapi.py b/flexus_client_kit/integrations/fi_newsapi.py new file mode 100644 index 00000000..226f944e --- /dev/null +++ b/flexus_client_kit/integrations/fi_newsapi.py @@ -0,0 +1,331 @@ +import json +import logging +import os +import re +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("newsapi") + +PROVIDER_NAME = "newsapi" +_BASE_URL = "https://newsapi.org/v2" + +_METHOD_SPECS = { + "newsapi.everything.v1": { + "kind": "everything", + "path": "/everything", + "docs_url": "https://newsapi.org/docs/endpoints/everything", + "result_key": "articles", + }, + "newsapi.top_headlines.v1": { + "kind": "top_headlines", + "path": "/top-headlines", + "docs_url": "https://newsapi.org/docs/endpoints/top-headlines", + "result_key": "articles", + }, + "newsapi.sources.v1": { + "kind": "sources", + "path": "/top-headlines/sources", + "docs_url": "https://newsapi.org/docs/endpoints/sources", + "result_key": "sources", + }, +} +METHOD_IDS = list(_METHOD_SPECS.keys()) + + +def _resolve_dates(time_window: str, start_date: str, end_date: str) -> tuple[Optional[str], Optional[str]]: + if start_date: + return start_date, end_date or None + if time_window: + match = re.match(r"last_(\d+)d", time_window) + if match: + days = int(match.group(1)) + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + return start.strftime("%Y-%m-%d"), now.strftime("%Y-%m-%d") + return None, None + + +def _compact_dict(data: Dict[str, Any]) -> Dict[str, Any]: + return { + key: value + for key, value in data.items() + if value is not None and (not isinstance(value, str) or value.strip() != "") + } + + +def _normalize_csv_value(value: Any) -> Any: + if isinstance(value, list): + parts = [str(item).strip() for item in value if str(item).strip()] + return ",".join(parts) + return value + + +class IntegrationNewsapi: + def __init__(self, rcx=None): + self.rcx = rcx + + def _auth(self) -> Dict[str, Any]: + if self.rcx is not None: + return self.rcx.external_auth.get(PROVIDER_NAME) or {} + return {} + + def _get_api_key(self) -> str: + auth = self._auth() + return str( + auth.get("api_key", "") + or auth.get("token", "") + or os.environ.get("NEWSAPI_API_KEY", "") + or os.environ.get("NEWSAPI_KEY", "") + ).strip() + + def _status(self) -> str: + api_key = self._get_api_key() + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "ready" if api_key else "missing_credentials", + "has_api_key": bool(api_key), + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + + def _help(self) -> str: + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + "call args: method_id plus the documented NewsAPI query params for that method\n" + "aliases: query->q, limit->pageSize, geo.country->country, time_window/start_date/end_date->from/to for everything\n" + f"methods={len(METHOD_IDS)}" + ) + + def _clean_params(self, args: Dict[str, Any]) -> Dict[str, Any]: + return { + key: value + for key, value in args.items() + if key not in {"method_id", "include_raw"} + and value is not None + and (not isinstance(value, str) or value.strip() != "") + } + + def _normalize_params(self, method_id: str, args: Dict[str, Any]) -> Dict[str, Any]: + params = self._clean_params(args) + geo = params.pop("geo", None) + query = params.pop("query", None) + limit = params.pop("limit", None) + time_window = str(params.pop("time_window", "")).strip() + start_date = str(params.pop("start_date", "")).strip() + end_date = str(params.pop("end_date", "")).strip() + + if query is not None and "q" not in params: + params["q"] = query + if limit is not None and "pageSize" not in params: + params["pageSize"] = limit + if isinstance(geo, dict): + country = str(geo.get("country", "")).strip().lower() + if country and "country" not in params: + params["country"] = country + + if method_id == "newsapi.everything.v1": + start_resolved, end_resolved = _resolve_dates(time_window, start_date, end_date) + if start_resolved and "from" not in params: + params["from"] = start_resolved + if end_resolved and "to" not in params: + params["to"] = end_resolved + + for key in ("searchIn", "sources", "domains", "excludeDomains"): + if key in params: + params[key] = _normalize_csv_value(params[key]) + + return _compact_dict(params) + + def _coerce_positive_int(self, params: Dict[str, Any], key: str, maximum: int | None = None) -> None: + if key not in params: + return + try: + value = int(params[key]) + except (TypeError, ValueError): + raise ValueError(f"{key} must be an integer.") from None + if value < 1: + raise ValueError(f"{key} must be >= 1.") + if maximum is not None and value > maximum: + raise ValueError(f"{key} must be <= {maximum}.") + params[key] = value + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return self._help() + if op == "status": + return self._status() + if op == "list_methods": + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "method_ids": METHOD_IDS, + "methods": _METHOD_SPECS, + }, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + raw_call_args = args.get("args") or {} + if not isinstance(raw_call_args, dict): + return json.dumps({ + "ok": False, + "error_code": "INVALID_ARGS", + "message": "args must be an object.", + }, indent=2, ensure_ascii=False) + nested_params = raw_call_args.get("params") + if nested_params is not None and not isinstance(nested_params, dict): + return json.dumps({ + "ok": False, + "error_code": "INVALID_ARGS", + "message": "args.params must be an object when provided.", + }, indent=2, ensure_ascii=False) + call_args = dict(nested_params or {}) + call_args.update({ + key: value + for key, value in raw_call_args.items() + if key != "params" + }) + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in _METHOD_SPECS: + return json.dumps({ + "ok": False, + "error_code": "METHOD_UNKNOWN", + "method_id": method_id, + }, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "newsapi.everything.v1": + return await self._everything(method_id, args) + if method_id == "newsapi.top_headlines.v1": + return await self._top_headlines(method_id, args) + if method_id == "newsapi.sources.v1": + return await self._sources(method_id, args) + return json.dumps({ + "ok": False, + "error_code": "METHOD_UNIMPLEMENTED", + "method_id": method_id, + }, indent=2, ensure_ascii=False) + + async def _request_json( + self, + method_id: str, + params: Dict[str, Any], + include_raw: bool, + ) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({ + "ok": False, + "error_code": "AUTH_MISSING", + "message": "Set api_key in newsapi auth or NEWSAPI_API_KEY env var.", + }, indent=2, ensure_ascii=False) + spec = _METHOD_SPECS[method_id] + try: + async with httpx.AsyncClient(timeout=20.0) as client: + response = await client.get( + _BASE_URL + spec["path"], + params=params, + headers={"X-Api-Key": api_key, "Accept": "application/json"}, + ) + if response.status_code >= 400: + logger.info("%s GET %s HTTP %s: %s", PROVIDER_NAME, spec["path"], response.status_code, response.text[:200]) + return json.dumps({ + "ok": False, + "error_code": "PROVIDER_ERROR", + "status": response.status_code, + "detail": response.text[:300], + }, indent=2, ensure_ascii=False) + data = response.json() + result_key = spec["result_key"] + items = data.get(result_key, []) + result = { + "ok": True, + "provider": PROVIDER_NAME, + "method_id": method_id, + "docs_url": spec["docs_url"], + "total": data.get("totalResults", len(items)), + result_key: items, + } + if include_raw: + result["raw"] = data + return json.dumps(result, indent=2, ensure_ascii=False) + except httpx.TimeoutException: + return json.dumps({ + "ok": False, + "error_code": "TIMEOUT", + "provider": PROVIDER_NAME, + }, indent=2, ensure_ascii=False) + except (httpx.HTTPError, json.JSONDecodeError) as e: + return json.dumps({ + "ok": False, + "error_code": "HTTP_ERROR", + "detail": f"{type(e).__name__}: {e}", + }, indent=2, ensure_ascii=False) + + async def _everything(self, method_id: str, args: Dict[str, Any]) -> str: + try: + include_raw = bool(args.get("include_raw", False)) + params = self._normalize_params(method_id, args) + self._coerce_positive_int(params, "pageSize", maximum=100) + self._coerce_positive_int(params, "page") + if not any(params.get(key) for key in ("q", "sources", "domains")): + return json.dumps({ + "ok": False, + "error_code": "MISSING_ARGS", + "message": "Provide at least one of q, sources, or domains.", + }, indent=2, ensure_ascii=False) + return await self._request_json(method_id, params, include_raw) + except ValueError as e: + return json.dumps({ + "ok": False, + "error_code": "INVALID_ARG", + "message": str(e), + }, indent=2, ensure_ascii=False) + + async def _top_headlines(self, method_id: str, args: Dict[str, Any]) -> str: + try: + include_raw = bool(args.get("include_raw", False)) + params = self._normalize_params(method_id, args) + self._coerce_positive_int(params, "pageSize", maximum=100) + self._coerce_positive_int(params, "page") + if params.get("sources") and (params.get("country") or params.get("category")): + return json.dumps({ + "ok": False, + "error_code": "INVALID_ARGS", + "message": "sources cannot be combined with country or category for top_headlines.", + }, indent=2, ensure_ascii=False) + return await self._request_json(method_id, params, include_raw) + except ValueError as e: + return json.dumps({ + "ok": False, + "error_code": "INVALID_ARG", + "message": str(e), + }, indent=2, ensure_ascii=False) + + async def _sources(self, method_id: str, args: Dict[str, Any]) -> str: + try: + include_raw = bool(args.get("include_raw", False)) + params = self._normalize_params(method_id, args) + params.pop("pageSize", None) + params.pop("page", None) + params.pop("q", None) + return await self._request_json(method_id, params, include_raw) + except ValueError as e: + return json.dumps({ + "ok": False, + "error_code": "INVALID_ARG", + "message": str(e), + }, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_newscatcher.py b/flexus_client_kit/integrations/fi_newscatcher.py new file mode 100644 index 00000000..9b52cfed --- /dev/null +++ b/flexus_client_kit/integrations/fi_newscatcher.py @@ -0,0 +1,172 @@ +import json +import logging +import os +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional +import re + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("newscatcher") + +PROVIDER_NAME = "newscatcher" +METHOD_IDS = [ + "newscatcher.search.v1", + "newscatcher.latest_headlines.v1", +] + +_BASE_URL = "https://v3-api.newscatcherapi.com/api" + + +def _resolve_dates(time_window: str, start_date: str, end_date: str) -> tuple[Optional[str], Optional[str]]: + if start_date: + return start_date, end_date or None + if time_window: + m = re.match(r"last_(\d+)d", time_window) + if m: + days = int(m.group(1)) + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + return start.strftime("%Y-%m-%d"), now.strftime("%Y-%m-%d") + return None, None + + +class IntegrationNewscatcher: + def __init__(self, rcx=None): + self.rcx = rcx + + def _get_api_key(self) -> str: + if self.rcx is not None: + return (self.rcx.external_auth.get("newscatcher") or {}).get("api_key", "") + return "" + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + api_key = self._get_api_key() + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available" if api_key else "auth_missing", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "newscatcher.search.v1": + return await self._search(args) + if method_id == "newscatcher.latest_headlines.v1": + return await self._latest_headlines(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _search(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set NEWSCATCHER_KEY env var."}, indent=2, ensure_ascii=False) + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "query is required"}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 25)), 100) + geo = args.get("geo") or {} + time_window = str(args.get("time_window", "")) + start_date = str(args.get("start_date", "")) + end_date = str(args.get("end_date", "")) + include_raw = bool(args.get("include_raw", False)) + + params: Dict[str, Any] = { + "q": query, + "lang": "en", + "page_size": limit, + "sort_by": "date", + } + country = geo.get("country", "") if isinstance(geo, dict) else "" + if country: + params["countries"] = country + sd, ed = _resolve_dates(time_window, start_date, end_date) + if sd: + params["from_"] = sd + if ed: + params["to_"] = ed + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get( + _BASE_URL + "/search", + params=params, + headers={"x-api-token": api_key, "Accept": "application/json"}, + ) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + articles = data.get("articles", []) + total = data.get("total_hits", len(articles)) + result: Dict[str, Any] = {"ok": True, "results": articles, "total": total} + if include_raw: + result["raw"] = data + summary = f"Found {len(articles)} article(s) from {PROVIDER_NAME} (total={total})." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _latest_headlines(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set NEWSCATCHER_KEY env var."}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 25)), 100) + geo = args.get("geo") or {} + query = str(args.get("query", "")).strip() + include_raw = bool(args.get("include_raw", False)) + + params: Dict[str, Any] = { + "lang": "en", + "page_size": limit, + } + if query: + params["q"] = query + country = geo.get("country", "") if isinstance(geo, dict) else "" + if country: + params["countries"] = country + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get( + _BASE_URL + "/latest_headlines", + params=params, + headers={"x-api-token": api_key, "Accept": "application/json"}, + ) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + articles = data.get("articles", []) + total = data.get("total_hits", len(articles)) + result: Dict[str, Any] = {"ok": True, "results": articles, "total": total} + if include_raw: + result["raw"] = data + summary = f"Found {len(articles)} headline(s) from {PROVIDER_NAME} (total={total})." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_newsdata.py b/flexus_client_kit/integrations/fi_newsdata.py new file mode 100644 index 00000000..4f115732 --- /dev/null +++ b/flexus_client_kit/integrations/fi_newsdata.py @@ -0,0 +1,129 @@ +import json +import logging +import os +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional +import re + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("newsdata") + +PROVIDER_NAME = "newsdata" +METHOD_IDS = [ + "newsdata.news.search.v1", +] + +_BASE_URL = "https://newsdata.io/api/1" + + +def _resolve_dates(time_window: str, start_date: str, end_date: str) -> tuple[Optional[str], Optional[str]]: + if start_date: + return start_date, end_date or None + if time_window: + m = re.match(r"last_(\d+)d", time_window) + if m: + days = int(m.group(1)) + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + return start.strftime("%Y-%m-%d"), now.strftime("%Y-%m-%d") + return None, None + + +class IntegrationNewsdata: + def __init__(self, rcx=None): + self.rcx = rcx + + def _get_api_key(self) -> str: + if self.rcx is not None: + return (self.rcx.external_auth.get("newsdata") or {}).get("api_key", "") + return "" + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + api_key = self._get_api_key() + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available" if api_key else "auth_missing", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "newsdata.news.search.v1": + return await self._news_search(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _news_search(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set NEWSDATA_KEY env var."}, indent=2, ensure_ascii=False) + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "query is required"}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 10)), 10) + geo = args.get("geo") or {} + time_window = str(args.get("time_window", "")) + start_date = str(args.get("start_date", "")) + end_date = str(args.get("end_date", "")) + cursor = args.get("cursor", "") + include_raw = bool(args.get("include_raw", False)) + + params: Dict[str, Any] = { + "apikey": api_key, + "q": query, + "language": "en", + "size": limit, + } + country = geo.get("country", "") if isinstance(geo, dict) else "" + if country: + params["country"] = country.lower() + sd, ed = _resolve_dates(time_window, start_date, end_date) + if sd: + params["from_date"] = sd + if ed: + params["to_date"] = ed + if cursor: + params["page"] = cursor + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(_BASE_URL + "/news", params=params, headers={"Accept": "application/json"}) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + articles = data.get("results", []) + total = data.get("totalResults", len(articles)) + next_page = data.get("nextPage") + result: Dict[str, Any] = {"ok": True, "results": articles, "total": total} + if next_page: + result["next_cursor"] = next_page + if include_raw: + result["raw"] = data + summary = f"Found {len(articles)} article(s) from {PROVIDER_NAME} (total={total})." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_optimizely.py b/flexus_client_kit/integrations/fi_optimizely.py new file mode 100644 index 00000000..dcdc975d --- /dev/null +++ b/flexus_client_kit/integrations/fi_optimizely.py @@ -0,0 +1,113 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("optimizely") + +PROVIDER_NAME = "optimizely" +METHOD_IDS = [ + "optimizely.experiments.create.v1", + "optimizely.experiments.get.v1", +] + +BASE_URL = "https://api.optimizely.com/v2" + + +class IntegrationOptimizely: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nmethods: {', '.join(METHOD_IDS)}" + if op == "status": + key = os.environ.get("OPTIMIZELY_TOKEN", "") + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available" if key else "no_credentials", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, call_args: Dict[str, Any]) -> str: + token = os.environ.get("OPTIMIZELY_TOKEN", "") + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + if method_id == "optimizely.experiments.create.v1": + return await self._experiments_create(headers, call_args) + if method_id == "optimizely.experiments.get.v1": + return await self._experiments_get(headers, call_args) + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _experiments_create(self, headers: Dict[str, str], call_args: Dict[str, Any]) -> str: + name = call_args.get("name") + if not name: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "name"}, indent=2, ensure_ascii=False) + project_id = call_args.get("project_id") + if project_id is None: + env_pid = os.environ.get("OPTIMIZELY_PROJECT_ID", "") + if env_pid: + project_id = int(env_pid) + if project_id is None: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "project_id"}, indent=2, ensure_ascii=False) + body: Dict[str, Any] = { + "project_id": project_id, + "name": name, + "status": call_args.get("status", "paused"), + "type": call_args.get("type", "a/b"), + } + if call_args.get("description") is not None: + body["description"] = call_args["description"] + if call_args.get("variations") is not None: + body["variations"] = call_args["variations"] + async with httpx.AsyncClient() as client: + try: + resp = await client.post(f"{BASE_URL}/experiments", headers=headers, json=body) + resp.raise_for_status() + return json.dumps({"ok": True, "result": resp.json()}, indent=2, ensure_ascii=False) + except httpx.HTTPStatusError as e: + logger.info("optimizely create experiment HTTP error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "status_code": e.response.status_code, "body": e.response.text}, indent=2, ensure_ascii=False) + except httpx.RequestError as e: + logger.info("optimizely create experiment request error: %s", e) + return json.dumps({"ok": False, "error_code": "REQUEST_ERROR", "error": str(e)}, indent=2, ensure_ascii=False) + + async def _experiments_get(self, headers: Dict[str, str], call_args: Dict[str, Any]) -> str: + experiment_id = call_args.get("experiment_id") + if not experiment_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "experiment_id"}, indent=2, ensure_ascii=False) + async with httpx.AsyncClient() as client: + try: + resp = await client.get(f"{BASE_URL}/experiments/{experiment_id}", headers=headers) + resp.raise_for_status() + data = resp.json() + normalized = { + "id": data.get("id"), + "name": data.get("name"), + "status": data.get("status"), + "type": data.get("type"), + "project_id": data.get("project_id"), + "created": data.get("created"), + "last_modified": data.get("last_modified"), + "variations": data.get("variations"), + } + return json.dumps({"ok": True, "result": normalized}, indent=2, ensure_ascii=False) + except httpx.HTTPStatusError as e: + logger.info("optimizely get experiment HTTP error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "status_code": e.response.status_code, "body": e.response.text}, indent=2, ensure_ascii=False) + except httpx.RequestError as e: + logger.info("optimizely get experiment request error: %s", e) + return json.dumps({"ok": False, "error_code": "REQUEST_ERROR", "error": str(e)}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_outreach.py b/flexus_client_kit/integrations/fi_outreach.py new file mode 100644 index 00000000..309533ff --- /dev/null +++ b/flexus_client_kit/integrations/fi_outreach.py @@ -0,0 +1,332 @@ +import json +import logging +import os +from typing import Any, Dict, List + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("outreach") + +PROVIDER_NAME = "outreach" +API_BASE = "https://api.outreach.io/api/v2" +METHOD_IDS = [ + "outreach.prospects.list.v1", + "outreach.prospects.create.v1", + "outreach.sequences.list.v1", +] +TIMEOUT_S = 30.0 + + +class IntegrationOutreach: + def _headers(self, token: str) -> Dict[str, str]: + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/vnd.api+json", + "Accept": "application/vnd.api+json", + } + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + token = os.environ.get("OUTREACH_ACCESS_TOKEN", "") + status = "available" if token else "no_credentials" + return json.dumps( + {"ok": True, "provider": PROVIDER_NAME, "status": status, "method_count": len(METHOD_IDS)}, + indent=2, + ensure_ascii=False, + ) + if op == "list_methods": + return json.dumps( + {"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, + indent=2, + ensure_ascii=False, + ) + if op != "call": + return "Error: unknown op." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required." + if method_id not in METHOD_IDS: + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, call_args: Dict[str, Any]) -> str: + if method_id == "outreach.prospects.list.v1": + return await self._prospects_list(call_args) + if method_id == "outreach.prospects.create.v1": + return await self._prospects_create(call_args) + if method_id == "outreach.sequences.list.v1": + return await self._sequences_list(call_args) + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + + def _ensure_token(self) -> str: + token = os.environ.get("OUTREACH_ACCESS_TOKEN", "") + if not token: + raise ValueError("NO_CREDENTIALS") + return token + + async def _prospects_list(self, args: Dict[str, Any]) -> str: + try: + token = self._ensure_token() + except ValueError: + return json.dumps( + {"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + page_size = min(max(int(args.get("page_size", 10)), 1), 100) + page_number = max(int(args.get("page_number", 1)), 1) + page_offset = (page_number - 1) * page_size + params: Dict[str, Any] = { + "page[size]": page_size, + "page[offset]": page_offset, + "count": "false", + } + email = str(args.get("email", "")).strip() + if email: + params["filter[emails]"] = email + name = str(args.get("name", "")).strip() + if name: + params["filter[name]"] = name + filter_owner_id = args.get("filter_owner_id") + if filter_owner_id is not None: + params["filter[owner][id]"] = int(filter_owner_id) + url = f"{API_BASE}/prospects" + try: + async with httpx.AsyncClient(timeout=TIMEOUT_S) as client: + r = await client.get(url, params=params, headers=self._headers(token)) + except httpx.TimeoutException as e: + logger.info("Outreach prospects list timeout: %s", e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("Outreach prospects list HTTP error: %s", e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + if r.status_code >= 400: + logger.info("Outreach prospects list HTTP %s: %s", r.status_code, r.text[:200]) + return json.dumps( + {"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, + indent=2, + ensure_ascii=False, + ) + try: + payload = r.json() + data_list = payload.get("data") or [] + except (json.JSONDecodeError, KeyError) as e: + logger.info("Outreach prospects list parse error: %s", e) + return json.dumps( + {"ok": False, "error_code": "UNEXPECTED_RESPONSE", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + results: List[Dict[str, Any]] = [] + for item in data_list: + attrs = (item.get("attributes") or {}) if isinstance(item, dict) else {} + rels = (item.get("relationships") or {}) if isinstance(item, dict) else {} + stage_data = (rels.get("stage") or {}).get("data") if isinstance(rels.get("stage"), dict) else None + stage_id = stage_data.get("id") if isinstance(stage_data, dict) else None + emails = attrs.get("emails") or [] + primary_email = emails[0] if emails else None + results.append({ + "id": item.get("id"), + "first_name": attrs.get("firstName"), + "last_name": attrs.get("lastName"), + "email": primary_email, + "title": attrs.get("title"), + "company": attrs.get("company"), + "stage": stage_id, + "created_at": attrs.get("createdAt"), + "updated_at": attrs.get("updatedAt"), + }) + return json.dumps( + {"ok": True, "results": results, "count": len(results)}, + indent=2, + ensure_ascii=False, + ) + + async def _prospects_create(self, args: Dict[str, Any]) -> str: + try: + token = self._ensure_token() + except ValueError: + return json.dumps( + {"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + email = str(args.get("email", "")).strip() + if not email: + return json.dumps( + {"ok": False, "error_code": "MISSING_ARG", "message": "email required."}, + indent=2, + ensure_ascii=False, + ) + attrs: Dict[str, Any] = {"emails": [email]} + first_name = str(args.get("first_name", "")).strip() + if first_name: + attrs["firstName"] = first_name + last_name = str(args.get("last_name", "")).strip() + if last_name: + attrs["lastName"] = last_name + title = str(args.get("title", "")).strip() + if title: + attrs["title"] = title + company = str(args.get("company", "")).strip() + if company: + attrs["company"] = company + phone = str(args.get("phone", "")).strip() + if phone: + attrs["mobilePhones"] = [phone] + body = {"data": {"type": "prospect", "attributes": attrs}} + url = f"{API_BASE}/prospects" + try: + async with httpx.AsyncClient(timeout=TIMEOUT_S) as client: + r = await client.post(url, json=body, headers=self._headers(token)) + except httpx.TimeoutException as e: + logger.info("Outreach prospects create timeout: %s", e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("Outreach prospects create HTTP error: %s", e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + if r.status_code >= 400: + logger.info("Outreach prospects create HTTP %s: %s", r.status_code, r.text[:200]) + return json.dumps( + {"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, + indent=2, + ensure_ascii=False, + ) + try: + payload = r.json() + data = payload.get("data") + if not isinstance(data, dict): + raise KeyError("data") + attrs_res = data.get("attributes") or {} + emails_res = attrs_res.get("emails") or [] + primary_email_res = emails_res[0] if emails_res else email + except (json.JSONDecodeError, KeyError, ValueError) as e: + logger.info("Outreach prospects create parse error: %s", e) + return json.dumps( + {"ok": False, "error_code": "UNEXPECTED_RESPONSE", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + result = { + "id": data.get("id"), + "first_name": attrs_res.get("firstName"), + "last_name": attrs_res.get("lastName"), + "email": primary_email_res, + "title": attrs_res.get("title"), + "company": attrs_res.get("company"), + } + return json.dumps( + {"ok": True, "prospect": result}, + indent=2, + ensure_ascii=False, + ) + + async def _sequences_list(self, args: Dict[str, Any]) -> str: + try: + token = self._ensure_token() + except ValueError: + return json.dumps( + {"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + page_size = min(max(int(args.get("page_size", 10)), 1), 100) + page_number = max(int(args.get("page_number", 1)), 1) + page_offset = (page_number - 1) * page_size + params: Dict[str, Any] = { + "page[size]": page_size, + "page[offset]": page_offset, + "count": "false", + } + name = str(args.get("name", "")).strip() + if name: + params["filter[name]"] = name + url = f"{API_BASE}/sequences" + try: + async with httpx.AsyncClient(timeout=TIMEOUT_S) as client: + r = await client.get(url, params=params, headers=self._headers(token)) + except httpx.TimeoutException as e: + logger.info("Outreach sequences list timeout: %s", e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("Outreach sequences list HTTP error: %s", e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + if r.status_code >= 400: + logger.info("Outreach sequences list HTTP %s: %s", r.status_code, r.text[:200]) + return json.dumps( + {"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, + indent=2, + ensure_ascii=False, + ) + try: + payload = r.json() + data_list = payload.get("data") or [] + except (json.JSONDecodeError, KeyError) as e: + logger.info("Outreach sequences list parse error: %s", e) + return json.dumps( + {"ok": False, "error_code": "UNEXPECTED_RESPONSE", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + results: List[Dict[str, Any]] = [] + for item in data_list: + attrs = (item.get("attributes") or {}) if isinstance(item, dict) else {} + results.append({ + "id": item.get("id"), + "name": attrs.get("name"), + "description": attrs.get("description"), + "enabled": attrs.get("enabled"), + "created_at": attrs.get("createdAt"), + }) + return json.dumps( + {"ok": True, "results": results, "count": len(results)}, + indent=2, + ensure_ascii=False, + ) diff --git a/flexus_client_kit/integrations/fi_oxylabs.py b/flexus_client_kit/integrations/fi_oxylabs.py new file mode 100644 index 00000000..48517e20 --- /dev/null +++ b/flexus_client_kit/integrations/fi_oxylabs.py @@ -0,0 +1,94 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("oxylabs") + +PROVIDER_NAME = "oxylabs" +METHOD_IDS = [ + "oxylabs.jobs.source_query.v1", +] + +_BASE_URL = "https://realtime.oxylabs.io/v1" + + +class IntegrationOxylabs: + # XXX: requires multiple credentials (OXYLABS_USERNAME + OXYLABS_PASSWORD). + # manual auth (single api_key field) does not cover this provider. + # currently reads from env vars as a fallback. + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + username = os.environ.get("OXYLABS_USERNAME", "") + password = os.environ.get("OXYLABS_PASSWORD", "") + if not username or not password: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set OXYLABS_USERNAME and OXYLABS_PASSWORD env vars."}, indent=2, ensure_ascii=False) + + if method_id == "oxylabs.jobs.source_query.v1": + return await self._source_query(args, username, password) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _source_query(self, args: Dict[str, Any], username: str, password: str) -> str: + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "args.query required."}, indent=2, ensure_ascii=False) + + geo = args.get("geo") or {} + if isinstance(geo, str): + geo = {"country": geo} + geo_location = str(geo.get("country", "United States")) or "United States" + + source = str(args.get("source", "google_search_jobs")) + body = { + "source": source, + "query": query, + "domain": "com", + "geo_location": geo_location, + "parse": True, + } + try: + async with httpx.AsyncClient(timeout=30.0, auth=(username, password)) as client: + r = await client.post(_BASE_URL + "/queries", json=body) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + results = data.get("results", []) + include_raw = bool(args.get("include_raw")) + out: Dict[str, Any] = {"ok": True, "results": results if include_raw else results} + summary = f"Found {len(results)} result(s) from {PROVIDER_NAME}." + return summary + "\n\n```json\n" + json.dumps(out, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_paddle.py b/flexus_client_kit/integrations/fi_paddle.py new file mode 100644 index 00000000..c5c85a70 --- /dev/null +++ b/flexus_client_kit/integrations/fi_paddle.py @@ -0,0 +1,349 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("paddle") + +PROVIDER_NAME = "paddle" +METHOD_IDS = [ + "paddle.prices.list.v1", + "paddle.products.list.v1", + "paddle.subscriptions.list.v1", + "paddle.transactions.list.v1", + "paddle.transactions.get.v1", +] + +_BASE_URL = "https://api.paddle.com" +_SANDBOX_URL = "https://sandbox-api.paddle.com" + + +def _base_url() -> str: + if str(os.environ.get("PADDLE_SANDBOX", "")).lower() == "true": + return _SANDBOX_URL + return _BASE_URL + + +class IntegrationPaddle: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + key = os.environ.get("PADDLE_API_KEY", "") + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if key else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "paddle.products.list.v1": + return await self._products_list(args) + if method_id == "paddle.prices.list.v1": + return await self._prices_list(args) + if method_id == "paddle.subscriptions.list.v1": + return await self._subscriptions_list(args) + if method_id == "paddle.transactions.list.v1": + return await self._transactions_list(args) + if method_id == "paddle.transactions.get.v1": + return await self._transactions_get(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _products_list(self, args: Dict[str, Any]) -> str: + key = os.environ.get("PADDLE_API_KEY", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "PADDLE_API_KEY env var not set"}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {} + if args.get("status"): + params["status"] = str(args["status"]) + if args.get("after"): + params["after"] = str(args["after"]) + per_page = args.get("per_page", 50) + params["per_page"] = min(max(int(per_page) if per_page is not None else 50, 1), 200) + if args.get("type"): + params["type"] = str(args["type"]) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_base_url()}/products", + params=params, + headers={"Authorization": f"Bearer {key}"}, + ) + if resp.status_code != 200: + logger.info("paddle products.list error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + raw_items = data.get("data") or [] + items = [] + for p in raw_items: + items.append({ + "id": p.get("id"), + "name": p.get("name"), + "description": p.get("description"), + "status": p.get("status"), + "created_at": p.get("created_at"), + "custom_data": p.get("custom_data"), + }) + meta = data.get("meta") or {} + pagination = meta.get("pagination") or {} + result: Dict[str, Any] = { + "ok": True, + "provider": PROVIDER_NAME, + "method_id": "paddle.products.list.v1", + "data": items, + "meta": {"pagination": {"next": pagination.get("next"), "has_more": pagination.get("has_more", False)}}, + } + return json.dumps(result, indent=2, ensure_ascii=False) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + logger.info("paddle products.list HTTP error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + except (KeyError, ValueError) as e: + logger.info("paddle products.list parse error: %s", e) + return json.dumps({"ok": False, "error_code": "PARSE_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _prices_list(self, args: Dict[str, Any]) -> str: + key = os.environ.get("PADDLE_API_KEY", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "PADDLE_API_KEY env var not set"}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {} + if args.get("product_id"): + params["product_id"] = str(args["product_id"]) + if args.get("status"): + params["status"] = str(args["status"]) + if args.get("after"): + params["after"] = str(args["after"]) + per_page = args.get("per_page", 50) + params["per_page"] = min(max(int(per_page) if per_page is not None else 50, 1), 200) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_base_url()}/prices", + params=params, + headers={"Authorization": f"Bearer {key}"}, + ) + if resp.status_code != 200: + logger.info("paddle prices.list error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + raw_items = data.get("data") or [] + items = [] + for p in raw_items: + unit_price_obj = p.get("unit_price") or {} + items.append({ + "id": p.get("id"), + "product_id": p.get("product_id"), + "description": p.get("description"), + "unit_price": unit_price_obj.get("amount"), + "billing_cycle": p.get("billing_cycle"), + "status": p.get("status"), + "currency_code": unit_price_obj.get("currency_code"), + }) + meta = data.get("meta") or {} + pagination = meta.get("pagination") or {} + result: Dict[str, Any] = { + "ok": True, + "provider": PROVIDER_NAME, + "method_id": "paddle.prices.list.v1", + "data": items, + "meta": {"pagination": {"next": pagination.get("next"), "has_more": pagination.get("has_more", False)}}, + } + return json.dumps(result, indent=2, ensure_ascii=False) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + logger.info("paddle prices.list HTTP error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + except (KeyError, ValueError) as e: + logger.info("paddle prices.list parse error: %s", e) + return json.dumps({"ok": False, "error_code": "PARSE_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _subscriptions_list(self, args: Dict[str, Any]) -> str: + key = os.environ.get("PADDLE_API_KEY", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "PADDLE_API_KEY env var not set"}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {} + if args.get("status"): + params["status"] = str(args["status"]) + if args.get("customer_id"): + params["customer_id"] = str(args["customer_id"]) + if args.get("price_id"): + params["price_id"] = str(args["price_id"]) + if args.get("after"): + params["after"] = str(args["after"]) + per_page = args.get("per_page", 20) + params["per_page"] = min(max(int(per_page) if per_page is not None else 20, 1), 200) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_base_url()}/subscriptions", + params=params, + headers={"Authorization": f"Bearer {key}"}, + ) + if resp.status_code != 200: + logger.info("paddle subscriptions.list error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + raw_items = data.get("data") or [] + items = [] + for s in raw_items: + billing_cycle = s.get("billing_cycle") or {} + items.append({ + "id": s.get("id"), + "status": s.get("status"), + "customer_id": s.get("customer_id"), + "currency_code": s.get("currency_code"), + "billing_interval": billing_cycle.get("interval"), + "billing_frequency": billing_cycle.get("frequency"), + "created_at": s.get("created_at"), + "started_at": s.get("started_at"), + "next_billed_at": s.get("next_billed_at"), + "canceled_at": s.get("canceled_at"), + }) + meta = data.get("meta") or {} + pagination = meta.get("pagination") or {} + result: Dict[str, Any] = { + "ok": True, + "provider": PROVIDER_NAME, + "method_id": "paddle.subscriptions.list.v1", + "data": items, + "meta": {"pagination": {"next": pagination.get("next"), "has_more": pagination.get("has_more", False)}}, + } + return json.dumps(result, indent=2, ensure_ascii=False) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + logger.info("paddle subscriptions.list HTTP error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + except (KeyError, ValueError) as e: + logger.info("paddle subscriptions.list parse error: %s", e) + return json.dumps({"ok": False, "error_code": "PARSE_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _transactions_list(self, args: Dict[str, Any]) -> str: + key = os.environ.get("PADDLE_API_KEY", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "PADDLE_API_KEY env var not set"}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {} + if args.get("status"): + params["status"] = str(args["status"]) + if args.get("after"): + params["after"] = str(args["after"]) + per_page = args.get("per_page", 50) + params["per_page"] = min(max(int(per_page) if per_page is not None else 50, 1), 200) + if args.get("customer_id"): + params["customer_id"] = str(args["customer_id"]) + if args.get("subscription_id"): + params["subscription_id"] = str(args["subscription_id"]) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_base_url()}/transactions", + params=params, + headers={"Authorization": f"Bearer {key}"}, + ) + if resp.status_code != 200: + logger.info("paddle transactions.list error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + raw_items = data.get("data") or [] + items = [] + for t in raw_items: + items.append({ + "id": t.get("id"), + "status": t.get("status"), + "customer_id": t.get("customer_id"), + "subscription_id": t.get("subscription_id"), + "currency_code": t.get("currency_code"), + "details": t.get("details"), + "created_at": t.get("created_at"), + "updated_at": t.get("updated_at"), + }) + meta = data.get("meta") or {} + pagination = meta.get("pagination") or {} + result: Dict[str, Any] = { + "ok": True, + "provider": PROVIDER_NAME, + "method_id": "paddle.transactions.list.v1", + "data": items, + "meta": {"pagination": {"next": pagination.get("next"), "has_more": pagination.get("has_more", False)}}, + } + return json.dumps(result, indent=2, ensure_ascii=False) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + logger.info("paddle transactions.list HTTP error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + except (KeyError, ValueError) as e: + logger.info("paddle transactions.list parse error: %s", e) + return json.dumps({"ok": False, "error_code": "PARSE_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _transactions_get(self, args: Dict[str, Any]) -> str: + key = os.environ.get("PADDLE_API_KEY", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "PADDLE_API_KEY env var not set"}, indent=2, ensure_ascii=False) + transaction_id = args.get("transaction_id") + if not transaction_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "transaction_id required"}, indent=2, ensure_ascii=False) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_base_url()}/transactions/{transaction_id}", + headers={"Authorization": f"Bearer {key}"}, + ) + if resp.status_code != 200: + logger.info("paddle transactions.get error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + raw = resp.json() + t = raw.get("data") or raw + result: Dict[str, Any] = { + "ok": True, + "provider": PROVIDER_NAME, + "method_id": "paddle.transactions.get.v1", + "data": { + "id": t.get("id"), + "status": t.get("status"), + "customer_id": t.get("customer_id"), + "subscription_id": t.get("subscription_id"), + "currency_code": t.get("currency_code"), + "details": t.get("details"), + "created_at": t.get("created_at"), + "updated_at": t.get("updated_at"), + "items": t.get("items"), + }, + } + return json.dumps(result, indent=2, ensure_ascii=False) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + logger.info("paddle transactions.get HTTP error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + except (KeyError, ValueError) as e: + logger.info("paddle transactions.get parse error: %s", e) + return json.dumps({"ok": False, "error_code": "PARSE_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_pandadoc.py b/flexus_client_kit/integrations/fi_pandadoc.py new file mode 100644 index 00000000..1d0c516a --- /dev/null +++ b/flexus_client_kit/integrations/fi_pandadoc.py @@ -0,0 +1,342 @@ +import json +import logging +import os +from typing import Any, Dict, List + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("pandadoc") +PROVIDER_NAME = "pandadoc" +METHOD_IDS = [ + "pandadoc.documents.create.v1", + "pandadoc.documents.details.get.v1", +] +_BASE_URL = "https://api.pandadoc.com/public/v1" +_TIMEOUT_S = 30.0 + +_STATUS_MAP = { + 0: "draft", + 1: "sent", + 2: "completed", + 3: "expired", + 4: "declined", + 5: "viewed", + 6: "waiting_approval", +} + + +def _status_str(code: Any) -> str: + if isinstance(code, int) and code in _STATUS_MAP: + return _STATUS_MAP[code] + return str(code) if code is not None else "" + + +class IntegrationPandadoc: + def _headers(self, api_key: str) -> Dict[str, str]: + return { + "Authorization": f"API-Key {api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + def _ensure_api_key(self) -> str: + key = os.environ.get("PANDADOC_API_KEY", "") + if not key: + raise ValueError("NO_CREDENTIALS") + return key + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help\n" + "op=status\n" + "op=list_methods\n" + "op=call(args={method_id: ...})\n" + f"known_method_ids={len(METHOD_IDS)}" + ) + if op == "status": + key = os.environ.get("PANDADOC_API_KEY", "") + status = "available" if key else "no_credentials" + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": status, + "method_count": len(METHOD_IDS), + }, + indent=2, + ensure_ascii=False, + ) + if op == "list_methods": + return json.dumps( + {"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, + indent=2, + ensure_ascii=False, + ) + if op != "call": + return "Error: unknown op." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required." + if method_id not in METHOD_IDS: + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, call_args: Dict[str, Any]) -> str: + if method_id == "pandadoc.documents.create.v1": + return await self._documents_create(call_args) + if method_id == "pandadoc.documents.details.get.v1": + return await self._documents_details_get(call_args) + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + + async def _documents_create(self, call_args: Dict[str, Any]) -> str: + try: + api_key = self._ensure_api_key() + except ValueError: + return json.dumps( + {"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + name = str(call_args.get("name", "")).strip() + if not name: + return json.dumps( + {"ok": False, "error_code": "INVALID_ARGS", "message": "name required"}, + indent=2, + ensure_ascii=False, + ) + template_uuid = str(call_args.get("template_uuid", "")).strip() + if not template_uuid: + return json.dumps( + {"ok": False, "error_code": "INVALID_ARGS", "message": "template_uuid required"}, + indent=2, + ensure_ascii=False, + ) + recipients_raw = call_args.get("recipients") + if not isinstance(recipients_raw, list) or not recipients_raw: + return json.dumps( + {"ok": False, "error_code": "INVALID_ARGS", "message": "recipients required (list with at least one object)"}, + indent=2, + ensure_ascii=False, + ) + recipients: List[Dict[str, Any]] = [] + for r in recipients_raw: + if not isinstance(r, dict): + continue + email = str(r.get("email", "")).strip() + if not email: + continue + rec: Dict[str, Any] = {"email": email} + fn = str(r.get("first_name", "")).strip() + ln = str(r.get("last_name", "")).strip() + role = str(r.get("role", "")).strip() + if fn: + rec["first_name"] = fn + if ln: + rec["last_name"] = ln + if role: + rec["role"] = role + recipients.append(rec) + if not recipients: + return json.dumps( + {"ok": False, "error_code": "INVALID_ARGS", "message": "recipients must have at least one object with email"}, + indent=2, + ensure_ascii=False, + ) + body: Dict[str, Any] = { + "name": name, + "template_uuid": template_uuid, + "recipients": recipients, + } + tokens_raw = call_args.get("tokens") + if isinstance(tokens_raw, list) and tokens_raw: + tokens: List[Dict[str, str]] = [] + for t in tokens_raw: + if not isinstance(t, dict): + continue + tn = str(t.get("name", "")).strip() + tv = str(t.get("value", "")).strip() + if tn: + tokens.append({"name": tn, "value": tv}) + if tokens: + body["tokens"] = tokens + url = f"{_BASE_URL}/documents" + try: + async with httpx.AsyncClient(timeout=_TIMEOUT_S) as client: + response = await client.post(url, json=body, headers=self._headers(api_key)) + response.raise_for_status() + except httpx.TimeoutException as e: + logger.info("PandaDoc timeout documents create: %s", e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPStatusError as e: + logger.info("PandaDoc HTTP error documents create status=%s: %s", e.response.status_code, e) + return json.dumps( + { + "ok": False, + "error_code": "HTTP_ERROR", + "provider": PROVIDER_NAME, + "status_code": e.response.status_code, + }, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("PandaDoc HTTP error documents create: %s", e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + payload = response.json() + except json.JSONDecodeError as e: + logger.info("PandaDoc JSON decode error documents create: %s", e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + doc_id = payload.get("id") + doc_name = payload.get("name", "") + status_raw = payload.get("status") + status = _status_str(status_raw) + date_created = payload.get("date_created", "") + expiration_date = payload.get("expiration_date", "") + uuid_val = payload.get("uuid", "") + except (KeyError, ValueError) as e: + logger.info("PandaDoc response missing key documents create: %s", e) + return json.dumps( + {"ok": False, "error_code": "UNEXPECTED_RESPONSE", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + normalized = { + "ok": True, + "id": doc_id, + "name": doc_name, + "status": status, + "date_created": date_created, + "expiration_date": expiration_date, + "uuid": uuid_val, + } + return json.dumps(normalized, indent=2, ensure_ascii=False) + + async def _documents_details_get(self, call_args: Dict[str, Any]) -> str: + try: + api_key = self._ensure_api_key() + except ValueError: + return json.dumps( + {"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + document_id = str(call_args.get("document_id", "")).strip() + if not document_id: + return json.dumps( + {"ok": False, "error_code": "INVALID_ARGS", "message": "document_id required"}, + indent=2, + ensure_ascii=False, + ) + url = f"{_BASE_URL}/documents/{document_id}/details" + try: + async with httpx.AsyncClient(timeout=_TIMEOUT_S) as client: + response = await client.get(url, headers=self._headers(api_key)) + response.raise_for_status() + except httpx.TimeoutException as e: + logger.info("PandaDoc timeout documents details document_id=%s: %s", document_id, e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "document_id": document_id}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPStatusError as e: + logger.info("PandaDoc HTTP error documents details document_id=%s status=%s: %s", document_id, e.response.status_code, e) + return json.dumps( + { + "ok": False, + "error_code": "HTTP_ERROR", + "provider": PROVIDER_NAME, + "document_id": document_id, + "status_code": e.response.status_code, + }, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("PandaDoc HTTP error documents details document_id=%s: %s", document_id, e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "document_id": document_id}, + indent=2, + ensure_ascii=False, + ) + try: + payload = response.json() + except json.JSONDecodeError as e: + logger.info("PandaDoc JSON decode error documents details document_id=%s: %s", document_id, e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "provider": PROVIDER_NAME, "document_id": document_id}, + indent=2, + ensure_ascii=False, + ) + try: + doc_id = payload.get("id") + doc_name = payload.get("name", "") + status_raw = payload.get("status") + status = _status_str(status_raw) + date_created = payload.get("date_created", "") + date_modified = payload.get("date_modified", "") + expiration_date = payload.get("expiration_date", "") + recipients_raw = payload.get("recipients") or [] + recipients: List[Dict[str, Any]] = [] + for r in recipients_raw: + if not isinstance(r, dict): + continue + rec: Dict[str, Any] = { + "email": str(r.get("email", "")), + "first_name": str(r.get("first_name", "")), + "last_name": str(r.get("last_name", "")), + "role": str(r.get("role", "")), + "has_completed": bool(r.get("has_completed", False)), + } + recipients.append(rec) + except (KeyError, ValueError) as e: + logger.info("PandaDoc response missing key documents details document_id=%s: %s", document_id, e) + return json.dumps( + {"ok": False, "error_code": "UNEXPECTED_RESPONSE", "provider": PROVIDER_NAME, "document_id": document_id}, + indent=2, + ensure_ascii=False, + ) + normalized = { + "ok": True, + "id": doc_id, + "name": doc_name, + "status": status, + "date_created": date_created, + "date_modified": date_modified, + "expiration_date": expiration_date, + "recipients": recipients, + } + return json.dumps(normalized, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_partnerstack.py b/flexus_client_kit/integrations/fi_partnerstack.py new file mode 100644 index 00000000..12ddde3b --- /dev/null +++ b/flexus_client_kit/integrations/fi_partnerstack.py @@ -0,0 +1,463 @@ +import json +import logging +import os +from typing import Any, Dict, List + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("partnerstack") + +PROVIDER_NAME = "partnerstack" +API_BASE = "https://api.partnerstack.com/api/v2" +METHOD_IDS = [ + "partnerstack.partnerships.list.v1", + "partnerstack.partners.list.v1", + "partnerstack.transactions.list.v1", + "partnerstack.transactions.create.v1", + "partnerstack.payouts.list.v1", +] +_TIMEOUT = 30.0 + + +def _check_credentials() -> tuple[str, str] | None: + public_key = os.environ.get("PARTNERSTACK_PUBLIC_KEY", "") + secret_key = os.environ.get("PARTNERSTACK_SECRET_KEY", "") + if not public_key or not secret_key: + return None + return (public_key, secret_key) + + +def _auth() -> httpx.BasicAuth | None: + creds = _check_credentials() + if not creds: + return None + return httpx.BasicAuth(username=creds[0], password=creds[1]) + + +def _clamp_limit(limit: Any, default: int = 25, max_val: int = 250) -> int: + try: + n = int(limit) if limit is not None else default + return max(1, min(max_val, n)) + except (TypeError, ValueError): + return default + + +class IntegrationPartnerstack: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"known_method_ids={len(METHOD_IDS)}" + ) + if op == "status": + creds = _check_credentials() + status = "no_credentials" if not creds else "available" + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": status, + "method_count": len(METHOD_IDS), + }, + indent=2, + ensure_ascii=False, + ) + if op == "list_methods": + return json.dumps( + {"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, + indent=2, + ensure_ascii=False, + ) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, call_args: Dict[str, Any]) -> str: + auth = _auth() + if not auth: + return json.dumps( + {"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + handlers = { + "partnerstack.partnerships.list.v1": self._partnerships_list, + "partnerstack.partners.list.v1": self._partners_list, + "partnerstack.transactions.list.v1": self._transactions_list, + "partnerstack.transactions.create.v1": self._transactions_create, + "partnerstack.payouts.list.v1": self._payouts_list, + } + handler = handlers.get(method_id) + if not handler: + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + return await handler(call_args, auth) + + def _http_error_response(self, method_id: str, e: Exception, extra: Dict[str, Any] | None = None) -> str: + out: Dict[str, Any] = { + "ok": False, + "error_code": "HTTP_ERROR", + "provider": PROVIDER_NAME, + "method_id": method_id, + } + if extra: + out.update(extra) + if isinstance(e, httpx.TimeoutException): + out["error_code"] = "TIMEOUT" + logger.info("PartnerStack timeout method_id=%s: %s", method_id, e) + elif isinstance(e, httpx.HTTPStatusError): + out["status_code"] = e.response.status_code + logger.info("PartnerStack HTTP error method_id=%s status=%s: %s", method_id, e.response.status_code, e) + else: + logger.info("PartnerStack HTTP error method_id=%s: %s", method_id, e) + return json.dumps(out, indent=2, ensure_ascii=False) + + async def _partnerships_list(self, call_args: Dict[str, Any], auth: httpx.BasicAuth) -> str: + limit = _clamp_limit(call_args.get("limit"), 25, 250) + params: Dict[str, Any] = {"limit": limit} + min_created = call_args.get("min_created") + max_created = call_args.get("max_created") + status = call_args.get("status") + if min_created is not None: + params["min_created"] = int(min_created) + if max_created is not None: + params["max_created"] = int(max_created) + if status: + params["status"] = str(status).strip() + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get(f"{API_BASE}/partnerships", auth=auth, params=params) + response.raise_for_status() + except httpx.TimeoutException as e: + return self._http_error_response("partnerstack.partnerships.list.v1", e) + except httpx.HTTPStatusError as e: + return self._http_error_response("partnerstack.partnerships.list.v1", e) + except httpx.HTTPError as e: + return self._http_error_response("partnerstack.partnerships.list.v1", e) + try: + payload = response.json() + except json.JSONDecodeError as e: + logger.info("PartnerStack JSON decode partnerships: %s", e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + data = payload.get("data") or {} + items = data.get("items") or [] + has_more = data.get("has_more", False) + total_count = data.get("total_count") or data.get("total") + except (KeyError, ValueError) as e: + logger.info("PartnerStack response partnerships: %s", e) + return json.dumps( + {"ok": False, "error_code": "UNEXPECTED_RESPONSE", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + normalized: List[Dict[str, Any]] = [] + for it in items: + company = it.get("company") or {} + normalized.append({ + "key": it.get("key", ""), + "name": company.get("name", ""), + "email": company.get("email", ""), + "created_at": it.get("created_at"), + "group_key": company.get("key", ""), + "approved_state": it.get("status", ""), + }) + return json.dumps( + {"ok": True, "items": normalized, "has_more": has_more, "total_count": total_count}, + indent=2, + ensure_ascii=False, + ) + + async def _partners_list(self, call_args: Dict[str, Any], auth: httpx.BasicAuth) -> str: + limit = _clamp_limit(call_args.get("limit"), 25, 250) + params: Dict[str, Any] = {"limit": limit} + min_created = call_args.get("min_created") + max_created = call_args.get("max_created") + if min_created is not None: + params["min_created"] = int(min_created) + if max_created is not None: + params["max_created"] = int(max_created) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get(f"{API_BASE}/partners", auth=auth, params=params) + response.raise_for_status() + except httpx.TimeoutException as e: + return self._http_error_response("partnerstack.partners.list.v1", e) + except httpx.HTTPStatusError as e: + return self._http_error_response("partnerstack.partners.list.v1", e) + except httpx.HTTPError as e: + return self._http_error_response("partnerstack.partners.list.v1", e) + try: + payload = response.json() + except json.JSONDecodeError as e: + logger.info("PartnerStack JSON decode partners: %s", e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + data = payload.get("data") or {} + items = data.get("items") or [] + has_more = data.get("has_more", False) + except (KeyError, ValueError) as e: + logger.info("PartnerStack response partners: %s", e) + return json.dumps( + {"ok": False, "error_code": "UNEXPECTED_RESPONSE", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + normalized: List[Dict[str, Any]] = [] + for it in items: + normalized.append({ + "key": it.get("key", ""), + "name": it.get("name", ""), + "email": it.get("email", ""), + "created_at": it.get("created_at"), + }) + return json.dumps( + {"ok": True, "items": normalized, "has_more": has_more}, + indent=2, + ensure_ascii=False, + ) + + async def _transactions_list(self, call_args: Dict[str, Any], auth: httpx.BasicAuth) -> str: + limit = _clamp_limit(call_args.get("limit"), 25, 250) + params: Dict[str, Any] = {"limit": limit} + min_created = call_args.get("min_created") + max_created = call_args.get("max_created") + partnership_key = call_args.get("partnership_key") + if min_created is not None: + params["min_created"] = int(min_created) + if max_created is not None: + params["max_created"] = int(max_created) + if partnership_key: + params["partnership_key"] = str(partnership_key).strip() + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get(f"{API_BASE}/transactions", auth=auth, params=params) + response.raise_for_status() + except httpx.TimeoutException as e: + return self._http_error_response("partnerstack.transactions.list.v1", e) + except httpx.HTTPStatusError as e: + return self._http_error_response("partnerstack.transactions.list.v1", e) + except httpx.HTTPError as e: + return self._http_error_response("partnerstack.transactions.list.v1", e) + try: + payload = response.json() + except json.JSONDecodeError as e: + logger.info("PartnerStack JSON decode transactions list: %s", e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + data = payload.get("data") or {} + items = data.get("items") or [] + has_more = data.get("has_more", False) + except (KeyError, ValueError) as e: + logger.info("PartnerStack response transactions list: %s", e) + return json.dumps( + {"ok": False, "error_code": "UNEXPECTED_RESPONSE", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + normalized: List[Dict[str, Any]] = [] + for it in items: + cust = it.get("customer") or {} + cust_key = cust.get("key") if isinstance(cust, dict) else None + if cust_key is None: + cust_key = it.get("customer_key", "") + st = it.get("status") + if st is None and "approved" in it: + st = "approved" if it.get("approved") else "pending" + normalized.append({ + "key": it.get("key", ""), + "amount": it.get("amount"), + "currency": it.get("currency", ""), + "customer_key": cust_key, + "partnership_key": it.get("partnership_key", ""), + "created_at": it.get("created_at"), + "status": st or "", + }) + return json.dumps( + {"ok": True, "items": normalized, "has_more": has_more}, + indent=2, + ensure_ascii=False, + ) + + async def _transactions_create(self, call_args: Dict[str, Any], auth: httpx.BasicAuth) -> str: + partnership_key = str(call_args.get("partnership_key", "")).strip() + customer_key = str(call_args.get("customer_key", "")).strip() + amount = call_args.get("amount") + currency = str(call_args.get("currency", "usd")).strip().upper() or "USD" + description = call_args.get("description") + if not partnership_key: + return json.dumps( + {"ok": False, "error_code": "MISSING_ARG", "message": "partnership_key required"}, + indent=2, + ensure_ascii=False, + ) + if not customer_key: + return json.dumps( + {"ok": False, "error_code": "MISSING_ARG", "message": "customer_key required"}, + indent=2, + ensure_ascii=False, + ) + try: + amount_int = int(amount) + except (TypeError, ValueError): + return json.dumps( + {"ok": False, "error_code": "MISSING_ARG", "message": "amount (int, cents) required"}, + indent=2, + ensure_ascii=False, + ) + body: Dict[str, Any] = { + "partnership_key": partnership_key, + "customer_key": customer_key, + "amount": amount_int, + "currency": currency, + } + if description is not None: + body["description"] = str(description) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{API_BASE}/transactions", + auth=auth, + json=body, + ) + response.raise_for_status() + except httpx.TimeoutException as e: + return self._http_error_response("partnerstack.transactions.create.v1", e) + except httpx.HTTPStatusError as e: + return self._http_error_response("partnerstack.transactions.create.v1", e) + except httpx.HTTPError as e: + return self._http_error_response("partnerstack.transactions.create.v1", e) + try: + payload = response.json() + except json.JSONDecodeError as e: + logger.info("PartnerStack JSON decode transactions create: %s", e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + data = payload.get("data") + if isinstance(data, dict): + it = data + elif isinstance(data, list) and data: + it = data[0] + else: + it = payload + cust = it.get("customer") or {} + cust_key = cust.get("key") if isinstance(cust, dict) else None + if cust_key is None: + cust_key = it.get("customer_key", "") + st = it.get("status") + if st is None and "approved" in it: + st = "approved" if it.get("approved") else "pending" + normalized = { + "key": it.get("key", ""), + "amount": it.get("amount"), + "currency": it.get("currency", ""), + "customer_key": cust_key, + "partnership_key": it.get("partnership_key", ""), + "created_at": it.get("created_at"), + "status": st or "", + } + except (KeyError, ValueError) as e: + logger.info("PartnerStack response transactions create: %s", e) + return json.dumps( + {"ok": False, "error_code": "UNEXPECTED_RESPONSE", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + return json.dumps( + {"ok": True, "transaction": normalized}, + indent=2, + ensure_ascii=False, + ) + + async def _payouts_list(self, call_args: Dict[str, Any], auth: httpx.BasicAuth) -> str: + limit = _clamp_limit(call_args.get("limit"), 25, 250) + params: Dict[str, Any] = {"limit": limit} + min_created = call_args.get("min_created") + max_created = call_args.get("max_created") + if min_created is not None: + params["min_created"] = int(min_created) + if max_created is not None: + params["max_created"] = int(max_created) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get(f"{API_BASE}/payouts", auth=auth, params=params) + response.raise_for_status() + except httpx.TimeoutException as e: + return self._http_error_response("partnerstack.payouts.list.v1", e) + except httpx.HTTPStatusError as e: + return self._http_error_response("partnerstack.payouts.list.v1", e) + except httpx.HTTPError as e: + return self._http_error_response("partnerstack.payouts.list.v1", e) + try: + payload = response.json() + except json.JSONDecodeError as e: + logger.info("PartnerStack JSON decode payouts: %s", e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + data = payload.get("data") or {} + items = data.get("items") or [] + has_more = data.get("has_more", False) + except (KeyError, ValueError) as e: + logger.info("PartnerStack response payouts: %s", e) + return json.dumps( + {"ok": False, "error_code": "UNEXPECTED_RESPONSE", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + normalized: List[Dict[str, Any]] = [] + for it in items: + normalized.append({ + "key": it.get("key", ""), + "amount": it.get("amount"), + "currency": it.get("currency", ""), + "status": it.get("status", ""), + "created_at": it.get("created_at"), + "partnership_key": it.get("partnership_key", ""), + }) + return json.dumps( + {"ok": True, "items": normalized, "has_more": has_more}, + indent=2, + ensure_ascii=False, + ) diff --git a/flexus_client_kit/integrations/fi_pdl.py b/flexus_client_kit/integrations/fi_pdl.py new file mode 100644 index 00000000..c8ed338d --- /dev/null +++ b/flexus_client_kit/integrations/fi_pdl.py @@ -0,0 +1,176 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("pdl") + +PROVIDER_NAME = "pdl" +METHOD_IDS = [ + "pdl.company.enrich.v1", + "pdl.person.enrich.v1", +] + +_BASE_URL = "https://api.peopledatalabs.com/v5" + + +class IntegrationPdl: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + key = os.environ.get("PDL_API_KEY", "") + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if key else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "pdl.company.enrich.v1": + return await self._company_enrich(args) + if method_id == "pdl.person.enrich.v1": + return await self._person_enrich(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _company_enrich(self, args: Dict[str, Any]) -> str: + key = os.environ.get("PDL_API_KEY", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "PDL_API_KEY env var not set"}, indent=2, ensure_ascii=False) + domain = str(args.get("domain", "")).strip() + name = str(args.get("name", "")).strip() + ticker = str(args.get("ticker", "")).strip() + include_raw = bool(args.get("include_raw", False)) + if not domain and not name and not ticker: + return json.dumps({"ok": False, "error_code": "MISSING_ARGS", "message": "At least one of domain/name/ticker is required"}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {"pretty": "true"} + if domain: + params["website"] = domain + if name: + params["name"] = name + if ticker: + params["ticker"] = ticker + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_BASE_URL}/company/enrich", + headers={"X-Api-Key": key}, + params=params, + ) + if resp.status_code == 404: + return json.dumps({"ok": False, "error_code": "NOT_FOUND", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + if resp.status_code == 402: + return json.dumps({"ok": False, "error_code": "CREDITS_EXHAUSTED", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + if resp.status_code != 200: + logger.info("pdl error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + result: Dict[str, Any] = { + "ok": True, + "credit_note": "1 PDL credit used", + "provider": PROVIDER_NAME, + "data": { + "name": data.get("name"), + "website": data.get("website"), + "industry": data.get("industry"), + "employee_count": data.get("employee_count"), + "revenue": data.get("inferred_revenue"), + "location": data.get("location"), + "tech": data.get("tech"), + }, + } + if include_raw: + result["raw"] = data + return f"pdl.company.enrich ok\n\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _person_enrich(self, args: Dict[str, Any]) -> str: + key = os.environ.get("PDL_API_KEY", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "PDL_API_KEY env var not set"}, indent=2, ensure_ascii=False) + email = str(args.get("email", "")).strip() + name = str(args.get("name", "")).strip() + company = str(args.get("company", "")).strip() + location = str(args.get("location", "")).strip() + profile = str(args.get("profile", "")).strip() + include_raw = bool(args.get("include_raw", False)) + if not email and not name and not company and not location and not profile: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "At least one of email/name/company/location/profile is required"}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {"pretty": "true"} + if email: + params["email"] = email + if name: + params["name"] = name + if company: + params["company"] = company + if location: + params["location"] = location + if profile: + params["profile"] = profile + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_BASE_URL}/person/enrich", + headers={"X-Api-Key": key}, + params=params, + ) + if resp.status_code == 404: + return json.dumps({"ok": False, "error_code": "NOT_FOUND", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + if resp.status_code == 402: + return json.dumps({"ok": False, "error_code": "CREDITS_EXHAUSTED", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + if resp.status_code != 200: + logger.info("pdl error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + inner = data.get("data") or data + result: Dict[str, Any] = { + "ok": True, + "credit_note": "This call consumes API credits.", + "provider": PROVIDER_NAME, + "data": { + "id": inner.get("id"), + "full_name": inner.get("full_name"), + "email": inner.get("email") or inner.get("work_email"), + "job_title": inner.get("job_title"), + "company": inner.get("job_company_name"), + "location": inner.get("location"), + "linkedin_url": inner.get("linkedin_url"), + "likelihood": data.get("likelihood"), + }, + } + if include_raw: + result["raw"] = data + return f"pdl.person.enrich ok\n\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_perigon.py b/flexus_client_kit/integrations/fi_perigon.py new file mode 100644 index 00000000..75f0242f --- /dev/null +++ b/flexus_client_kit/integrations/fi_perigon.py @@ -0,0 +1,165 @@ +import json +import logging +import os +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional +import re + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("perigon") + +PROVIDER_NAME = "perigon" +METHOD_IDS = [ + "perigon.all.search.v1", + "perigon.topics.search.v1", +] + +_BASE_URL = "https://api.goperigon.com/v1" + + +def _resolve_dates(time_window: str, start_date: str, end_date: str) -> tuple[Optional[str], Optional[str]]: + if start_date: + return start_date, end_date or None + if time_window: + m = re.match(r"last_(\d+)d", time_window) + if m: + days = int(m.group(1)) + now = datetime.now(timezone.utc) + start = now - timedelta(days=days) + return start.strftime("%Y-%m-%d"), now.strftime("%Y-%m-%d") + return None, None + + +class IntegrationPerigon: + def __init__(self, rcx=None): + self.rcx = rcx + + def _get_api_key(self) -> str: + if self.rcx is not None: + return (self.rcx.external_auth.get("perigon") or {}).get("api_key", "") + return "" + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + api_key = self._get_api_key() + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available" if api_key else "auth_missing", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "perigon.all.search.v1": + return await self._all_search(args) + if method_id == "perigon.topics.search.v1": + return await self._topics_search(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _all_search(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set PERIGON_KEY env var."}, indent=2, ensure_ascii=False) + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "query is required"}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 25)), 100) + geo = args.get("geo") or {} + time_window = str(args.get("time_window", "")) + start_date = str(args.get("start_date", "")) + end_date = str(args.get("end_date", "")) + cursor = args.get("cursor", None) + include_raw = bool(args.get("include_raw", False)) + + params: Dict[str, Any] = { + "apiKey": api_key, + "q": query, + "language": "en", + "size": limit, + "sortBy": "date", + } + country = geo.get("country", "") if isinstance(geo, dict) else "" + if country: + params["country"] = country + sd, ed = _resolve_dates(time_window, start_date, end_date) + if sd: + params["from"] = sd + if ed: + params["to"] = ed + if cursor is not None: + params["page"] = int(cursor) + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(_BASE_URL + "/all", params=params, headers={"Accept": "application/json"}) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + articles = data.get("articles", data.get("results", [])) + total = data.get("numResults", data.get("total", len(articles))) + result: Dict[str, Any] = {"ok": True, "results": articles, "total": total} + if include_raw: + result["raw"] = data + summary = f"Found {len(articles)} article(s) from {PROVIDER_NAME} (total={total})." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _topics_search(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set PERIGON_KEY env var."}, indent=2, ensure_ascii=False) + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "query is required"}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 25)), 100) + include_raw = bool(args.get("include_raw", False)) + + params: Dict[str, Any] = { + "apiKey": api_key, + "q": query, + "size": limit, + } + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(_BASE_URL + "/topics", params=params, headers={"Accept": "application/json"}) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + topics = data.get("topics", data.get("results", [])) + total = data.get("numResults", data.get("total", len(topics))) + result: Dict[str, Any] = {"ok": True, "results": topics, "total": total} + if include_raw: + result["raw"] = data + summary = f"Found {len(topics)} topic(s) from {PROVIDER_NAME} (total={total})." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_pinterest.py b/flexus_client_kit/integrations/fi_pinterest.py new file mode 100644 index 00000000..bb9204ac --- /dev/null +++ b/flexus_client_kit/integrations/fi_pinterest.py @@ -0,0 +1,125 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("pinterest") + +PROVIDER_NAME = "pinterest" +METHOD_IDS = [ + "pinterest.trends.keywords_top.v1", +] + +_TOKEN_URL = "https://api.pinterest.com/v5/oauth/token" +_BASE_URL = "https://api.pinterest.com/v5" + + +class IntegrationPinterest: + # XXX: requires multiple credentials (PINTEREST_APP_ID + PINTEREST_APP_SECRET). + # manual auth (single api_key field) does not cover this provider. + # currently reads from env vars as a fallback. + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _get_access_token(self, app_id: str, app_secret: str) -> str: + async with httpx.AsyncClient(timeout=15.0) as client: + r = await client.post( + _TOKEN_URL, + data={"grant_type": "client_credentials"}, + auth=(app_id, app_secret), + headers={"Accept": "application/json"}, + ) + if r.status_code >= 400: + logger.info("pinterest token request failed: HTTP %s: %s", r.status_code, r.text[:200]) + return "" + return r.json().get("access_token", "") + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + app_id = os.environ.get("PINTEREST_APP_ID", "") + app_secret = os.environ.get("PINTEREST_APP_SECRET", "") + if not app_id or not app_secret: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set PINTEREST_APP_ID and PINTEREST_APP_SECRET env vars."}, indent=2, ensure_ascii=False) + + try: + token = await self._get_access_token(app_id, app_secret) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "AUTH_FAILED", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + if not token: + return json.dumps({"ok": False, "error_code": "AUTH_FAILED", "message": "Could not obtain Pinterest access token. Check PINTEREST_APP_ID and PINTEREST_APP_SECRET."}, indent=2, ensure_ascii=False) + + if method_id == "pinterest.trends.keywords_top.v1": + return await self._keywords_top(token, args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _keywords_top(self, token: str, args: Dict) -> str: + keyword = str(args.get("query", "")) + if not keyword: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.query (keyword) is required."}, indent=2, ensure_ascii=False) + + geo = args.get("geo") or {} + region = (geo.get("country", "US") if geo else "US").upper() + limit = int(args.get("limit", 50)) + + params: Dict[str, Any] = {"region": region, "limit": limit} + headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"} + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get( + f"{_BASE_URL}/trends/keywords/{keyword}/top/", + params=params, + headers=headers, + ) + if r.status_code == 403: + logger.info("%s HTTP 403 — Trends API may require Pinterest Partner access.", PROVIDER_NAME) + return json.dumps({ + "ok": False, + "error_code": "PROVIDER_ERROR", + "status": 403, + "detail": "Pinterest Trends API requires approved Partner access. Apply at https://developers.pinterest.com/", + }, indent=2, ensure_ascii=False) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + results = data.get("trends", data.get("items", data.get("results", []))) + if not isinstance(results, list): + results = [results] + summary = f"Found {len(results)} trend(s) from {PROVIDER_NAME} for keyword '{keyword}'." + payload: Dict[str, Any] = {"ok": True, "results": results, "total": len(results)} + if args.get("include_raw"): + payload["raw"] = data + return summary + "\n\n```json\n" + json.dumps(payload, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_pipedrive.py b/flexus_client_kit/integrations/fi_pipedrive.py new file mode 100644 index 00000000..12bfab19 --- /dev/null +++ b/flexus_client_kit/integrations/fi_pipedrive.py @@ -0,0 +1,197 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("pipedrive") + +PROVIDER_NAME = "pipedrive" +METHOD_IDS = [ + "pipedrive.deals.list.v1", + "pipedrive.deals.search.v1", + "pipedrive.itemsearch.search.v1", + "pipedrive.organizations.search.v1", +] + +_BASE_URL = "https://api.pipedrive.com/v1" + + +class IntegrationPipedrive: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + key = os.environ.get("PIPEDRIVE_API_TOKEN", "") + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if key else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "pipedrive.organizations.search.v1": + return await self._organizations_search(args) + if method_id == "pipedrive.deals.search.v1": + return await self._deals_search(args) + if method_id == "pipedrive.deals.list.v1": + return await self._deals_list(args) + if method_id == "pipedrive.itemsearch.search.v1": + return await self._itemsearch_search(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _organizations_search(self, args: Dict[str, Any]) -> str: + key = os.environ.get("PIPEDRIVE_API_TOKEN", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "PIPEDRIVE_API_TOKEN env var not set"}, indent=2, ensure_ascii=False) + term = str(args.get("term", "")).strip() + if not term: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "term is required"}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = { + "api_token": key, + "term": term, + "limit": int(args.get("limit", 10)), + "start": int(args.get("start", 0)), + "exact_match": str(args.get("exact_match", False)).lower(), + } + if args.get("fields"): + params["fields"] = str(args["fields"]) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(f"{_BASE_URL}/organizations/search", params=params) + if resp.status_code != 200: + logger.info("pipedrive organizations_search error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + result = { + "ok": True, + "items": (data.get("data") or {}).get("items", []), + "result_score": (data.get("data") or {}).get("result_score", 0), + } + return f"pipedrive.organizations.search ok\n\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _deals_search(self, args: Dict[str, Any]) -> str: + key = os.environ.get("PIPEDRIVE_API_TOKEN", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "PIPEDRIVE_API_TOKEN env var not set"}, indent=2, ensure_ascii=False) + term = str(args.get("term", "")).strip() + if not term: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "term is required"}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = { + "api_token": key, + "term": term, + "limit": int(args.get("limit", 10)), + "start": int(args.get("start", 0)), + "exact_match": str(args.get("exact_match", False)).lower(), + } + if args.get("fields"): + params["fields"] = str(args["fields"]) + if args.get("status"): + params["status"] = str(args["status"]) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(f"{_BASE_URL}/deals/search", params=params) + if resp.status_code != 200: + logger.info("pipedrive deals_search error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + result = { + "ok": True, + "items": (data.get("data") or {}).get("items", []), + "result_score": (data.get("data") or {}).get("result_score", 0), + } + return f"pipedrive.deals.search ok\n\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _deals_list(self, args: Dict[str, Any]) -> str: + key = os.environ.get("PIPEDRIVE_API_TOKEN", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "PIPEDRIVE_API_TOKEN env var not set"}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = { + "api_token": key, + "limit": int(args.get("limit", 20)), + "start": int(args.get("start", 0)), + } + if args.get("status"): + params["status"] = str(args["status"]) + if args.get("user_id") is not None: + params["user_id"] = int(args["user_id"]) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(f"{_BASE_URL}/deals", params=params) + if resp.status_code != 200: + logger.info("pipedrive deals_list error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + items = (data.get("data") or []) if isinstance(data.get("data"), list) else [] + result = {"ok": True, "items": items} + return f"pipedrive.deals.list ok\n\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _itemsearch_search(self, args: Dict[str, Any]) -> str: + key = os.environ.get("PIPEDRIVE_API_TOKEN", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "PIPEDRIVE_API_TOKEN env var not set"}, indent=2, ensure_ascii=False) + term = str(args.get("term", "")).strip() + if not term: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "term is required"}, indent=2, ensure_ascii=False) + limit = int(args.get("limit", 10)) + limit = min(max(limit, 1), 500) + params: Dict[str, Any] = { + "api_token": key, + "term": term, + "exact_match": str(args.get("exact_match", False)).lower(), + "limit": limit, + "start": int(args.get("start", 0)), + } + if args.get("item_types"): + params["item_types"] = str(args["item_types"]) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(f"{_BASE_URL}/itemSearch", params=params) + if resp.status_code != 200: + logger.info("pipedrive itemsearch_search error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + items = (data.get("data") or {}).get("items", []) + result = {"ok": True, "items": items} + return f"pipedrive.itemsearch.search ok\n\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_producthunt.py b/flexus_client_kit/integrations/fi_producthunt.py new file mode 100644 index 00000000..886c0ad4 --- /dev/null +++ b/flexus_client_kit/integrations/fi_producthunt.py @@ -0,0 +1,189 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("producthunt") + +PROVIDER_NAME = "producthunt" +METHOD_IDS = [ + "producthunt.graphql.posts.v1", + "producthunt.graphql.topics.v1", +] + +_GRAPHQL_URL = "https://api.producthunt.com/v2/api/graphql" + +_POSTS_QUERY = """ +query Posts($first: Int, $after: String, $topic: String, $postedAfter: DateTime) { + posts(first: $first, after: $after, topic: $topic, postedAfter: $postedAfter, featured: true, order: VOTES) { + edges { + node { + id + name + tagline + description + votesCount + commentsCount + website + url + thumbnail { url } + topics { edges { node { name } } } + createdAt + } + } + pageInfo { hasNextPage endCursor } + } +} +""" + +_TOPICS_QUERY = """ +query Topics($first: Int) { + topics(first: $first) { + edges { + node { + id + name + slug + description + followersCount + postsCount + } + } + } +} +""" + + +class IntegrationProducthunt: + def __init__(self, rcx=None): + self.rcx = rcx + + def _get_api_key(self) -> str: + if self.rcx is not None: + return (self.rcx.external_auth.get("producthunt") or {}).get("api_key", "") + return "" + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set PRODUCTHUNT_API_KEY env var (Developer Token from https://www.producthunt.com/v2/oauth/applications)."}, indent=2, ensure_ascii=False) + + if method_id == "producthunt.graphql.posts.v1": + return await self._posts(api_key, args) + if method_id == "producthunt.graphql.topics.v1": + return await self._topics(api_key, args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _graphql(self, api_key: str, query: str, variables: Dict) -> Dict: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.post( + _GRAPHQL_URL, + json={"query": query, "variables": variables}, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return {"error": True, "status": r.status_code, "detail": r.text[:300]} + return r.json() + + async def _posts(self, api_key: str, args: Dict) -> str: + limit = int(args.get("limit", 20)) + cursor = args.get("cursor") or None + query_slug = args.get("query") or None + posted_after = args.get("posted_after") or None + + variables: Dict[str, Any] = {"first": limit} + if cursor: + variables["after"] = cursor + if query_slug: + variables["topic"] = query_slug + if posted_after: + variables["postedAfter"] = posted_after + + try: + data = await self._graphql(api_key, _POSTS_QUERY, variables) + except (httpx.TimeoutException,): + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + if data.get("error"): + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": data.get("status"), "detail": data.get("detail")}, indent=2, ensure_ascii=False) + + errors = data.get("errors") + if errors: + return json.dumps({"ok": False, "error_code": "GRAPHQL_ERROR", "errors": errors}, indent=2, ensure_ascii=False) + + posts_data = data.get("data", {}).get("posts", {}) + edges = posts_data.get("edges", []) + results = [e.get("node", e) for e in edges] + page_info = posts_data.get("pageInfo", {}) + + summary = f"Found {len(results)} post(s) from {PROVIDER_NAME}." + payload: Dict[str, Any] = {"ok": True, "results": results, "total": len(results), "page_info": page_info} + if args.get("include_raw"): + payload["raw"] = data + return summary + "\n\n```json\n" + json.dumps(payload, indent=2, ensure_ascii=False) + "\n```" + + async def _topics(self, api_key: str, args: Dict) -> str: + limit = int(args.get("limit", 20)) + variables: Dict[str, Any] = {"first": limit} + + try: + data = await self._graphql(api_key, _TOPICS_QUERY, variables) + except (httpx.TimeoutException,): + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + if data.get("error"): + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": data.get("status"), "detail": data.get("detail")}, indent=2, ensure_ascii=False) + + errors = data.get("errors") + if errors: + return json.dumps({"ok": False, "error_code": "GRAPHQL_ERROR", "errors": errors}, indent=2, ensure_ascii=False) + + topics_data = data.get("data", {}).get("topics", {}) + edges = topics_data.get("edges", []) + results = [e.get("node", e) for e in edges] + + summary = f"Found {len(results)} topic(s) from {PROVIDER_NAME}." + payload: Dict[str, Any] = {"ok": True, "results": results, "total": len(results)} + if args.get("include_raw"): + payload["raw"] = data + return summary + "\n\n```json\n" + json.dumps(payload, indent=2, ensure_ascii=False) + "\n```" diff --git a/flexus_client_kit/integrations/fi_prolific.py b/flexus_client_kit/integrations/fi_prolific.py new file mode 100644 index 00000000..3b4c5705 --- /dev/null +++ b/flexus_client_kit/integrations/fi_prolific.py @@ -0,0 +1,295 @@ +import json +import logging +import os +from typing import Any, Dict, Optional + +import httpx + +from flexus_client_kit import ckit_cloudtool + + +logger = logging.getLogger("prolific") + +PROVIDER_NAME = "prolific" +METHOD_IDS = [ + "prolific.studies.list.v1", + "prolific.studies.create.v1", + "prolific.studies.get.v1", + "prolific.participant_groups.list.v1", + "prolific.participant_groups.create.v1", + "prolific.participant_groups.participants.list.v1", + "prolific.participant_groups.participants.add.v1", + "prolific.participant_groups.participants.remove.v1", + "prolific.submissions.list.v1", + "prolific.submissions.approve.v1", + "prolific.submissions.reject.v1", + "prolific.bonuses.create.v1", + "prolific.webhooks.list.v1", + "prolific.webhooks.create.v1", + "prolific.webhooks.delete.v1", +] + +_BASE_URL = "https://api.prolific.com/api/v1" +_TIMEOUT = 30.0 + +# Prolific uses a server-side API token, not OAuth. +# Required value: +# - PROLIFIC_API_TOKEN: generated in the Prolific researcher workspace / API settings. +# Where Flexus colleagues register it: +# - environment or secret manager consumed by the runtime hosting this integration. +# This integration does not own token provisioning and does not expose bot setup fields for secrets. +PROLIFIC_SETUP_SCHEMA: list[dict[str, Any]] = [] + + +class IntegrationProlific: + def __init__(self, rcx=None) -> None: + self.rcx = rcx + + def _api_token(self) -> str: + try: + return str(os.environ.get("PROLIFIC_API_TOKEN", "")).strip() + except (TypeError, ValueError): + return "" + + def _status(self) -> str: + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": "ready" if self._api_token() else "missing_credentials", + "method_count": len(METHOD_IDS), + "auth_type": "api_token", + "required_env": ["PROLIFIC_API_TOKEN"], + "products": [ + "Studies", + "Participant Groups", + "Submissions", + "Bonuses", + "Webhooks", + ], + }, + indent=2, + ensure_ascii=False, + ) + + def _help(self) -> str: + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}\n" + "notes:\n" + "- Prolific credentials come from PROLIFIC_API_TOKEN.\n" + "- Participant groups support allowlists and blocklists across studies.\n" + "- Webhooks are preferred over polling when the platform setup allows inbound delivery.\n" + ) + + def _result(self, method_id: str, result: Any) -> str: + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "method_id": method_id, + "result": result, + }, + indent=2, + ensure_ascii=False, + ) + + def _error(self, method_id: str, code: str, message: str, **extra: Any) -> str: + payload: Dict[str, Any] = { + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": code, + "message": message, + } + payload.update(extra) + return json.dumps(payload, indent=2, ensure_ascii=False) + + def _headers(self) -> Dict[str, str]: + return { + "Authorization": f"Token {self._api_token()}", + "Content-Type": "application/json", + } + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Optional[Dict[str, Any]], + ) -> str: + try: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return self._help() + if op == "status": + return self._status() + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return self._error(method_id, "METHOD_UNKNOWN", "Unknown Prolific method.") + if not self._api_token(): + return self._error(method_id, "AUTH_MISSING", "Set PROLIFIC_API_TOKEN in the runtime environment.") + return await self._dispatch(method_id, call_args) + except (TypeError, ValueError) as e: + logger.error("prolific called_by_model failed", exc_info=e) + return self._error("prolific.runtime", "RUNTIME_ERROR", f"{type(e).__name__}: {e}") + + async def _request( + self, + method_id: str, + http_method: str, + path: str, + *, + body: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + ) -> str: + url = _BASE_URL + path + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + if http_method == "GET": + response = await client.get(url, headers=self._headers(), params=params) + elif http_method == "POST": + response = await client.post(url, headers=self._headers(), params=params, json=body) + elif http_method == "DELETE": + response = await client.delete(url, headers=self._headers(), params=params) + else: + return self._error(method_id, "UNSUPPORTED_HTTP_METHOD", f"Unsupported HTTP method {http_method}.") + except httpx.TimeoutException: + return self._error(method_id, "TIMEOUT", "Prolific request timed out.") + except httpx.HTTPError as e: + logger.error("prolific request failed", exc_info=e) + return self._error(method_id, "HTTP_ERROR", f"{type(e).__name__}: {e}") + + if response.status_code >= 400: + detail: Any = response.text[:1000] + try: + detail = response.json() + except json.JSONDecodeError: + pass + logger.info("prolific provider error method=%s status=%s body=%s", method_id, response.status_code, response.text[:300]) + return self._error(method_id, "PROVIDER_ERROR", "Prolific returned an error.", http_status=response.status_code, detail=detail) + + if not response.text.strip(): + return self._result(method_id, {}) + try: + return self._result(method_id, response.json()) + except json.JSONDecodeError: + return self._result(method_id, response.text) + + def _require_str(self, method_id: str, args: Dict[str, Any], key: str) -> str: + value = str(args.get(key, "")).strip() + if not value: + raise ValueError(f"{key} is required for {method_id}.") + return value + + def _study_create_body(self, args: Dict[str, Any]) -> Dict[str, Any]: + name = self._require_str("prolific.studies.create.v1", args, "name") + description = self._require_str("prolific.studies.create.v1", args, "description") + total_available_places = int(args.get("total_available_places")) + reward = int(args.get("reward")) + estimated_completion_time = int(args.get("estimated_completion_time")) + body: Dict[str, Any] = { + "name": name, + "description": description, + "total_available_places": total_available_places, + "reward": reward, + "estimated_completion_time": estimated_completion_time, + } + for optional_key in [ + "internal_name", + "external_study_url", + "completion_code", + "completion_option", + "prolific_id_option", + ]: + value = args.get(optional_key) + if value not in (None, ""): + body[optional_key] = value + if isinstance(args.get("eligibility_requirements"), list): + body["eligibility_requirements"] = args["eligibility_requirements"] + if isinstance(args.get("filters"), list): + body["filters"] = args["filters"] + return body + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + try: + if method_id == "prolific.studies.list.v1": + return await self._request(method_id, "GET", "/studies/", params={"workspace_id": args.get("workspace_id", "") or None}) + if method_id == "prolific.studies.create.v1": + return await self._request(method_id, "POST", "/studies/", body=self._study_create_body(args)) + if method_id == "prolific.studies.get.v1": + study_id = self._require_str(method_id, args, "study_id") + return await self._request(method_id, "GET", f"/studies/{study_id}/") + if method_id == "prolific.participant_groups.list.v1": + return await self._request(method_id, "GET", "/participant-groups/") + if method_id == "prolific.participant_groups.create.v1": + name = self._require_str(method_id, args, "name") + body: Dict[str, Any] = {"name": name} + description = str(args.get("description", "")).strip() + if description: + body["description"] = description + return await self._request(method_id, "POST", "/participant-groups/", body=body) + if method_id == "prolific.participant_groups.participants.list.v1": + participant_group_id = self._require_str(method_id, args, "participant_group_id") + return await self._request(method_id, "GET", f"/participant-groups/{participant_group_id}/participants/") + if method_id == "prolific.participant_groups.participants.add.v1": + participant_group_id = self._require_str(method_id, args, "participant_group_id") + participant_ids = args.get("participant_ids") + if not isinstance(participant_ids, list) or not participant_ids: + return self._error(method_id, "INVALID_ARGS", "participant_ids must be a non-empty list.") + return await self._request( + method_id, + "POST", + f"/participant-groups/{participant_group_id}/participants/", + body={"participant_ids": participant_ids}, + ) + if method_id == "prolific.participant_groups.participants.remove.v1": + participant_group_id = self._require_str(method_id, args, "participant_group_id") + participant_ids = args.get("participant_ids") + if not isinstance(participant_ids, list) or not participant_ids: + return self._error(method_id, "INVALID_ARGS", "participant_ids must be a non-empty list.") + return await self._request( + method_id, + "POST", + f"/participant-groups/{participant_group_id}/participants/remove/", + body={"participant_ids": participant_ids}, + ) + if method_id == "prolific.submissions.list.v1": + study_id = self._require_str(method_id, args, "study_id") + return await self._request(method_id, "GET", f"/studies/{study_id}/submissions/") + if method_id == "prolific.submissions.approve.v1": + submission_id = self._require_str(method_id, args, "submission_id") + return await self._request(method_id, "POST", f"/submissions/{submission_id}/transition/", body={"action": "APPROVE"}) + if method_id == "prolific.submissions.reject.v1": + submission_id = self._require_str(method_id, args, "submission_id") + body: Dict[str, Any] = {"action": "REJECT"} + rejection_category = str(args.get("rejection_category", "")).strip() + if rejection_category: + body["rejection_category"] = rejection_category + return await self._request(method_id, "POST", f"/submissions/{submission_id}/transition/", body=body) + if method_id == "prolific.bonuses.create.v1": + submission_id = self._require_str(method_id, args, "submission_id") + amount = int(args.get("amount")) + reason = self._require_str(method_id, args, "reason") + return await self._request(method_id, "POST", "/bonuses/", body={"submission_id": submission_id, "amount": amount, "reason": reason}) + if method_id == "prolific.webhooks.list.v1": + return await self._request(method_id, "GET", "/webhooks/") + if method_id == "prolific.webhooks.create.v1": + target_url = self._require_str(method_id, args, "target_url") + events = args.get("events") + if not isinstance(events, list) or not events: + return self._error(method_id, "INVALID_ARGS", "events must be a non-empty list.") + return await self._request(method_id, "POST", "/webhooks/", body={"target_url": target_url, "events": events}) + if method_id == "prolific.webhooks.delete.v1": + webhook_id = self._require_str(method_id, args, "webhook_id") + return await self._request(method_id, "DELETE", f"/webhooks/{webhook_id}/") + except ValueError as e: + return self._error(method_id, "INVALID_ARGS", str(e)) + return self._error(method_id, "METHOD_UNIMPLEMENTED", "Method is declared but not implemented.") diff --git a/flexus_client_kit/integrations/fi_purespectrum.py b/flexus_client_kit/integrations/fi_purespectrum.py new file mode 100644 index 00000000..868fa08e --- /dev/null +++ b/flexus_client_kit/integrations/fi_purespectrum.py @@ -0,0 +1,237 @@ +import json +import logging +import os +from typing import Any, Dict, Optional + +import httpx + +from flexus_client_kit import ckit_cloudtool + + +logger = logging.getLogger("purespectrum") + +PROVIDER_NAME = "purespectrum" +METHOD_IDS = [ + "purespectrum.surveys.list.v1", + "purespectrum.surveys.create.v1", + "purespectrum.surveys.get.v1", + "purespectrum.surveys.update.v1", + "purespectrum.feasibility.get.v1", + "purespectrum.suppliers.list.v1", + "purespectrum.traffic_channels.list.v1", +] + +_TIMEOUT = 30.0 + +# PureSpectrum Buy API requires enterprise-issued access tokens. +# Required values: +# - PURESPECTRUM_ACCESS_TOKEN +# Optional values: +# - PURESPECTRUM_ENV=staging to use the staging buyer endpoint +# Credentials are issued by the PureSpectrum product / support team. +PURESPECTRUM_SETUP_SCHEMA: list[dict[str, Any]] = [] + + +class IntegrationPurespectrum: + def __init__(self, rcx=None) -> None: + self.rcx = rcx + + def _access_token(self) -> str: + return str(os.environ.get("PURESPECTRUM_ACCESS_TOKEN", "")).strip() + + def _base_url(self) -> str: + if str(os.environ.get("PURESPECTRUM_ENV", "")).strip().lower() == "staging": + return "https://staging.spectrumsurveys.com/buyers/v2" + return "https://api.spectrumsurveys.com/buyers/v2" + + def _headers(self) -> Dict[str, str]: + return { + "access-token": self._access_token(), + "Content-Type": "application/json", + } + + def _status(self) -> str: + env = str(os.environ.get("PURESPECTRUM_ENV", "production")).strip().lower() or "production" + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": "ready" if self._access_token() else "missing_credentials", + "method_count": len(METHOD_IDS), + "auth_type": "access_token_header", + "required_env": ["PURESPECTRUM_ACCESS_TOKEN"], + "optional_env": ["PURESPECTRUM_ENV"], + "environment": env, + "products": ["Buyer surveys", "Feasibility", "Suppliers", "Traffic channels"], + }, + indent=2, + ensure_ascii=False, + ) + + def _help(self) -> str: + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}\n" + "notes:\n" + "- PureSpectrum Buy API automates survey procurement and sample fulfillment.\n" + "- Use PURESPECTRUM_ENV=staging during provider onboarding.\n" + "- Survey payloads usually include category, localization, IR, LOI, live_url, field_time, and quota definitions.\n" + ) + + def _error(self, method_id: str, code: str, message: str, **extra: Any) -> str: + payload: Dict[str, Any] = { + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": code, + "message": message, + } + payload.update(extra) + return json.dumps(payload, indent=2, ensure_ascii=False) + + def _result(self, method_id: str, result: Any) -> str: + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": result}, indent=2, ensure_ascii=False) + + def _require_str(self, method_id: str, args: Dict[str, Any], key: str) -> str: + value = str(args.get(key, "")).strip() + if not value: + raise ValueError(f"{key} is required for {method_id}.") + return value + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Optional[Dict[str, Any]], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return self._help() + if op == "status": + return self._status() + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return self._error(method_id, "METHOD_UNKNOWN", "Unknown PureSpectrum method.") + if not self._access_token(): + return self._error(method_id, "AUTH_MISSING", "Set PURESPECTRUM_ACCESS_TOKEN in the runtime environment.") + return await self._dispatch(method_id, call_args) + + async def _request( + self, + method_id: str, + http_method: str, + path: str, + *, + body: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + ) -> str: + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + url = self._base_url() + path + if http_method == "GET": + response = await client.get(url, headers=self._headers(), params=params) + elif http_method == "POST": + response = await client.post(url, headers=self._headers(), params=params, json=body) + elif http_method == "PATCH": + response = await client.patch(url, headers=self._headers(), params=params, json=body) + else: + return self._error(method_id, "UNSUPPORTED_HTTP_METHOD", f"Unsupported HTTP method {http_method}.") + except httpx.TimeoutException: + return self._error(method_id, "TIMEOUT", "PureSpectrum request timed out.") + except httpx.HTTPError as e: + logger.error("purespectrum request failed", exc_info=e) + return self._error(method_id, "HTTP_ERROR", f"{type(e).__name__}: {e}") + + if response.status_code >= 400: + detail: Any = response.text[:1000] + try: + detail = response.json() + except json.JSONDecodeError: + pass + logger.info("purespectrum provider error method=%s status=%s body=%s", method_id, response.status_code, response.text[:300]) + return self._error(method_id, "PROVIDER_ERROR", "PureSpectrum returned an error.", http_status=response.status_code, detail=detail) + + if not response.text.strip(): + return self._result(method_id, {}) + try: + return self._result(method_id, response.json()) + except json.JSONDecodeError: + return self._result(method_id, response.text) + + def _survey_body(self, method_id: str, args: Dict[str, Any]) -> Dict[str, Any]: + body: Dict[str, Any] = { + "survey_title": self._require_str(method_id, args, "survey_title"), + "survey_category_code": self._require_str(method_id, args, "survey_category_code"), + "survey_localization": self._require_str(method_id, args, "survey_localization"), + "completes_required": int(args.get("completes_required")), + "expected_ir": int(args.get("expected_ir")), + "expected_loi": int(args.get("expected_loi")), + "live_url": self._require_str(method_id, args, "live_url"), + "field_time": int(args.get("field_time")), + } + for key in ["cpi", "qualifications", "quotas", "traffic_channels", "supplier_ids", "exclusions", "metadata"]: + value = args.get(key) + if value not in (None, ""): + body[key] = value + return body + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + try: + if method_id == "purespectrum.surveys.list.v1": + params: Dict[str, Any] = {} + for key in ["page", "per_page", "status"]: + value = args.get(key) + if value not in (None, ""): + params[key] = value + return await self._request(method_id, "GET", "/surveys", params=params) + if method_id == "purespectrum.surveys.create.v1": + return await self._request(method_id, "POST", "/surveys", body=self._survey_body(method_id, args)) + if method_id == "purespectrum.surveys.get.v1": + survey_id = self._require_str(method_id, args, "survey_id") + return await self._request(method_id, "GET", f"/surveys/{survey_id}") + if method_id == "purespectrum.surveys.update.v1": + survey_id = self._require_str(method_id, args, "survey_id") + body: Dict[str, Any] = {} + for key in [ + "survey_title", + "completes_required", + "expected_ir", + "expected_loi", + "field_time", + "live_url", + "qualifications", + "quotas", + "traffic_channels", + "supplier_ids", + ]: + value = args.get(key) + if value not in (None, ""): + body[key] = value + if not body: + return self._error(method_id, "INVALID_ARGS", "At least one survey field is required.") + return await self._request(method_id, "PATCH", f"/surveys/{survey_id}", body=body) + if method_id == "purespectrum.feasibility.get.v1": + params: Dict[str, Any] = {} + for key in ["survey_category_code", "survey_localization", "completes_required", "expected_ir", "expected_loi"]: + value = args.get(key) + if value not in (None, ""): + params[key] = value + qualifications = args.get("qualifications") + if qualifications not in (None, ""): + params["qualifications"] = json.dumps(qualifications, ensure_ascii=False) + return await self._request(method_id, "GET", "/feasibility", params=params) + if method_id == "purespectrum.suppliers.list.v1": + return await self._request(method_id, "GET", "/suppliers") + if method_id == "purespectrum.traffic_channels.list.v1": + return await self._request(method_id, "GET", "/traffic_channels") + except ValueError as e: + return self._error(method_id, "INVALID_ARGS", str(e)) + return self._error(method_id, "METHOD_UNIMPLEMENTED", "Method is declared but not implemented.") diff --git a/flexus_client_kit/integrations/fi_qualtrics.py b/flexus_client_kit/integrations/fi_qualtrics.py new file mode 100644 index 00000000..f3909a3c --- /dev/null +++ b/flexus_client_kit/integrations/fi_qualtrics.py @@ -0,0 +1,388 @@ +import base64 +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("qualtrics") + +PROVIDER_NAME = "qualtrics" +METHOD_IDS = [ + "qualtrics.contacts.create.v1", + "qualtrics.contacts.list.v1", + "qualtrics.distributions.create.v1", + "qualtrics.mailinglists.list.v1", + "qualtrics.responseexports.file.get.v1", + "qualtrics.responseexports.progress.get.v1", + "qualtrics.responseexports.start.v1", + "qualtrics.surveys.create.v1", + "qualtrics.surveys.update.v1", +] + +_NO_CREDENTIALS = json.dumps( + {"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, +) + + +class IntegrationQualtrics: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + token = os.environ.get("QUALTRICS_API_TOKEN", "") + base_url = os.environ.get("QUALTRICS_BASE_URL", "") + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if (token and base_url) else "no_credentials", + "method_count": len(METHOD_IDS), + }, + indent=2, + ensure_ascii=False, + ) + if op == "list_methods": + return json.dumps( + {"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, + indent=2, + ensure_ascii=False, + ) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + dispatch = { + "qualtrics.surveys.create.v1": self._surveys_create, + "qualtrics.surveys.update.v1": self._surveys_update, + "qualtrics.responseexports.start.v1": self._responseexports_start, + "qualtrics.responseexports.progress.get.v1": self._responseexports_progress_get, + "qualtrics.responseexports.file.get.v1": self._responseexports_file_get, + "qualtrics.mailinglists.list.v1": self._mailinglists_list, + "qualtrics.contacts.create.v1": self._contacts_create, + "qualtrics.contacts.list.v1": self._contacts_list, + "qualtrics.distributions.create.v1": self._distributions_create, + } + return await dispatch[method_id](args) + + def _get_credentials(self): + token = os.environ.get("QUALTRICS_API_TOKEN", "") + base_url = os.environ.get("QUALTRICS_BASE_URL", "").rstrip("/") + return token, base_url + + def _headers(self, token: str) -> Dict[str, str]: + return {"X-API-TOKEN": token, "Content-Type": "application/json"} + + def _ok(self, method_id: str, data: Any) -> str: + return ( + f"qualtrics.{method_id} ok\n\n" + f"```json\n{json.dumps({'ok': True, 'result': data}, indent=2, ensure_ascii=False)}\n```" + ) + + def _provider_error(self, status_code: int, detail: Any) -> str: + logger.info("qualtrics provider error status=%s detail=%s", status_code, detail) + return json.dumps( + {"ok": False, "error_code": "PROVIDER_ERROR", "status": status_code, "detail": detail}, + indent=2, + ensure_ascii=False, + ) + + async def _surveys_create(self, args: Dict[str, Any]) -> str: + token, base_url = self._get_credentials() + if not token or not base_url: + return _NO_CREDENTIALS + survey_name = str(args.get("survey_name", "")).strip() + language = str(args.get("language", "EN")).strip() or "EN" + if not survey_name: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "survey_name"}, indent=2, ensure_ascii=False) + body = {"SurveyName": survey_name, "Language": language, "ProjectCategory": "CORE"} + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.post( + f"{base_url}/API/v3/survey-definitions", + headers=self._headers(token), + json=body, + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code not in (200, 201): + return self._provider_error(resp.status_code, resp.text) + try: + data = resp.json().get("result", resp.json()) + except json.JSONDecodeError: + data = resp.text + return self._ok("surveys.create.v1", data) + + async def _surveys_update(self, args: Dict[str, Any]) -> str: + token, base_url = self._get_credentials() + if not token or not base_url: + return _NO_CREDENTIALS + survey_id = str(args.get("survey_id", "")).strip() + if not survey_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "survey_id"}, indent=2, ensure_ascii=False) + body: Dict[str, Any] = {} + survey_name = args.get("survey_name") + if survey_name: + body["SurveyName"] = str(survey_name).strip() + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.put( + f"{base_url}/API/v3/survey-definitions/{survey_id}", + headers=self._headers(token), + json=body, + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code not in (200, 204): + return self._provider_error(resp.status_code, resp.text) + try: + data = resp.json().get("result", resp.json()) + except json.JSONDecodeError: + data = {"survey_id": survey_id, "updated": True} + return self._ok("surveys.update.v1", data) + + async def _responseexports_start(self, args: Dict[str, Any]) -> str: + token, base_url = self._get_credentials() + if not token or not base_url: + return _NO_CREDENTIALS + survey_id = str(args.get("survey_id", "")).strip() + if not survey_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "survey_id"}, indent=2, ensure_ascii=False) + fmt = str(args.get("format", "json")).strip() or "json" + body = {"format": fmt} + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.post( + f"{base_url}/API/v3/surveys/{survey_id}/export-responses", + headers=self._headers(token), + json=body, + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code not in (200, 202): + return self._provider_error(resp.status_code, resp.text) + try: + data = resp.json().get("result", resp.json()) + except json.JSONDecodeError: + data = resp.text + return self._ok("responseexports.start.v1", data) + + async def _responseexports_progress_get(self, args: Dict[str, Any]) -> str: + token, base_url = self._get_credentials() + if not token or not base_url: + return _NO_CREDENTIALS + survey_id = str(args.get("survey_id", "")).strip() + progress_id = str(args.get("progress_id", "")).strip() + if not survey_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "survey_id"}, indent=2, ensure_ascii=False) + if not progress_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "progress_id"}, indent=2, ensure_ascii=False) + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.get( + f"{base_url}/API/v3/surveys/{survey_id}/export-responses/{progress_id}", + headers=self._headers(token), + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code != 200: + return self._provider_error(resp.status_code, resp.text) + try: + data = resp.json().get("result", resp.json()) + except json.JSONDecodeError: + data = resp.text + return self._ok("responseexports.progress.get.v1", data) + + async def _responseexports_file_get(self, args: Dict[str, Any]) -> str: + token, base_url = self._get_credentials() + if not token or not base_url: + return _NO_CREDENTIALS + survey_id = str(args.get("survey_id", "")).strip() + file_id = str(args.get("file_id", "")).strip() + if not survey_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "survey_id"}, indent=2, ensure_ascii=False) + if not file_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "file_id"}, indent=2, ensure_ascii=False) + headers = {"X-API-TOKEN": token} + async with httpx.AsyncClient(timeout=60) as client: + try: + resp = await client.get( + f"{base_url}/API/v3/surveys/{survey_id}/export-responses/{file_id}/file", + headers=headers, + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code != 200: + return self._provider_error(resp.status_code, resp.text) + encoded = base64.b64encode(resp.content).decode("ascii") + data = {"note": "ZIP file, base64 encoded", "content_type": resp.headers.get("content-type", ""), "data_base64": encoded} + return self._ok("responseexports.file.get.v1", data) + + async def _mailinglists_list(self, args: Dict[str, Any]) -> str: + token, base_url = self._get_credentials() + if not token or not base_url: + return _NO_CREDENTIALS + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.get( + f"{base_url}/API/v3/mailinglists", + headers=self._headers(token), + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code != 200: + return self._provider_error(resp.status_code, resp.text) + try: + data = resp.json().get("result", resp.json()) + except json.JSONDecodeError: + data = resp.text + return self._ok("mailinglists.list.v1", data) + + async def _contacts_create(self, args: Dict[str, Any]) -> str: + token, base_url = self._get_credentials() + if not token or not base_url: + return _NO_CREDENTIALS + mailing_list_id = str(args.get("mailing_list_id", "")).strip() + email = str(args.get("email", "")).strip() + if not mailing_list_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "mailing_list_id"}, indent=2, ensure_ascii=False) + if not email: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "email"}, indent=2, ensure_ascii=False) + body: Dict[str, Any] = {"email": email} + first_name = args.get("first_name") + last_name = args.get("last_name") + if first_name: + body["firstName"] = str(first_name).strip() + if last_name: + body["lastName"] = str(last_name).strip() + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.post( + f"{base_url}/API/v3/mailinglists/{mailing_list_id}/contacts", + headers=self._headers(token), + json=body, + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code not in (200, 201): + return self._provider_error(resp.status_code, resp.text) + try: + data = resp.json().get("result", resp.json()) + except json.JSONDecodeError: + data = resp.text + return self._ok("contacts.create.v1", data) + + async def _contacts_list(self, args: Dict[str, Any]) -> str: + token, base_url = self._get_credentials() + if not token or not base_url: + return _NO_CREDENTIALS + mailing_list_id = str(args.get("mailing_list_id", "")).strip() + if not mailing_list_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "mailing_list_id"}, indent=2, ensure_ascii=False) + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.get( + f"{base_url}/API/v3/mailinglists/{mailing_list_id}/contacts", + headers=self._headers(token), + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code != 200: + return self._provider_error(resp.status_code, resp.text) + try: + data = resp.json().get("result", resp.json()) + except json.JSONDecodeError: + data = resp.text + return self._ok("contacts.list.v1", data) + + async def _distributions_create(self, args: Dict[str, Any]) -> str: + token, base_url = self._get_credentials() + if not token or not base_url: + return _NO_CREDENTIALS + survey_id = str(args.get("survey_id", "")).strip() + mailing_list_id = str(args.get("mailing_list_id", "")).strip() + send_date = str(args.get("send_date", "")).strip() + from_email = str(args.get("from_email", "")).strip() + from_name = str(args.get("from_name", "")).strip() + subject = str(args.get("subject", "")).strip() + missing = [k for k, v in { + "survey_id": survey_id, + "mailing_list_id": mailing_list_id, + "send_date": send_date, + "from_email": from_email, + "from_name": from_name, + "subject": subject, + }.items() if not v] + if missing: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "args": missing}, indent=2, ensure_ascii=False) + body = { + "surveyId": survey_id, + "recipients": {"mailingListId": mailing_list_id}, + "sendDate": send_date, + "header": { + "fromEmail": from_email, + "fromName": from_name, + "subject": subject, + "replyToEmail": from_email, + }, + } + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.post( + f"{base_url}/API/v3/distributions", + headers=self._headers(token), + json=body, + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code not in (200, 201): + return self._provider_error(resp.status_code, resp.text) + try: + data = resp.json().get("result", resp.json()) + except json.JSONDecodeError: + data = resp.text + return self._ok("distributions.create.v1", data) diff --git a/flexus_client_kit/integrations/fi_recurly.py b/flexus_client_kit/integrations/fi_recurly.py new file mode 100644 index 00000000..1815fdeb --- /dev/null +++ b/flexus_client_kit/integrations/fi_recurly.py @@ -0,0 +1,188 @@ +import json +import logging +import os +from typing import Any, Dict, List + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("recurly") + +PROVIDER_NAME = "recurly" +API_BASE = "https://v3.recurly.com" +METHOD_IDS = [ + "recurly.subscriptions.list.v1", +] +_TIMEOUT = 30.0 + + +class IntegrationRecurly: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + api_key = os.environ.get("RECURLY_API_KEY", "") + if os.environ.get("NO_CREDENTIALS"): + status = "no_credentials" + else: + status = "available" if api_key else "no_credentials" + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": status, + "method_count": len(METHOD_IDS), + }, + indent=2, + ensure_ascii=False, + ) + if op == "list_methods": + return json.dumps( + {"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, + indent=2, + ensure_ascii=False, + ) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + if method_id == "recurly.subscriptions.list.v1": + return await self._subscriptions_list(call_args) + return json.dumps( + {"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + + async def _subscriptions_list(self, call_args: Dict[str, Any]) -> str: + api_key = os.environ.get("RECURLY_API_KEY", "") + if not api_key or os.environ.get("NO_CREDENTIALS"): + return json.dumps( + { + "ok": False, + "error_code": "NO_CREDENTIALS", + "provider": PROVIDER_NAME, + "message": "RECURLY_API_KEY env var not set", + }, + indent=2, + ensure_ascii=False, + ) + params: Dict[str, Any] = {} + state = str(call_args.get("state", "")).strip() + if state: + params["state"] = state + customer_email = str(call_args.get("customer_email", "")).strip() + if customer_email: + params["email"] = customer_email + limit = call_args.get("limit", 20) + try: + limit = min(max(int(limit), 1), 200) + except (TypeError, ValueError): + limit = 20 + params["limit"] = limit + begin_time = str(call_args.get("begin_time", "")).strip() + if begin_time: + params["begin_time"] = begin_time + end_time = str(call_args.get("end_time", "")).strip() + if end_time: + params["end_time"] = end_time + url = f"{API_BASE}/subscriptions" + headers = { + "Content-Type": "application/json", + "Accept": "application/vnd.recurly.v2021-02-25+json", + } + try: + async with httpx.AsyncClient( + auth=httpx.BasicAuth(username=api_key, password=""), + timeout=_TIMEOUT, + ) as client: + response = await client.get(url, params=params, headers=headers) + response.raise_for_status() + except httpx.TimeoutException as e: + logger.info("Recurly subscriptions timeout: %s", e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPStatusError as e: + logger.info("Recurly HTTP error status=%s: %s", e.response.status_code, e) + return json.dumps( + { + "ok": False, + "error_code": "HTTP_ERROR", + "provider": PROVIDER_NAME, + "status_code": e.response.status_code, + }, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("Recurly HTTP error: %s", e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + payload = response.json() + except json.JSONDecodeError as e: + logger.info("Recurly JSON decode error: %s", e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + data = payload.get("data", []) + has_more = payload.get("has_more", False) + normalized: List[Dict[str, Any]] = [] + for item in data: + normalized.append(self._normalize_subscription(item)) + except (KeyError, ValueError) as e: + logger.info("Recurly response parse error: %s", e) + return json.dumps( + {"ok": False, "error_code": "UNEXPECTED_RESPONSE", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + return json.dumps( + {"ok": True, "data": normalized, "has_more": has_more}, + indent=2, + ensure_ascii=False, + ) + + def _normalize_subscription(self, item: Dict[str, Any]) -> Dict[str, Any]: + plan = item.get("plan") or {} + account = item.get("account") or {} + return { + "id": item.get("id"), + "state": item.get("state"), + "plan_code": plan.get("code"), + "account_code": account.get("code"), + "account_email": account.get("email"), + "current_period_started_at": item.get("current_period_started_at"), + "current_period_ends_at": item.get("current_period_ends_at"), + "unit_amount": item.get("unit_amount"), + "currency": item.get("currency"), + "quantity": item.get("quantity"), + } diff --git a/flexus_client_kit/integrations/fi_reddit.py b/flexus_client_kit/integrations/fi_reddit.py new file mode 100644 index 00000000..5b30fa1d --- /dev/null +++ b/flexus_client_kit/integrations/fi_reddit.py @@ -0,0 +1,145 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("reddit") + +PROVIDER_NAME = "reddit" +METHOD_IDS = [ + "reddit.comments.list.v1", + "reddit.search.posts.v1", + "reddit.subreddit.hot.v1", + "reddit.subreddit.new.v1", +] + +_TOKEN_URL = "https://www.reddit.com/api/v1/access_token" +_API_BASE = "https://oauth.reddit.com" +_USER_AGENT = "Flexus Market Signal Bot/1.0" + +_TIME_WINDOW_MAP = { + "last_7d": "week", + "last_30d": "month", + "last_90d": "year", +} + + +class IntegrationReddit: + # XXX: requires multiple credentials (REDDIT_CLIENT_ID + REDDIT_CLIENT_SECRET). + # manual auth (single api_key field) does not cover this provider. + # currently reads from env vars as a fallback. + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _get_access_token(self) -> str: + client_id = os.environ.get("REDDIT_CLIENT_ID", "") + client_secret = os.environ.get("REDDIT_CLIENT_SECRET", "") + if not client_id or not client_secret: + return "" + async with httpx.AsyncClient(timeout=15.0) as client: + r = await client.post( + _TOKEN_URL, + data={"grant_type": "client_credentials"}, + auth=(client_id, client_secret), + headers={"User-Agent": _USER_AGENT}, + ) + if r.status_code >= 400: + logger.info("reddit token request failed: HTTP %s: %s", r.status_code, r.text[:200]) + return "" + return r.json().get("access_token", "") + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + client_id = os.environ.get("REDDIT_CLIENT_ID", "") + client_secret = os.environ.get("REDDIT_CLIENT_SECRET", "") + if not client_id or not client_secret: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET env vars."}, indent=2, ensure_ascii=False) + + try: + token = await self._get_access_token() + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "AUTH_FAILED", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + if not token: + return json.dumps({"ok": False, "error_code": "AUTH_FAILED", "message": "Could not obtain Reddit access token. Check REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET."}, indent=2, ensure_ascii=False) + + headers = {"Authorization": f"Bearer {token}", "User-Agent": _USER_AGENT} + subreddit = str(args.get("subreddit", "all")) + limit = int(args.get("limit", 25)) + query = str(args.get("query", "")) + time_window = str(args.get("time_window", "")) + cursor = args.get("cursor", "") + + if method_id == "reddit.subreddit.new.v1": + url = f"{_API_BASE}/r/{subreddit}/new.json" + params: Dict[str, Any] = {"limit": limit} + if cursor: + params["after"] = cursor + return await self._get(url, params, headers, method_id, args) + if method_id == "reddit.subreddit.hot.v1": + url = f"{_API_BASE}/r/{subreddit}/hot.json" + params = {"limit": limit} + return await self._get(url, params, headers, method_id, args) + if method_id == "reddit.search.posts.v1": + url = f"{_API_BASE}/search.json" + time_filter = _TIME_WINDOW_MAP.get(time_window, "all") + params = {"q": query, "sort": "relevance", "limit": limit, "t": time_filter} + return await self._get(url, params, headers, method_id, args) + if method_id == "reddit.comments.list.v1": + url = f"{_API_BASE}/r/{subreddit}/comments.json" + params = {"limit": limit} + return await self._get(url, params, headers, method_id, args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _get(self, url: str, params: Dict, headers: Dict, method_id: str, args: Dict) -> str: + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(url, params=params, headers=headers) + if r.status_code in (403, 429): + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + children = data.get("data", {}).get("children", []) + results = [c.get("data", c) for c in children] + summary = f"Found {len(results)} result(s) from {PROVIDER_NAME} ({method_id})." + payload: Dict[str, Any] = {"ok": True, "results": results, "total": len(results)} + after = data.get("data", {}).get("after") + if after: + payload["next_cursor"] = after + if args.get("include_raw"): + payload["raw"] = data + return summary + "\n\n```json\n" + json.dumps(payload, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_respondent.py b/flexus_client_kit/integrations/fi_respondent.py new file mode 100644 index 00000000..0ad4be28 --- /dev/null +++ b/flexus_client_kit/integrations/fi_respondent.py @@ -0,0 +1,228 @@ +import json +import logging +import os +from typing import Any, Dict, Optional + +import httpx + +from flexus_client_kit import ckit_cloudtool + + +logger = logging.getLogger("respondent") + +PROVIDER_NAME = "respondent" +METHOD_IDS = [ + "respondent.projects.create.v1", + "respondent.projects.publish.v1", + "respondent.screener_responses.list.v1", + "respondent.screener_responses.qualify.v1", + "respondent.screener_responses.invite.v1", + "respondent.screener_responses.attended.v1", + "respondent.screener_responses.reject.v1", + "respondent.screener_responses.report.v1", +] + +_BASE_URL = "https://api.respondent.io/v1" +_TIMEOUT = 30.0 + +# Respondent requires partner credentials and staging review before production credentials. +# Required values: +# - RESPONDENT_API_KEY +# - RESPONDENT_API_SECRET +# Common runtime IDs used in onboarding: +# - organization_id +# - team_id +# - researcher_id +# These come from the Respondent researcher organization used for API projects. +RESPONDENT_SETUP_SCHEMA: list[dict[str, Any]] = [] + + +class IntegrationRespondent: + def __init__(self, rcx=None) -> None: + self.rcx = rcx + + def _api_key(self) -> str: + return str(os.environ.get("RESPONDENT_API_KEY", "")).strip() + + def _api_secret(self) -> str: + return str(os.environ.get("RESPONDENT_API_SECRET", "")).strip() + + def _headers(self) -> Dict[str, str]: + return { + "x-api-key": self._api_key(), + "x-api-secret": self._api_secret(), + "Content-Type": "application/json", + } + + def _status(self) -> str: + has_key = bool(self._api_key()) + has_secret = bool(self._api_secret()) + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": "ready" if (has_key and has_secret) else "missing_credentials", + "method_count": len(METHOD_IDS), + "auth_type": "api_key_and_secret", + "required_env": [ + v for v, present in [ + ("RESPONDENT_API_KEY", has_key), + ("RESPONDENT_API_SECRET", has_secret), + ] if not present + ], + "products": ["Projects", "Screener responses", "Invite and attendance workflow"], + "message": "Production credentials require a staging demo and API partner approval from Respondent.", + }, + indent=2, + ensure_ascii=False, + ) + + def _help(self) -> str: + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}\n" + "notes:\n" + "- Respondent requires staging implementation review before production credentials are issued.\n" + "- Invite, attended, reject, and report flows are mandatory for a compliant production integration.\n" + "- Moderated studies also require a scheduling and messaging mechanism outside this core integration file.\n" + ) + + def _error(self, method_id: str, code: str, message: str, **extra: Any) -> str: + payload: Dict[str, Any] = { + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": code, + "message": message, + } + payload.update(extra) + return json.dumps(payload, indent=2, ensure_ascii=False) + + def _result(self, method_id: str, result: Any) -> str: + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": result}, indent=2, ensure_ascii=False) + + def _require_str(self, method_id: str, args: Dict[str, Any], key: str) -> str: + value = str(args.get(key, "")).strip() + if not value: + raise ValueError(f"{key} is required for {method_id}.") + return value + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Optional[Dict[str, Any]], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return self._help() + if op == "status": + return self._status() + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return self._error(method_id, "METHOD_UNKNOWN", "Unknown Respondent method.") + if not self._api_key() or not self._api_secret(): + return self._error(method_id, "AUTH_MISSING", "Set RESPONDENT_API_KEY and RESPONDENT_API_SECRET in the runtime environment.") + return await self._dispatch(method_id, call_args) + + async def _request( + self, + method_id: str, + http_method: str, + path: str, + *, + body: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + ) -> str: + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + url = _BASE_URL + path + if http_method == "GET": + response = await client.get(url, headers=self._headers(), params=params) + elif http_method == "POST": + response = await client.post(url, headers=self._headers(), params=params, json=body) + elif http_method == "PATCH": + response = await client.patch(url, headers=self._headers(), params=params, json=body) + else: + return self._error(method_id, "UNSUPPORTED_HTTP_METHOD", f"Unsupported HTTP method {http_method}.") + except httpx.TimeoutException: + return self._error(method_id, "TIMEOUT", "Respondent request timed out.") + except httpx.HTTPError as e: + logger.error("respondent request failed", exc_info=e) + return self._error(method_id, "HTTP_ERROR", f"{type(e).__name__}: {e}") + + if response.status_code >= 400: + detail: Any = response.text[:1000] + try: + detail = response.json() + except json.JSONDecodeError: + pass + logger.info("respondent provider error method=%s status=%s body=%s", method_id, response.status_code, response.text[:300]) + return self._error(method_id, "PROVIDER_ERROR", "Respondent returned an error.", http_status=response.status_code, detail=detail) + + if not response.text.strip(): + return self._result(method_id, {}) + try: + return self._result(method_id, response.json()) + except json.JSONDecodeError: + return self._result(method_id, response.text) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + try: + if method_id == "respondent.projects.create.v1": + return await self._request(method_id, "POST", "/projects", body=args) + if method_id == "respondent.projects.publish.v1": + project_id = self._require_str(method_id, args, "project_id") + return await self._request(method_id, "PATCH", f"/projects/{project_id}/publish", body={}) + if method_id == "respondent.screener_responses.list.v1": + project_id = self._require_str(method_id, args, "project_id") + params: Dict[str, Any] = {} + for key in ["page", "per_page", "status", "qualified"]: + value = args.get(key) + if value not in (None, ""): + params[key] = value + return await self._request(method_id, "GET", f"/projects/{project_id}/screener-responses", params=params) + if method_id == "respondent.screener_responses.qualify.v1": + project_id = self._require_str(method_id, args, "project_id") + screener_response_id = self._require_str(method_id, args, "screener_response_id") + return await self._request(method_id, "PATCH", f"/projects/{project_id}/screener-responses/{screener_response_id}/qualify", body={}) + if method_id == "respondent.screener_responses.invite.v1": + project_id = self._require_str(method_id, args, "project_id") + screener_response_id = self._require_str(method_id, args, "screener_response_id") + body: Dict[str, Any] = {} + for key in ["meetingLink", "bookingLink", "message"]: + value = args.get(key) + if value not in (None, ""): + body[key] = value + return await self._request(method_id, "PATCH", f"/projects/{project_id}/screener-responses/{screener_response_id}/invite", body=body) + if method_id == "respondent.screener_responses.attended.v1": + project_id = self._require_str(method_id, args, "project_id") + screener_response_id = self._require_str(method_id, args, "screener_response_id") + return await self._request(method_id, "PATCH", f"/projects/{project_id}/screener-responses/{screener_response_id}/attended", body={}) + if method_id == "respondent.screener_responses.reject.v1": + project_id = self._require_str(method_id, args, "project_id") + screener_response_id = self._require_str(method_id, args, "screener_response_id") + body: Dict[str, Any] = {} + reason = str(args.get("reason", "")).strip() + if reason: + body["reason"] = reason + return await self._request(method_id, "PATCH", f"/projects/{project_id}/screener-responses/{screener_response_id}/reject", body=body) + if method_id == "respondent.screener_responses.report.v1": + project_id = self._require_str(method_id, args, "project_id") + screener_response_id = self._require_str(method_id, args, "screener_response_id") + body = {} + report_reason = str(args.get("reason", "")).strip() + if report_reason: + body["reason"] = report_reason + return await self._request(method_id, "PATCH", f"/projects/{project_id}/screener-responses/{screener_response_id}/report", body=body) + except ValueError as e: + return self._error(method_id, "INVALID_ARGS", str(e)) + return self._error(method_id, "METHOD_UNIMPLEMENTED", "Method is declared but not implemented.") diff --git a/flexus_client_kit/integrations/fi_salesforce.py b/flexus_client_kit/integrations/fi_salesforce.py new file mode 100644 index 00000000..f7f9539c --- /dev/null +++ b/flexus_client_kit/integrations/fi_salesforce.py @@ -0,0 +1,174 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("salesforce") + +PROVIDER_NAME = "salesforce" +METHOD_IDS = [ + "salesforce.query.account.v1", + "salesforce.query.opportunity.v1", + "salesforce.query.soql.v1", + "salesforce.query.user.v1", +] + +_API_VERSION = "v58.0" + + +class IntegrationSalesforce: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + token = os.environ.get("SALESFORCE_ACCESS_TOKEN", "") + url = os.environ.get("SALESFORCE_INSTANCE_URL", "") + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if (token and url) else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "salesforce.query.soql.v1": + return await self._query_soql(args) + if method_id == "salesforce.query.opportunity.v1": + return await self._query_opportunity(args) + if method_id == "salesforce.query.account.v1": + return await self._query_account(args) + if method_id == "salesforce.query.user.v1": + return await self._query_user(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _soql_exec(self, soql: str, max_records: int) -> str: + token = os.environ.get("SALESFORCE_ACCESS_TOKEN", "") + instance_url = os.environ.get("SALESFORCE_INSTANCE_URL", "") + if not token or not instance_url: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "SALESFORCE_ACCESS_TOKEN and SALESFORCE_INSTANCE_URL env vars required"}, indent=2, ensure_ascii=False) + base_url = f"{instance_url.rstrip('/')}/services/data/{_API_VERSION}" + headers = {"Authorization": f"Bearer {token}"} + records: list[Dict[str, Any]] = [] + total_size = 0 + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(f"{base_url}/query", params={"q": soql}, headers=headers) + if resp.status_code != 200: + logger.info("salesforce soql error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + total_size = data.get("totalSize", 0) + records.extend(data.get("records", [])) + while not data.get("done", True) and len(records) < max_records: + next_url = data.get("nextRecordsUrl", "") + if not next_url: + break + resp = await client.get(f"{instance_url.rstrip('/')}{next_url}", headers=headers) + if resp.status_code != 200: + break + data = resp.json() + records.extend(data.get("records", [])) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + records = records[:max_records] + return json.dumps({"ok": True, "total_size": total_size, "returned": len(records), "records": records}, indent=2, ensure_ascii=False) + + async def _query_opportunity(self, args: Dict[str, Any]) -> str: + limit = min(int(args.get("limit", 100)), 200) + where = str(args.get("where", "")).strip() + where_clause = f" WHERE {where}" if where else "" + soql = f"SELECT Id, Name, Amount, StageName, CloseDate, AccountId, OwnerId, Probability, Type, LeadSource FROM Opportunity{where_clause} LIMIT {limit}" + return await self._soql_exec(soql, limit) + + async def _query_account(self, args: Dict[str, Any]) -> str: + limit = min(int(args.get("limit", 100)), 200) + where = str(args.get("where", "")).strip() + where_clause = f" WHERE {where}" if where else "" + soql = f"SELECT Id, Name, Industry, AnnualRevenue, NumberOfEmployees, BillingCountry, BillingState, OwnerId, Type FROM Account{where_clause} LIMIT {limit}" + return await self._soql_exec(soql, limit) + + async def _query_user(self, args: Dict[str, Any]) -> str: + limit = min(int(args.get("limit", 50)), 200) + where = str(args.get("where", "IsActive = true")).strip() + where_clause = f" WHERE {where}" if where else "" + soql = f"SELECT Id, Name, Email, Title, UserType, IsActive, Profile.Name FROM User{where_clause} LIMIT {limit}" + return await self._soql_exec(soql, limit) + + async def _query_soql(self, args: Dict[str, Any]) -> str: + token = os.environ.get("SALESFORCE_ACCESS_TOKEN", "") + instance_url = os.environ.get("SALESFORCE_INSTANCE_URL", "") + if not token or not instance_url: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "SALESFORCE_ACCESS_TOKEN and SALESFORCE_INSTANCE_URL env vars required"}, indent=2, ensure_ascii=False) + soql = str(args.get("soql", "")).strip() + if not soql: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "soql is required"}, indent=2, ensure_ascii=False) + max_records = int(args.get("max_records", 100)) + base_url = f"{instance_url.rstrip('/')}/services/data/{_API_VERSION}" + headers = {"Authorization": f"Bearer {token}"} + records = [] + total_size = 0 + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{base_url}/query", + params={"q": soql}, + headers=headers, + ) + if resp.status_code != 200: + logger.info("salesforce query_soql error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + total_size = data.get("totalSize", 0) + records.extend(data.get("records", [])) + while not data.get("done", True) and len(records) < max_records: + next_url = data.get("nextRecordsUrl", "") + if not next_url: + break + resp = await client.get( + f"{instance_url.rstrip('/')}{next_url}", + headers=headers, + ) + if resp.status_code != 200: + logger.info("salesforce query_soql pagination error %s: %s", resp.status_code, resp.text[:200]) + break + data = resp.json() + records.extend(data.get("records", [])) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + records = records[:max_records] + result = { + "ok": True, + "total_size": total_size, + "returned": len(records), + "records": records, + } + return f"salesforce.query.soql ok\n\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" diff --git a/flexus_client_kit/integrations/fi_salesloft.py b/flexus_client_kit/integrations/fi_salesloft.py new file mode 100644 index 00000000..35723c41 --- /dev/null +++ b/flexus_client_kit/integrations/fi_salesloft.py @@ -0,0 +1,291 @@ +import json +import logging +import os +from typing import Any, Dict, List + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("salesloft") + +PROVIDER_NAME = "salesloft" +API_BASE = "https://api.salesloft.com/v2" +METHOD_IDS = [ + "salesloft.people.list.v1", + "salesloft.cadence_memberships.create.v1", +] + + +def _extract_phone(p: Dict[str, Any]) -> str: + phone = p.get("phone", "") + if phone: + return str(phone) + phones = p.get("phone_numbers") + if isinstance(phones, list) and phones: + first = phones[0] + return first.get("value", "") if isinstance(first, dict) else "" + return "" + + +def _normalize_person(p: Dict[str, Any]) -> Dict[str, Any]: + email = "" + if isinstance(p.get("email_address"), str): + email = p["email_address"] + elif isinstance(p.get("email_addresses"), list) and p["email_addresses"]: + email = str(p["email_addresses"][0]) if p["email_addresses"] else "" + return { + "id": p.get("id"), + "first_name": p.get("first_name", ""), + "last_name": p.get("last_name", ""), + "email": email, + "title": p.get("title", ""), + "company": p.get("organization", {}).get("name", "") if isinstance(p.get("organization"), dict) else (p.get("company", "") or ""), + "phone": _extract_phone(p), + "created_at": p.get("created_at", ""), + "updated_at": p.get("updated_at", ""), + "crm_url": p.get("crm_url", ""), + } + + +class IntegrationSalesloft: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help\n" + "op=status\n" + "op=list_methods\n" + "op=call(args={method_id: ...})\n" + f"known_method_ids={len(METHOD_IDS)}" + ) + if op == "status": + token = os.environ.get("SALESLOFT_ACCESS_TOKEN", "") + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if token else "no_credentials", + "method_count": len(METHOD_IDS), + }, + indent=2, + ensure_ascii=False, + ) + if op == "list_methods": + return json.dumps( + {"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, + indent=2, + ensure_ascii=False, + ) + if op != "call": + return "Error: unknown op." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required." + if method_id not in METHOD_IDS: + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, call_args: Dict[str, Any]) -> str: + if method_id == "salesloft.people.list.v1": + return await self._people_list(call_args) + if method_id == "salesloft.cadence_memberships.create.v1": + return await self._cadence_memberships_create(call_args) + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + + def _get_token(self) -> str: + return os.environ.get("SALESLOFT_ACCESS_TOKEN", "") + + def _headers(self) -> Dict[str, str]: + token = self._get_token() + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + async def _people_list(self, call_args: Dict[str, Any]) -> str: + token = self._get_token() + if not token: + return json.dumps( + {"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + params: Dict[str, Any] = {} + email = str(call_args.get("email", "")).strip() + if email: + params["email_addresses[]"] = email + per_page = call_args.get("per_page", 25) + if isinstance(per_page, int) and 1 <= per_page <= 100: + params["per_page"] = per_page + else: + params["per_page"] = 25 + page = call_args.get("page", 1) + if isinstance(page, int) and page >= 1: + params["page"] = page + else: + params["page"] = 1 + include_paging = call_args.get("include_paging_counts") + if include_paging is True: + params["include_paging_counts"] = "true" + url = f"{API_BASE}/people.json" + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, params=params, headers=self._headers(), timeout=30.0) + response.raise_for_status() + except httpx.TimeoutException as e: + logger.info("Salesloft people.list timeout: %s", e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPStatusError as e: + logger.info("Salesloft people.list HTTP status=%s: %s", e.response.status_code, e) + return json.dumps( + { + "ok": False, + "error_code": "HTTP_ERROR", + "provider": PROVIDER_NAME, + "status_code": e.response.status_code, + }, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("Salesloft people.list HTTP error: %s", e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + payload = response.json() + except ValueError as e: + logger.info("Salesloft people.list JSON decode: %s", e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + data: List[Dict[str, Any]] = payload.get("data") or [] + metadata = payload.get("metadata") or {} + paging = metadata.get("paging") or {} + except (KeyError, ValueError) as e: + logger.info("Salesloft people.list response structure: %s", e) + return json.dumps( + {"ok": False, "error_code": "UNEXPECTED_RESPONSE", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + normalized = [_normalize_person(p) for p in data if isinstance(p, dict)] + result: Dict[str, Any] = { + "ok": True, + "data": normalized, + "metadata": {"paging": paging}, + } + return json.dumps(result, indent=2, ensure_ascii=False) + + async def _cadence_memberships_create(self, call_args: Dict[str, Any]) -> str: + token = self._get_token() + if not token: + return json.dumps( + {"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + person_id = call_args.get("person_id") + cadence_id = call_args.get("cadence_id") + if person_id is None or cadence_id is None: + return json.dumps( + {"ok": False, "error_code": "MISSING_ARGS", "message": "person_id and cadence_id required"}, + indent=2, + ensure_ascii=False, + ) + try: + person_id = int(person_id) + cadence_id = int(cadence_id) + except (TypeError, ValueError): + return json.dumps( + {"ok": False, "error_code": "INVALID_ARGS", "message": "person_id and cadence_id must be integers"}, + indent=2, + ensure_ascii=False, + ) + body: Dict[str, Any] = {"person_id": person_id, "cadence_id": cadence_id} + user_id = call_args.get("user_id") + if user_id is not None: + try: + body["user_id"] = int(user_id) + except (TypeError, ValueError): + pass + url = f"{API_BASE}/cadence_memberships.json" + try: + async with httpx.AsyncClient() as client: + response = await client.post( + url, + json=body, + headers=self._headers(), + timeout=30.0, + ) + response.raise_for_status() + except httpx.TimeoutException as e: + logger.info("Salesloft cadence_memberships.create timeout: %s", e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPStatusError as e: + logger.info("Salesloft cadence_memberships.create HTTP status=%s: %s", e.response.status_code, e) + return json.dumps( + { + "ok": False, + "error_code": "HTTP_ERROR", + "provider": PROVIDER_NAME, + "status_code": e.response.status_code, + }, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("Salesloft cadence_memberships.create HTTP error: %s", e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + try: + payload = response.json() + except ValueError as e: + logger.info("Salesloft cadence_memberships.create JSON decode: %s", e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + data = payload.get("data") or payload + result = { + "ok": True, + "id": data.get("id"), + "person_id": data.get("person_id"), + "cadence_id": data.get("cadence_id"), + "current_state": data.get("current_state"), + "created_at": data.get("created_at"), + } + return json.dumps(result, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_segment.py b/flexus_client_kit/integrations/fi_segment.py new file mode 100644 index 00000000..df980146 --- /dev/null +++ b/flexus_client_kit/integrations/fi_segment.py @@ -0,0 +1,139 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("segment") + +PROVIDER_NAME = "segment" +METHOD_IDS = [ + "segment.tracking_plans.list.v1", + "segment.tracking_plans.get.v1", +] + +BASE_URL = "https://api.segmentapis.com" + + +class IntegrationSegment: + async def called_by_model(self, toolcall, model_produced_args): + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nmethods: {', '.join(METHOD_IDS)}" + if op == "status": + key = os.environ.get("SEGMENT_ACCESS_TOKEN", "") + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available" if key else "no_credentials", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id, call_args): + if method_id == "segment.tracking_plans.list.v1": + return await self._tracking_plans_list(call_args) + if method_id == "segment.tracking_plans.get.v1": + return await self._tracking_plan_get(call_args) + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + + def _headers(self): + token = os.environ.get("SEGMENT_ACCESS_TOKEN", "") + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + async def _tracking_plans_list(self, call_args): + pagination_count = int(call_args.get("pagination_count", 10)) + pagination_cursor = call_args.get("pagination_cursor", None) + params = {"pagination[count]": pagination_count} + if pagination_cursor: + params["pagination[cursor]"] = pagination_cursor + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(f"{BASE_URL}/tracking-plans", headers=self._headers(), params=params) + resp.raise_for_status() + data = resp.json() + except httpx.TimeoutException as e: + logger.info("segment tracking_plans list timeout: %s", e) + return json.dumps({"ok": False, "error_code": "TIMEOUT", "error": str(e)}, indent=2, ensure_ascii=False) + except httpx.HTTPStatusError as e: + logger.info("segment tracking_plans list http status error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "status_code": e.response.status_code, "error": str(e)}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + logger.info("segment tracking_plans list http error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "error": str(e)}, indent=2, ensure_ascii=False) + except json.JSONDecodeError as e: + logger.info("segment tracking_plans list json decode error: %s", e) + return json.dumps({"ok": False, "error_code": "JSON_DECODE_ERROR", "error": str(e)}, indent=2, ensure_ascii=False) + try: + raw_plans = data["data"]["trackingPlans"] + except KeyError as e: + logger.info("segment tracking_plans list unexpected response shape: %s", e) + return json.dumps({"ok": False, "error_code": "UNEXPECTED_RESPONSE", "error": f"Missing key: {e}"}, indent=2, ensure_ascii=False) + tracking_plans = [ + { + "id": p.get("id", ""), + "name": p.get("name", ""), + "slug": p.get("slug", ""), + "description": p.get("description", ""), + "created_at": p.get("createdAt", ""), + "updated_at": p.get("updatedAt", ""), + "rules_count": p.get("rulesCount", 0), + } + for p in raw_plans + ] + return json.dumps({"ok": True, "tracking_plans": tracking_plans}, indent=2, ensure_ascii=False) + + async def _tracking_plan_get(self, call_args): + tracking_plan_id = str(call_args.get("tracking_plan_id", "")).strip() + if not tracking_plan_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "error": "tracking_plan_id is required"}, indent=2, ensure_ascii=False) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(f"{BASE_URL}/tracking-plans/{tracking_plan_id}", headers=self._headers()) + resp.raise_for_status() + data = resp.json() + except httpx.TimeoutException as e: + logger.info("segment tracking_plan get timeout: %s", e) + return json.dumps({"ok": False, "error_code": "TIMEOUT", "error": str(e)}, indent=2, ensure_ascii=False) + except httpx.HTTPStatusError as e: + logger.info("segment tracking_plan get http status error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "status_code": e.response.status_code, "error": str(e)}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + logger.info("segment tracking_plan get http error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "error": str(e)}, indent=2, ensure_ascii=False) + except json.JSONDecodeError as e: + logger.info("segment tracking_plan get json decode error: %s", e) + return json.dumps({"ok": False, "error_code": "JSON_DECODE_ERROR", "error": str(e)}, indent=2, ensure_ascii=False) + try: + plan = data["data"]["trackingPlan"] + except KeyError as e: + logger.info("segment tracking_plan get unexpected response shape: %s", e) + return json.dumps({"ok": False, "error_code": "UNEXPECTED_RESPONSE", "error": f"Missing key: {e}"}, indent=2, ensure_ascii=False) + raw_rules = plan.get("rules") or [] + rules = [ + { + "key": r.get("key", ""), + "type": r.get("type", ""), + "schema": r.get("jsonSchema", {}), + } + for r in raw_rules + ] + return json.dumps({ + "ok": True, + "id": plan.get("id", ""), + "name": plan.get("name", ""), + "description": plan.get("description", ""), + "rules": rules, + }, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_serpapi.py b/flexus_client_kit/integrations/fi_serpapi.py new file mode 100644 index 00000000..fabe0212 --- /dev/null +++ b/flexus_client_kit/integrations/fi_serpapi.py @@ -0,0 +1,345 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("serpapi") + +PROVIDER_NAME = "serpapi" +_BASE_URL = "https://serpapi.com" + +_SEARCH_METHOD_ROWS = [ + ("serpapi.search.amazon_product.v1", "amazon-product-api", "amazon_product"), + ("serpapi.search.amazon.v1", "amazon-search-api", "amazon"), + ("serpapi.search.apple_app_store.v1", "apple-app-store", "apple_app_store"), + ("serpapi.search.baidu_news.v1", "baidu-news-api", "baidu_news"), + ("serpapi.search.baidu.v1", "baidu-search-api", "baidu"), + ("serpapi.search.bing_copilot.v1", "bing-copilot-api", "bing_copilot"), + ("serpapi.search.bing_images.v1", "bing-images-api", "bing_images"), + ("serpapi.search.bing_maps.v1", "bing-maps-api", "bing_maps"), + ("serpapi.search.bing_news.v1", "bing-news-api", "bing_news"), + ("serpapi.search.bing_reverse_image.v1", "bing-reverse-image-api", "bing_reverse_image"), + ("serpapi.search.bing.v1", "bing-search-api", "bing"), + ("serpapi.search.bing_shopping.v1", "bing-shopping-api", "bing_shopping"), + ("serpapi.search.bing_videos.v1", "bing-videos-api", "bing_videos"), + ("serpapi.search.direct_answer_box.v1", "direct-answer-box-api", "direct_answer_box"), + ("serpapi.search.duckduckgo_light.v1", "duckduckgo-light-api", "duckduckgo_light"), + ("serpapi.search.duckduckgo_maps.v1", "duckduckgo-maps-api", "duckduckgo_maps"), + ("serpapi.search.duckduckgo_news.v1", "duckduckgo-news-api", "duckduckgo_news"), + ("serpapi.search.duckduckgo.v1", "duckduckgo-search-api", "duckduckgo"), + ("serpapi.search.duckduckgo_search_assist.v1", "duckduckgo-search-assist-api", "duckduckgo_search_assist"), + ("serpapi.search.ebay_product.v1", "ebay-product-api", "ebay_product"), + ("serpapi.search.ebay.v1", "ebay-search-api", "ebay"), + ("serpapi.search.facebook_profile.v1", "facebook-profile-api", "facebook_profile"), + ("serpapi.search.google_ads_transparency_center.v1", "google-ads-transparency-center-api", "google_ads_transparency_center"), + ("serpapi.search.google_ai_mode.v1", "google-ai-mode-api", "google_ai_mode"), + ("serpapi.search.google_ai_overview.v1", "google-ai-overview-api", "google_ai_overview"), + ("serpapi.search.google_autocomplete.v1", "google-autocomplete-api", "google_autocomplete"), + ("serpapi.search.google_events.v1", "google-events-api", "google_events"), + ("serpapi.search.google_finance.v1", "google-finance-api", "google_finance"), + ("serpapi.search.google_flights.v1", "google-flights-api", "google_flights"), + ("serpapi.search.google_flights_autocomplete.v1", "google-flights-autocomplete-api", "google_flights_autocomplete"), + ("serpapi.search.google_forums.v1", "google-forums-api", "google_forums"), + ("serpapi.search.google_hotels.v1", "google-hotels-api", "google_hotels"), + ("serpapi.search.google_hotels_autocomplete.v1", "google-hotels-autocomplete-api", "google_hotels_autocomplete"), + ("serpapi.search.google_hotels_photos.v1", "google-hotels-photos-api", "google_hotels_photos"), + ("serpapi.search.google_hotels_reviews.v1", "google-hotels-reviews-api", "google_hotels_reviews"), + ("serpapi.search.google_images.v1", "google-images-api", "google_images"), + ("serpapi.search.google_images_light.v1", "google-images-light-api", "google_images_light"), + ("serpapi.search.google_images_related_content.v1", "google-images-related-content-api", "google_images_related_content"), + ("serpapi.search.google_immersive_product.v1", "google-immersive-product-api", "google_immersive_product"), + ("serpapi.search.google_jobs.v1", "google-jobs-api", "google_jobs"), + ("serpapi.search.google_jobs_listing.v1", "google-jobs-listing-api", "google_jobs_listing"), + ("serpapi.search.google_lens_about_this_image.v1", "google-lens-about-this-image-api", "google_lens_about_this_image"), + ("serpapi.search.google_lens.v1", "google-lens-api", "google_lens"), + ("serpapi.search.google_lens_exact_matches.v1", "google-lens-exact-matches-api", "google_lens_exact_matches"), + ("serpapi.search.google_lens_image_sources.v1", "google-lens-image-sources-api", "google_lens_image_sources"), + ("serpapi.search.google_lens_products.v1", "google-lens-products-api", "google_lens_products"), + ("serpapi.search.google_lens_visual_matches.v1", "google-lens-visual-matches-api", "google_lens_visual_matches"), + ("serpapi.search.google_light.v1", "google-light-api", "google_light"), + ("serpapi.search.google_light_fast.v1", "google-light-fast-api", "google_light"), + ("serpapi.search.google_local.v1", "google-local-api", "google_local"), + ("serpapi.search.google_local_services.v1", "google-local-services-api", "google_local_services"), + ("serpapi.search.google_maps.v1", "google-maps-api", "google_maps"), + ("serpapi.search.google_maps_autocomplete.v1", "google-maps-autocomplete-api", "google_maps_autocomplete"), + ("serpapi.search.google_maps_contributor_reviews.v1", "google-maps-contributor-reviews-api", "google_maps_contributor_reviews"), + ("serpapi.search.google_maps_directions.v1", "google-maps-directions-api", "google_maps_directions"), + ("serpapi.search.google_maps_photo_meta.v1", "google-maps-photo-meta-api", "google_maps_photo_meta"), + ("serpapi.search.google_maps_photos.v1", "google-maps-photos-api", "google_maps_photos"), + ("serpapi.search.google_maps_posts.v1", "google-maps-posts-api", "google_maps_posts"), + ("serpapi.search.google_maps_reviews.v1", "google-maps-reviews-api", "google_maps_reviews"), + ("serpapi.search.google_news.v1", "google-news-api", "google_news"), + ("serpapi.search.google_news_light.v1", "google-news-light-api", "google_news_light"), + ("serpapi.search.google_patents.v1", "google-patents-api", "google_patents"), + ("serpapi.search.google_patents_details.v1", "google-patents-details-api", "google_patents_details"), + ("serpapi.search.google_play.v1", "google-play-api", "google_play"), + ("serpapi.search.google_play_product.v1", "google-play-product-api", "google_play_product"), + ("serpapi.search.google_related_questions.v1", "google-related-questions-api", "google_related_questions"), + ("serpapi.search.google_reverse_image.v1", "google-reverse-image", "google_reverse_image"), + ("serpapi.search.google_scholar.v1", "google-scholar-api", "google_scholar"), + ("serpapi.search.google_scholar_author.v1", "google-scholar-author-api", "google_scholar_author"), + ("serpapi.search.google_scholar_cite.v1", "google-scholar-cite-api", "google_scholar_cite"), + ("serpapi.search.google_scholar_profiles.v1", "google-scholar-profiles-api", "google_scholar_profiles"), + ("serpapi.search.google_shopping.v1", "google-shopping-api", "google_shopping"), + ("serpapi.search.google_shopping_filters.v1", "google-shopping-filters-api", "google_shopping_filters"), + ("serpapi.search.google_shopping_light.v1", "google-shopping-light-api", "google_shopping_light"), + ("serpapi.search.google_short_videos.v1", "google-short-videos-api", "google_short_videos"), + ("serpapi.search.google_travel_explore.v1", "google-travel-explore-api", "google_travel_explore"), + ("serpapi.search.google_trends.v1", "google-trends-api", "google_trends"), + ("serpapi.search.google_videos.v1", "google-videos-api", "google_videos"), + ("serpapi.search.google_videos_light.v1", "google-videos-light-api", "google_videos_light"), + ("serpapi.search.home_depot.v1", "home-depot-search-api", "home_depot"), + ("serpapi.search.naver_ai_overview.v1", "naver-ai-overview-api", "naver_ai_overview"), + ("serpapi.search.naver_images.v1", "naver-images-api", "naver_images"), + ("serpapi.search.naver.v1", "naver-search-api", "naver"), + ("serpapi.search.open_table_reviews.v1", "open-table-reviews-api", "open_table_reviews"), + ("serpapi.search.google.v1", "search-api", "google"), + ("serpapi.search.search_index.v1", "search-index-api", "search_index"), + ("serpapi.search.tripadvisor_place.v1", "tripadvisor-place-api", "tripadvisor_place"), + ("serpapi.search.tripadvisor.v1", "tripadvisor-search-api", "tripadvisor"), + ("serpapi.search.walmart_product.v1", "walmart-product-api", "walmart_product"), + ("serpapi.search.walmart_product_reviews.v1", "walmart-product-reviews-api", "walmart_product_reviews"), + ("serpapi.search.walmart_product_sellers.v1", "walmart-product-sellers-api", "walmart_product_sellers"), + ("serpapi.search.walmart.v1", "walmart-search-api", "walmart"), + ("serpapi.search.yahoo_images.v1", "yahoo-images-api", "yahoo_images"), + ("serpapi.search.yahoo.v1", "yahoo-search-api", "yahoo"), + ("serpapi.search.yahoo_shopping.v1", "yahoo-shopping-search-api", "yahoo_shopping"), + ("serpapi.search.yahoo_videos.v1", "yahoo-videos-api", "yahoo_videos"), + ("serpapi.search.yandex_images.v1", "yandex-images-api", "yandex_images"), + ("serpapi.search.yandex.v1", "yandex-search-api", "yandex"), + ("serpapi.search.yandex_videos.v1", "yandex-videos-api", "yandex_videos"), + ("serpapi.search.yelp_reviews.v1", "yelp-reviews-api", "yelp_reviews"), + ("serpapi.search.yelp.v1", "yelp-search-api", "yelp"), + ("serpapi.search.youtube.v1", "youtube-search-api", "youtube"), + ("serpapi.search.youtube_video.v1", "youtube-video-api", "youtube_video"), +] + +_SEARCH_METHODS = { + method_id: { + "kind": "search", + "slug": slug, + "engine": engine, + "docs_url": f"{_BASE_URL}/{slug}", + } + for method_id, slug, engine in _SEARCH_METHOD_ROWS +} + +_EXTRA_METHODS = { + "serpapi.account.get.v1": { + "kind": "account", + "docs_url": f"{_BASE_URL}/account-api", + }, + "serpapi.locations.search.v1": { + "kind": "locations", + "docs_url": f"{_BASE_URL}/locations-api", + }, + "serpapi.search_archive.get.v1": { + "kind": "search_archive", + "docs_url": f"{_BASE_URL}/search-archive-api", + }, + "serpapi.pixel_position.search.v1": { + "kind": "pixel_position_search", + "docs_url": f"{_BASE_URL}/pixel-position-api", + }, + "serpapi.pixel_position.archive.v1": { + "kind": "pixel_position_archive", + "docs_url": f"{_BASE_URL}/pixel-position-api", + }, +} + +METHOD_IDS = [*_SEARCH_METHODS.keys(), *_EXTRA_METHODS.keys()] +METHOD_SPECS = {**_SEARCH_METHODS, **_EXTRA_METHODS} + + +class IntegrationSerpapi: + def __init__(self, rcx=None): + self.rcx = rcx + + def _auth(self) -> Dict[str, Any]: + if self.rcx is not None: + return self.rcx.external_auth.get(PROVIDER_NAME) or {} + return {} + + def _api_key(self) -> str: + auth = self._auth() + return str( + auth.get("api_key", "") + or auth.get("token", "") + or os.environ.get("SERPAPI_API_KEY", "") + ).strip() + + def _status(self) -> str: + api_key = self._api_key() + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "ready" if api_key else "missing_credentials", + "has_api_key": bool(api_key), + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + + def _help(self) -> str: + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + "call args: method_id plus the documented SerpApi query params for that method\n" + "special methods: serpapi.account.get.v1, serpapi.locations.search.v1, serpapi.search_archive.get.v1, serpapi.pixel_position.search.v1, serpapi.pixel_position.archive.v1\n" + f"methods={len(METHOD_IDS)}" + ) + + def _clean_params(self, args: Dict[str, Any]) -> Dict[str, Any]: + return { + k: v + for k, v in args.items() + if k not in {"method_id", "include_raw"} + and v is not None + and (not isinstance(v, str) or v.strip() != "") + } + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return self._help() + if op == "status": + return self._status() + if op == "list_methods": + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "method_ids": METHOD_IDS, + "methods": METHOD_SPECS, + }, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_SPECS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id in _SEARCH_METHODS: + return await self._search(method_id, args, _SEARCH_METHODS[method_id]) + if method_id == "serpapi.account.get.v1": + return await self._account() + if method_id == "serpapi.locations.search.v1": + return await self._locations(args) + if method_id == "serpapi.search_archive.get.v1": + return await self._search_archive(args) + if method_id == "serpapi.pixel_position.search.v1": + return await self._pixel_position_search(args) + if method_id == "serpapi.pixel_position.archive.v1": + return await self._pixel_position_archive(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _get(self, path: str, params: Dict[str, Any]) -> str: + api_key = self._api_key() + if not api_key: + return json.dumps({ + "ok": False, + "error_code": "AUTH_MISSING", + "message": "Set api_key in serpapi auth or SERPAPI_API_KEY env var.", + }, indent=2, ensure_ascii=False) + req_params = {**params, "api_key": api_key} + try: + async with httpx.AsyncClient(timeout=40.0) as client: + r = await client.get(_BASE_URL + path, params=req_params) + if r.status_code >= 400: + logger.info("%s GET %s HTTP %s: %s", PROVIDER_NAME, path, r.status_code, r.text[:200]) + return json.dumps({ + "ok": False, + "error_code": "PROVIDER_ERROR", + "status": r.status_code, + "detail": r.text[:300], + }, indent=2, ensure_ascii=False) + if path.endswith(".html") or str(req_params.get("output", "")).strip() == "html": + return r.text + try: + return json.dumps(r.json(), indent=2, ensure_ascii=False) + except json.JSONDecodeError: + return r.text + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _search(self, method_id: str, args: Dict[str, Any], spec: Dict[str, Any]) -> str: + params = self._clean_params(args) + supplied_engine = str(params.pop("engine", "")).strip() + if supplied_engine and supplied_engine != spec["engine"]: + return json.dumps({ + "ok": False, + "error_code": "ENGINE_MISMATCH", + "message": f"{method_id} is bound to engine={spec['engine']}.", + }, indent=2, ensure_ascii=False) + if not params: + return json.dumps({ + "ok": False, + "error_code": "MISSING_ARGS", + "message": "Provide the documented SerpApi query params for this method.", + }, indent=2, ensure_ascii=False) + return await self._get("/search", {"engine": spec["engine"], **params}) + + async def _account(self) -> str: + return await self._get("/account.json", {}) + + async def _locations(self, args: Dict[str, Any]) -> str: + params = self._clean_params(args) + return await self._get("/locations.json", params) + + async def _search_archive(self, args: Dict[str, Any]) -> str: + params = self._clean_params(args) + search_id = str(params.pop("search_id", params.pop("id", ""))).strip() + if not search_id: + return json.dumps({ + "ok": False, + "error_code": "MISSING_ARG", + "message": "search_id is required.", + }, indent=2, ensure_ascii=False) + output = str(params.pop("output", "json")).strip() or "json" + if output not in {"json", "html", "json_with_pixel_position"}: + return json.dumps({ + "ok": False, + "error_code": "INVALID_OUTPUT", + "message": "output must be one of: json, html, json_with_pixel_position.", + }, indent=2, ensure_ascii=False) + return await self._get(f"/searches/{search_id}.{output}", params) + + async def _pixel_position_search(self, args: Dict[str, Any]) -> str: + params = self._clean_params(args) + supplied_engine = str(params.pop("engine", "google")).strip() or "google" + if supplied_engine != "google": + return json.dumps({ + "ok": False, + "error_code": "ENGINE_UNSUPPORTED", + "message": "Pixel Position currently supports only engine=google.", + }, indent=2, ensure_ascii=False) + if not params: + return json.dumps({ + "ok": False, + "error_code": "MISSING_ARGS", + "message": "Provide Google Search params such as q, gl, hl, location, or similar.", + }, indent=2, ensure_ascii=False) + return await self._get("/search.json_with_pixel_position", {"engine": "google", **params}) + + async def _pixel_position_archive(self, args: Dict[str, Any]) -> str: + params = self._clean_params(args) + search_id = str(params.pop("search_id", params.pop("id", ""))).strip() + if not search_id: + return json.dumps({ + "ok": False, + "error_code": "MISSING_ARG", + "message": "search_id is required.", + }, indent=2, ensure_ascii=False) + return await self._get(f"/searches/{search_id}.json_with_pixel_position", params) diff --git a/flexus_client_kit/integrations/fi_shopify.py b/flexus_client_kit/integrations/fi_shopify.py index cddf60ad..cc9e7402 100644 --- a/flexus_client_kit/integrations/fi_shopify.py +++ b/flexus_client_kit/integrations/fi_shopify.py @@ -303,7 +303,7 @@ def parse_ts(s: Optional[str]) -> float: return 0.0 try: return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() - except Exception: + except (TypeError, ValueError): return 0.0 @@ -507,7 +507,7 @@ async def _register_webhooks(self) -> str: except httpx.HTTPStatusError as e: if e.response.status_code != 404: logger.warning("failed to delete webhook %s: %s", w['id'], e) - except Exception as e: + except (httpx.HTTPError, KeyError, TypeError, ValueError) as e: logger.warning("failed to delete webhook %s: %s", w['id'], e) del ours[topic] failed = [] @@ -523,7 +523,7 @@ async def _register_webhooks(self) -> str: body = e.response.text[:200] if e.response else "" logger.warning("webhook %s failed for %s: %s %s", topic, self.shop.shop_domain, e.response.status_code, body) failed.append(f"{topic} ({body})" if body else topic) - except Exception as e: + except (httpx.HTTPError, KeyError, TypeError, ValueError) as e: logger.warning("webhook %s failed for %s: %s", topic, self.shop.shop_domain, e) failed.append(topic) if failed: @@ -612,7 +612,7 @@ async def _upsert(self, table: str, ws: str, upsert_key: str, recs: list, fk_res if isinstance(res, dict) and res.get("errors"): return f"{table}: {res['failed']} failed — {res['errors']}" return "" - except Exception as e: + except (TypeError, ValueError, KeyError, RuntimeError) as e: return f"{table} upsert failed: {e}" @@ -723,7 +723,7 @@ async def _try_detect_new_shop(self) -> str: try: r = await _shop_req(domain, token, "GET", "shop.json") info = r.json()["shop"] - except Exception as e: + except (httpx.HTTPError, ValueError, KeyError, TypeError) as e: return f"Failed to verify shop connection: {e}" ws = self.rcx.persona.ws_id await ckit_erp.create_erp_record(self.fclient, "com_shop", ws, { @@ -1072,7 +1072,7 @@ async def _op_disconnect(self, toolcall): await _shop_req(self.shop.shop_domain, token, "DELETE", f"webhooks/{w['id']}.json", c=c) if auth: await ckit_external_auth.external_auth_disconnect(self.fclient, self.rcx.persona.ws_id, self.rcx.persona.persona_id, "shopify") - except Exception as e: + except (httpx.HTTPError, ValueError, KeyError, TypeError, RuntimeError) as e: cleanup_err = str(e) logger.warning("failed to clean up shop %s: %s", self.shop.shop_id, e) await ckit_erp.patch_erp_record( diff --git a/flexus_client_kit/integrations/fi_sixsense.py b/flexus_client_kit/integrations/fi_sixsense.py new file mode 100644 index 00000000..422ab4c1 --- /dev/null +++ b/flexus_client_kit/integrations/fi_sixsense.py @@ -0,0 +1,187 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("sixsense") + +PROVIDER_NAME = "sixsense" +METHOD_IDS = [ + "sixsense.company.identification.v1", + "sixsense.people.search.v1", +] + +_BASE_EPSILON = "https://epsilon.6sense.com/v3" +_BASE_API = "https://api.6sense.com" + + +class IntegrationSixsense: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + key = os.environ.get("SIXSENSE_API_KEY", "") + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if key else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "sixsense.company.identification.v1": + return await self._company_identification(args) + if method_id == "sixsense.people.search.v1": + return await self._people_search(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + def _get_key(self) -> str: + return os.environ.get("SIXSENSE_API_KEY", "") + + def _auth_headers(self, key: str) -> Dict[str, str]: + return {"Authorization": f"Token {key}"} + + def _handle_error_status(self, status: int, text: str) -> str | None: + if status == 401: + return json.dumps({ + "ok": False, + "error_code": "AUTH_ERROR", + "provider": PROVIDER_NAME, + "message": "API key invalid or expired", + }, indent=2, ensure_ascii=False) + if status == 402: + return json.dumps({ + "ok": False, + "error_code": "QUOTA_EXHAUSTED", + "provider": PROVIDER_NAME, + "message": "API credit quota exhausted. Contact your 6sense CSM to top-up.", + }, indent=2, ensure_ascii=False) + if status == 403: + return json.dumps({ + "ok": False, + "error_code": "ENTITLEMENT_MISSING", + "provider": PROVIDER_NAME, + "message": "This API requires a 6sense contract/plan entitlement. Contact 6sense sales.", + }, indent=2, ensure_ascii=False) + if status == 429: + return json.dumps({ + "ok": False, + "error_code": "RATE_LIMITED", + "provider": PROVIDER_NAME, + "message": "Rate limit exceeded (100 req/min). Please retry later.", + }, indent=2, ensure_ascii=False) + if status != 200: + logger.info("sixsense error %s: %s", status, text[:200]) + return json.dumps({ + "ok": False, + "error_code": "PROVIDER_ERROR", + "status": status, + "detail": text[:500], + }, indent=2, ensure_ascii=False) + return None + + async def _company_identification(self, args: Dict[str, Any]) -> str: + key = self._get_key() + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "SIXSENSE_API_KEY env var not set"}, indent=2, ensure_ascii=False) + + domain = str(args.get("domain", "")).strip() + company_name = str(args.get("company_name", "")).strip() + ip = str(args.get("ip", "")).strip() + + if not domain and not company_name and not ip: + return json.dumps({ + "ok": False, + "error_code": "MISSING_ARGS", + "message": "At least one of: domain, company_name, ip is required", + }, indent=2, ensure_ascii=False) + + # Company Identification API v3 — identifies companies by IP of the request. + # For server-side use: pass ip/domain/company as query params (6sense docs note + # this API is primarily client-side; server-side results may vary by plan). + params: Dict[str, str] = {} + if ip: + params["ip"] = ip + if domain: + params["domain"] = domain + if company_name: + params["company"] = company_name + + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_BASE_EPSILON}/company/details", + headers=self._auth_headers(key), + params=params, + ) + err = self._handle_error_status(resp.status_code, resp.text) + if err: + return err + data = resp.json() + return f"sixsense.company.identification ok\n\n```json\n{json.dumps({'ok': True, 'result': data}, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _people_search(self, args: Dict[str, Any]) -> str: + key = self._get_key() + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "SIXSENSE_API_KEY env var not set"}, indent=2, ensure_ascii=False) + + domain = str(args.get("domain", "")).strip() + company_name = str(args.get("company_name", "")).strip() + title = str(args.get("title", "")).strip() + limit = int(args.get("limit", 10)) + + # People Search API v2 — POST https://api.6sense.com/v2/search/people + # Accepts arrays for domain, jobTitle; pageSize controls result count. + payload: Dict[str, Any] = {"pageNo": 1, "pageSize": min(limit, 1000)} + if domain: + payload["domain"] = [domain] + if company_name: + payload["companyName"] = [company_name] + if title: + payload["jobTitle"] = [title] + + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{_BASE_API}/v2/search/people", + headers={**self._auth_headers(key), "Content-Type": "application/json"}, + json=payload, + ) + err = self._handle_error_status(resp.status_code, resp.text) + if err: + return err + data = resp.json() + return f"sixsense.people.search ok\n\n```json\n{json.dumps({'ok': True, 'result': data}, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_stackexchange.py b/flexus_client_kit/integrations/fi_stackexchange.py new file mode 100644 index 00000000..5fab3beb --- /dev/null +++ b/flexus_client_kit/integrations/fi_stackexchange.py @@ -0,0 +1,163 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("stackexchange") + +PROVIDER_NAME = "stackexchange" +METHOD_IDS = [ + "stackexchange.questions.list.v1", + "stackexchange.tags.info.v1", + "stackexchange.tags.related.v1", +] + +_BASE_URL = "https://api.stackexchange.com/2.3" + + +class IntegrationStackexchange: + def __init__(self, rcx=None): + self.rcx = rcx + + def _get_api_key(self) -> str: + if self.rcx is not None: + return (self.rcx.external_auth.get("stackexchange") or {}).get("api_key", "") + return os.environ.get("STACKEXCHANGE_KEY", "") + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "stackexchange.questions.list.v1": + return await self._questions_list(args) + if method_id == "stackexchange.tags.info.v1": + return await self._tags_info(args) + if method_id == "stackexchange.tags.related.v1": + return await self._tags_related(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + def _base_params(self) -> Dict[str, Any]: + params: Dict[str, Any] = {"site": "stackoverflow"} + key = self._get_api_key() + if key: + params["key"] = key + return params + + async def _questions_list(self, args: Dict[str, Any]) -> str: + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "args.query required."}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 20)), 100) + params = self._base_params() + params.update({"q": query, "sort": "relevance", "order": "desc", "pagesize": limit, "filter": "withbody"}) + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(_BASE_URL + "/search/advanced", params=params) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + items = data.get("items", []) + include_raw = bool(args.get("include_raw")) + out: Dict[str, Any] = {"ok": True, "quota_remaining": data.get("quota_remaining"), "results": items if include_raw else [ + { + "title": q.get("title"), + "score": q.get("score"), + "answer_count": q.get("answer_count"), + "view_count": q.get("view_count"), + "tags": q.get("tags", []), + "creation_date": q.get("creation_date"), + "link": q.get("link"), + } + for q in items + ]} + summary = f"Found {len(items)} question(s) from Stack Overflow for '{query}'." + return summary + "\n\n```json\n" + json.dumps(out, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _tags_info(self, args: Dict[str, Any]) -> str: + query = str(args.get("query", "")).strip().replace(" ", "-") + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "args.query required (tag name)."}, indent=2, ensure_ascii=False) + params = self._base_params() + params["filter"] = "default" + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(f"{_BASE_URL}/tags/{query}/info", params=params) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + items = data.get("items", []) + include_raw = bool(args.get("include_raw")) + out: Dict[str, Any] = {"ok": True, "results": items if include_raw else [ + { + "name": t.get("name"), + "count": t.get("count"), + "is_moderator_only": t.get("is_moderator_only"), + "is_required": t.get("is_required"), + } + for t in items + ]} + summary = f"Tag info for '{query}' from Stack Overflow." + return summary + "\n\n```json\n" + json.dumps(out, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _tags_related(self, args: Dict[str, Any]) -> str: + query = str(args.get("query", "")).strip().replace(" ", "-") + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "args.query required (tag name)."}, indent=2, ensure_ascii=False) + params = self._base_params() + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(f"{_BASE_URL}/tags/{query}/related", params=params) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + items = data.get("items", []) + include_raw = bool(args.get("include_raw")) + out: Dict[str, Any] = {"ok": True, "results": items if include_raw else [ + {"name": t.get("name"), "count": t.get("count")} + for t in items + ]} + summary = f"Found {len(items)} related tag(s) for '{query}' on Stack Overflow." + return summary + "\n\n```json\n" + json.dumps(out, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_statsig.py b/flexus_client_kit/integrations/fi_statsig.py new file mode 100644 index 00000000..b1167089 --- /dev/null +++ b/flexus_client_kit/integrations/fi_statsig.py @@ -0,0 +1,105 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("statsig") + +PROVIDER_NAME = "statsig" +METHOD_IDS = [ + "statsig.experiments.create.v1", + "statsig.experiments.update.v1", +] + +BASE_URL = "https://statsigapi.net/console/v1" + + +class IntegrationStatsig: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nmethods: {', '.join(METHOD_IDS)}" + if op == "status": + key = os.environ.get("STATSIG_CONSOLE_API_KEY", "") + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available" if key else "no_credentials", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, call_args: Dict[str, Any]) -> str: + key = os.environ.get("STATSIG_CONSOLE_API_KEY", "") + headers = {"statsig-api-key": key, "Content-Type": "application/json"} + if method_id == "statsig.experiments.create.v1": + return await self._experiments_create(headers, call_args) + if method_id == "statsig.experiments.update.v1": + return await self._experiments_update(headers, call_args) + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _experiments_create(self, headers: Dict[str, str], call_args: Dict[str, Any]) -> str: + name = call_args.get("name") + if not name: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "name"}, indent=2, ensure_ascii=False) + body: Dict[str, Any] = { + "name": name, + "idType": call_args.get("id_type", "userID"), + "allocation": call_args.get("allocation", 0.1), + } + if call_args.get("description") is not None: + body["description"] = call_args["description"] + if call_args.get("hypothesis") is not None: + body["hypothesis"] = call_args["hypothesis"] + if call_args.get("primary_metric_tags") is not None: + body["primaryMetricTags"] = call_args["primary_metric_tags"] + if call_args.get("duration") is not None: + body["duration"] = call_args["duration"] + async with httpx.AsyncClient() as client: + try: + resp = await client.post(f"{BASE_URL}/experiments", headers=headers, json=body) + resp.raise_for_status() + return json.dumps({"ok": True, "result": resp.json()}, indent=2, ensure_ascii=False) + except httpx.HTTPStatusError as e: + logger.info("statsig create experiment HTTP error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "status_code": e.response.status_code, "body": e.response.text}, indent=2, ensure_ascii=False) + except httpx.RequestError as e: + logger.info("statsig create experiment request error: %s", e) + return json.dumps({"ok": False, "error_code": "REQUEST_ERROR", "error": str(e)}, indent=2, ensure_ascii=False) + + async def _experiments_update(self, headers: Dict[str, str], call_args: Dict[str, Any]) -> str: + experiment_id = call_args.get("experiment_id") + if not experiment_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "experiment_id"}, indent=2, ensure_ascii=False) + body: Dict[str, Any] = {} + if call_args.get("status") is not None: + body["status"] = call_args["status"] + if call_args.get("allocation") is not None: + body["allocation"] = call_args["allocation"] + if call_args.get("description") is not None: + body["description"] = call_args["description"] + async with httpx.AsyncClient() as client: + try: + resp = await client.patch(f"{BASE_URL}/experiments/{experiment_id}", headers=headers, json=body) + resp.raise_for_status() + return json.dumps({"ok": True, "result": resp.json()}, indent=2, ensure_ascii=False) + except httpx.HTTPStatusError as e: + logger.info("statsig update experiment HTTP error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "status_code": e.response.status_code, "body": e.response.text}, indent=2, ensure_ascii=False) + except httpx.RequestError as e: + logger.info("statsig update experiment request error: %s", e) + return json.dumps({"ok": False, "error_code": "REQUEST_ERROR", "error": str(e)}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_surveymonkey.py b/flexus_client_kit/integrations/fi_surveymonkey.py new file mode 100644 index 00000000..478c66f5 --- /dev/null +++ b/flexus_client_kit/integrations/fi_surveymonkey.py @@ -0,0 +1,220 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("surveymonkey") + +PROVIDER_NAME = "surveymonkey" +METHOD_IDS = [ + "surveymonkey.collectors.create.v1", + "surveymonkey.responses.list.v1", + "surveymonkey.surveys.create.v1", + "surveymonkey.surveys.responses.list.v1", + "surveymonkey.surveys.update.v1", +] + +_BASE_URL = "https://api.surveymonkey.com/v3" + + +class IntegrationSurveymonkey: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + key = os.environ.get("SURVEYMONKEY_ACCESS_TOKEN", "") + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if key else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "surveymonkey.surveys.create.v1": + return await self._surveys_create(args) + if method_id == "surveymonkey.surveys.update.v1": + return await self._surveys_update(args) + if method_id == "surveymonkey.collectors.create.v1": + return await self._collectors_create(args) + if method_id == "surveymonkey.responses.list.v1": + return await self._responses_list(args) + if method_id == "surveymonkey.surveys.responses.list.v1": + return await self._responses_list(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + + def _headers(self, token: str) -> Dict[str, str]: + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + def _ok(self, method_label: str, data: Any) -> str: + return ( + f"surveymonkey.{method_label} ok\n\n" + f"```json\n{json.dumps({'ok': True, 'result': data}, indent=2, ensure_ascii=False)}\n```" + ) + + def _provider_error(self, status_code: int, detail: str) -> str: + logger.info("surveymonkey provider error status=%s detail=%s", status_code, detail) + return json.dumps({ + "ok": False, + "error_code": "PROVIDER_ERROR", + "status": status_code, + "detail": detail, + }, indent=2, ensure_ascii=False) + + async def _surveys_create(self, args: Dict[str, Any]) -> str: + token = os.environ.get("SURVEYMONKEY_ACCESS_TOKEN", "") + if not token: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + title = str(args.get("title", "")).strip() + if not title: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "title"}, indent=2, ensure_ascii=False) + payload: Dict[str, Any] = {"title": title} + pages = args.get("pages") + if pages: + payload["pages"] = pages + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.post( + f"{_BASE_URL}/surveys", + headers=self._headers(token), + json=payload, + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code not in (200, 201): + return self._provider_error(resp.status_code, resp.text[:500]) + try: + data = resp.json() + except json.JSONDecodeError: + return self._provider_error(resp.status_code, "invalid json in response") + return self._ok("surveys.create.v1", data) + + async def _surveys_update(self, args: Dict[str, Any]) -> str: + token = os.environ.get("SURVEYMONKEY_ACCESS_TOKEN", "") + if not token: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + survey_id = str(args.get("survey_id", "")).strip() + if not survey_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "survey_id"}, indent=2, ensure_ascii=False) + payload: Dict[str, Any] = {} + title = args.get("title") + if title is not None: + payload["title"] = str(title) + pages = args.get("pages") + if pages is not None: + payload["pages"] = pages + if not payload: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "title or pages"}, indent=2, ensure_ascii=False) + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.patch( + f"{_BASE_URL}/surveys/{survey_id}", + headers=self._headers(token), + json=payload, + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code == 204: + return self._ok("surveys.update.v1", {}) + if resp.status_code not in (200, 201): + return self._provider_error(resp.status_code, resp.text[:500]) + try: + data = resp.json() + except json.JSONDecodeError: + return self._provider_error(resp.status_code, "invalid json in response") + return self._ok("surveys.update.v1", data) + + async def _collectors_create(self, args: Dict[str, Any]) -> str: + token = os.environ.get("SURVEYMONKEY_ACCESS_TOKEN", "") + if not token: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + survey_id = str(args.get("survey_id", "")).strip() + if not survey_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "survey_id"}, indent=2, ensure_ascii=False) + collector_type = str(args.get("type", "weblink")).strip() + payload: Dict[str, Any] = {"type": collector_type} + name = args.get("name") + if name: + payload["name"] = str(name) + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.post( + f"{_BASE_URL}/surveys/{survey_id}/collectors", + headers=self._headers(token), + json=payload, + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code not in (200, 201): + return self._provider_error(resp.status_code, resp.text[:500]) + try: + data = resp.json() + except json.JSONDecodeError: + return self._provider_error(resp.status_code, "invalid json in response") + return self._ok("collectors.create.v1", data) + + async def _responses_list(self, args: Dict[str, Any]) -> str: + token = os.environ.get("SURVEYMONKEY_ACCESS_TOKEN", "") + if not token: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + survey_id = str(args.get("survey_id", "")).strip() + if not survey_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "survey_id"}, indent=2, ensure_ascii=False) + try: + per_page = int(args.get("per_page", 50)) + page = int(args.get("page", 1)) + except (ValueError, TypeError) as e: + return json.dumps({"ok": False, "error_code": "INVALID_ARG", "detail": str(e)}, indent=2, ensure_ascii=False) + params = {"per_page": per_page, "page": page} + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.get( + f"{_BASE_URL}/surveys/{survey_id}/responses/bulk", + headers=self._headers(token), + params=params, + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code != 200: + return self._provider_error(resp.status_code, resp.text[:500]) + try: + data = resp.json() + except json.JSONDecodeError: + return self._provider_error(resp.status_code, "invalid json in response") + return self._ok("surveys.responses.list.v1", data) diff --git a/flexus_client_kit/integrations/fi_theirstack.py b/flexus_client_kit/integrations/fi_theirstack.py new file mode 100644 index 00000000..b9c082eb --- /dev/null +++ b/flexus_client_kit/integrations/fi_theirstack.py @@ -0,0 +1,143 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("theirstack") + +PROVIDER_NAME = "theirstack" +METHOD_IDS = [ + "theirstack.jobs.search.v1", + "theirstack.companies.hiring.v1", +] + +_BASE_URL = "https://api.theirstack.com/v1" + + +class IntegrationTheirstack: + def __init__(self, rcx=None): + self.rcx = rcx + + def _get_api_key(self) -> str: + if self.rcx is not None: + return (self.rcx.external_auth.get("theirstack") or {}).get("api_key", "") + return "" + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set THEIRSTACK_API_KEY env var."}, indent=2, ensure_ascii=False) + + if method_id == "theirstack.jobs.search.v1": + return await self._jobs_search(args, api_key) + if method_id == "theirstack.companies.hiring.v1": + return await self._companies_hiring(args, api_key) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + def _headers(self, api_key: str) -> Dict[str, str]: + return { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + async def _jobs_search(self, args: Dict[str, Any], api_key: str) -> str: + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "args.query required."}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 10)), 25) + body = { + "job_title_pattern_or": [query], + "limit": limit, + "posted_at_max_age_days": 30, + } + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.post(_BASE_URL + "/jobs/search", json=body, headers=self._headers(api_key)) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + results = data.get("data", data.get("jobs", [])) + include_raw = bool(args.get("include_raw")) + out = {"ok": True, "total": data.get("total", len(results)), "results": results if include_raw else [ + { + "job_title": j.get("job_title"), + "company_name": j.get("company_name"), + "location": j.get("location"), + "date_posted": j.get("date_posted"), + "url": j.get("url"), + } + for j in (results if isinstance(results, list) else []) + ]} + summary = f"Found {len(results) if isinstance(results, list) else 1} job(s) from {PROVIDER_NAME}." + return summary + "\n\n```json\n" + json.dumps(out, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _companies_hiring(self, args: Dict[str, Any], api_key: str) -> str: + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "args.query required."}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 10)), 25) + body = { + "job_title_pattern_or": [query], + "limit": limit, + } + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.post(_BASE_URL + "/companies/search", json=body, headers=self._headers(api_key)) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + results = data.get("data", data.get("companies", [])) + include_raw = bool(args.get("include_raw")) + out = {"ok": True, "total": data.get("total", len(results) if isinstance(results, list) else 1), "results": results if include_raw else [ + { + "name": c.get("name"), + "domain": c.get("domain"), + "industry": c.get("industry"), + "employee_count": c.get("employee_count"), + "open_jobs_count": c.get("open_jobs_count"), + } + for c in (results if isinstance(results, list) else []) + ]} + summary = f"Found {len(results) if isinstance(results, list) else 1} company(ies) hiring from {PROVIDER_NAME}." + return summary + "\n\n```json\n" + json.dumps(out, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_tiktok.py b/flexus_client_kit/integrations/fi_tiktok.py new file mode 100644 index 00000000..d58eb89e --- /dev/null +++ b/flexus_client_kit/integrations/fi_tiktok.py @@ -0,0 +1,126 @@ +import json +import logging +import os +from datetime import datetime, timedelta +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("tiktok") + +PROVIDER_NAME = "tiktok" +METHOD_IDS = [ + "tiktok.research.video_query.v1", +] + +_TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/" +_VIDEO_QUERY_URL = "https://open.tiktokapis.com/v2/research/video/query/" + + +class IntegrationTiktok: + # XXX: requires multiple credentials (TIKTOK_CLIENT_KEY + TIKTOK_CLIENT_SECRET). + # manual auth (single api_key field) does not cover this provider. + # currently reads from env vars as a fallback. + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _get_access_token(self, client_key: str, client_secret: str) -> str: + async with httpx.AsyncClient(timeout=15.0) as client: + r = await client.post( + _TOKEN_URL, + data={"client_key": client_key, "client_secret": client_secret, "grant_type": "client_credentials"}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if r.status_code >= 400: + logger.info("tiktok token request failed: HTTP %s: %s", r.status_code, r.text[:200]) + return "" + return r.json().get("data", {}).get("access_token", r.json().get("access_token", "")) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + client_key = os.environ.get("TIKTOK_CLIENT_KEY", "") + client_secret = os.environ.get("TIKTOK_CLIENT_SECRET", "") + if not client_key or not client_secret: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set TIKTOK_CLIENT_KEY and TIKTOK_CLIENT_SECRET env vars."}, indent=2, ensure_ascii=False) + + try: + token = await self._get_access_token(client_key, client_secret) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "AUTH_FAILED", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + if not token: + return json.dumps({"ok": False, "error_code": "AUTH_FAILED", "message": "Could not obtain TikTok access token. Check TIKTOK_CLIENT_KEY and TIKTOK_CLIENT_SECRET."}, indent=2, ensure_ascii=False) + + if method_id == "tiktok.research.video_query.v1": + return await self._video_query(token, args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _video_query(self, token: str, args: Dict) -> str: + query = str(args.get("query", "")) + limit = int(args.get("limit", 20)) + + now = datetime.utcnow() + default_end = now.strftime("%Y%m%d") + default_start = (now - timedelta(days=30)).strftime("%Y%m%d") + + start_date = str(args.get("start_date", "")).replace("-", "") or default_start + end_date = str(args.get("end_date", "")).replace("-", "") or default_end + + body = { + "query": { + "and": [{"operation": "IN", "field_name": "keyword", "field_values": [query]}], + }, + "max_count": min(limit, 100), + "start_date": start_date, + "end_date": end_date, + } + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.post( + _VIDEO_QUERY_URL, + json=body, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + ) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + videos = data.get("data", {}).get("videos", data.get("videos", [])) + summary = f"Found {len(videos)} video(s) from {PROVIDER_NAME}." + payload: Dict[str, Any] = {"ok": True, "results": videos, "total": len(videos)} + if args.get("include_raw"): + payload["raw"] = data + return summary + "\n\n```json\n" + json.dumps(payload, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_toloka.py b/flexus_client_kit/integrations/fi_toloka.py new file mode 100644 index 00000000..63b73b4d --- /dev/null +++ b/flexus_client_kit/integrations/fi_toloka.py @@ -0,0 +1,220 @@ +import json +import logging +import os +from typing import Any, Dict, Optional + +import httpx + +from flexus_client_kit import ckit_cloudtool + + +logger = logging.getLogger("toloka") + +PROVIDER_NAME = "toloka" +METHOD_IDS = [ + "toloka.projects.list.v1", + "toloka.projects.create.v1", + "toloka.pools.create.v1", + "toloka.pools.open.v1", + "toloka.tasks.batch_create.v1", + "toloka.assignments.list.v1", + "toloka.assignments.approve.v1", + "toloka.assignments.reject.v1", + "toloka.webhook_subscriptions.list.v1", + "toloka.webhook_subscriptions.create.v1", +] + +_TIMEOUT = 30.0 + +# Toloka uses ApiKey authentication. +# Required values: +# - TOLOKA_API_KEY +# Optional values: +# - TOLOKA_ENV=sandbox to route to the sandbox API host +# Toloka sandbox should be used during questionnaire and anti-fraud dry runs. +TOLOKA_SETUP_SCHEMA: list[dict[str, Any]] = [] + + +class IntegrationToloka: + def __init__(self, rcx=None) -> None: + self.rcx = rcx + + def _api_key(self) -> str: + return str(os.environ.get("TOLOKA_API_KEY", "")).strip() + + def _base_url(self) -> str: + if str(os.environ.get("TOLOKA_ENV", "")).strip().lower() == "sandbox": + return "https://sandbox.toloka.dev/api/v1" + return "https://toloka.dev/api/v1" + + def _headers(self) -> Dict[str, str]: + return { + "Authorization": f"ApiKey {self._api_key()}", + "Content-Type": "application/json", + } + + def _status(self) -> str: + env = str(os.environ.get("TOLOKA_ENV", "production")).strip().lower() or "production" + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": "ready" if self._api_key() else "missing_credentials", + "method_count": len(METHOD_IDS), + "auth_type": "api_key_header", + "required_env": ["TOLOKA_API_KEY"], + "optional_env": ["TOLOKA_ENV"], + "environment": env, + "products": ["Projects", "Pools", "Tasks", "Assignments", "Webhook subscriptions"], + }, + indent=2, + ensure_ascii=False, + ) + + def _help(self) -> str: + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}\n" + "notes:\n" + "- Toloka is useful for budget-sensitive validation, screeners, and quick survey operations.\n" + "- Use sandbox first to validate pool setup, filters, and review rules.\n" + "- Assignments should be adjudicated explicitly because quality-control rules differ by project.\n" + ) + + def _error(self, method_id: str, code: str, message: str, **extra: Any) -> str: + payload: Dict[str, Any] = { + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": code, + "message": message, + } + payload.update(extra) + return json.dumps(payload, indent=2, ensure_ascii=False) + + def _result(self, method_id: str, result: Any) -> str: + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": result}, indent=2, ensure_ascii=False) + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Optional[Dict[str, Any]], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return self._help() + if op == "status": + return self._status() + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return self._error(method_id, "METHOD_UNKNOWN", "Unknown Toloka method.") + if not self._api_key(): + return self._error(method_id, "AUTH_MISSING", "Set TOLOKA_API_KEY in the runtime environment.") + return await self._dispatch(method_id, call_args) + + async def _request( + self, + method_id: str, + http_method: str, + path: str, + *, + body: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + ) -> str: + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + url = self._base_url() + path + if http_method == "GET": + response = await client.get(url, headers=self._headers(), params=params) + elif http_method == "POST": + response = await client.post(url, headers=self._headers(), params=params, json=body) + elif http_method == "PATCH": + response = await client.patch(url, headers=self._headers(), params=params, json=body) + else: + return self._error(method_id, "UNSUPPORTED_HTTP_METHOD", f"Unsupported HTTP method {http_method}.") + except httpx.TimeoutException: + return self._error(method_id, "TIMEOUT", "Toloka request timed out.") + except httpx.HTTPError as e: + logger.error("toloka request failed", exc_info=e) + return self._error(method_id, "HTTP_ERROR", f"{type(e).__name__}: {e}") + if response.status_code >= 400: + detail: Any = response.text[:1000] + try: + detail = response.json() + except json.JSONDecodeError: + pass + logger.info("toloka provider error method=%s status=%s body=%s", method_id, response.status_code, response.text[:300]) + return self._error(method_id, "PROVIDER_ERROR", "Toloka returned an error.", http_status=response.status_code, detail=detail) + if not response.text.strip(): + return self._result(method_id, {}) + try: + return self._result(method_id, response.json()) + except json.JSONDecodeError: + return self._result(method_id, response.text) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "toloka.projects.list.v1": + params: Dict[str, Any] = {} + for key in ["id_gt", "id_lte", "status"]: + value = args.get(key) + if value not in (None, ""): + params[key] = value + return await self._request(method_id, "GET", "/projects", params=params) + if method_id == "toloka.projects.create.v1": + return await self._request(method_id, "POST", "/projects", body=args) + if method_id == "toloka.pools.create.v1": + return await self._request(method_id, "POST", "/pools", body=args) + if method_id == "toloka.pools.open.v1": + pool_id = str(args.get("pool_id", "")).strip() + if not pool_id: + return self._error(method_id, "INVALID_ARGS", "pool_id is required.") + return await self._request(method_id, "PATCH", f"/pools/{pool_id}/open", body={}) + if method_id == "toloka.tasks.batch_create.v1": + tasks = args.get("tasks") + if not isinstance(tasks, list) or not tasks: + return self._error(method_id, "INVALID_ARGS", "tasks must be a non-empty list.") + body: Dict[str, Any] = {"tasks": tasks} + pool_id = args.get("pool_id") + if pool_id not in (None, ""): + body["pool_id"] = pool_id + allow_defaults = args.get("allow_defaults") + if allow_defaults is not None: + body["allow_defaults"] = bool(allow_defaults) + return await self._request(method_id, "POST", "/tasks", body=body) + if method_id == "toloka.assignments.list.v1": + params = {} + for key in ["pool_id", "status", "limit", "sort"]: + value = args.get(key) + if value not in (None, ""): + params[key] = value + return await self._request(method_id, "GET", "/assignments", params=params) + if method_id == "toloka.assignments.approve.v1": + assignment_id = str(args.get("assignment_id", "")).strip() + if not assignment_id: + return self._error(method_id, "INVALID_ARGS", "assignment_id is required.") + body = {"public_comment": str(args.get("public_comment", "")).strip()} + return await self._request(method_id, "PATCH", f"/assignments/{assignment_id}/approve", body=body) + if method_id == "toloka.assignments.reject.v1": + assignment_id = str(args.get("assignment_id", "")).strip() + if not assignment_id: + return self._error(method_id, "INVALID_ARGS", "assignment_id is required.") + body = {"public_comment": str(args.get("public_comment", "")).strip()} + return await self._request(method_id, "PATCH", f"/assignments/{assignment_id}/reject", body=body) + if method_id == "toloka.webhook_subscriptions.list.v1": + return await self._request(method_id, "GET", "/webhook-subscriptions") + if method_id == "toloka.webhook_subscriptions.create.v1": + event_type = str(args.get("event_type", "")).strip() + webhook_url = str(args.get("webhook_url", "")).strip() + if not event_type or not webhook_url: + return self._error(method_id, "INVALID_ARGS", "event_type and webhook_url are required.") + return await self._request(method_id, "POST", "/webhook-subscriptions", body={"event_type": event_type, "webhook_url": webhook_url}) + return self._error(method_id, "METHOD_UNIMPLEMENTED", "Method is declared but not implemented.") diff --git a/flexus_client_kit/integrations/fi_trustpilot.py b/flexus_client_kit/integrations/fi_trustpilot.py new file mode 100644 index 00000000..b130d9c6 --- /dev/null +++ b/flexus_client_kit/integrations/fi_trustpilot.py @@ -0,0 +1,220 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("trustpilot") + +PROVIDER_NAME = "trustpilot" +METHOD_IDS = [ + "trustpilot.business_units.find.v1", + "trustpilot.business_units.get_public.v1", + "trustpilot.reviews.list.v1", +] + +_BASE_URL = "https://api.trustpilot.com/v1" + + +class IntegrationTrustpilot: + def __init__(self, rcx=None): + self.rcx = rcx + + def _get_api_key(self) -> str: + if self.rcx is not None: + return (self.rcx.external_auth.get("trustpilot") or {}).get("api_key", "") + return "" + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + api_key = self._get_api_key() + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available" if api_key else "auth_missing", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "trustpilot.business_units.find.v1": + return await self._find_business_unit(args) + if method_id == "trustpilot.business_units.get_public.v1": + return await self._get_business_unit(args) + if method_id == "trustpilot.reviews.list.v1": + return await self._list_reviews(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _find_business_unit(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set TRUSTPILOT_API_KEY env var."}, indent=2, ensure_ascii=False) + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "query (business name or domain) is required"}, indent=2, ensure_ascii=False) + geo = args.get("geo") or {} + country = geo.get("country", "US") if isinstance(geo, dict) else "US" + include_raw = bool(args.get("include_raw", False)) + + params: Dict[str, Any] = { + "name": query, + "country": country, + "apikey": api_key, + } + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(_BASE_URL + "/business-units/find", params=params, headers={"Accept": "application/json"}) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + result: Dict[str, Any] = {"ok": True, "results": [data], "total": 1} + if include_raw: + result["raw"] = data + summary = f"Found business unit from {PROVIDER_NAME}: {data.get('displayName', query)}." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _get_business_unit(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set TRUSTPILOT_API_KEY env var."}, indent=2, ensure_ascii=False) + business_unit_id = str(args.get("business_unit_id", "")).strip() + include_raw = bool(args.get("include_raw", False)) + + if not business_unit_id: + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "business_unit_id or query is required"}, indent=2, ensure_ascii=False) + geo = args.get("geo") or {} + country = geo.get("country", "US") if isinstance(geo, dict) else "US" + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get( + _BASE_URL + "/business-units/find", + params={"name": query, "country": country, "apikey": api_key}, + headers={"Accept": "application/json"}, + ) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + found = r.json() + business_unit_id = found.get("id", "") + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + if not business_unit_id: + return json.dumps({"ok": False, "error_code": "NOT_FOUND", "message": "Could not resolve business unit ID"}, indent=2, ensure_ascii=False) + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get( + f"{_BASE_URL}/business-units/{business_unit_id}", + params={"apikey": api_key}, + headers={"Accept": "application/json"}, + ) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + result: Dict[str, Any] = {"ok": True, "results": [data], "total": 1} + if include_raw: + result["raw"] = data + summary = f"Got business unit from {PROVIDER_NAME}: {data.get('displayName', business_unit_id)}." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _list_reviews(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set TRUSTPILOT_API_KEY env var."}, indent=2, ensure_ascii=False) + business_unit_id = str(args.get("business_unit_id", "")).strip() + limit = min(int(args.get("limit", 20)), 20) + cursor = args.get("cursor", None) + include_raw = bool(args.get("include_raw", False)) + + if not business_unit_id: + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "business_unit_id or query is required"}, indent=2, ensure_ascii=False) + geo = args.get("geo") or {} + country = geo.get("country", "US") if isinstance(geo, dict) else "US" + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get( + _BASE_URL + "/business-units/find", + params={"name": query, "country": country, "apikey": api_key}, + headers={"Accept": "application/json"}, + ) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + found = r.json() + business_unit_id = found.get("id", "") + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + if not business_unit_id: + return json.dumps({"ok": False, "error_code": "NOT_FOUND", "message": "Could not resolve business unit ID"}, indent=2, ensure_ascii=False) + + params: Dict[str, Any] = { + "apikey": api_key, + "perPage": limit, + "language": "en", + } + if cursor: + params["page"] = int(cursor) + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get( + f"{_BASE_URL}/business-units/{business_unit_id}/reviews", + params=params, + headers={"Accept": "application/json"}, + ) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + reviews = data.get("reviews", []) + total = data.get("pagination", {}).get("total", len(reviews)) + result: Dict[str, Any] = {"ok": True, "results": reviews, "total": total} + if include_raw: + result["raw"] = data + summary = f"Found {len(reviews)} review(s) from {PROVIDER_NAME} (total={total})." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_typeform.py b/flexus_client_kit/integrations/fi_typeform.py new file mode 100644 index 00000000..a1105255 --- /dev/null +++ b/flexus_client_kit/integrations/fi_typeform.py @@ -0,0 +1,201 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("typeform") + +PROVIDER_NAME = "typeform" +METHOD_IDS = [ + "typeform.forms.create.v1", + "typeform.forms.update.v1", + "typeform.responses.list.v1", +] + +_BASE_URL = "https://api.typeform.com" + + +class IntegrationTypeform: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + key = os.environ.get("TYPEFORM_ACCESS_TOKEN", "") + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if key else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "typeform.forms.create.v1": + return await self._forms_create(args) + if method_id == "typeform.forms.update.v1": + return await self._forms_update(args) + if method_id == "typeform.responses.list.v1": + return await self._responses_list(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _forms_create(self, args: Dict[str, Any]) -> str: + token = os.environ.get("TYPEFORM_ACCESS_TOKEN", "") + if not token: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + title = str(args.get("title", "")).strip() + if not title: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "title"}, indent=2, ensure_ascii=False) + fields = args.get("fields") or [] + body: Dict[str, Any] = {"title": title} + if fields: + body["fields"] = fields + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post(f"{_BASE_URL}/forms", json=body, headers=headers) + if resp.status_code != 201: + logger.info("typeform forms.create failed: status=%d body=%s", resp.status_code, resp.text[:500]) + return json.dumps({ + "ok": False, + "error_code": "PROVIDER_ERROR", + "status": resp.status_code, + "detail": resp.text[:500], + }, indent=2, ensure_ascii=False) + data = resp.json() + except httpx.TimeoutException as e: + logger.info("typeform forms.create timeout: %s", e) + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + logger.info("typeform forms.create http error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + result = { + "id": data.get("id", ""), + "title": data.get("title", ""), + "_links": data.get("_links", {}), + "full": data, + } + return ( + f"typeform.forms.create.v1 ok\n\n" + f"```json\n{json.dumps({'ok': True, 'result': result}, indent=2, ensure_ascii=False)}\n```" + ) + + async def _forms_update(self, args: Dict[str, Any]) -> str: + token = os.environ.get("TYPEFORM_ACCESS_TOKEN", "") + if not token: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + form_id = str(args.get("form_id", "")).strip() + if not form_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "form_id"}, indent=2, ensure_ascii=False) + body: Dict[str, Any] = {} + if args.get("title"): + body["title"] = str(args["title"]).strip() + if args.get("fields"): + body["fields"] = args["fields"] + if not body: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "detail": "Provide title or fields to update"}, indent=2, ensure_ascii=False) + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.put(f"{_BASE_URL}/forms/{form_id}", json=body, headers=headers) + if resp.status_code != 200: + logger.info("typeform forms.update failed: status=%d body=%s", resp.status_code, resp.text[:500]) + return json.dumps({ + "ok": False, + "error_code": "PROVIDER_ERROR", + "status": resp.status_code, + "detail": resp.text[:500], + }, indent=2, ensure_ascii=False) + data = resp.json() + except httpx.TimeoutException as e: + logger.info("typeform forms.update timeout: %s", e) + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + logger.info("typeform forms.update http error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + result = { + "id": data.get("id", ""), + "title": data.get("title", ""), + "_links": data.get("_links", {}), + "full": data, + } + return ( + f"typeform.forms.update.v1 ok\n\n" + f"```json\n{json.dumps({'ok': True, 'result': result}, indent=2, ensure_ascii=False)}\n```" + ) + + async def _responses_list(self, args: Dict[str, Any]) -> str: + token = os.environ.get("TYPEFORM_ACCESS_TOKEN", "") + if not token: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + form_id = str(args.get("form_id", "")).strip() + if not form_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "form_id"}, indent=2, ensure_ascii=False) + page_size = int(args.get("page_size", 25)) + params: Dict[str, Any] = {"page_size": page_size} + before = args.get("before") + if before: + params["before"] = str(before) + headers = { + "Authorization": f"Bearer {token}", + } + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_BASE_URL}/forms/{form_id}/responses", + params=params, + headers=headers, + ) + if resp.status_code != 200: + logger.info("typeform responses.list failed: status=%d body=%s", resp.status_code, resp.text[:500]) + return json.dumps({ + "ok": False, + "error_code": "PROVIDER_ERROR", + "status": resp.status_code, + "detail": resp.text[:500], + }, indent=2, ensure_ascii=False) + data = resp.json() + except httpx.TimeoutException as e: + logger.info("typeform responses.list timeout: %s", e) + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + logger.info("typeform responses.list http error: %s", e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + result = { + "total_items": data.get("total_items", 0), + "page_count": data.get("page_count", 0), + "items": data.get("items", []), + } + return ( + f"typeform.responses.list.v1 ok\n\n" + f"```json\n{json.dumps({'ok': True, 'result': result}, indent=2, ensure_ascii=False)}\n```" + ) diff --git a/flexus_client_kit/integrations/fi_userinterviews.py b/flexus_client_kit/integrations/fi_userinterviews.py new file mode 100644 index 00000000..6b6ca9d3 --- /dev/null +++ b/flexus_client_kit/integrations/fi_userinterviews.py @@ -0,0 +1,252 @@ +import json +import logging +import os +from typing import Any, Dict, Optional + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("userinterviews") + +PROVIDER_NAME = "userinterviews" +METHOD_IDS = [ + "userinterviews.participants.list.v1", + "userinterviews.participants.get.v1", + "userinterviews.participants.create.v1", + "userinterviews.participants.update.v1", + "userinterviews.participants.delete.v1", +] + +_BASE_URL = "https://www.userinterviews.com/api" +_ACCEPT = "application/vnd.user-interviews.v2+json" + +# User Interviews Hub API uses a bearer API key for reviewed account access. +# Required value: +# - USERINTERVIEWS_API_KEY: issued for the Research Hub / API-enabled account. +# Where colleagues register it: +# - runtime environment or secret manager for the Flexus deployment. +# This module currently targets the publicly confirmed participant profile surface. +USERINTERVIEWS_SETUP_SCHEMA: list[dict[str, Any]] = [] + + +class IntegrationUserinterviews: + def __init__(self, rcx=None) -> None: + self.rcx = rcx + + def _api_key(self) -> str: + try: + return str(os.environ.get("USERINTERVIEWS_API_KEY", "")).strip() + except (TypeError, ValueError): + return "" + + def _headers(self) -> Dict[str, str]: + return { + "Authorization": f"Bearer {self._api_key()}", + "Accept": _ACCEPT, + "Content-Type": "application/json", + } + + def _status(self) -> str: + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "ready" if self._api_key() else "missing_credentials", + "method_count": len(METHOD_IDS), + "auth_type": "bearer_api_key", + "required_env": ["USERINTERVIEWS_API_KEY"], + "products": ["Research Hub participant profiles"], + "message": "Project and invite APIs are only added when the public documentation confirms the current surface.", + }, indent=2, ensure_ascii=False) + + def _help(self) -> str: + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}\n" + "notes:\n" + "- This integration targets the documented participant profile API surface.\n" + "- Use metadata for custom audience fields synced into Research Hub.\n" + "- Invite and project orchestration remain intentionally out of scope until their API surface is publicly documented.\n" + ) + + def _error(self, method_id: str, code: str, message: str, **extra: Any) -> str: + payload: Dict[str, Any] = { + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": code, + "message": message, + } + payload.update(extra) + return json.dumps(payload, indent=2, ensure_ascii=False) + + def _result(self, method_id: str, result: Any) -> str: + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": result}, indent=2, ensure_ascii=False) + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Optional[Dict[str, Any]], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return self._help() + if op == "status": + return self._status() + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return self._error(method_id, "METHOD_UNKNOWN", "Unknown User Interviews method.") + if not self._api_key(): + return self._error(method_id, "AUTH_MISSING", "Set USERINTERVIEWS_API_KEY in the runtime environment.") + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "userinterviews.participants.list.v1": + return await self._participants_list(args) + if method_id == "userinterviews.participants.get.v1": + return await self._participants_get(args) + if method_id == "userinterviews.participants.create.v1": + return await self._participants_create(args) + if method_id == "userinterviews.participants.update.v1": + return await self._participants_update(args) + if method_id == "userinterviews.participants.delete.v1": + return await self._participants_delete(args) + return self._error(method_id, "METHOD_UNIMPLEMENTED", "Method is declared but not implemented.") + + async def _participants_list(self, args: Dict[str, Any]) -> str: + try: + params: Dict[str, Any] = {} + page = args.get("page") + per_page = args.get("per_page") + if page is not None: + params["page"] = int(page) + if per_page is not None: + params["per_page"] = int(per_page) + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(f"{_BASE_URL}/participants", headers=self._headers(), params=params) + if resp.status_code != 200: + logger.info("userinterviews participants.list error %s: %s", resp.status_code, resp.text[:200]) + return self._error("userinterviews.participants.list.v1", "PROVIDER_ERROR", "User Interviews returned an error.", status=resp.status_code, detail=resp.text[:500]) + data = resp.json() + return self._result("userinterviews.participants.list.v1", data) + except httpx.TimeoutException: + return self._error("userinterviews.participants.list.v1", "TIMEOUT", "User Interviews request timed out.") + except httpx.HTTPError as e: + return self._error("userinterviews.participants.list.v1", "HTTP_ERROR", str(e)) + except (TypeError, ValueError, json.JSONDecodeError) as e: + logger.error("userinterviews participants.list failed", exc_info=e) + return self._error("userinterviews.participants.list.v1", "RUNTIME_ERROR", f"{type(e).__name__}: {e}") + + async def _participants_get(self, args: Dict[str, Any]) -> str: + try: + participant_id = str(args.get("participant_id", "")).strip() + if not participant_id: + return self._error("userinterviews.participants.get.v1", "MISSING_ARG", "participant_id is required") + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(f"{_BASE_URL}/participants/{participant_id}", headers=self._headers()) + if resp.status_code != 200: + logger.info("userinterviews participants.get error %s: %s", resp.status_code, resp.text[:200]) + return self._error("userinterviews.participants.get.v1", "PROVIDER_ERROR", "User Interviews returned an error.", status=resp.status_code, detail=resp.text[:500]) + data = resp.json() + return self._result("userinterviews.participants.get.v1", data) + except httpx.TimeoutException: + return self._error("userinterviews.participants.get.v1", "TIMEOUT", "User Interviews request timed out.") + except httpx.HTTPError as e: + return self._error("userinterviews.participants.get.v1", "HTTP_ERROR", str(e)) + except (TypeError, ValueError, json.JSONDecodeError) as e: + logger.error("userinterviews participants.get failed", exc_info=e) + return self._error("userinterviews.participants.get.v1", "RUNTIME_ERROR", f"{type(e).__name__}: {e}") + + async def _participants_create(self, args: Dict[str, Any]) -> str: + email = str(args.get("email", "")).strip() + if not email: + return self._error("userinterviews.participants.create.v1", "MISSING_ARG", "email is required") + body: Dict[str, Any] = {"email": email} + name = args.get("name") + if name: + body["name"] = str(name) + metadata = args.get("metadata") + if metadata and isinstance(metadata, dict): + body["metadata"] = metadata + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{_BASE_URL}/participants", + headers=self._headers(), + json=body, + ) + if resp.status_code not in (200, 201): + logger.info("userinterviews participants.create error %s: %s", resp.status_code, resp.text[:200]) + return self._error("userinterviews.participants.create.v1", "PROVIDER_ERROR", "User Interviews returned an error.", status=resp.status_code, detail=resp.text[:500]) + data = resp.json() + return self._result("userinterviews.participants.create.v1", data) + except httpx.TimeoutException: + return self._error("userinterviews.participants.create.v1", "TIMEOUT", "User Interviews request timed out.") + except httpx.HTTPError as e: + return self._error("userinterviews.participants.create.v1", "HTTP_ERROR", str(e)) + + async def _participants_update(self, args: Dict[str, Any]) -> str: + participant_id = str(args.get("participant_id", "")).strip() + if not participant_id: + return self._error("userinterviews.participants.update.v1", "MISSING_ARG", "participant_id is required") + body: Dict[str, Any] = {} + email = args.get("email") + if email is not None: + body["email"] = str(email).strip() + name = args.get("name") + if name is not None: + body["name"] = str(name) + metadata = args.get("metadata") + if metadata is not None and isinstance(metadata, dict): + body["metadata"] = metadata + if not body: + return self._error("userinterviews.participants.update.v1", "MISSING_ARG", "at least one of email, name, metadata must be provided") + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.patch( + f"{_BASE_URL}/participants/{participant_id}", + headers=self._headers(), + json=body, + ) + if resp.status_code not in (200, 204): + logger.info("userinterviews participants.update error %s: %s", resp.status_code, resp.text[:200]) + return self._error("userinterviews.participants.update.v1", "PROVIDER_ERROR", "User Interviews returned an error.", status=resp.status_code, detail=resp.text[:500]) + if resp.status_code == 204 or not resp.content: + return self._result("userinterviews.participants.update.v1", {"participant_id": participant_id, "updated": list(body.keys())}) + data = resp.json() + return self._result("userinterviews.participants.update.v1", data) + except httpx.TimeoutException: + return self._error("userinterviews.participants.update.v1", "TIMEOUT", "User Interviews request timed out.") + except httpx.HTTPError as e: + return self._error("userinterviews.participants.update.v1", "HTTP_ERROR", str(e)) + + async def _participants_delete(self, args: Dict[str, Any]) -> str: + participant_id = str(args.get("participant_id", "")).strip() + if not participant_id: + return self._error("userinterviews.participants.delete.v1", "MISSING_ARG", "participant_id is required") + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.delete( + f"{_BASE_URL}/participants/{participant_id}", + headers={ + "Authorization": f"Bearer {self._api_key()}", + "Accept": _ACCEPT, + }, + ) + if resp.status_code not in (200, 204): + logger.info("userinterviews participants.delete error %s: %s", resp.status_code, resp.text[:200]) + return self._error("userinterviews.participants.delete.v1", "PROVIDER_ERROR", "User Interviews returned an error.", status=resp.status_code, detail=resp.text[:500]) + return self._result("userinterviews.participants.delete.v1", {"participant_id": participant_id, "deleted": True}) + except httpx.TimeoutException: + return self._error("userinterviews.participants.delete.v1", "TIMEOUT", "User Interviews request timed out.") + except httpx.HTTPError as e: + return self._error("userinterviews.participants.delete.v1", "HTTP_ERROR", str(e)) diff --git a/flexus_client_kit/integrations/fi_usertesting.py b/flexus_client_kit/integrations/fi_usertesting.py new file mode 100644 index 00000000..8db8d19c --- /dev/null +++ b/flexus_client_kit/integrations/fi_usertesting.py @@ -0,0 +1,180 @@ +import json +import logging +import os +from typing import Any, Dict, Optional + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("usertesting") + +PROVIDER_NAME = "usertesting" +METHOD_IDS = [ + "usertesting.tests.get.v1", + "usertesting.tests.sessions.list.v1", + "usertesting.results.transcript.get.v1", + "usertesting.results.video.get.v1", + "usertesting.results.qxscore.get.v1", +] + +_BASE_URL = "https://api.use2.usertesting.com/api/v2" +_TIMEOUT = 30.0 + +# UserTesting access is reviewed and normally enterprise-gated. +# Required values once the account is approved: +# - USERTESTING_ACCESS_TOKEN: bearer token for the Results / platform APIs. +# Optional values for colleague onboarding notes: +# - USERTESTING_CLIENT_ID +# - USERTESTING_CLIENT_SECRET +# These values come from the UserTesting developer portal after the API team approves the app. +USERTESTING_SETUP_SCHEMA: list[dict[str, Any]] = [] + + +class IntegrationUsertesting: + def __init__(self, rcx=None) -> None: + self.rcx = rcx + + def _access_token(self) -> str: + try: + return str(os.environ.get("USERTESTING_ACCESS_TOKEN", "")).strip() + except (TypeError, ValueError): + return "" + + def _headers(self) -> Dict[str, str]: + return { + "Authorization": f"Bearer {self._access_token()}", + "Content-Type": "application/json", + } + + def _status(self) -> str: + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": "ready" if self._access_token() else "enterprise_review_required", + "method_count": len(METHOD_IDS), + "auth_type": "reviewed_bearer_token", + "required_env": ["USERTESTING_ACCESS_TOKEN"], + "optional_env": ["USERTESTING_CLIENT_ID", "USERTESTING_CLIENT_SECRET"], + "message": "UserTesting API access requires enterprise approval and app review before tokens are issued.", + }, + indent=2, + ensure_ascii=False, + ) + + def _help(self) -> str: + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}\n" + "notes:\n" + "- UserTesting access is reviewed; obtain credentials from developer.usertesting.com after approval.\n" + "- This integration focuses on documented test and results retrieval flows.\n" + "- Test creation is intentionally not exposed until the create flow is officially confirmed for the approved account tier.\n" + ) + + def _error(self, method_id: str, code: str, message: str, **extra: Any) -> str: + payload: Dict[str, Any] = { + "ok": False, + "provider": PROVIDER_NAME, + "method_id": method_id, + "error_code": code, + "message": message, + } + payload.update(extra) + return json.dumps(payload, indent=2, ensure_ascii=False) + + def _result(self, method_id: str, result: Any) -> str: + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": result}, indent=2, ensure_ascii=False) + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Optional[Dict[str, Any]], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return self._help() + if op == "status": + return self._status() + if op == "list_methods": + return json.dumps( + {"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, + indent=2, + ensure_ascii=False, + ) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return self._error(method_id, "METHOD_UNKNOWN", "Unknown UserTesting method.") + if not self._access_token(): + return self._error( + method_id, + "AUTH_MISSING", + "Set USERTESTING_ACCESS_TOKEN after the UserTesting API team approves the app.", + ) + return await self._dispatch(method_id, call_args) + + async def _request(self, method_id: str, path: str) -> str: + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get(_BASE_URL + path, headers=self._headers()) + except httpx.TimeoutException: + return self._error(method_id, "TIMEOUT", "UserTesting request timed out.") + except httpx.HTTPError as e: + logger.error("usertesting request failed", exc_info=e) + return self._error(method_id, "HTTP_ERROR", f"{type(e).__name__}: {e}") + + if response.status_code >= 400: + detail: Any = response.text[:1000] + try: + detail = response.json() + except json.JSONDecodeError: + pass + logger.info("usertesting provider error method=%s status=%s body=%s", method_id, response.status_code, response.text[:300]) + return self._error(method_id, "PROVIDER_ERROR", "UserTesting returned an error.", http_status=response.status_code, detail=detail) + + if not response.text.strip(): + return self._result(method_id, {}) + try: + return self._result(method_id, response.json()) + except json.JSONDecodeError: + return self._result(method_id, response.text) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + try: + if method_id == "usertesting.tests.get.v1": + test_id = str(args.get("test_id", "")).strip() + if not test_id: + return self._error(method_id, "INVALID_ARGS", "test_id is required.") + return await self._request(method_id, f"/tests/{test_id}") + if method_id == "usertesting.tests.sessions.list.v1": + test_id = str(args.get("test_id", "")).strip() + if not test_id: + return self._error(method_id, "INVALID_ARGS", "test_id is required.") + return await self._request(method_id, f"/testResults/{test_id}/sessions") + if method_id == "usertesting.results.transcript.get.v1": + session_id = str(args.get("session_id", "")).strip() + if not session_id: + return self._error(method_id, "INVALID_ARGS", "session_id is required.") + return await self._request(method_id, f"/sessions/{session_id}/transcript-vtt") + if method_id == "usertesting.results.video.get.v1": + session_id = str(args.get("session_id", "")).strip() + if not session_id: + return self._error(method_id, "INVALID_ARGS", "session_id is required.") + return await self._request(method_id, f"/sessions/{session_id}/video") + if method_id == "usertesting.results.qxscore.get.v1": + test_id = str(args.get("test_id", "")).strip() + if not test_id: + return self._error(method_id, "INVALID_ARGS", "test_id is required.") + return await self._request(method_id, f"/testResults/{test_id}/qxScores") + except (TypeError, ValueError) as e: + logger.error("usertesting dispatch failed", exc_info=e) + return self._error(method_id, "RUNTIME_ERROR", f"{type(e).__name__}: {e}") + return self._error(method_id, "METHOD_UNIMPLEMENTED", "Method is declared but not implemented.") diff --git a/flexus_client_kit/integrations/fi_wappalyzer.py b/flexus_client_kit/integrations/fi_wappalyzer.py new file mode 100644 index 00000000..b5846399 --- /dev/null +++ b/flexus_client_kit/integrations/fi_wappalyzer.py @@ -0,0 +1,113 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("wappalyzer") + +PROVIDER_NAME = "wappalyzer" +METHOD_IDS = [ + "wappalyzer.lookup.v2", +] + +_BASE_URL = "https://api.wappalyzer.com/v2" + + +class IntegrationWappalyzer: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + key = os.environ.get("WAPPALYZER_API_KEY", "") + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if key else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "wappalyzer.lookup.v2": + return await self._lookup(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _lookup(self, args: Dict[str, Any]) -> str: + key = os.environ.get("WAPPALYZER_API_KEY", "") + if not key: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "WAPPALYZER_API_KEY env var not set"}, indent=2, ensure_ascii=False) + url = str(args.get("url", "")).strip() + if not url: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "url is required"}, indent=2, ensure_ascii=False) + include_raw = bool(args.get("include_raw", False)) + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_BASE_URL}/lookup/", + params={"urls": url}, + headers={"x-api-key": key}, + ) + if resp.status_code == 401: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "Invalid API key"}, indent=2, ensure_ascii=False) + if resp.status_code == 403: + return json.dumps({"ok": False, "error_code": "ENTITLEMENT_MISSING", "message": "Wappalyzer Business-tier plan required"}, indent=2, ensure_ascii=False) + if resp.status_code != 200: + logger.info("wappalyzer error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + if include_raw: + result = { + "ok": True, + "credit_note": "1 Wappalyzer lookup credit used", + "raw": data, + } + else: + normalized = [] + for entry in data: + techs = [ + { + "name": t.get("name"), + "categories": t.get("categories", []), + "version": t.get("version"), + "confidence": t.get("confidence"), + } + for t in entry.get("technologies", []) + ] + normalized.append({ + "url": entry.get("url"), + "technologies": techs, + }) + result = { + "ok": True, + "credit_note": "1 Wappalyzer lookup credit used", + "results": normalized, + } + return f"wappalyzer.lookup.v2 ok\n\n```json\n{json.dumps(result, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_wikimedia.py b/flexus_client_kit/integrations/fi_wikimedia.py new file mode 100644 index 00000000..32ef2f85 --- /dev/null +++ b/flexus_client_kit/integrations/fi_wikimedia.py @@ -0,0 +1,158 @@ +import json +import logging +from datetime import datetime, timedelta +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("wikimedia") + +PROVIDER_NAME = "wikimedia" +METHOD_IDS = [ + "wikimedia.pageviews.per_article.v1", + "wikimedia.pageviews.aggregate.v1", +] + +_BASE_URL = "https://wikimedia.org/api/rest_v1" +_HEADERS = {"User-Agent": "Flexus-Market-Signal/1.0 (contact@flexus.app)"} + + +class IntegrationWikimedia: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "wikimedia.pageviews.per_article.v1": + return await self._per_article(args) + if method_id == "wikimedia.pageviews.aggregate.v1": + return await self._aggregate(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + def _date_range(self, args: Dict[str, Any], granularity: str) -> tuple[str, str]: + fmt_daily = "%Y%m%d" + fmt_monthly = "%Y%m01" + fmt = fmt_daily if granularity == "daily" else fmt_monthly + + start_arg = str(args.get("start_date", "")) + end_arg = str(args.get("end_date", "")) + + if start_arg and end_arg: + return start_arg, end_arg + + now = datetime.utcnow() + time_window = str(args.get("time_window", "90d")) + days = 90 + if time_window.endswith("d"): + days = int(time_window[:-1]) + elif time_window.endswith("m"): + days = int(time_window[:-1]) * 30 + + start = now - timedelta(days=days) + return start.strftime(fmt), now.strftime(fmt) + + async def _per_article(self, args: Dict[str, Any]) -> str: + article = str(args.get("article", args.get("query", ""))).strip() + if not article: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "args.article required (Wikipedia article title)."}, indent=2, ensure_ascii=False) + + article_encoded = article.replace(" ", "_") + project = str(args.get("project", "en.wikipedia.org")) + granularity = str(args.get("granularity", "monthly")) + if granularity not in ("daily", "monthly"): + granularity = "monthly" + + start, end = self._date_range(args, granularity) + url = f"{_BASE_URL}/metrics/pageviews/per-article/{project}/all-access/all-agents/{article_encoded}/{granularity}/{start}/{end}" + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(url, headers=_HEADERS) + if r.status_code == 404: + return json.dumps({"ok": False, "error_code": "NOT_FOUND", "message": f"Article '{article}' not found on {project}."}, indent=2, ensure_ascii=False) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + items = data.get("items", []) + total_views = sum(i.get("views", 0) for i in items) + include_raw = bool(args.get("include_raw")) + out: Dict[str, Any] = { + "ok": True, + "article": article, + "project": project, + "granularity": granularity, + "period": {"start": start, "end": end}, + "total_views": total_views, + "data_points": items if include_raw else [ + {"timestamp": i.get("timestamp"), "views": i.get("views")} + for i in items + ], + } + summary = f"Pageviews for '{article}' on {project}: {total_views:,} total views ({granularity})." + return summary + "\n\n```json\n" + json.dumps(out, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _aggregate(self, args: Dict[str, Any]) -> str: + project = str(args.get("project", "en.wikipedia.org")) + granularity = str(args.get("granularity", "monthly")) + if granularity not in ("daily", "monthly", "hourly"): + granularity = "monthly" + + start, end = self._date_range(args, granularity) + url = f"{_BASE_URL}/metrics/pageviews/aggregate/{project}/all-access/all-agents/{granularity}/{start}/{end}" + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(url, headers=_HEADERS) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + items = data.get("items", []) + total_views = sum(i.get("views", 0) for i in items) + out: Dict[str, Any] = { + "ok": True, + "project": project, + "granularity": granularity, + "period": {"start": start, "end": end}, + "total_views": total_views, + "data_points": [ + {"timestamp": i.get("timestamp"), "views": i.get("views")} + for i in items + ], + } + summary = f"Aggregate pageviews for {project}: {total_views:,} total views ({granularity})." + return summary + "\n\n```json\n" + json.dumps(out, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_x.py b/flexus_client_kit/integrations/fi_x.py new file mode 100644 index 00000000..a99d5900 --- /dev/null +++ b/flexus_client_kit/integrations/fi_x.py @@ -0,0 +1,785 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("x") + +PROVIDER_NAME = "x" +METHOD_IDS = [ + # Basic plan — tweets + "x.tweets.counts_recent.v1", + "x.tweets.search_recent.v1", + "x.tweets.lookup.v1", + "x.tweets.lookup_single.v1", + "x.tweets.quote_tweets.v1", + "x.tweets.liking_users.v1", + "x.tweets.retweeted_by.v1", + # Basic plan — users + "x.users.lookup_by_ids.v1", + "x.users.lookup_by_usernames.v1", + "x.users.lookup_single.v1", + "x.users.lookup_by_username.v1", + "x.users.tweets_timeline.v1", + "x.users.mentions_timeline.v1", + "x.users.liked_tweets.v1", + "x.users.followers.v1", + "x.users.following.v1", + "x.users.owned_lists.v1", + "x.users.list_memberships.v1", + "x.users.pinned_lists.v1", + # Basic plan — lists + "x.lists.lookup.v1", + "x.lists.tweets.v1", + "x.lists.members.v1", + "x.lists.followers.v1", + # Basic plan — spaces (Bearer Token supported) + "x.spaces.lookup.v1", + "x.spaces.lookup_single.v1", + "x.spaces.by_creator_ids.v1", + "x.spaces.search.v1", + # Pro plan only + "x.tweets.search_all.v1", + "x.tweets.counts_all.v1", + "x.users.search.v1", + "x.trends.by_woeid.v1", +] + +_BASE_URL = "https://api.twitter.com/2" + + +class IntegrationX: + def __init__(self, rcx=None): + self.rcx = rcx + + def _get_api_key(self) -> str: + if self.rcx is not None: + return (self.rcx.external_auth.get("x") or {}).get("api_key", "") + return os.environ.get("X_BEARER_TOKEN", "") + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + bearer_token = self._get_api_key() + if not bearer_token: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set X_BEARER_TOKEN env var or configure api_key in integrations."}, indent=2, ensure_ascii=False) + + headers = {"Authorization": f"Bearer {bearer_token}"} + + if method_id == "x.tweets.counts_recent.v1": + return await self._counts_recent(headers, str(args.get("query", "")), args) + if method_id == "x.tweets.search_recent.v1": + return await self._search_recent(headers, str(args.get("query", "")), int(args.get("limit", 10)), args) + if method_id == "x.tweets.lookup.v1": + return await self._tweets_lookup(headers, args) + if method_id == "x.tweets.lookup_single.v1": + return await self._tweet_lookup_single(headers, args) + if method_id == "x.tweets.quote_tweets.v1": + return await self._tweet_quote_tweets(headers, args) + if method_id == "x.tweets.liking_users.v1": + return await self._tweet_liking_users(headers, args) + if method_id == "x.tweets.retweeted_by.v1": + return await self._tweet_retweeted_by(headers, args) + if method_id == "x.users.lookup_by_ids.v1": + return await self._users_lookup_by_ids(headers, args) + if method_id == "x.users.lookup_by_usernames.v1": + return await self._users_lookup_by_usernames(headers, args) + if method_id == "x.users.lookup_single.v1": + return await self._user_lookup_single(headers, args) + if method_id == "x.users.lookup_by_username.v1": + return await self._user_lookup_by_username(headers, args) + if method_id == "x.users.tweets_timeline.v1": + return await self._user_tweets_timeline(headers, args) + if method_id == "x.users.mentions_timeline.v1": + return await self._user_mentions_timeline(headers, args) + if method_id == "x.users.liked_tweets.v1": + return await self._user_liked_tweets(headers, args) + if method_id == "x.users.followers.v1": + return await self._user_followers(headers, args) + if method_id == "x.users.following.v1": + return await self._user_following(headers, args) + if method_id == "x.users.owned_lists.v1": + return await self._user_owned_lists(headers, args) + if method_id == "x.users.list_memberships.v1": + return await self._user_list_memberships(headers, args) + if method_id == "x.users.pinned_lists.v1": + return await self._user_pinned_lists(headers, args) + if method_id == "x.lists.lookup.v1": + return await self._list_lookup(headers, args) + if method_id == "x.lists.tweets.v1": + return await self._list_tweets(headers, args) + if method_id == "x.lists.members.v1": + return await self._list_members(headers, args) + if method_id == "x.lists.followers.v1": + return await self._list_followers(headers, args) + if method_id == "x.spaces.lookup.v1": + return await self._spaces_lookup(headers, args) + if method_id == "x.spaces.lookup_single.v1": + return await self._space_lookup_single(headers, args) + if method_id == "x.spaces.by_creator_ids.v1": + return await self._spaces_by_creator_ids(headers, args) + if method_id == "x.spaces.search.v1": + return await self._spaces_search(headers, args) + if method_id == "x.tweets.search_all.v1": + return await self._tweets_search_all(headers, args) + if method_id == "x.tweets.counts_all.v1": + return await self._tweets_counts_all(headers, args) + if method_id == "x.users.search.v1": + return await self._users_search(headers, args) + if method_id == "x.trends.by_woeid.v1": + return await self._trends_by_woeid(headers, args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + # ─── helpers ─────────────────────────────────────────────────────────────── + + async def _get(self, headers: Dict, url: str, params: Dict) -> str: + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(url, params=params, headers=headers) + if r.status_code == 403: + logger.info("%s HTTP 403: %s", PROVIDER_NAME, r.text[:200]) + body = {} + try: + body = r.json() + except (json.JSONDecodeError, TypeError, ValueError): + pass + detail = body.get("detail") or r.text[:300] + if "pro" in detail.lower() or "subscription" in detail.lower() or "access" in detail.lower(): + return json.dumps({"ok": False, "error_code": "PRO_PLAN_REQUIRED", "message": "This endpoint requires X API Pro plan or higher.", "detail": detail}, indent=2, ensure_ascii=False) + return json.dumps({"ok": False, "error_code": "FORBIDDEN", "detail": detail}, indent=2, ensure_ascii=False) + if r.status_code == 429: + logger.info("%s HTTP 429 rate limit", PROVIDER_NAME) + return json.dumps({"ok": False, "error_code": "RATE_LIMIT", "message": "Rate limit exceeded. Retry after a moment."}, indent=2, ensure_ascii=False) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + return r.text + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + def _ok(self, data: Any, meta: Any = None, summary: str = "") -> str: + payload: Dict[str, Any] = {"ok": True, "data": data} + if meta is not None: + payload["meta"] = meta + result = json.dumps(payload, indent=2, ensure_ascii=False) + return (summary + "\n\n" + result) if summary else result + + # ─── existing methods ─────────────────────────────────────────────────────── + + async def _counts_recent(self, headers: Dict, query: str, args: Dict) -> str: + params = {"query": query, "granularity": str(args.get("granularity", "day"))} + raw = await self._get(headers, _BASE_URL + "/tweets/counts/recent", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + meta = data.get("meta", {}) + summary = f"Found {len(results)} day(s) of tweet counts. Total tweets: {meta.get('total_tweet_count', 'N/A')}." + return self._ok(results, meta, summary) + + async def _search_recent(self, headers: Dict, query: str, limit: int, args: Dict) -> str: + max_results = max(10, min(limit, 100)) + params = { + "query": query, + "max_results": max_results, + "tweet.fields": str(args.get("tweet.fields", "created_at,author_id,public_metrics,lang")), + } + raw = await self._get(headers, _BASE_URL + "/tweets/search/recent", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + meta = data.get("meta", {}) + summary = f"Found {len(results)} tweet(s) matching '{query}'." + return self._ok(results, meta, summary) + + # ─── tweets lookup ───────────────────────────────────────────────────────── + + async def _tweets_lookup(self, headers: Dict, args: Dict) -> str: + ids = str(args.get("ids", "")).strip() + if not ids: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.ids required (comma-separated tweet IDs)."}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {"ids": ids} + if args.get("tweet.fields"): + params["tweet.fields"] = str(args["tweet.fields"]) + raw = await self._get(headers, _BASE_URL + "/tweets", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + return self._ok(results, summary=f"Retrieved {len(results)} tweet(s).") + + async def _tweet_lookup_single(self, headers: Dict, args: Dict) -> str: + tweet_id = str(args.get("id", "")).strip() + if not tweet_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required."}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {} + if args.get("tweet.fields"): + params["tweet.fields"] = str(args["tweet.fields"]) + raw = await self._get(headers, f"{_BASE_URL}/tweets/{tweet_id}", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + return self._ok(data.get("data"), summary=f"Retrieved tweet {tweet_id}.") + + # ─── users lookup ────────────────────────────────────────────────────────── + + async def _users_lookup_by_ids(self, headers: Dict, args: Dict) -> str: + ids = str(args.get("ids", "")).strip() + if not ids: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.ids required (comma-separated user IDs)."}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {"ids": ids} + if args.get("user.fields"): + params["user.fields"] = str(args["user.fields"]) + raw = await self._get(headers, _BASE_URL + "/users", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + return self._ok(results, summary=f"Retrieved {len(results)} user(s).") + + async def _users_lookup_by_usernames(self, headers: Dict, args: Dict) -> str: + usernames = str(args.get("usernames", "")).strip() + if not usernames: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.usernames required (comma-separated handles, without @)."}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {"usernames": usernames} + if args.get("user.fields"): + params["user.fields"] = str(args["user.fields"]) + raw = await self._get(headers, _BASE_URL + "/users/by", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + return self._ok(results, summary=f"Retrieved {len(results)} user(s).") + + async def _user_lookup_single(self, headers: Dict, args: Dict) -> str: + user_id = str(args.get("id", "")).strip() + if not user_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required (numeric user ID)."}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {} + if args.get("user.fields"): + params["user.fields"] = str(args["user.fields"]) + raw = await self._get(headers, f"{_BASE_URL}/users/{user_id}", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + return self._ok(data.get("data"), summary=f"Retrieved user {user_id}.") + + async def _user_lookup_by_username(self, headers: Dict, args: Dict) -> str: + username = str(args.get("username", "")).strip().lstrip("@") + if not username: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.username required (handle without @)."}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {} + if args.get("user.fields"): + params["user.fields"] = str(args["user.fields"]) + raw = await self._get(headers, f"{_BASE_URL}/users/by/username/{username}", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + return self._ok(data.get("data"), summary=f"Retrieved user @{username}.") + + # ─── timelines ───────────────────────────────────────────────────────────── + + async def _user_tweets_timeline(self, headers: Dict, args: Dict) -> str: + user_id = str(args.get("id", "")).strip() + if not user_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required (numeric user ID)."}, indent=2, ensure_ascii=False) + max_results = max(5, min(int(args.get("max_results", 10)), 100)) + params: Dict[str, Any] = { + "max_results": max_results, + "tweet.fields": str(args.get("tweet.fields", "created_at,public_metrics,lang")), + } + if args.get("start_time"): + params["start_time"] = str(args["start_time"]) + if args.get("end_time"): + params["end_time"] = str(args["end_time"]) + raw = await self._get(headers, f"{_BASE_URL}/users/{user_id}/tweets", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + meta = data.get("meta", {}) + return self._ok(results, meta, summary=f"Retrieved {len(results)} tweet(s) from user {user_id}.") + + async def _user_mentions_timeline(self, headers: Dict, args: Dict) -> str: + user_id = str(args.get("id", "")).strip() + if not user_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required (numeric user ID)."}, indent=2, ensure_ascii=False) + max_results = max(5, min(int(args.get("max_results", 10)), 100)) + params: Dict[str, Any] = { + "max_results": max_results, + "tweet.fields": str(args.get("tweet.fields", "created_at,public_metrics,lang")), + } + if args.get("start_time"): + params["start_time"] = str(args["start_time"]) + if args.get("end_time"): + params["end_time"] = str(args["end_time"]) + raw = await self._get(headers, f"{_BASE_URL}/users/{user_id}/mentions", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + meta = data.get("meta", {}) + return self._ok(results, meta, summary=f"Retrieved {len(results)} mention(s) for user {user_id}.") + + # ─── lists ───────────────────────────────────────────────────────────────── + + async def _list_lookup(self, headers: Dict, args: Dict) -> str: + list_id = str(args.get("id", "")).strip() + if not list_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required (list ID)."}, indent=2, ensure_ascii=False) + raw = await self._get(headers, f"{_BASE_URL}/lists/{list_id}", {}) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + return self._ok(data.get("data"), summary=f"Retrieved list {list_id}.") + + async def _list_tweets(self, headers: Dict, args: Dict) -> str: + list_id = str(args.get("id", "")).strip() + if not list_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required (list ID)."}, indent=2, ensure_ascii=False) + max_results = max(1, min(int(args.get("max_results", 10)), 100)) + params: Dict[str, Any] = { + "max_results": max_results, + "tweet.fields": str(args.get("tweet.fields", "created_at,author_id,public_metrics")), + } + raw = await self._get(headers, f"{_BASE_URL}/lists/{list_id}/tweets", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + meta = data.get("meta", {}) + return self._ok(results, meta, summary=f"Retrieved {len(results)} tweet(s) from list {list_id}.") + + # ─── tweet engagement ────────────────────────────────────────────────────── + + async def _tweet_quote_tweets(self, headers: Dict, args: Dict) -> str: + tweet_id = str(args.get("id", "")).strip() + if not tweet_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required (tweet ID)."}, indent=2, ensure_ascii=False) + max_results = max(1, min(int(args.get("max_results", 10)), 100)) + params: Dict[str, Any] = { + "max_results": max_results, + "tweet.fields": str(args.get("tweet.fields", "created_at,author_id,public_metrics")), + } + raw = await self._get(headers, f"{_BASE_URL}/tweets/{tweet_id}/quote_tweets", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + meta = data.get("meta", {}) + return self._ok(results, meta, summary=f"Retrieved {len(results)} quote tweet(s) for tweet {tweet_id}.") + + async def _tweet_liking_users(self, headers: Dict, args: Dict) -> str: + tweet_id = str(args.get("id", "")).strip() + if not tweet_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required (tweet ID)."}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {} + if args.get("user.fields"): + params["user.fields"] = str(args["user.fields"]) + raw = await self._get(headers, f"{_BASE_URL}/tweets/{tweet_id}/liking_users", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + meta = data.get("meta", {}) + return self._ok(results, meta, summary=f"Retrieved {len(results)} user(s) who liked tweet {tweet_id}.") + + async def _tweet_retweeted_by(self, headers: Dict, args: Dict) -> str: + tweet_id = str(args.get("id", "")).strip() + if not tweet_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required (tweet ID)."}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {} + if args.get("user.fields"): + params["user.fields"] = str(args["user.fields"]) + raw = await self._get(headers, f"{_BASE_URL}/tweets/{tweet_id}/retweeted_by", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + meta = data.get("meta", {}) + return self._ok(results, meta, summary=f"Retrieved {len(results)} user(s) who retweeted tweet {tweet_id}.") + + # ─── user liked / social graph ───────────────────────────────────────────── + + async def _user_liked_tweets(self, headers: Dict, args: Dict) -> str: + user_id = str(args.get("id", "")).strip() + if not user_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required (numeric user ID)."}, indent=2, ensure_ascii=False) + max_results = max(1, min(int(args.get("max_results", 10)), 100)) + params: Dict[str, Any] = { + "max_results": max_results, + "tweet.fields": str(args.get("tweet.fields", "created_at,author_id,public_metrics")), + } + raw = await self._get(headers, f"{_BASE_URL}/users/{user_id}/liked_tweets", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + meta = data.get("meta", {}) + return self._ok(results, meta, summary=f"Retrieved {len(results)} tweet(s) liked by user {user_id}.") + + async def _user_followers(self, headers: Dict, args: Dict) -> str: + user_id = str(args.get("id", "")).strip() + if not user_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required (numeric user ID)."}, indent=2, ensure_ascii=False) + max_results = max(1, min(int(args.get("max_results", 100)), 1000)) + params: Dict[str, Any] = {"max_results": max_results} + if args.get("user.fields"): + params["user.fields"] = str(args["user.fields"]) + raw = await self._get(headers, f"{_BASE_URL}/users/{user_id}/followers", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + meta = data.get("meta", {}) + return self._ok(results, meta, summary=f"Retrieved {len(results)} follower(s) of user {user_id}.") + + async def _user_following(self, headers: Dict, args: Dict) -> str: + user_id = str(args.get("id", "")).strip() + if not user_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required (numeric user ID)."}, indent=2, ensure_ascii=False) + max_results = max(1, min(int(args.get("max_results", 100)), 1000)) + params: Dict[str, Any] = {"max_results": max_results} + if args.get("user.fields"): + params["user.fields"] = str(args["user.fields"]) + raw = await self._get(headers, f"{_BASE_URL}/users/{user_id}/following", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + meta = data.get("meta", {}) + return self._ok(results, meta, summary=f"Retrieved {len(results)} account(s) followed by user {user_id}.") + + # ─── user list relations ──────────────────────────────────────────────────── + + async def _user_owned_lists(self, headers: Dict, args: Dict) -> str: + user_id = str(args.get("id", "")).strip() + if not user_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required (numeric user ID)."}, indent=2, ensure_ascii=False) + max_results = max(1, min(int(args.get("max_results", 100)), 100)) + params: Dict[str, Any] = {"max_results": max_results} + raw = await self._get(headers, f"{_BASE_URL}/users/{user_id}/owned_lists", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + meta = data.get("meta", {}) + return self._ok(results, meta, summary=f"Retrieved {len(results)} list(s) owned by user {user_id}.") + + async def _user_list_memberships(self, headers: Dict, args: Dict) -> str: + user_id = str(args.get("id", "")).strip() + if not user_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required (numeric user ID)."}, indent=2, ensure_ascii=False) + max_results = max(1, min(int(args.get("max_results", 100)), 100)) + params: Dict[str, Any] = {"max_results": max_results} + raw = await self._get(headers, f"{_BASE_URL}/users/{user_id}/list_memberships", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + meta = data.get("meta", {}) + return self._ok(results, meta, summary=f"Retrieved {len(results)} list(s) user {user_id} is member of.") + + async def _user_pinned_lists(self, headers: Dict, args: Dict) -> str: + user_id = str(args.get("id", "")).strip() + if not user_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required (numeric user ID)."}, indent=2, ensure_ascii=False) + raw = await self._get(headers, f"{_BASE_URL}/users/{user_id}/pinned_lists", {}) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + return self._ok(results, summary=f"Retrieved {len(results)} pinned list(s) for user {user_id}.") + + # ─── additional list endpoints ───────────────────────────────────────────── + + async def _list_members(self, headers: Dict, args: Dict) -> str: + list_id = str(args.get("id", "")).strip() + if not list_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required (list ID)."}, indent=2, ensure_ascii=False) + max_results = max(1, min(int(args.get("max_results", 100)), 100)) + params: Dict[str, Any] = {"max_results": max_results} + if args.get("user.fields"): + params["user.fields"] = str(args["user.fields"]) + raw = await self._get(headers, f"{_BASE_URL}/lists/{list_id}/members", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + meta = data.get("meta", {}) + return self._ok(results, meta, summary=f"Retrieved {len(results)} member(s) of list {list_id}.") + + async def _list_followers(self, headers: Dict, args: Dict) -> str: + list_id = str(args.get("id", "")).strip() + if not list_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required (list ID)."}, indent=2, ensure_ascii=False) + max_results = max(1, min(int(args.get("max_results", 100)), 100)) + params: Dict[str, Any] = {"max_results": max_results} + if args.get("user.fields"): + params["user.fields"] = str(args["user.fields"]) + raw = await self._get(headers, f"{_BASE_URL}/lists/{list_id}/followers", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + meta = data.get("meta", {}) + return self._ok(results, meta, summary=f"Retrieved {len(results)} follower(s) of list {list_id}.") + + # ─── spaces ──────────────────────────────────────────────────────────────── + + async def _spaces_lookup(self, headers: Dict, args: Dict) -> str: + ids = str(args.get("ids", "")).strip() + if not ids: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.ids required (comma-separated space IDs, up to 100)."}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {"ids": ids} + if args.get("space.fields"): + params["space.fields"] = str(args["space.fields"]) + raw = await self._get(headers, _BASE_URL + "/spaces", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + return self._ok(results, summary=f"Retrieved {len(results)} space(s).") + + async def _space_lookup_single(self, headers: Dict, args: Dict) -> str: + space_id = str(args.get("id", "")).strip() + if not space_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.id required (space ID)."}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {} + if args.get("space.fields"): + params["space.fields"] = str(args["space.fields"]) + raw = await self._get(headers, f"{_BASE_URL}/spaces/{space_id}", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + return self._ok(data.get("data"), summary=f"Retrieved space {space_id}.") + + async def _spaces_by_creator_ids(self, headers: Dict, args: Dict) -> str: + creator_ids = str(args.get("creator_ids", "")).strip() + if not creator_ids: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.creator_ids required (comma-separated user IDs)."}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {"user_ids": creator_ids} + if args.get("space.fields"): + params["space.fields"] = str(args["space.fields"]) + raw = await self._get(headers, _BASE_URL + "/spaces/by/creator_ids", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + return self._ok(results, summary=f"Retrieved {len(results)} space(s) by creator IDs.") + + async def _spaces_search(self, headers: Dict, args: Dict) -> str: + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.query required."}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {"query": query} + state = str(args.get("state", "all")).strip() + if state in ("live", "scheduled", "all"): + params["state"] = state + if args.get("space.fields"): + params["space.fields"] = str(args["space.fields"]) + raw = await self._get(headers, _BASE_URL + "/spaces/search", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + return self._ok(results, summary=f"Found {len(results)} space(s) matching '{query}'.") + + # ─── Pro-tier methods ─────────────────────────────────────────────────────── + + async def _tweets_search_all(self, headers: Dict, args: Dict) -> str: + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.query required."}, indent=2, ensure_ascii=False) + max_results = max(10, min(int(args.get("max_results", 10)), 500)) + params: Dict[str, Any] = { + "query": query, + "max_results": max_results, + "tweet.fields": str(args.get("tweet.fields", "created_at,author_id,public_metrics,lang")), + } + if args.get("start_time"): + params["start_time"] = str(args["start_time"]) + if args.get("end_time"): + params["end_time"] = str(args["end_time"]) + raw = await self._get(headers, _BASE_URL + "/tweets/search/all", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + meta = data.get("meta", {}) + return self._ok(results, meta, summary=f"[Pro] Found {len(results)} tweet(s) (full archive) matching '{query}'.") + + async def _tweets_counts_all(self, headers: Dict, args: Dict) -> str: + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.query required."}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = { + "query": query, + "granularity": str(args.get("granularity", "day")), + } + if args.get("start_time"): + params["start_time"] = str(args["start_time"]) + if args.get("end_time"): + params["end_time"] = str(args["end_time"]) + raw = await self._get(headers, _BASE_URL + "/tweets/counts/all", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + meta = data.get("meta", {}) + summary = f"[Pro] Found {len(results)} period(s) of tweet counts. Total: {meta.get('total_tweet_count', 'N/A')}." + return self._ok(results, meta, summary) + + async def _users_search(self, headers: Dict, args: Dict) -> str: + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.query required."}, indent=2, ensure_ascii=False) + max_results = max(1, min(int(args.get("max_results", 10)), 100)) + params: Dict[str, Any] = { + "query": query, + "max_results": max_results, + } + if args.get("user.fields"): + params["user.fields"] = str(args["user.fields"]) + raw = await self._get(headers, _BASE_URL + "/users/search", params) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + return self._ok(results, summary=f"[Pro] Found {len(results)} user(s) matching '{query}'.") + + async def _trends_by_woeid(self, headers: Dict, args: Dict) -> str: + woeid = args.get("woeid") + if woeid is None: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "args.woeid required (e.g. 1=worldwide, 23424977=USA, 26062=London)."}, indent=2, ensure_ascii=False) + raw = await self._get(headers, f"{_BASE_URL}/trends/by/woeid/{woeid}", {}) + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return raw + if not data.get("ok", True): + return raw + results = data.get("data", []) + return self._ok(results, summary=f"[Pro] Retrieved {len(results)} trend(s) for WOEID {woeid}.") diff --git a/flexus_client_kit/integrations/fi_x_ads.py b/flexus_client_kit/integrations/fi_x_ads.py new file mode 100644 index 00000000..f706486d --- /dev/null +++ b/flexus_client_kit/integrations/fi_x_ads.py @@ -0,0 +1,374 @@ +import asyncio +import json +import logging +import os +from typing import Any, Dict, List + +import requests +from requests_oauthlib import OAuth1 + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("x_ads") + +PROVIDER_NAME = "x_ads" +METHOD_IDS = [ + "x_ads.campaigns.create.v1", + "x_ads.line_items.create.v1", + "x_ads.stats.query.v1", +] + +_BASE_URL = "https://ads-api.x.com/12" +_TIMEOUT = 30.0 + + +class IntegrationXAds: + def _get_oauth(self) -> OAuth1: + return OAuth1( + os.environ.get("X_ADS_CONSUMER_KEY", ""), + os.environ.get("X_ADS_CONSUMER_SECRET", ""), + os.environ.get("X_ADS_ACCESS_TOKEN", ""), + os.environ.get("X_ADS_ACCESS_TOKEN_SECRET", ""), + ) + + def _get_account_id(self) -> str: + return os.environ.get("X_ADS_ACCOUNT_ID", "") + + def _check_credentials(self) -> str: + if not all([ + os.environ.get("X_ADS_CONSUMER_KEY"), + os.environ.get("X_ADS_CONSUMER_SECRET"), + os.environ.get("X_ADS_ACCESS_TOKEN"), + os.environ.get("X_ADS_ACCESS_TOKEN_SECRET"), + os.environ.get("X_ADS_ACCOUNT_ID"), + ]): + return json.dumps({ + "ok": False, + "error_code": "NO_CREDENTIALS", + "provider": PROVIDER_NAME, + "message": "Set X_ADS_CONSUMER_KEY, X_ADS_CONSUMER_SECRET, X_ADS_ACCESS_TOKEN, X_ADS_ACCESS_TOKEN_SECRET, X_ADS_ACCOUNT_ID", + }, indent=2, ensure_ascii=False) + return "" + + def _api_error(self, method_id: str, status_code: int, body: str) -> str: + try: + data = json.loads(body) + msg = data.get("errors", [{}])[0].get("message", body) if data.get("errors") else body + except (json.JSONDecodeError, IndexError, KeyError): + msg = body + logger.info("x_ads api error method=%s status=%s msg=%s", method_id, status_code, msg) + return json.dumps({ + "ok": False, + "error_code": "API_ERROR", + "provider": PROVIDER_NAME, + "method_id": method_id, + "http_status": status_code, + "message": msg, + }, indent=2, ensure_ascii=False) + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help\n" + "op=status\n" + "op=list_methods\n" + "op=call(args={method_id: ...})\n" + f"known_method_ids={len(METHOD_IDS)}" + ) + if op == "status": + cred_err = self._check_credentials() + if cred_err: + return json.dumps({ + "ok": False, + "provider": PROVIDER_NAME, + "status": "missing_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + cred_err = self._check_credentials() + if cred_err: + return cred_err + if method_id == "x_ads.campaigns.create.v1": + return await self._campaigns_create(method_id, args) + if method_id == "x_ads.line_items.create.v1": + return await self._line_items_create(method_id, args) + if method_id == "x_ads.stats.query.v1": + return await self._stats_query(method_id, args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _campaigns_create(self, method_id: str, args: Dict[str, Any]) -> str: + name = str(args.get("name", "")).strip() + funding_instrument_id = str(args.get("funding_instrument_id", "")).strip() + daily_budget_amount_local_micro = args.get("daily_budget_amount_local_micro") + if not name: + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "name is required"}, indent=2, ensure_ascii=False) + if not funding_instrument_id: + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "funding_instrument_id is required"}, indent=2, ensure_ascii=False) + if daily_budget_amount_local_micro is None: + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "daily_budget_amount_local_micro is required"}, indent=2, ensure_ascii=False) + try: + daily_budget_amount_local_micro = int(daily_budget_amount_local_micro) + except (TypeError, ValueError): + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "daily_budget_amount_local_micro must be int"}, indent=2, ensure_ascii=False) + + entity_status = str(args.get("entity_status", "PAUSED")).strip() + start_time = str(args.get("start_time", "")).strip() + end_time = str(args.get("end_time", "")).strip() + + data: Dict[str, Any] = { + "name": name, + "funding_instrument_id": funding_instrument_id, + "daily_budget_amount_local_micro": daily_budget_amount_local_micro, + "entity_status": entity_status, + } + if start_time: + data["start_time"] = start_time + if end_time: + data["end_time"] = end_time + + url = f"{_BASE_URL}/accounts/{self._get_account_id()}/campaigns" + + def _do_post() -> requests.Response: + return requests.post( + url, + auth=self._get_oauth(), + data=data, + timeout=_TIMEOUT, + ) + + try: + resp = await asyncio.to_thread(_do_post) + except requests.Timeout as e: + logger.info("x_ads timeout method=%s: %s", method_id, e) + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "method_id": method_id}, indent=2, ensure_ascii=False) + except requests.RequestException as e: + logger.info("x_ads request error method=%s: %s", method_id, e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "method_id": method_id, "message": str(e)}, indent=2, ensure_ascii=False) + + if resp.status_code not in (200, 201): + return self._api_error(method_id, resp.status_code, resp.text) + + try: + payload = resp.json() + raw = payload.get("data", payload) + if isinstance(raw, list): + raw = raw[0] if raw else {} + campaign_id = raw.get("id", "") + campaign_name = raw.get("name", name) + entity_status_res = raw.get("entity_status", entity_status) + daily_budget = raw.get("daily_budget_amount_local_micro", daily_budget_amount_local_micro) + currency = raw.get("currency", "") + result = { + "id": campaign_id, + "name": campaign_name, + "entity_status": entity_status_res, + "daily_budget_amount_local_micro": daily_budget, + "currency": currency, + } + except (KeyError, ValueError, IndexError) as e: + logger.info("x_ads response parse error method=%s: %s", method_id, e) + return json.dumps({"ok": False, "error_code": "UNEXPECTED_RESPONSE", "method_id": method_id}, indent=2, ensure_ascii=False) + + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": result}, indent=2, ensure_ascii=False) + + async def _line_items_create(self, method_id: str, args: Dict[str, Any]) -> str: + campaign_id = str(args.get("campaign_id", "")).strip() + name = str(args.get("name", "")).strip() + objective = str(args.get("objective", "")).strip() + if not campaign_id: + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "campaign_id is required"}, indent=2, ensure_ascii=False) + if not name: + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "name is required"}, indent=2, ensure_ascii=False) + if not objective: + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "objective is required"}, indent=2, ensure_ascii=False) + + product_type = str(args.get("product_type", "PROMOTED_TWEETS")).strip() + placements = args.get("placements", ["ALL_ON_TWITTER"]) + if isinstance(placements, str): + placements = [placements] + bid_type = str(args.get("bid_type", "AUTO")).strip() + entity_status = str(args.get("entity_status", "PAUSED")).strip() + + data: Dict[str, Any] = { + "campaign_id": campaign_id, + "name": name, + "product_type": product_type, + "bid_type": bid_type, + "objective": objective, + "entity_status": entity_status, + } + if placements: + data["placements"] = ",".join(str(p) for p in placements) + + url = f"{_BASE_URL}/accounts/{self._get_account_id()}/line_items" + + def _do_post() -> requests.Response: + return requests.post( + url, + auth=self._get_oauth(), + data=data, + timeout=_TIMEOUT, + ) + + try: + resp = await asyncio.to_thread(_do_post) + except requests.Timeout as e: + logger.info("x_ads timeout method=%s: %s", method_id, e) + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "method_id": method_id}, indent=2, ensure_ascii=False) + except requests.RequestException as e: + logger.info("x_ads request error method=%s: %s", method_id, e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "method_id": method_id, "message": str(e)}, indent=2, ensure_ascii=False) + + if resp.status_code not in (200, 201): + return self._api_error(method_id, resp.status_code, resp.text) + + try: + payload = resp.json() + raw = payload.get("data", payload) + if isinstance(raw, list): + raw = raw[0] if raw else {} + line_item_id = raw.get("id", "") + line_item_name = raw.get("name", name) + result = { + "id": line_item_id, + "name": line_item_name, + "campaign_id": campaign_id, + "product_type": raw.get("product_type", product_type), + "placements": raw.get("placements", placements) if isinstance(raw.get("placements"), list) else placements, + "bid_type": raw.get("bid_type", bid_type), + "objective": raw.get("objective", objective), + "entity_status": raw.get("entity_status", entity_status), + } + except (KeyError, ValueError, IndexError) as e: + logger.info("x_ads response parse error method=%s: %s", method_id, e) + return json.dumps({"ok": False, "error_code": "UNEXPECTED_RESPONSE", "method_id": method_id}, indent=2, ensure_ascii=False) + + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": result}, indent=2, ensure_ascii=False) + + async def _stats_query(self, method_id: str, args: Dict[str, Any]) -> str: + entity = str(args.get("entity", "")).strip() + entity_ids = args.get("entity_ids") + metric_groups = args.get("metric_groups") + start_time = str(args.get("start_time", "")).strip() + end_time = str(args.get("end_time", "")).strip() + if not entity: + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "entity is required"}, indent=2, ensure_ascii=False) + if not entity_ids: + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "entity_ids is required"}, indent=2, ensure_ascii=False) + if not metric_groups: + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "metric_groups is required"}, indent=2, ensure_ascii=False) + if not start_time: + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "start_time is required"}, indent=2, ensure_ascii=False) + if not end_time: + return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "end_time is required"}, indent=2, ensure_ascii=False) + + if isinstance(entity_ids, list): + entity_ids_str = ",".join(str(x) for x in entity_ids) + else: + entity_ids_str = str(entity_ids) + if isinstance(metric_groups, list): + metric_groups_str = ",".join(str(x) for x in metric_groups) + else: + metric_groups_str = str(metric_groups) + + granularity = str(args.get("granularity", "DAY")).strip() + + params: Dict[str, str] = { + "entity": entity, + "entity_ids": entity_ids_str, + "metric_groups": metric_groups_str, + "start_time": start_time, + "end_time": end_time, + "granularity": granularity, + } + + url = f"{_BASE_URL}/stats/accounts/{self._get_account_id()}" + + def _do_get() -> requests.Response: + return requests.get( + url, + auth=self._get_oauth(), + params=params, + timeout=_TIMEOUT, + ) + + try: + resp = await asyncio.to_thread(_do_get) + except requests.Timeout as e: + logger.info("x_ads timeout method=%s: %s", method_id, e) + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "method_id": method_id}, indent=2, ensure_ascii=False) + except requests.RequestException as e: + logger.info("x_ads request error method=%s: %s", method_id, e) + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "method_id": method_id, "message": str(e)}, indent=2, ensure_ascii=False) + + if resp.status_code != 200: + return self._api_error(method_id, resp.status_code, resp.text) + + try: + payload = resp.json() + raw_data = payload.get("data", []) + if not isinstance(raw_data, list): + raw_data = [raw_data] if raw_data else [] + + result: List[Dict[str, Any]] = [] + for item in raw_data: + row_id = item.get("id", "") + id_data = item.get("id_data", []) + metrics_obj = {} + if id_data and isinstance(id_data[0], dict): + metrics_obj = id_data[0].get("metrics", {}) + elif isinstance(item.get("metrics"), dict): + metrics_obj = item.get("metrics", {}) + + def _sum_or_first(val: Any) -> int: + if val is None: + return 0 + if isinstance(val, list): + return sum(int(x) for x in val if x is not None) if val else 0 + try: + return int(val) + except (TypeError, ValueError): + return 0 + + impressions = _sum_or_first(metrics_obj.get("impressions")) + clicks = _sum_or_first(metrics_obj.get("clicks")) + spend_micro = _sum_or_first(metrics_obj.get("billed_charge_local_micro")) + + result.append({ + "id": row_id, + "metrics": { + "impressions": impressions, + "clicks": clicks, + "spend_micro": spend_micro, + }, + }) + except (KeyError, ValueError, TypeError) as e: + logger.info("x_ads response parse error method=%s: %s", method_id, e) + return json.dumps({"ok": False, "error_code": "UNEXPECTED_RESPONSE", "method_id": method_id}, indent=2, ensure_ascii=False) + + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": result}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_yelp.py b/flexus_client_kit/integrations/fi_yelp.py new file mode 100644 index 00000000..54ccfd06 --- /dev/null +++ b/flexus_client_kit/integrations/fi_yelp.py @@ -0,0 +1,168 @@ +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("yelp") + +PROVIDER_NAME = "yelp" +METHOD_IDS = [ + "yelp.businesses.search.v1", + "yelp.businesses.get.v1", + "yelp.businesses.reviews.v1", +] + +_BASE_URL = "https://api.yelp.com/v3" + + +class IntegrationYelp: + def __init__(self, rcx=None): + self.rcx = rcx + + def _get_api_key(self) -> str: + if self.rcx is not None: + return (self.rcx.external_auth.get("yelp") or {}).get("api_key", "") + return "" + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + api_key = self._get_api_key() + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available" if api_key else "auth_missing", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "yelp.businesses.search.v1": + return await self._businesses_search(args) + if method_id == "yelp.businesses.get.v1": + return await self._businesses_get(args) + if method_id == "yelp.businesses.reviews.v1": + return await self._businesses_reviews(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + def _auth_headers(self, api_key: str) -> Dict[str, str]: + return {"Authorization": f"Bearer {api_key}", "Accept": "application/json"} + + async def _businesses_search(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set YELP_API_KEY env var."}, indent=2, ensure_ascii=False) + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "query is required"}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 10)), 50) + geo = args.get("geo") or {} + include_raw = bool(args.get("include_raw", False)) + + city = geo.get("city", "") if isinstance(geo, dict) else "" + country = geo.get("country", "") if isinstance(geo, dict) else "" + location = city or country or "New York" + + params: Dict[str, Any] = { + "term": query, + "location": location, + "limit": limit, + } + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(_BASE_URL + "/businesses/search", params=params, headers=self._auth_headers(api_key)) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + businesses = data.get("businesses", []) + total = data.get("total", len(businesses)) + result: Dict[str, Any] = {"ok": True, "results": businesses, "total": total} + if include_raw: + result["raw"] = data + summary = f"Found {len(businesses)} business(es) from {PROVIDER_NAME} (total={total})." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _businesses_get(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set YELP_API_KEY env var."}, indent=2, ensure_ascii=False) + business_id = str(args.get("business_id", "")).strip() + if not business_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "business_id is required"}, indent=2, ensure_ascii=False) + include_raw = bool(args.get("include_raw", False)) + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(f"{_BASE_URL}/businesses/{business_id}", headers=self._auth_headers(api_key)) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + result: Dict[str, Any] = {"ok": True, "results": [data], "total": 1} + if include_raw: + result["raw"] = data + summary = f"Got business from {PROVIDER_NAME}: {data.get('name', business_id)}." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _businesses_reviews(self, args: Dict[str, Any]) -> str: + api_key = self._get_api_key() + if not api_key: + return json.dumps({"ok": False, "error_code": "AUTH_MISSING", "message": "Set YELP_API_KEY env var."}, indent=2, ensure_ascii=False) + business_id = str(args.get("business_id", "")).strip() + if not business_id: + return json.dumps({"ok": False, "error_code": "MISSING_PARAM", "message": "business_id is required"}, indent=2, ensure_ascii=False) + limit = min(int(args.get("limit", 3)), 3) + include_raw = bool(args.get("include_raw", False)) + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get( + f"{_BASE_URL}/businesses/{business_id}/reviews", + params={"limit": limit}, + headers=self._auth_headers(api_key), + ) + if r.status_code >= 400: + logger.info("%s HTTP %s: %s", PROVIDER_NAME, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + data = r.json() + reviews = data.get("reviews", []) + total = data.get("total", len(reviews)) + result: Dict[str, Any] = {"ok": True, "results": reviews, "total": total, "note": "Free tier limited to 3 reviews per call"} + if include_raw: + result["raw"] = data + summary = f"Found {len(reviews)} review(s) from {PROVIDER_NAME} (free tier max 3)." + return summary + "\n\n```json\n" + json.dumps(result, indent=2, ensure_ascii=False) + "\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError, KeyError, json.JSONDecodeError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_youtube.py b/flexus_client_kit/integrations/fi_youtube.py new file mode 100644 index 00000000..9d59d332 --- /dev/null +++ b/flexus_client_kit/integrations/fi_youtube.py @@ -0,0 +1,419 @@ +import json +import logging +from typing import Any, Dict, List, Optional + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("youtube") + +PROVIDER_NAME = "youtube" +_BASE_URL = "https://www.googleapis.com/youtube/v3" + +# These require multipart binary file upload — not feasible via JSON integration +_BINARY_UPLOAD_METHODS = { + "youtube.captions.insert.v1", + "youtube.channel_banners.insert.v1", + "youtube.thumbnails.set.v1", + "youtube.videos.insert.v1", + "youtube.watermarks.set.v1", +} + +METHOD_IDS = [ + # Read — API key sufficient + "youtube.activities.list.v1", + "youtube.captions.list.v1", + "youtube.channel_sections.list.v1", + "youtube.channels.list.v1", + "youtube.comment_threads.list.v1", + "youtube.comments.list.v1", + "youtube.i18n_languages.list.v1", + "youtube.i18n_regions.list.v1", + "youtube.playlist_items.list.v1", + "youtube.playlists.list.v1", + "youtube.search.list.v1", + "youtube.subscriptions.list.v1", + "youtube.video_abuse_report_reasons.list.v1", + "youtube.video_categories.list.v1", + "youtube.videos.list.v1", + # Read — OAuth required + "youtube.captions.download.v1", + "youtube.members.list.v1", + "youtube.memberships_levels.list.v1", + "youtube.videos.get_rating.v1", + # Write — OAuth required (JSON body) + "youtube.activities.insert.v1", + "youtube.captions.delete.v1", + "youtube.captions.update.v1", + "youtube.channel_sections.delete.v1", + "youtube.channel_sections.insert.v1", + "youtube.channel_sections.update.v1", + "youtube.channels.update.v1", + "youtube.comment_threads.insert.v1", + "youtube.comments.delete.v1", + "youtube.comments.insert.v1", + "youtube.comments.set_moderation_status.v1", + "youtube.comments.update.v1", + "youtube.playlist_items.delete.v1", + "youtube.playlist_items.insert.v1", + "youtube.playlist_items.update.v1", + "youtube.playlists.delete.v1", + "youtube.playlists.insert.v1", + "youtube.playlists.update.v1", + "youtube.subscriptions.delete.v1", + "youtube.subscriptions.insert.v1", + "youtube.videos.delete.v1", + "youtube.videos.rate.v1", + "youtube.videos.report_abuse.v1", + "youtube.videos.update.v1", + "youtube.watermarks.unset.v1", + # Write — OAuth required (binary file upload, not executable via JSON integration) + "youtube.captions.insert.v1", + "youtube.channel_banners.insert.v1", + "youtube.thumbnails.set.v1", + "youtube.videos.insert.v1", + "youtube.watermarks.set.v1", +] + + +class IntegrationYoutube: + def __init__(self, rcx=None): + self.rcx = rcx + + def _auth(self) -> Dict: + return (self.rcx.external_auth.get("youtube") or {}) if self.rcx else {} + + def _pick(self, args: Dict, keys: List[str]) -> Dict: + return {k: args[k] for k in keys if args.get(k) is not None} + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + if method_id in _BINARY_UPLOAD_METHODS: + return json.dumps({ + "ok": False, + "error_code": "BINARY_UPLOAD_REQUIRED", + "message": ( + f"{method_id} requires multipart binary file upload. " + "Use the YouTube Data API directly with a resumable upload session." + ), + }, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _req( + self, + http_method: str, + endpoint: str, + params: Dict, + body: Optional[Dict] = None, + oauth: bool = False, + ) -> str: + auth = self._auth() + api_key = auth.get("api_key", "") + token = auth.get("oauth_token", "") + + if oauth: + if not token: + return json.dumps({ + "ok": False, + "error_code": "OAUTH_REQUIRED", + "message": "This method requires an OAuth 2.0 token. Set oauth_token in youtube auth.", + }, indent=2, ensure_ascii=False) + headers: Dict = {"Authorization": f"Bearer {token}"} + if body is not None: + headers["Content-Type"] = "application/json" + req_params = params + else: + if not api_key: + return json.dumps({ + "ok": False, + "error_code": "AUTH_MISSING", + "message": "Set api_key in youtube auth.", + }, indent=2, ensure_ascii=False) + headers = {} + req_params = {**params, "key": api_key} + + try: + async with httpx.AsyncClient(timeout=20.0) as client: + if http_method == "GET": + r = await client.get(_BASE_URL + endpoint, params=req_params, headers=headers) + elif http_method == "POST": + r = await client.post(_BASE_URL + endpoint, params=req_params, json=body, headers=headers) + elif http_method == "PUT": + r = await client.put(_BASE_URL + endpoint, params=req_params, json=body, headers=headers) + elif http_method == "DELETE": + r = await client.delete(_BASE_URL + endpoint, params=req_params, headers=headers) + else: + return json.dumps({"ok": False, "error_code": "UNSUPPORTED_HTTP_METHOD"}, indent=2, ensure_ascii=False) + if r.status_code == 204: + return json.dumps({"ok": True, "result": "success"}, indent=2, ensure_ascii=False) + if r.status_code >= 400: + logger.info("%s %s %s HTTP %s: %s", PROVIDER_NAME, http_method, endpoint, r.status_code, r.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": r.status_code, "detail": r.text[:300]}, indent=2, ensure_ascii=False) + return r.text + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + except (httpx.HTTPError, ValueError) as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False) + + async def _dispatch(self, method_id: str, args: Dict) -> str: # noqa: C901 + # === Activities === + if method_id == "youtube.activities.list.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, [ + "channelId", "home", "mine", "maxResults", "pageToken", + "publishedAfter", "publishedBefore", "regionCode", + ])} + return await self._req("GET", "/activities", params) + + if method_id == "youtube.activities.insert.v1": + params = {"part": args.get("part", "snippet")} + return await self._req("POST", "/activities", params, body=args.get("body"), oauth=True) + + # === Captions === + if method_id == "youtube.captions.list.v1": + params = {"part": args.get("part", "snippet"), "videoId": args.get("videoId", ""), **self._pick(args, [ + "id", "onBehalfOf", "onBehalfOfContentOwner", + ])} + return await self._req("GET", "/captions", params) + + if method_id == "youtube.captions.download.v1": + caption_id = args.get("id", "") + params = self._pick(args, ["onBehalfOf", "onBehalfOfContentOwner", "tfmt", "tlang"]) + return await self._req("GET", f"/captions/{caption_id}", params, oauth=True) + + if method_id == "youtube.captions.delete.v1": + params = {"id": args.get("id", ""), **self._pick(args, ["onBehalfOf", "onBehalfOfContentOwner"])} + return await self._req("DELETE", "/captions", params, oauth=True) + + if method_id == "youtube.captions.update.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, [ + "onBehalfOf", "onBehalfOfContentOwner", "sync", + ])} + return await self._req("PUT", "/captions", params, body=args.get("body"), oauth=True) + + # === ChannelBanners — insert is binary upload, handled above === + + # === ChannelSections === + if method_id == "youtube.channel_sections.list.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, [ + "channelId", "hl", "id", "mine", "onBehalfOfContentOwner", + ])} + return await self._req("GET", "/channelSections", params) + + if method_id == "youtube.channel_sections.insert.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, ["onBehalfOfContentOwner"])} + return await self._req("POST", "/channelSections", params, body=args.get("body"), oauth=True) + + if method_id == "youtube.channel_sections.update.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, ["onBehalfOfContentOwner"])} + return await self._req("PUT", "/channelSections", params, body=args.get("body"), oauth=True) + + if method_id == "youtube.channel_sections.delete.v1": + params = {"id": args.get("id", ""), **self._pick(args, ["onBehalfOfContentOwner"])} + return await self._req("DELETE", "/channelSections", params, oauth=True) + + # === Channels === + if method_id == "youtube.channels.list.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, [ + "categoryId", "forHandle", "forUsername", "hl", "id", + "managedByMe", "maxResults", "mine", "mySubscribers", + "onBehalfOfContentOwner", "pageToken", + ])} + return await self._req("GET", "/channels", params) + + if method_id == "youtube.channels.update.v1": + params = {"part": args.get("part", "brandingSettings"), **self._pick(args, ["onBehalfOfContentOwner"])} + return await self._req("PUT", "/channels", params, body=args.get("body"), oauth=True) + + # === CommentThreads === + if method_id == "youtube.comment_threads.list.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, [ + "allThreadsRelatedToChannelId", "channelId", "id", "maxResults", + "moderationStatus", "order", "pageToken", "searchTerms", "videoId", + ])} + return await self._req("GET", "/commentThreads", params) + + if method_id == "youtube.comment_threads.insert.v1": + params = {"part": args.get("part", "snippet")} + return await self._req("POST", "/commentThreads", params, body=args.get("body"), oauth=True) + + # === Comments === + if method_id == "youtube.comments.list.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, [ + "id", "maxResults", "pageToken", "parentId", "textFormat", + ])} + return await self._req("GET", "/comments", params) + + if method_id == "youtube.comments.insert.v1": + params = {"part": args.get("part", "snippet")} + return await self._req("POST", "/comments", params, body=args.get("body"), oauth=True) + + if method_id == "youtube.comments.update.v1": + params = {"part": args.get("part", "snippet")} + return await self._req("PUT", "/comments", params, body=args.get("body"), oauth=True) + + if method_id == "youtube.comments.delete.v1": + return await self._req("DELETE", "/comments", {"id": args.get("id", "")}, oauth=True) + + if method_id == "youtube.comments.set_moderation_status.v1": + params = { + "id": args.get("id", ""), + "moderationStatus": args.get("moderationStatus", ""), + **self._pick(args, ["banAuthor"]), + } + return await self._req("POST", "/comments/setModerationStatus", params, oauth=True) + + # === I18n === + if method_id == "youtube.i18n_languages.list.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, ["hl"])} + return await self._req("GET", "/i18nLanguages", params) + + if method_id == "youtube.i18n_regions.list.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, ["hl"])} + return await self._req("GET", "/i18nRegions", params) + + # === Members === + if method_id == "youtube.members.list.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, [ + "filterByMemberChannelId", "hasAccessToLevel", "maxResults", "mode", "pageToken", + ])} + return await self._req("GET", "/members", params, oauth=True) + + if method_id == "youtube.memberships_levels.list.v1": + params = {"part": args.get("part", "id,snippet")} + return await self._req("GET", "/membershipsLevels", params, oauth=True) + + # === PlaylistItems === + if method_id == "youtube.playlist_items.list.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, [ + "id", "maxResults", "onBehalfOfContentOwner", "pageToken", "playlistId", "videoId", + ])} + return await self._req("GET", "/playlistItems", params) + + if method_id == "youtube.playlist_items.insert.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, ["onBehalfOfContentOwner"])} + return await self._req("POST", "/playlistItems", params, body=args.get("body"), oauth=True) + + if method_id == "youtube.playlist_items.update.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, ["onBehalfOfContentOwner"])} + return await self._req("PUT", "/playlistItems", params, body=args.get("body"), oauth=True) + + if method_id == "youtube.playlist_items.delete.v1": + params = {"id": args.get("id", ""), **self._pick(args, ["onBehalfOfContentOwner"])} + return await self._req("DELETE", "/playlistItems", params, oauth=True) + + # === Playlists === + if method_id == "youtube.playlists.list.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, [ + "channelId", "hl", "id", "maxResults", "mine", "onBehalfOfContentOwner", "pageToken", + ])} + return await self._req("GET", "/playlists", params) + + if method_id == "youtube.playlists.insert.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, ["onBehalfOfContentOwner"])} + return await self._req("POST", "/playlists", params, body=args.get("body"), oauth=True) + + if method_id == "youtube.playlists.update.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, ["onBehalfOfContentOwner"])} + return await self._req("PUT", "/playlists", params, body=args.get("body"), oauth=True) + + if method_id == "youtube.playlists.delete.v1": + params = {"id": args.get("id", ""), **self._pick(args, ["onBehalfOfContentOwner"])} + return await self._req("DELETE", "/playlists", params, oauth=True) + + # === Search === + if method_id == "youtube.search.list.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, [ + "q", "type", "channelId", "channelType", "eventType", + "forContentOwner", "forDeveloper", "forMine", "location", "locationRadius", + "maxResults", "onBehalfOfContentOwner", "order", "pageToken", + "publishedAfter", "publishedBefore", "regionCode", "relatedToVideoId", + "relevanceLanguage", "safeSearch", "topicId", "videoCaption", + "videoCategoryId", "videoDefinition", "videoDimension", "videoDuration", + "videoEmbeddable", "videoLicense", "videoSyndicated", "videoType", + ])} + return await self._req("GET", "/search", params) + + # === Subscriptions === + if method_id == "youtube.subscriptions.list.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, [ + "channelId", "forChannelId", "id", "maxResults", "mine", + "myRecentSubscribers", "mySubscribers", "onBehalfOfContentOwner", + "order", "pageToken", + ])} + return await self._req("GET", "/subscriptions", params) + + if method_id == "youtube.subscriptions.insert.v1": + params = {"part": args.get("part", "snippet")} + return await self._req("POST", "/subscriptions", params, body=args.get("body"), oauth=True) + + if method_id == "youtube.subscriptions.delete.v1": + return await self._req("DELETE", "/subscriptions", {"id": args.get("id", "")}, oauth=True) + + # === VideoAbuseReportReasons === + if method_id == "youtube.video_abuse_report_reasons.list.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, ["hl"])} + return await self._req("GET", "/videoAbuseReportReasons", params) + + # === VideoCategories === + if method_id == "youtube.video_categories.list.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, ["hl", "id", "regionCode"])} + return await self._req("GET", "/videoCategories", params) + + # === Videos === + if method_id == "youtube.videos.list.v1": + params = {"part": args.get("part", "snippet,statistics"), **self._pick(args, [ + "chart", "hl", "id", "locale", "maxHeight", "maxResults", "maxWidth", + "myRating", "onBehalfOfContentOwner", "pageToken", "regionCode", "videoCategoryId", + ])} + return await self._req("GET", "/videos", params) + + if method_id == "youtube.videos.get_rating.v1": + params = {"id": args.get("id", ""), **self._pick(args, ["onBehalfOfContentOwner"])} + return await self._req("GET", "/videos/getRating", params, oauth=True) + + if method_id == "youtube.videos.update.v1": + params = {"part": args.get("part", "snippet"), **self._pick(args, ["onBehalfOfContentOwner", "stabilize"])} + return await self._req("PUT", "/videos", params, body=args.get("body"), oauth=True) + + if method_id == "youtube.videos.delete.v1": + params = {"id": args.get("id", ""), **self._pick(args, ["onBehalfOfContentOwner"])} + return await self._req("DELETE", "/videos", params, oauth=True) + + if method_id == "youtube.videos.rate.v1": + params = {"id": args.get("id", ""), "rating": args.get("rating", "")} + return await self._req("POST", "/videos/rate", params, oauth=True) + + if method_id == "youtube.videos.report_abuse.v1": + params = self._pick(args, ["onBehalfOfContentOwner"]) + return await self._req("POST", "/videos/reportAbuse", params, body=args.get("body"), oauth=True) + + # === Watermarks === + if method_id == "youtube.watermarks.unset.v1": + params = {"channelId": args.get("channelId", ""), **self._pick(args, ["onBehalfOfContentOwner"])} + return await self._req("POST", "/watermarks/unset", params, oauth=True) + + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_zendesk.py b/flexus_client_kit/integrations/fi_zendesk.py new file mode 100644 index 00000000..d1073bba --- /dev/null +++ b/flexus_client_kit/integrations/fi_zendesk.py @@ -0,0 +1,266 @@ +import datetime +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("zendesk") + +PROVIDER_NAME = "zendesk" +METHOD_IDS = [ + "zendesk.incremental.ticket_events.comment_events.list.v1", + "zendesk.ticket_comments.list.v1", + "zendesk.tickets.audits.list.v1", + "zendesk.tickets.list.v1", + "zendesk.tickets.search.v1", +] + + +def _base_url() -> str: + subdomain = os.environ.get("ZENDESK_SUBDOMAIN", "") + return f"https://{subdomain}.zendesk.com/api/v2" + + +def _auth() -> tuple: + email = os.environ.get("ZENDESK_EMAIL", "") + api_token = os.environ.get("ZENDESK_API_TOKEN", "") + return (f"{email}/token", api_token) + + +def _no_creds() -> str: + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False) + + +class IntegrationZendesk: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + subdomain = os.environ.get("ZENDESK_SUBDOMAIN", "") + email = os.environ.get("ZENDESK_EMAIL", "") + api_token = os.environ.get("ZENDESK_API_TOKEN", "") + has_creds = bool(subdomain and email and api_token) + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if has_creds else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "zendesk.incremental.ticket_events.comment_events.list.v1": + return await self._incremental_ticket_events_comment_events_list(args) + if method_id == "zendesk.ticket_comments.list.v1": + return await self._ticket_comments_list(args) + if method_id == "zendesk.tickets.audits.list.v1": + return await self._tickets_audits_list(args) + if method_id == "zendesk.tickets.list.v1": + return await self._tickets_list(args) + if method_id == "zendesk.tickets.search.v1": + return await self._tickets_search(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + + def _ok(self, method_label: str, data: Any) -> str: + return f"zendesk.{method_label} ok\n\n```json\n{json.dumps({'ok': True, 'result': data}, indent=2, ensure_ascii=False)}\n```" + + def _provider_error(self, status_code: int, detail: str) -> str: + logger.info("zendesk provider error status=%s detail=%s", status_code, detail) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": status_code, "detail": detail}, indent=2, ensure_ascii=False) + + def _has_creds(self) -> bool: + return bool( + os.environ.get("ZENDESK_SUBDOMAIN", "") + and os.environ.get("ZENDESK_EMAIL", "") + and os.environ.get("ZENDESK_API_TOKEN", "") + ) + + async def _incremental_ticket_events_comment_events_list(self, args: Dict[str, Any]) -> str: + if not self._has_creds(): + return _no_creds() + start_time_raw = args.get("start_time") + if start_time_raw is None: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "start_time"}, indent=2, ensure_ascii=False) + if isinstance(start_time_raw, str): + try: + start_time = int(datetime.datetime.fromisoformat(start_time_raw).timestamp()) + except ValueError as e: + return json.dumps({"ok": False, "error_code": "INVALID_ARG", "arg": "start_time", "detail": str(e)}, indent=2, ensure_ascii=False) + else: + start_time = int(start_time_raw) + params: Dict[str, Any] = {"start_time": start_time, "include": "comment_events"} + limit = args.get("limit") + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.get( + f"{_base_url()}/incremental/ticket_events.json", + params=params, + auth=_auth(), + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code != 200: + return self._provider_error(resp.status_code, resp.text[:500]) + try: + data = resp.json() + except json.JSONDecodeError: + return self._provider_error(resp.status_code, "invalid json in response") + events = data.get("ticket_events", []) + if limit is not None: + events = events[:int(limit)] + result = { + "ticket_events": events, + "next_page": data.get("next_page"), + "count": data.get("count"), + "end_time": data.get("end_time"), + } + return self._ok("incremental.ticket_events.comment_events.list.v1", result) + + async def _ticket_comments_list(self, args: Dict[str, Any]) -> str: + if not self._has_creds(): + return _no_creds() + ticket_id = args.get("ticket_id") + if not ticket_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "ticket_id"}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {} + cursor = args.get("cursor") + if cursor: + params["page[after]"] = cursor + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.get( + f"{_base_url()}/tickets/{ticket_id}/comments.json", + params=params, + auth=_auth(), + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code != 200: + return self._provider_error(resp.status_code, resp.text[:500]) + try: + data = resp.json() + except json.JSONDecodeError: + return self._provider_error(resp.status_code, "invalid json in response") + return self._ok("ticket_comments.list.v1", data) + + async def _tickets_audits_list(self, args: Dict[str, Any]) -> str: + if not self._has_creds(): + return _no_creds() + ticket_id = args.get("ticket_id") + if not ticket_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "ticket_id"}, indent=2, ensure_ascii=False) + params: Dict[str, Any] = {} + cursor = args.get("cursor") + if cursor: + params["cursor"] = cursor + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.get( + f"{_base_url()}/tickets/{ticket_id}/audits.json", + params=params, + auth=_auth(), + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code != 200: + return self._provider_error(resp.status_code, resp.text[:500]) + try: + data = resp.json() + except json.JSONDecodeError: + return self._provider_error(resp.status_code, "invalid json in response") + return self._ok("tickets.audits.list.v1", data) + + async def _tickets_list(self, args: Dict[str, Any]) -> str: + if not self._has_creds(): + return _no_creds() + sort_by = str(args.get("sort_by", "created_at")) + sort_order = str(args.get("sort_order", "desc")) + page = int(args.get("page", 1)) + per_page = min(int(args.get("per_page", 25)), 100) + params: Dict[str, Any] = { + "sort_by": sort_by, + "sort_order": sort_order, + "page": page, + "per_page": per_page, + } + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.get( + f"{_base_url()}/tickets.json", + params=params, + auth=_auth(), + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code != 200: + return self._provider_error(resp.status_code, resp.text[:500]) + try: + data = resp.json() + except json.JSONDecodeError: + return self._provider_error(resp.status_code, "invalid json in response") + return self._ok("tickets.list.v1", data) + + async def _tickets_search(self, args: Dict[str, Any]) -> str: + if not self._has_creds(): + return _no_creds() + query = str(args.get("query", "")).strip() + if not query: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "arg": "query"}, indent=2, ensure_ascii=False) + page = int(args.get("page", 1)) + per_page = int(args.get("per_page", 25)) + params: Dict[str, Any] = { + "query": query, + "type": "ticket", + "page": page, + "per_page": per_page, + } + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.get( + f"{_base_url()}/search.json", + params=params, + auth=_auth(), + ) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + if resp.status_code != 200: + return self._provider_error(resp.status_code, resp.text[:500]) + try: + data = resp.json() + except json.JSONDecodeError: + return self._provider_error(resp.status_code, "invalid json in response") + return self._ok("tickets.search.v1", data) diff --git a/flexus_client_kit/integrations/fi_zendesk_sell.py b/flexus_client_kit/integrations/fi_zendesk_sell.py new file mode 100644 index 00000000..b1cafcfd --- /dev/null +++ b/flexus_client_kit/integrations/fi_zendesk_sell.py @@ -0,0 +1,260 @@ +import json +import logging +import os +from typing import Any, Dict, List, Union + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("zendesk_sell") + +PROVIDER_NAME = "zendesk_sell" +METHOD_IDS = [ + "zendesk_sell.contacts.list.v1", + "zendesk_sell.deals.list.v1", +] + +_BASE_URL = "https://api.getbase.com/v2" + + +class IntegrationZendeskSell: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help\n" + "op=status\n" + "op=list_methods\n" + "op=call(args={method_id: ...})\n" + f"known_method_ids={len(METHOD_IDS)}" + ) + if op == "status": + token = os.environ.get("ZENDESK_SELL_ACCESS_TOKEN", "") + return json.dumps( + { + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if token else "no_credentials", + "method_count": len(METHOD_IDS), + }, + indent=2, + ensure_ascii=False, + ) + if op == "list_methods": + return json.dumps( + {"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, + indent=2, + ensure_ascii=False, + ) + if op != "call": + return "Error: unknown op." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required." + if method_id not in METHOD_IDS: + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + return await self._dispatch(method_id, call_args) + + async def _dispatch(self, method_id: str, call_args: Dict[str, Any]) -> str: + if method_id == "zendesk_sell.contacts.list.v1": + return await self._contacts_list(call_args) + if method_id == "zendesk_sell.deals.list.v1": + return await self._deals_list(call_args) + return json.dumps( + {"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, + indent=2, + ensure_ascii=False, + ) + + def _headers(self) -> Dict[str, str]: + token = os.environ.get("ZENDESK_SELL_ACCESS_TOKEN", "") + if not token: + return {} + return {"Authorization": f"Bearer {token}", "Accept": "application/json"} + + async def _request(self, path: str, params: Dict[str, Any]) -> Union[str, Dict[str, Any]]: + token = os.environ.get("ZENDESK_SELL_ACCESS_TOKEN", "") + if not token: + return json.dumps( + {"ok": False, "error_code": "NO_CREDENTIALS", "provider": PROVIDER_NAME}, + indent=2, + ensure_ascii=False, + ) + url = f"{_BASE_URL}{path}" + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, params=params, headers=self._headers()) + response.raise_for_status() + except httpx.TimeoutException as e: + logger.info("Zendesk Sell timeout path=%s: %s", path, e) + return json.dumps( + {"ok": False, "error_code": "TIMEOUT", "path": path}, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPStatusError as e: + logger.info( + "Zendesk Sell HTTP error path=%s status=%s: %s", + path, + e.response.status_code, + e, + ) + return json.dumps( + { + "ok": False, + "error_code": "HTTP_ERROR", + "path": path, + "status_code": e.response.status_code, + }, + indent=2, + ensure_ascii=False, + ) + except httpx.HTTPError as e: + logger.info("Zendesk Sell HTTP error path=%s: %s", path, e) + return json.dumps( + {"ok": False, "error_code": "HTTP_ERROR", "path": path}, + indent=2, + ensure_ascii=False, + ) + try: + payload = response.json() + except json.JSONDecodeError as e: + logger.info("Zendesk Sell JSON decode error path=%s: %s", path, e) + return json.dumps( + {"ok": False, "error_code": "INVALID_JSON", "path": path}, + indent=2, + ensure_ascii=False, + ) + return payload + + def _normalize_contact(self, data: Dict[str, Any]) -> Dict[str, Any]: + phone = data.get("phone") or data.get("mobile") or "" + status = data.get("customer_status") or data.get("prospect_status") or "none" + return { + "id": data.get("id"), + "name": data.get("name") or "", + "email": data.get("email") or "", + "phone": phone, + "organization_name": "", + "status": status, + "created_at": data.get("created_at"), + "updated_at": data.get("updated_at"), + } + + def _normalize_deal(self, data: Dict[str, Any]) -> Dict[str, Any]: + return { + "id": data.get("id"), + "name": data.get("name") or "", + "value": data.get("value"), + "currency": data.get("currency") or "", + "stage_id": data.get("stage_id"), + "owner_id": data.get("owner_id"), + "contact_id": data.get("contact_id"), + "organization_id": data.get("organization_id"), + "created_at": data.get("created_at"), + "estimated_close_date": data.get("estimated_close_date"), + } + + async def _contacts_list(self, call_args: Dict[str, Any]) -> str: + params: Dict[str, Any] = {} + name = str(call_args.get("name", "")).strip() + if name: + params["name"] = name + email = str(call_args.get("email", "")).strip() + if email: + params["email"] = email + page = call_args.get("page") + if page is not None: + params["page"] = int(page) if isinstance(page, (int, float)) else 1 + else: + params["page"] = 1 + per_page = call_args.get("per_page") + if per_page is not None: + p = int(per_page) if isinstance(per_page, (int, float)) else 25 + params["per_page"] = min(max(p, 1), 100) + else: + params["per_page"] = 25 + sort_by = str(call_args.get("sort_by", "")).strip() + sort_order = str(call_args.get("sort_order", "asc")).strip().lower() + if sort_by: + if sort_order == "desc": + params["sort_by"] = f"{sort_by}:desc" + else: + params["sort_by"] = sort_by + + result = await self._request("/contacts", params) + if isinstance(result, str): + return result + items = result.get("items") or [] + meta = result.get("meta") or {} + normalized: List[Dict[str, Any]] = [] + for item in items: + data = (item or {}).get("data") or {} + normalized.append(self._normalize_contact(data)) + return json.dumps( + { + "ok": True, + "items": normalized, + "meta": {"type": meta.get("type", "collection"), "count": meta.get("count", len(normalized))}, + }, + indent=2, + ensure_ascii=False, + ) + + async def _deals_list(self, call_args: Dict[str, Any]) -> str: + params: Dict[str, Any] = {} + stage_id = call_args.get("stage_id") + if stage_id is not None: + params["stage_id"] = int(stage_id) if isinstance(stage_id, (int, float)) else stage_id + owner_id = call_args.get("owner_id") + if owner_id is not None: + params["owner_id"] = int(owner_id) if isinstance(owner_id, (int, float)) else owner_id + page = call_args.get("page") + if page is not None: + params["page"] = int(page) if isinstance(page, (int, float)) else 1 + else: + params["page"] = 1 + per_page = call_args.get("per_page") + if per_page is not None: + p = int(per_page) if isinstance(per_page, (int, float)) else 25 + params["per_page"] = min(max(p, 1), 100) + else: + params["per_page"] = 25 + sort_by = str(call_args.get("sort_by", "")).strip() + sort_order = str(call_args.get("sort_order", "asc")).strip().lower() + if sort_by: + if sort_order == "desc": + params["sort_by"] = f"{sort_by}:desc" + else: + params["sort_by"] = sort_by + + result = await self._request("/deals", params) + if isinstance(result, str): + return result + items = result.get("items") or [] + meta = result.get("meta") or {} + normalized: List[Dict[str, Any]] = [] + for item in items: + data = (item or {}).get("data") or {} + normalized.append(self._normalize_deal(data)) + return json.dumps( + { + "ok": True, + "items": normalized, + "meta": {"type": meta.get("type", "collection"), "count": meta.get("count", len(normalized))}, + }, + indent=2, + ensure_ascii=False, + ) diff --git a/flexus_client_kit/integrations/fi_zoom.py b/flexus_client_kit/integrations/fi_zoom.py new file mode 100644 index 00000000..c6ad65f2 --- /dev/null +++ b/flexus_client_kit/integrations/fi_zoom.py @@ -0,0 +1,190 @@ +import base64 +import json +import logging +import os +from typing import Any, Dict + +import httpx + +from flexus_client_kit import ckit_cloudtool + +logger = logging.getLogger("zoom") + +PROVIDER_NAME = "zoom" +METHOD_IDS = [ + "zoom.meetings.recordings.get.v1", + "zoom.recordings.list.v1", + "zoom.recordings.transcript.download.v1", +] + +_BASE_URL = "https://api.zoom.us/v2" +_TOKEN_URL = "https://zoom.us/oauth/token" + + +class IntegrationZoom: + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return ( + f"provider={PROVIDER_NAME}\n" + "op=help | status | list_methods | call\n" + f"methods: {', '.join(METHOD_IDS)}" + ) + if op == "status": + account_id = os.environ.get("ZOOM_ACCOUNT_ID", "") + client_id = os.environ.get("ZOOM_CLIENT_ID", "") + client_secret = os.environ.get("ZOOM_CLIENT_SECRET", "") + has_creds = bool(account_id and client_id and client_secret) + return json.dumps({ + "ok": True, + "provider": PROVIDER_NAME, + "status": "available" if has_creds else "no_credentials", + "method_count": len(METHOD_IDS), + }, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + if method_id not in METHOD_IDS: + return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) + return await self._dispatch(method_id, call_args) + + async def _get_token(self) -> str: + account_id = os.environ.get("ZOOM_ACCOUNT_ID", "") + client_id = os.environ.get("ZOOM_CLIENT_ID", "") + client_secret = os.environ.get("ZOOM_CLIENT_SECRET", "") + credentials = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + _TOKEN_URL, + params={"grant_type": "account_credentials", "account_id": account_id}, + headers={"Authorization": f"Basic {credentials}"}, + ) + resp.raise_for_status() + return resp.json()["access_token"] + + async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: + if method_id == "zoom.meetings.recordings.get.v1": + return await self._meetings_recordings_get(args) + if method_id == "zoom.recordings.list.v1": + return await self._recordings_list(args) + if method_id == "zoom.recordings.transcript.download.v1": + return await self._recordings_transcript_download(args) + return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) + + async def _meetings_recordings_get(self, args: Dict[str, Any]) -> str: + account_id = os.environ.get("ZOOM_ACCOUNT_ID", "") + client_id = os.environ.get("ZOOM_CLIENT_ID", "") + client_secret = os.environ.get("ZOOM_CLIENT_SECRET", "") + if not (account_id and client_id and client_secret): + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET env vars required"}, indent=2, ensure_ascii=False) + meeting_id = str(args.get("meeting_id", "")).strip() + if not meeting_id: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "meeting_id is required"}, indent=2, ensure_ascii=False) + try: + token = await self._get_token() + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_BASE_URL}/meetings/{meeting_id}/recordings", + headers={"Authorization": f"Bearer {token}"}, + ) + if resp.status_code != 200: + logger.info("zoom meetings.recordings.get error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + files = [ + {"file_id": f.get("id"), "file_type": f.get("file_type"), "file_size": f.get("file_size"), "status": f.get("status"), "download_url": f.get("download_url"), "play_url": f.get("play_url")} + for f in data.get("recording_files", []) + ] + return json.dumps({"ok": True, "meeting_id": data.get("id"), "topic": data.get("topic"), "start_time": data.get("start_time"), "duration": data.get("duration"), "recording_files": files}, indent=2, ensure_ascii=False) + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPStatusError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _recordings_list(self, args: Dict[str, Any]) -> str: + account_id = os.environ.get("ZOOM_ACCOUNT_ID", "") + client_id = os.environ.get("ZOOM_CLIENT_ID", "") + client_secret = os.environ.get("ZOOM_CLIENT_SECRET", "") + if not (account_id and client_id and client_secret): + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET env vars required"}, indent=2, ensure_ascii=False) + user_id = str(args.get("user_id", "me")).strip() or "me" + from_date = args.get("from_date") + to_date = args.get("to_date") + page_size = int(args.get("page_size", 30)) + try: + token = await self._get_token() + params: Dict[str, Any] = {"page_size": min(page_size, 300)} + if from_date: + params["from"] = from_date + if to_date: + params["to"] = to_date + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_BASE_URL}/users/{user_id}/recordings", + headers={"Authorization": f"Bearer {token}"}, + params=params, + ) + if resp.status_code != 200: + logger.info("zoom recordings.list error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + data = resp.json() + return f"zoom.recordings.list ok\n\n```json\n{json.dumps({'ok': True, 'meetings': data.get('meetings', []), 'total_records': data.get('total_records', 0)}, indent=2, ensure_ascii=False)}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPStatusError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) + + async def _recordings_transcript_download(self, args: Dict[str, Any]) -> str: + account_id = os.environ.get("ZOOM_ACCOUNT_ID", "") + client_id = os.environ.get("ZOOM_CLIENT_ID", "") + client_secret = os.environ.get("ZOOM_CLIENT_SECRET", "") + if not (account_id and client_id and client_secret): + return json.dumps({"ok": False, "error_code": "NO_CREDENTIALS", "message": "ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET env vars required"}, indent=2, ensure_ascii=False) + download_url = str(args.get("download_url", "")).strip() + meeting_uuid = str(args.get("meeting_uuid", "")).strip() + if not download_url and not meeting_uuid: + return json.dumps({"ok": False, "error_code": "MISSING_ARG", "message": "Either download_url or meeting_uuid is required"}, indent=2, ensure_ascii=False) + try: + token = await self._get_token() + if not download_url: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get( + f"{_BASE_URL}/meetings/{meeting_uuid}/recordings", + headers={"Authorization": f"Bearer {token}"}, + ) + if resp.status_code != 200: + logger.info("zoom recordings lookup error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + recording_data = resp.json() + recording_files = recording_data.get("recording_files", []) + transcript_file = next( + (f for f in recording_files if f.get("file_type") == "TRANSCRIPT"), + None, + ) + if not transcript_file: + return json.dumps({"ok": False, "error_code": "NO_TRANSCRIPT", "message": "No transcript file found for this recording"}, indent=2, ensure_ascii=False) + download_url = transcript_file.get("download_url", "") + async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client: + resp = await client.get( + download_url, + headers={"Authorization": f"Bearer {token}"}, + ) + if resp.status_code != 200: + logger.info("zoom transcript download error %s: %s", resp.status_code, resp.text[:200]) + return json.dumps({"ok": False, "error_code": "PROVIDER_ERROR", "status": resp.status_code, "detail": resp.text[:500]}, indent=2, ensure_ascii=False) + transcript_text = resp.text + return f"zoom.recordings.transcript.download ok\n\n```text\n{transcript_text[:10000]}\n```" + except httpx.TimeoutException: + return json.dumps({"ok": False, "error_code": "TIMEOUT"}, indent=2, ensure_ascii=False) + except httpx.HTTPStatusError as e: + return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": str(e)}, indent=2, ensure_ascii=False) diff --git a/flexus_simple_bots/admonster/__init__.py b/flexus_simple_bots/admonster/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/flexus_simple_bots/admonster/ad_monster-1024x1536.webp b/flexus_simple_bots/admonster/ad_monster-1024x1536.webp deleted file mode 100644 index 11c2124c017468e6c7ecb232bc1ab28db46b53d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 80514 zcmV)dK&QV_Nk&F`IspJzMM6+kP&il$0000G000300|5U806|PpNXMN301X+4ktD}$ zYW6_+8RnlbfP=mOBKki8d`HK4jRN|Di~`h53TW?9RU;~(3c*$ihBY|{d#3($S`Jl` z1`g1w%@`E!v<8CVZ#hvt44^F=V+0Dgch|w30^N-P=tcm+>K>RNckgP=3cWheXp2R5 zigRb~393C?$tv^e?ioOJx)!!j;NBzw=rMW1!5`1GmFJ|vlk6D46HEi=quUPf1l}$O zo?$Nm)6gUB>EBrZH@@mtJ&L+>;Yt@wSuxqjCb~hru506z=-mL-Ov%E?`Q+xgy*$I7 zPw)V8qUi9D2F5}Hj7c4)HDWTysA8?HY~y4h0HAmPwNnJ`&svr~ko z&9)jd+-xeCX|kcoT!XF!k0K^u0=z!|H(QcqOVUNRPtW6>d+t6%&v)#3MAJVz&+|N< zq37;9_dL(@-0dFjX504O_AU?dMOEmP_h@=iB@JfU=p>AbwyaUmVoQFJ>x)jIpPacC ztAxQ!8=ZuC*|vi^U=><2)UF>o#eQ<;TBsAoL8jd(X~?=l{43iZoxRnv{}+X|9|8EZ~Xs_|G)A7 zH~#;||KIrk8~=af|8M;NjsL&#|2O{s#{b{={~P~*Z6<6)+1P00Y4z9JXD`v-p z$j3@bQb_!d01u`}VNPG<-10n(GW;d2|k77Ap;&S0#COOu}LcA0He?XxU$bbR( zHyr^ZBd!X|V1zIqlmP(-d^`CX$mBl-a{Z6x>BJE-NJ-=)pxDF>dj1zniRS;_3gWJk zxLzOtV?YSZZGBw>)79s5{`+K?^82^rzwUTcee?rrwutKQiMt!`(rB5+DiqD{v$aJL zO+J~x@V?$E|K0zA|JA=a{dbTj;Cm2*Z(Sod;GVPoV?w?DZ~qt4e~o`Heg6)PMx!<% zU=-7g|2fj|zTJO7EZ5Ken}2@LzduH?wEqn?>;BPL+kfiWO^EURIDe1okn>m(98IX9 zIp6AkiT^G62TcU;w~h7}`HzxFgOP5je8JZK@&2;sGFR`bA>*U^#eZfo!-$i1%BcTf z#9o_>_tR=M7%kAQQEtU4H7Z_LN60d>n z?fY5+FMPltTh|Hq0f1z>p^%SPs&Lj~tQBNcuXwB)0grCH@d%~V?^|IfXUx@jbB({> zKk9Ed=-#(t09P1nxo-d(-oMHl{~wu62CR?kK5voOAEh8CA2If7gwMY67&US#o$ev) z26C5e*}PtEd5o$CGsHdAn*!$O>thr|Y_Z{>Ss?cd03mslO6eJF(E3@#XFWzCRn%a- z2ijoCy`fQ4K1NX$^M;6fG>oY+u&s|#EVsU**}V*;+(+V()9BHOiZc<-Ha%V8A|9Pu zas_`t&FE=aH8LKbl;^{{Bz?dJG)%6oJU+FMxv2m=Tppt?@5dKzZ8S`@$HB{^6YX`q z9m0d8N;Z#9%VB-J1B@DCK#j6MZVk0mJw9@P6U`9uE(;)W0P=#xK9JR-3JW<+$b3=8L&WZRxY9au?gXnbsq@u z9SIDG{*-m(@u`t~FLx}A5$(6+4_F}I6GHL$L|__kgfRW9P(D7vVnC<$2t|SJM>jJd z0|L}C*=n9e=QgHjCDjTvPO7p5Gx0GN7qamE;F}1r00U+m(ntf1aMA=|C^SXL;tqvx zaC?iZqW$uXG{UKk6@5v1NsMTMZzQE(OSv2?Ndz{$p zN0seisDsLNeS`OGm$>uXy11QBpH>x7BwG4F1BDs*RE@GtV#H2*AF1Rp6Id8e9TPlj zcDR2pEt6K07{mkAg21N9V(3r0AmzU!o(Ohy3Vq#|VYa72p_MJ#FdmfH*)4!g^Jum_ z^9R`Yl#2iX7HSOY68Tng4`|e=e{qFfv>4Ckwzl`8j8RyqPgljInruJ*7JCP z-xCcLc5xZIr(iHwy(jhZ9f6R=RnPfBcrk6ac{A!1CAv-9?vyVPt%^Uz#k<($&h=SX zjTwMKv{E&$s)X&TQS4?Py9-0=xUhUVY=x!nJJ)+29d)l*28?=IG6uy?TLN&CS5@n@5A)G|z2Zufs^Gcf)swjQumWmE+2zl$esZCC;o zG5U#Do=x7J8q|~3%fHFyFgX1Ld_VsVnr$Q9odLD0l}%PHAGc2ecDa1?Kk&v7j#UPZ zC85L4MyT+5={+FFv`kgl4M1(Rskd^*S{K>jITduMD4}|1!ZNLv+c24|>-m53IkDT@ z5B$wvF^H>06#NIG-Rx9xxctHZTpiVq*G;uMcQ%jO-hLHNW)4q*+GIcy88%1a4Yo#4QFf0=_VOl^RrTv8%DAh5_Ic zVjhSEp4-3ev)4D#-HA2}`~j0tin&MANZ$VFKE(%hS78vn=8r!!cpt@FL}S1JVZ&MqQV`A2oRsxW}XYf^`e#`v)PPs^oku*-ko z+n?SKLm@YQJKb~xK{TPtXaCXW7L9|mL$xJ>kh!txI;ClF44j5V#z0y%zYH5tbG0^G zV7E7x*WZ?*z(_I3Kmg>UlNbMYUX$i>OSaiQ4)tK3&{l0{ojDdXHp8m-T zLpFyH(zjT45xag?dD|F{jPN!n*i=@x&r-`s$EG~yDO!Vk1Ji@TWMfw67hfDTiUGO& zcmLf#9W+%nsn&09E5+_t^1y&JQw%P3@;lf6+BTtqV>d>$RTeEYpynA6m~=y$xPCRA zonTOp@A=Gt-B8nUFaI#?zLfTg*CCOcc)1M?2L#ns{nRdP~1UT(Sp0igNQ>R*AAHo&FJShU}On$nM| z=Dp1@sKHu2V3d}m>@c8;4-85*5h@0i$Y0hSOvI@f$?X$h8Y^(;oi-l)SL5IR>XB0eOcxq82Sf`6xIsP{ZZNn?oevDx z1j7vmlxi_B0LH3KHaH|(#z4nrr1nX6V@yj8$Z``nLAyR^ZwIJs$M^4sO8zo`9|xP0 z!ANy~q)%mN)GgXC`ZMm0F(2VKa)m*DDtsI$vy=26vT4&{K>ael+`ch5V_0rO z69;IDWBvtxdyuajnyl8D-n4XYfU1_K80;Dh)R?uFDqv8}OcR5XFf$SZED()V^*F(y zn+`OfULfwTI``i;=0PfX2<0F-TJ#^a+baxe>-{2n86JpS&O?_62xu{BHXBTJ;>?g+ zC&2Wgxqnvv;AF!=Qh_`Sl4kbe=~;(?6|WQR#u2IrhyfFg8|xL$T%p!A+<@9*)=~Km9O7ZWdo0=mZ0tO5D7|n9{ZNp!K+UyY6#>(TQ1#_Nhr7pIPf040vRU;n~2~ z?Tfbr7wD_hzY=81!SFmH~3kSL^izgCzMVEXNg9 zZ=x9R?KAzc|6Ih8IekCEe|B#TS9^r-Pon1o2V#KIhy@1OK;z}OLYOIRz-R`-k;}5~ z2e><5QPWI|4c5KS2?IT^U%ouRxY|uFuhXm)#6^gq7?>S8FgsT!(^5ROBI4KmW0`W0hT;fjd0*je2oD$tA{P(9_o%a z&plu~EVM(gAIvHYHg{`RQnswJoF`wW-G_22uKRoEwY@fZ;N+dX<_~J`z=Y8?VgSo| z4-#;NJt$&WkTg^q3>whxN;+*MrIQcJl8J~CbiM$JfwzR5&CG!r;cMEcCisfF1Nk1_ zKYjV%3J1-m3=GC_zWi>UV6f+D5}ST!mZR+q>n)_sfTCWb&_$^fUcW?D<}tf#vypi_ zZ~#)f@Um?x*M%(FYWmlb?{3%Shre~xFqIe-il}=18Uvo%u;~5HWQ{F}o^_oeBA~%| zhtLu4%S8!ML=P`b@QthU>Xg>^zRkb}%H8@yEHZiI1DKv4@ zUf=+-+F$?;=@P)phfn)@9y;nBLp3d|HTRa_y5+RFTVK&l45$fmmpOe$TDC1j^MxJ5 zfr!!41^{S-B%s!KPg1GkB?%v4umvzxb`;=D+DI_^q>f8eBJN06d=r7`dvUi~;_h4ERTkaTcasB|%Xe=9s*_e(JhLqJ@8I z1quxa8Vy$%c!QeVgOM=cG%5z(6lspGHnM0ZVQ}5XLR}YR5)H5bwPOF2|3{wH3MbZJ zST#Sn3;<|@MG|*m3|BZv$Lj@V*n^k&hu?S%Z$V^BqNeP)K$ZsrSl~A3=(nFlJB=8O zVN*sBFbuX)h`UBkApZCyEB0b-p~cXaEkrvtnD@@hmhQv|gm0qzGa%o>xQd{mK^6CH z-GQt;Q?GS}16mgPbGxSeC>xH9fW z7>sV>dlPqBv?GVr1Y0|CKUQ)Vl6b1L(}^_<*w}g-s=Z(WmO#!q`0E>H>`AL_7TbD= z$tT*HG*8NwaNDwcBLm`wH)^pI+`R?JS3tx)f$yTe$M5-(cIMFstRWBswATp+Us^wd z_(iZbE#p5Fd()SFg|1&?cvn$cLx~}`aNQO+7T}w>b=8|hLnR7rtjpW)|3q7M9jY0C zsz^vH?1h9|^D^tE%k@+2Rp~K9EHT6-M{5jFz#Ny;H~X-zYqst}C}^zYNVR|DaPI%z zk2ZTE$EY~%e)Sq_?A3M89G-lGpsF)=E*LMb!k^e-U-c z(MTISz!dX<(*}D~tdFtDpJ2F0b+*X7M63yS?KIYn+&As7$+pqj{j5vXTC?Y>O}daq z0mI;%#w+{PgowR5ZxL3XeuN=kky_L)t6Tmg-MTA z8s(n3>o*wouJPI;z-S;`wq*?hM^?4r{rKGc8NuY zW#LL((LMNzy2t|g1Bg(xAU4$&$smG$UxfAN)&ijI1%S494sv6s%^XAjTuJGT(fz4eck{t>`lYooyXI>*yNclcGjxlTPmwJGl(ze;kw+H>A}P$8xY+3DgW@p zZ`y`}{%+)E^KU;sCtw(@^KTw~$khvs#=4izTE01t81)h@+BDn(pkbsYtXB=E7>&|)sK@q+nyRbXE3DTDaO1>P)0f*p=eJvI zJGE_Z{?Q-5p6O!-K1_dR0s@B7da-=@%RXZ?>M4G9Rr?5ep|5jgj%WeECENZgIkSpR=37qSeCmxT5OZHgL-HFkxW44I>C3 zrtQc;xOM?(M!4$wns2QFHA%jetx7aUQr7K8nQu{P|4o*8)oL6qtIBNsjcqE$sQo`r z|M&OrzdO*ARIPo~VL8J9% z(Hlf`{Uw` z$n1q?(SDLMhXGLb+66}?OXjNVCI~FkAu8D*D7Vb?)&;%X#OBpzR4`iLjIS=QBS!Q0 zdDu<|YS4>ewLbVXVmP+z99)%4IkN#WY68f#-;iLgCXG#o2<0~J0#i}IhTkp(Dc0i{ zr98rj{_9=)5)>D0!c`{KRiyRxO~8=UCRuV+rR2_4(^$EEDv}D|puxWGx!EQ=4LHsljNL&$Vt(I-MMGO2=6sIxF{2Wrkr{DNAuwva?tqTcG=s#ch2P zH3dP)G~A5XxOr5v3jmOj9&t!XS4?CPYQh;tx6o-Z4l<}avNFIJS*U9iX3`A}Pg6Q7 zTTVG`1o+;}?FGJ0rm@G^B%0nX{pt_5c;ier60_HrpAf@&ujivrI`Fn_7p1uAFv{(} z2QKXIpMuwrPUUjYS~ep0-1d&io6F+~heo-Ko8YJ2A|Rkq0*qP>=`F&$46^-ToIb9Mv39K}R2RHYgwB`sAcoiz;ZFsVvX1>1O~U`Q%7 z<79{^&CF3PE?;XDVF|ZG=2~^zI&Byx&o}YLGnKAfeinYLe_MPzBx+rDa{Cge3@FL4 zJz$W<`w}5oZD+`)h|ONR&2L`agdwtCb~jb+ofImBktLIEgXu{xMB>H*wpRKb`Ftr zr_A|7yLfd~?J>P7TzNZSduLd$!hp(K3cZzEFmCqF3dgCwi5RA0231Ezm`IAnkWGDj z^we&o-J9Jdt$o^F9{&(~JfDan{qbk?zA&`7%8pk^Hfg7q2#P(#Pu6XRVQLUTrlZoU zKKc_UFZHcL@NICWp>1W3vZ>g1R+Nd992>gBK5N z&?;&(HiJafMvv{pj5b_U;awFwf4^P!{poPHMu3OLC4TzkmE6y>Uez&EJgOf0$KTAb z2X89q)30;ZF2-R3z)rrjjem83I%AMyqnrF6dxB`j;Sw`j@2aju)0b!#EIC-cmjRe_a08|Mltp>2)6n2^aP2FKvw-chcEbLv0hAi3U%;GT+V` zfr!C23{5M`FRYwl|D7;Jb#+?#@2t-&YlDL5kSwBop;JsEhPpxBmy9ed4+NMR`ZJ7b z!ax`SAruBsqOb#vhQAE^cLGKOObx`HW{Oq7&?kh>EBWOw|M!2_zvt`r`A^?}|I~L< z+c)?-Z!df7czn%I`7ydnu*6*>Ut8pKzFC<0gY6^{>PLgf<&_UTtb;)8iGdymGKMcqf*4`aV$$CmSCkxzIyvP9a;gO&YL)JnlFe)b!Epu!8%{^~#Kl`EbtJ2=2y zwjG6E{4ww@(SwUKAI1V+_*|u11jFbSUy5M4OoCk$J!uFMDq$1A3T|*$lvwv4`7TJ& z3o?Ef15U(i`9)~-ic<^p<;bu_%#&If7c)(5!uaTd{7O+qfFqP_r<1@S`p|kf%kWZ9 zn=UsNCB_mX1fWesYT`*d_t<4wfqDZ%)ddfysEi_kCHH}%Vn+s0yM?N}15gqHU~8SV zX3}-Kjqbp@;?U9I>nyOq!n#Jr!m@zVK}Ql4REz!_?7n40ubRX$3I*3&a_3o2oVfy! z9wRe%9q(G?qAkwM0g2Fq(c<-u4M3PoaJWYRLA0J!q} zV<0VAePSLikKjk zMt}?!gs!YB?_nxi!Jyi;G6~8SSjb&gyw?Xe+_m@EQn0}ko2Zq*2FcixEsSi0k<%6+ z3?M2mJ_`AYjEFSISVl6kHd65EGhgJ*41?;E7M(l=0CJ>Er(a;FJgmJZjWmD;1E~^# zFvtQ63oMK(w+ij3$WB-3cntBkZ*8z0zg_f zBd*#4g9QeV0dearq`B59GF1Aw?zT(4{Tbn(K8mcoH$ri0(gUmTewL~JB6B>=)5&SGgqvBtH)3AGG&!06~%1f z$R7`ifgGiM~4qIXoUAHvXCKgt=b9F$2_^% z$h_C@PESwe&@f+Tgs=Ek7S=OQ^;F1G{O-M%X5zoeSJ)O<9&|5Q1_QEeL^FLW;zF{m&JWi0!KWC4)e&Fg`>C&AVr0gyesyq_-l2f5rR%bHAk0kjRyG(D z1&KLp5KkQuBmgr5ZLe7ZG$oi(Te#24KFxpZ`)?D*#QF@tA4*Tt!SV*90;RxvRYBoA z_aDFh#DN&qT?OcBX2Ws9p&XmYU_RMTlG&n2!gXL^e0MWMBn}6E3;Y1BO z&H$icq|#u--jpW<8hB|~!+}P{I#uHhV@|NqafGvRaUzY zgBd5|jhT=f9e`HwUTB4iz}z+l$eFm19NTH9;|)9lB<0aDdC1@WjZeP};DA{mKh%Nn z&ECo8!=~|Yp^TO=?nVzt6df6ixSVe$R2#5~v98(38JuWpEsQ&|#mZ{Q7?~QH)78&@ zcK$(Elo-%0&Jf~ib_ho*7RMeigMtyt*yW4su!;qVFt@I8UQL#8rKF5I08CA7gg|51 zG#8(7(U1cqKhT-PV1WSBk>aqi~e4YBrNkzLg&>2I`?+1j6FOx%}WC z%K0H2YUJ>dak@_cEunFKt+Tc-DvWmn2?JpO0hsUGh3~Y5OU)QQWV+C^4UYK=A3tBe zDJqCNjW8PS)%wSiuk`H&T&m1NC56fR%39D02pDOm01Wpk-ji|t7%p{%_W=}qD;K}4-z0B^1I>v0wR+2wX7LJ6RjQRE!`DuFSzApPw>J_&6pjYs{*#Z$ z0d6%m!9IY%>LuiE^%edN`n~fKeO)lzx6v-ln%fmx$6_2}2+Q;{VH^JiVpWkSHYr#G zx>>`yD#QTRao^VHC4iz=_DwwLs);X)1`6N9=r8R=Z$c0EYB7lSJx=ts0{95iqu8`4 z8cmINqsSIN42vb)tFSeMHna5Bog0+RreYz-aMT_b3I;-r0Hl9B`zbbOaIp?;{bGUKJdo-6X6D!0NQ5sC0GiPNOPqA* zTR7PQd(V)VA%2VN-@Ey#uHr?ZU;yZ4+RuJPf=4lGl+g;Ko&$_95Cxu?nZK_Da0*h0HefKlZIjQ9H03oSaID$iU0shY}4zGwuZYErqk1* zTU?up*tOAUqXD33kL#9AI3tR}ntj`CE)xn%nVVWp%ch1%1%TWb;Q;{TN4CV3qDwW* z{n1413H-H3QEC!nIY|tV!N4MLjLR^|nJ54aNV?e6f`ZZl`5FskoFPz1mMuZhT%$n% zAXpitX{87N*g)1Eg$2ssI+OvTPyq)iAp$i>48<83S@=n}}-p8loD6 zjRb*YlcZ^`LPG(F8ObXOLDEbz9Iq48`68UMsT2fu<*I42JRDBVKg%cxH>lf`9gpZk zX+ud+3BX9YGT7A;3PKVlC`M&)5(p5KOacwGjoPv>Xv{3N8Ve9IEPVh;PG+G-0)|4= z66Sgt0UB&MTI&c5=nf31FJz0`crb5@5Obr!FNW+05a8P(z&&9#PO=ILPRnUwi*dN& zGWpIG3__!5(ScFgjK+-|ddUR>Os%kxt!vTDwfr{h2VZm^XlV3RgW0QhQwgv*VU!Zu58PXP9$PB@_ zso(hDNh}_ghy2a@8D7RBYbfUAr8QqXnAfIHFg)N&joH6Ajj-Y&m2p;`^73$%A81kJ zO}~sp0hv%$?Ja)G-r|AQkf$HTKY@Z$wm!(I`O8fxuB_f`s>^SG#iAl@;|U+8Z;oH$ zfmKzXNTR?LXb6ojV|Fb@gGlbZCST0u35B)5Vjs{*&%yae+z9Ah*Qu5XGnWo-rz%s?{$dP&=$XV}ptP z?h62*6&z=ryiN1+lk}lO0c!}!R*!*HDPMwsq2TmDXhZ(Ig;N#o51H1=^fnuI{@*D8 zKtu6ao*`_01qbXxh_2+pQ$gg%uR*|2gdoqt%Us$b2?|FGkO!8khV@7CPe*}jwe|4{ z=Dsd96b#tA&A`qXllgbP1Pc_u*17%^zqE~7p>PlySUF1F9)w@UowR}kFiBf1>ei!Y zQ!I!;!)le=_2mzKk#{i^CpDhp%ino%Hhr2=C<@HLrXYF%pyB=a54+!+9uov$C|=)S z^(6T9FNLqH;e#69A~zk1TajcE0!TP#;~Pl#VRR$mK@}hJH|87MNh@g9hcU)~p8vyX z8>{S?2yS{$=muhFRkG|iD-bXgv;r|bZ~NMNfoqm;a=$V+wYagd&`@CxC-zI6=NSMR zid)}KKk|P?{H$GHOg4@Qf5^HIaUU{H4;4k2_%oXX5HJ+D<}t+&Gm**!m+5rVWZmF? zW%(wzmIxYc^+wQUvi!E6F9?8!Vwc9leD3?wA9Kd&D*9T&jgslj9IA=`_PW6xhJx2% zFYq$omWS`Mz)?EgPco=cR$nw4{qn{9djO0y6vA~(+HHL+Nh%l7+r~6nrY|4mv-$P* z32~>P7(PbV-TuMpZ((CTx(KIx$bg#Fle7vxSz0{7oir568kOdglFp%@WsBj-fFIUB zTiXh}i=jB~kAcnKVV~%mb}>u22pFg|f;QPG3Tv7XMihdX;h_;W%?AI{P3|J@q!mu( zXPoIT@XfJ2o}3f_LzHH~)hrUc!%#R&0xYY7C<0|`P(yO_}Ru2F$+Dgy-#p9C>yo;gO?g4~b3ljZwd5+@L>L?A2a;&{w!2p1U zeWe~G?ph*v!)#rN79}zrJvlO@pS6pl2E4;ijJKBn7**?(azO%C_b3Tj8sr1qNkhSI zFwumMi2J}#I7B|4U&@0zR7X3cw&;E=D%u=ghZJ`HkGqT9O z<^fa8jyZ|PDE7^KBvH&W(dTN3Vn5m?uTJLkB2KJc+FMI?!58pJi>ntX}NA+QOk^zNPUV21}OvxLlI8E zKm)Ao;S@_OOO)Z`mCs(gCpW7a--#o%;1M4niIx{w%_oQ@MmE~f+P!!xAG3IQoMz^pp?GyMe zLh6nKJ9(hfXW{aNWE9;kLT--P2e?Js#f!IgnU0T4z0u_ns=mI0lb8Dhkd!xR-8hH! zZTX%rZR&n4szIlBn&#^Z6yYy${_)dKCXNA6WMSai-3Ayl9@yX64t4XODKGo`mo5u> zqn{ss>E;Zvy9S@mdkmP^Vkw7C>vCCMX<;UaU2I*$@=1T?zthKcpcuXWKzDOv*U(9Z zqnG;!C=dOQc%et?5Ln8`dUi5Y3zaSlvR0X(NVifK)@Or5tXth`KoSFF_4c~Z>7C!& z1ckcz2(6;`(H)5GZ3!*M69e?LF6Z-Wm&K#@)oT=NZ55%Oe^zzBVe zdoRlN z%&+Uet-Ne>_)ow5{C}uEj2KYosNdFhWfx}?r$CfI>tQsSAt;=@ecjw;qHLcZMg*8~ zDaMFsP64Rw<{{r8;89+za8P<_BM2_*@o%(TR=Pa1TviqyP7N9nwao%S$3V!epEELR zw`)n$Pu?iGo$^Uh=r+6&Je&&5i1biZgL72L6GMDTNq?b`B|XdD(^ye$3r9drfj$tzQU(bjd@~VOgzf|jA~DDja?P^w%3eOwk0zdYI29N$ z8V%wzmF6UHX9VkEkFWVtz7$cwn6z`6mJUAXp&ZzO<_?jdj` zB>Gp{4tg2|zM}5CD$Ka_o*@e9D!Ad1tJ*R#34}L7`jA4~WxXbZ^G4SyxNDh;Hfo`V zKl_KZ>|;OxrWcJEnUcc7dV@1n@Ie_A+)#wMR)BAZ?yTRHaOA;NBST4=r#q?KQa6G9 zSg#q3CasZR)6(^$AsvuJoU)qsZ9p!L?s9EH5)fhNE_ z94Ew1gjT>A_5q?s40H__0Hg^ExGifP9%i(fAsd6$kmAjm9w1HC5F5BTiJ@q(M}Kk2 zfo2|>+e^8D7HzUo9cmBHG!*menc1MSvGuyqV$BX_gEV6_7>f0Mn-Pe&Xwg`#Rq8e$ zutcgFoPWyLU|VG8MTvcibJ0=7w*p`U-QQ6`2`jd=7j+(GqGQ*AyHtax(^C>Kp+OA_ zg9rd}h{cEk-e?JtRI~dtnwraNCcJIGhEG9F8RXJ?w5+O>hZlgRc!SIz+ikw~b$x#@ z8k~O0tp_3i7=)%#G?IA0l%9bpi3#rCKsvju=Qo$1v=@t2);l0A9;#=w<7fI5r^893 zc=Ne%LuIsIEC0EZzE9N@5~+%E+r z+Gsr}t#ZGFmrB=IFaw z#C;0xWVgAj>L4%-WukaDFoSVz?b{d?_bwzH{gHiE6Fc2rNR(f;zj=Mt=R9mo4TZdU zbpBknUmQKs8Rqu_fjYX(i+9-ZdK)W!o~*1p!O%6*ia4p+SioUDeQg={PMDL+Xn}6$ z0P}X{U&?%4CB~s5L>3hBR$F6+PUN7vVnDLHv8JetV_+}b6{&H6um1G!_MyxeEfZ}4RE_1Bx>0zd`#TUupRg?aI%<~xgMrJtGC;-%*9tk%)X5Z;6og@oxc5W{z`)m$+Q3ilp=OFAm+t+Q|E7zi-TaG>KE}j zKk|#Ak=j^siLLnd)9+m>fD9373$Yxn4fg=R)~Mj*lMNJL5h56xZRuNUkqnaGlmK8f zKnpRSH0pcZNl$Td(jp9&PHgS{&{%JEXs6)*^;DBw$8MxMve;LDpNG6$4_XiUC)H*s$0^ z!mJ<@5H7k(i738MwXT^^#$6%^8MHYz$(I_2QvyRlmNj_~M%T1ZrS#^H>^hyQkP&tX z3d5i$?Y&BC870VlT|SD)E$ADMcROsl||`JlCdVU zsv>tZd$j)T#f-TBbO|8nFIER1k%pbLRwRWg4%joxZ%#kdPO3T-rt*b#OLqhVZ@3e8 zr5Rx~?g(f`>`9r9mM1f(j4R$W?YDgyvEwbCV)9q6&&tpFqi|o$n4V20I&e?#U%xwc zk^@a<61FP>eaj7Y-jHBXHGjvjlM0Gq;6tJ8CXthwK{#VC^$NSZekXDB3;6cm@VvV( z{A&*i7nm3l7-nSNc2sk#o0foK^gw zG?g1^0|d}uYPfd;6gFTWBfDD^Mm)+CwLvN`tB=!F~CPTDkY?gk`LL~y|DSQP87P6;XPiAbVlWK6m}Qc8 zqXFQcT@8tjP2;Id;0K}#sm_fI-ZyxC^m|W_@0JoZVh?`O_T{H;38%75WUH6ggBt>9 z1`ZyOGg^N)gGB7b-6h;DHd&a;gTxiL^{lb!9=Og|zx?H!7k6}3{dB#U4BnfT*V;8s zO=Y|Fa0C+->_P8C7gD+dDjw9q!%n&;J(|Rf&>5klJX|O>{)FoGUNec}L1v$+ccci||i?}yn ztbC0?F;l}Htb3fYOH8ON#>AjnIirmAahU1eNXPzTeu5)VNbJS^$Z78s1GE@x5i0f0 z=uR5;su6b@0g4Wuz;p^hE5;}W4~MKVrnooKS$-P?ZL8T|$CJf$;7(Pq*(DRHB{$X< zV68QisY?c&HLZ9|Rdc&W?XNRkM975_>V1 zym(XXjA*_2BVUA(2DhwU45l#b$(CW0>ZmF*ctRNtI+Iw68H=zeNNx!TsRp9hp6G4k zw^BDgV(`oA-CK4CKmk#Fmk6YHOk}Qwq4j%Wi#T10^T8X)4fu&IVtDZaxWOGQ=(u1Y(SI0 zt&w%X)k;;tLDNKSuYf|rXnR3e?h=|2G!)!~)h-Gw=Q?TIJu%1a;^sT}Zupb=n>1*z z?_YjhyCk5#ZeH@N;OeAR7o~)WVXr2!2vy%=;9Z=Z0zhekf}7Eji@LI>c6vLV?~(Nj zeBj^YWfaf`>n-D3H7!`SDz{<@EtW7sXmRr%zHB-a+U?qI0q>#|Pg_(GdsGZf{%f-W zGz_*2m4*{hEjPWlC_sWKyjqL*lp49%LaCWkaZy07q?SEXhfUqZxxz5uRb^#wQgxe_ zHnk*?WCU(OvnWU(#ZzktR5$vavMB z0%!n$V85-B0f0u9b_7FlEsP$Hx|6M~9?3Or4*(YPBOKdGG2pV2qHKn8*0i7a77_Q@ zfZ}ay85drCLFK({Oq|sQPhZr(SQC5GlAOx#J)&{I!tCBeV;0w6gy{%DiDJ%98MakY~uRq@_dG!m3e!G zw(LZKV5OLv6;jrQu3y4kPp<48H<<2;8alCsV(`SK#f|82)P4KA4+C~MeT`oZ|N7<4 zjgPtGnM4y`NY&HDqc?ECBxrIMgBi)Y0pc|zfHrslhzX3e!2+S6fnXOMX-@VBi`GH(p(Q$ScS(g>QqhcvI!dMd3q|9ZGvj-_yc_%5Bwfc zgD7Z&wVcb_y=j-v@ozcVVAtEY*<#5Jx_Fs&mL{OuJgeLC-Fu{7Pu{`#07h?~g1bhG z8bKNe0~jF*dpI{qrrAbF-Ypu0Co}AR$_U~CGDvWXst!m_+O@V_3;=yyUSuT<4HcCz@8@N|mYQ%0)tM>Xe2C+r1)I1RIszuHy zf~$yiB<}1}JUZ^(PP0l+ZK(nGZPNDicp~e*J)Tea%1*TJ_lRsr+$geQZ54Me+?226 zJPrgPo~i&4n&~8;x6`7Brn#@HnE)5I;iOr@?N%q(?|i?){Y%0Wz?b3ta6ONw{6?D1 zHTJ4ps+uJtolYUxh3@#EsyHBRZ_Qn1wtaQ`=@vz=QjvOpq`_kedSG+1{7Lh~7BAp@HQ33n0ji9-u3Iuh z>>1oNsL<9PJ4#h0_N=uY$Oa&ynDuS$0i#dp67KgV9Q4Wo=nr)w|4jZ?`CWVcReHQ9 z+9;QJ`|#WIqaC^0{Nck-2dcFg5Zk-0P+z>s7IhJj(za-k+8*J|0}-y6K-K{Oj5L~& zfMKN4b_B!`2A+m{G&PFCNJHUD1NsDaY{~nK!Cc~8U$%$W(?f2s9u+kwE3QMnpDX_? z^>MJWq1Eah!jTcE-}&=ZEL{AwdVL9}tRfgp^T@H0H}k%YnHZI{*rwLwerJArxXOb? zi1>65#9ilQ|GF<+q~A>7lv5eQfB-ntiJrXlpKN}`M#dbolf(Kxm|032Dh#$5E5rRa z6C7JcDps@#ty}VuIyh^*jf;2dSGOCEj1rHc-j8O8Pd6BFGsLt5x$-4~pkR$Lxh3<6 zg9FssJU@`-GaSiWMgm|6FHkew0~;LMBMiFtO<32+8AggPRG=!ZNi3__V6CsizW~1xJ_sBY z^JnpU?HO@`>6+>`gti{6)W;c~$9COB*l1`sC)+LV+ysA7UhjbK_U63#h#6B#f-Y9qvx zHX49->R)m$3+CW2Pp}8QShZu&NxrrYy)Qu4;B8#GClZ1^0DzTYWTrkpvTu7qp7=TT zVoI^xIR-uE$9jeWw8v2w*Y3$6X3=D@?y6=S8RmU}iN^9;PWHguHekPVT8TF^6rsXu zvxS37W(vVxB#om&5+>*+S99-W4_x{mS6+6G@I9$4LK3O~7p>+axhDW(gtnM?^WJCd zLDJY_SEKpXqDiq~Vwd3BMP;n9x$5_VVWjrY@K23(`7~iKs>U9>sx$n}`R(FtU2OPQ z{Apn8qErYb+Y6(&uArLtM2xcD9S{*BhGLTu^i#O#h-K@qbR4>jUE12Xtlb!xDg!cmJ^#yA`m+Qq>`Tk*2C-2sq{If5uMz1X35RfOMS!{yj>2#heKP)clSF=BTmf)%lW>k#79($B(9=o4$u{Mn;E-guy;H*3E4;D{s z{MnmzUA{#n0|02%-{8nki-~2QKCQ02yUJLuTk8JA>gs0MVj!z7ZJYBN#bl}{r;Dk$ zD?rtHRA_A zVt-1(a&SPvP)Jg)99TG!@ALKMy4+v%m(L%*!r&^}Rrn?QFGd}y&a%k7_|bQrl)ebO>q2iviSN3>-&tC911&rKFFzWdeX2T$Bt-A~W|xE>MC zKZ8uAkut`m2|6%K3~B3wdBDaNjRs+BVKFH$;Qj@?v-K9UwjAeFY8On<_b=MjpU}tB z!g-gzTt5G_U!owS0Y;4Tmb(CesexvM`!+S>^wJ7~M|eE_m51%2wd0vjoV}NBXpY*t z{B!o#c+ctDX+8EWKYe*n3kt%BS0-kLaa~56yMaiV$wUapO{40w+E5%G)!V#zl&;6L z#Uay`v01(3cXXq-5Bs?#7gd+m$JZKdib64T%XR&|gz;Q}=}ZE^Pr`|YqqES$Mf+an z@f`CW#bOMhDWCC3563YIM@nbh^&!b;d2-VqyC|^w5f6J5jl?>pAVzhr|R<9DTV zXCAn1kALe&PU`5i{x+^dj z|M7JEikJ8l&z-TkVT-TT3stk5ntp;uexr};`Q_294TS<=sm;*wi!eK#$rzQ^Dcm(J zQezhSlwc?r^IQBXe)RloK4$6Fr|5=NY`TM=7u5zXThLwuIEmHd=|MWMnQmAdfM%p& z>=vPJAPlBesFW`2J!v%1Ko|-{iuKl)oVE2>mE?-lGB)-$f6sDU2}R_!`G>_FDia z54Ae@j=xnOivSgPBQa)0YceQhPEX+YMN+MuMe}W@`>SwxTz;<|tM=Gv?ZptPi4>A3hnf5 z{(l=*vqHeI{6cR|PP*Uzd~%)}*9S9?=rJ~jywXNeLr5o$qBvCMV6_*4>yNfh31f5W z>$l(0!bwN2|5w$diYN~444>flcaINvmS=7cT#Jz75`=bY7E)KLC=vuMnn~kT1g<}J zaVt@ijYDyhhsG&a-S)ux&07?ZXSw=OeQi@)(CKN`B`RVpj+L61adh>qS6y5 z-+ehLILdG06+ZT_UY)|>%d*T6wkRAV8h{_6_#^-b;q<=uKP!K^JYR-RE9g)UN{qLQ zUl?#yc6mR(>dHluXt-CuV>B8VuF%W}FN#4gJjVMONOf*Nh`=i5!7(5=#meW8i za>%cq<*GtadX3rQd76HA^2F7lFCjC04VnxCsWEGNI0-0BJrHA+ztzIsua3xLDFa6h zf&tVUnE$NLC|1j}95+8qFZ^3qSIbsNF>Dn`7(_Od$fUr{ihh zh-n^xIMFGOCBR54QUG7;B;KU8b?W5a`D3h~KMow9s%8M1m-c-eJOTkjvD%_)x^sTU zD>(XERLYsCK#@&we6C`lzI(88~U<(F9SvpVY?B0rR$?gK>&sVx5XyJy7CGKfBvcbo+n<0NzGJk z*Vu9GtdifWk5CLB>O!^`*~R4&m!&dLO+swzU5Je7>=p%ai%sahh)!;gm&1Rjfzs=u z!j6Yc^!Tn;Fw%-3PjAg5yVeuuR=I2&9w=VAu-vg~VpBeS0s@92IH9fc!}2>BPToOc z4-F%r9y=Wrr7i6P6v>h@a}`*2agplU5Yn$zaKg(XpLb|6KpKl4WS5ck)+ihn;;GUY+$%AM!4S z;&}Zu{P25!F11nAtpPGJgVR(b?GT05ouZJYKmmhmFBil)wlXY8i&O0APs7jp)Gh$f z3a9oH`APg)ed63yDa8y;MyzdTz_wik5HJ+aHDZoN=XU&_f13ZWzq}0zU)K-Oa0lX- z{>SE54FDR7XBw7zn#$|V#;pnduYG^23`uKsf-~%3zN!z7HW>sA1+)Poo7L0#5uAIM z%dOAm#|O>9L+)?0NEwY=Lz^^cW+f8gc*4N&kbJh9F=ls%tbc zzcWv`Htls2+A+es&vqYV&T`#P&H;di0=w3DgF3pl8@&4D7tgoH|h@)Tp=8d{GAU193qsh3SL0h>us@9k7>=6LaP;mRCISSOVHGW!cuKMk` z5LY_Cw+fHq7jI@85P+fZPWe1t5{_L&z^E7-&A@x21;yzG1v#^dnRjg`xPEo^F)P&L zL0GgGT2T`gU`qK;LsOYf7AWEk5=n~DvE`HW9sK(2Sw9Y#Kaum3uMf@}6!KDH z6(Ffi*{zYP=DlGY6r=Z^ixmoc-)dGP+`7U)<#nFKaz66>H9q{Eix=w-#r-$QHoj={Uw;3L<`>#qhJn-A&QI~&Ex;J3a_K&f^*#kd7-Rn(m zlY5)V_Un^h$43tUr82Hi_wJ2^Sk20kJ?LYGOzI?mZVhSC*Ll#MU}cTpXX` ztG_+I&3@41ZENP@v}MR>{sSP!C~Z{N#?FCli#bp!N3xwaaPU@|d9V>Q7e^q?fsXN{ zvCEZy$b4l?3C`U?Wgc8}5pwGtoF&bJ1hImM zd)tXM=0QN+=QFg-j_oL2mteA{uLb^4l zXGG=PpW51tPEE{-4WN+*mu{``dS2#2?KI!YcO9F+sLq81Myk=Ng*hs6E(DNNZBwK} zV>eca=!;M=Z zOEV+$RBz_Uwi4udnHjAyxthJnj?5UOnGv9oNgP>9COkW8*J+X+nT4$RkxSph#f90i zNO;~~_?Iq>3{1?9;A5zghZ_&0oE>@fsu~>`TkP{ANmYn2SC$ITjsV}MBPkKoh0T?F z+~{Ll!G&A1B!dKsnhOg`%^8yPaT29=V(ARekN}8BrVG>Jm&0VtY+YDgXG&8nUcNZ1-Iq)3IZpy&W2tpuQEHtnMN8(s6lvzlMsi{A^~K^YZ1_$M=1< zWM+zSFq2O z3<%!CT-Wh<_!!GRO9E^vFEO8X*=b4MNX?bJHI(Gxw3p>_@SZazBs+qX$#s=ec&=nE zDwxo10e1CF2|$Z(gEp$uDn(i6OLM!jgdvy?d}4oelno-rlnWN_KL6hX|FdNhx&UqqKhI!w-&67nLH z*=e!wyj_-kt^~Ae9HB<#uvmQBYMn1h%SmeCva7t#otkv)w$LAjg@16V#^PwcrON^k z)ysoRfV937Gj&-W$_JTJFXuWJqq`FEF+a#eB~8rWuDf&jF@H0@N)IZP$ITP`ycdTB z7}W5fv(aIxWbe)F0VYXUpy5P2bO9qVoKg&F`<;0`M?qYv#PN&M^}Z61A%{_Y0V>R);t8!N-S(9g6)nhDY-+;IdXHV~hD4ici$4N% ztq295v_UkuElk&w48SlX%bv3M-q)1Av991tu*hgG3sBk=6E1By)KY7%nr~y`eHl>v z8-uk74y$|A{~rEd;p=udZ2Q^C=KK}Fcw8=uzjn!&coTheS2O^iVK^F%9@g?53@(8c2ruo?&b!&T*R=gR8s0{{ulmG#ja764BmQiA|UCF1qnB5d{m0KkZp z-Bu9$%>Qz?^q()+Tm0r^vuy#O!L`Nefq?p&zhlS83x@^8C#|ab50=E5zDxk1pf~0R z0_YLUJlyt5p3#5%so(m`ZHzyq?*(^V*k{t9x$7#1-hTyP(cd5M5ML8c)BFE+J z-{_a`Zo-Kr<^xOpviW2?gVVm0db?RbAtR{N7PGkV58Sj;&qXm zBkK`#rrth0Pr*&|7{xIoDA6ExT4%Uz8bHH{UVej{%UGY77g9)bTUU1Zr=I+uacFa? zrR$^~33a`m+V9BuG45BN4}W!>7p&zuwof;1i_j1g%>yLNFcQfe>Frs%zpd0{UML_k z-Bz^42mWXFkOH+uXP$>cuH{*~++00^tPS_=RLl%&+*HjQ-!eNaQ1WtX;sMiDWgbJ{ z*LBU@_ug>-%A6aRRgESw52vlNdd-%V#{8n+yhcE>;dNn4zMh!1TkK%X{92AP1K>E~ z!}5}D3kqPVO8WBngis9gx?Ny$G+$#!wU^lT*+GkLHuvVZ?hG(#QLXSD7CF5 zt}M`hgB{+hp3kq$4QU8SbYgclSbkG&s2IwA&1rtCr^h9BeD^c+LxXulIP}=))#`bL3cKCrunRX3MTb54kXZ^%~&V>U?T>udex+aHey8tsjoEl6TufLjAX7gb&`n1^`E zT}srG%|-#JNp20PMA%$a7=;kkv@j_dr`ZC4NHKG43&^tnTiSmPV%*jqcfC$mZzmZ* zG_j@S&`$Bh@w#)$8KN$yHXQelTiIs}IJ0k9$F*KL`@WQNN&W&Z~c2}_|Fh(JT z;30;!D%-q4&EV*t18CQ^*T!DrW{shJ$^Fx;0dq_&xw$(jBt#+8G`x_RHJXPg+VyZ`tWIbl(h%ZvfA zk(jbwRA&tiSEg;Jms!Kqol8Eu=By#l<>b%P6*X3Q-T;bZx=z*APs|#<`e;p89Lwd* z%psgi*{3+>+yO1DTh&Arn|Ef8iFSSE%tWmW=gh%G4F%J5?mEZUznwY6AwN^X)0C}< z-!Xp-Nt&jX^X1P!Gk?@kYm{kto_8EnQOkqNnc6*&sY^OZ(R;Q$$6m>>%;{40ykCT!q<~*YS8*)<0iz~BA8F@-d z2xb;3hcz83DKiUOM{TF$WHie(VnSA+nMElay-i3Yo>>S>#B^k%nMFC|)AqxJG{X$M ztnru+B!#D=kaBk6h#J$8WX~=LR|h!}Wp$ZdxcHR=k&}?vl(P$r*~}!oc(v>NB8xGa zgpkn;<63zV+AJrtOJDSh{#>tE4*aO{@uvw0RG4X26EOkpE-}~m zLC0^G2?(RPhRh`VHII_#8ArC$>jYGknPr{XHlJlRmxzo(yV zp{Yo=CK~|yL6_&Ucw*4o%?yDyigr>%FQaE_v!hCJP* zb$la!8Yqkt|O(29C?=%i;apJ4Q^yvX#MP zD*$drlAZo)yg4&baADJWv1aklZ{p53MQ02G#N(< zPc%w%ZDy9zKVju0WS1jlq9M3m$+p$(8H=fS&5AtDM2Y>Hy8G?4FFgY&CLt%7iB{li zz!5cjMpAeZN+w(_S+3b%`TBBPdInpXgr;4muemDIJ7OA+lfLEPDP}uumE{AuX4aYb7#AOcGg4P=@qmKdHS(rSJ~5xOXf{7 z4_5XHSYS;*A`aS@3)5>U+j4_k^5d7+SM`fT$kPvYHH8UQ=H^mH(6g49fW7JHy?BC& zLcLKx%bI||7Gi?6oi~e6s%KJ;2}t;&&o#^Ra{RP>y^%gYy?u;+@rC!u=?73qW_lIo zW+CK$K@KqqFIqDum@s)GSJuP6XP+puY@UJy6sFifH^L5->JzGhNr;VOMoci6n=7$# zuVBy=*FF(4#c&~aF|`(x^%j*k3-t-M zrXeAl$yFxHEr1}IWxjiTi^&Dm<#ely_v1^iRF#QHGM-!{f?L;cai1ts6Omat*0N12 z5#(K*#4)zshy%Y!N-2JHN+YIL*}5Gv$n>cUTJgi;5`iX`aNfZZhdkHpm5QVyNSuI} zSS0&)xw{lG%ak0NSnjs^6q0)7q#U#u3ds|TjczAChWG510s{*nrl0l%oAEh?ILJWnK!Q*y_GcD#4mM-ANWe?vILy6++vprA{iVj#b-T9IY42^oWZh zOQA_c*oC`wuE_mip&(w$WzTc0(@A>Y*E>m0>!;6t?vIt6Vl- zb4_dzY@PZ#HX29AE2rnzrzz#4{q@V8RNhMYkv?%Vk$LNuprAsh-or3D#U;d~8i>2M zdc=x=m^<1u5MHvCV1f0u9?ETBfUq^8oY^jSRg;JwDYmGQ?kIQS&C8rgH_hhjC<**+-p%8Aj3Elbj75~MLOmY8mfm?d>$l<&9-)z%J`89vivffo< zf!rTSn%lTtIvjXOiTE&(J1}x9;c0|QsR!;}mQ0U|nJS#^xoUa+uIe@&#=&rE8c}tM zdyG=fTKl7JMHzy#d0Dj~t*eN!Fzn1}grbc5pzKMHnve=}F;u?HpR}c9jEku(1>_0j z)hFGZgI%~s01a%ti$SzmSWge%G9Zm)Gkp?jvI|&gdX)+>_Y!jq_7QA$WH@T)*+(5b zb%^%?Ogonn4)=~l$y9Y|y(?hU zE3r{wOE^?jg?VBCy^^&@z)^O4@({5o*j*>?6@Y3cF6AoD-))bd4+5CIUry&LO&(Or zZl&qh{y3cY3w0@gF}}orsP@W)x?A9W0gQyXlaH$}+HGnum<1hsMZ4bO=ryJbW1Z4X zob6KG>()0KHK{wH`J^`(&JS8Itc z{Dt1u=>ibLV(PJ*N<~rM7@+1zI7!>BO)#i(rDtm#lsl6qy+-V=(vIE%&D4oB6TFkO z2pCvjUjFpnFIbUGmquDjzS$)QR`!mt(b^jKQLXB~mTklUt+6aUqspe~f-nGPyB0`& zBi<&m6j8?!GuCZ7$3WK->KT%?x;mx_AffH5?Jj{#@4y2QBpl~r(mzW*209NqWzQ({ zYkfMutW(9hzdEnmT{EU{J=mBU+$L30n>q}#tq9Y%yD2skB@F@AW0zG4Cdrlh`Q-ry zI^OEHwRZ%+QhoYiqA*0}6?S`h&$m8eBej^@&`4$sw!`&}9uBkTL;(ooeAiU%K0@@~ z;4(L8aB%SPfYsx^(Z`?IzMPOJ2P0aM7-qW`C-z=yEqJGK*S8pO%h^lFATv1{q-Ak5ihPn*uCPDqu9t@BcgP_dPN0v}QBb=mGTB=_(7m>*xsFbNexD7hpHA$sp9|546 z4^AR3>I>!o4qm0VYsM=}Fg1ExNQ+(1x*mM&D}$RP$rJ&AHdsa=_mh&6VsaEP4*=L& zdI~Bu2N5+)wr@Tq0K;IH*pAe-pO)9{Qh%vagDvrKhe3=BeVx_X&Ov0V#!mszFzB{0 zJ#9kLCTwgZ5Z9yc}T@N_=?d|6sjL&CikTY2(nQdr-f|5{6FfHI=DD%EAJ`LK`~ zhiz*KNuL|U#JJU0-l72%Wp71hh~Qzd9xq?&8RRzfd+xc#bYOu*Jro+a1$%E5a$k!c z7>?)lX9Q+&;Z4(l0GGN4O)N$IwXrD8gW{Rz-}*Cc4LEN76;}0h0FLsWTQh31^%kV; zcu;uKZ{N11J!Pr%Sd=H9CWP4XH4hD`?z5^Weozp^DTW79aKXP3ajcT(cZ*8mYZTSxayR(GJuVEILvSP zM@4DLE!pYyy_Ul=CB)(UiKZI$*t>psSso6Qig*Id5En92qJwNqzbjSzaA4vTl4)&3 z9=9G#PYd;ZzYL;PI@gB-8IGX#7(}0EObVnK#5T!8p^iG>9pu*R5YFC;Esm3c3~@T{ zjj{SHRabAS4+5DwuOBvB$saW@^;A$q( z7~~!+q6QCy+P9bbq*f)^Jyy4kDN%dfn$znWyR_%b-aCM-C!DA|422 zx2+sF)8FO5d0LR7F;>rE=fi*{Qtg@4@%D{a_xbhHKcRhbb7Oj@CdTY<^?K~Z9aXjxE4PADfwm!3r9s)pt z5UrLK^mrx)PgbJ%014ex&VLt>Ii`FL)eWe=m zaY9I%Y!dewfi(Ukzs1r%YT_V!i7+uG*rXH<5vJEmx-$NEtx>l2)mqU?Pv@cROo+wR zDTZ-uJOm(bqWz_mN{xQXWMfK9Ta>0`I(khCJ4at#zC6=`+d*ev)KAOVr)hyE?+d41 z+bO4H^r!Tt3uI@({iI;Jm2j`Qng>DAg+>PT(?=!pMAQ4_s|xl@pr2E(hYG{&i!cGT>0rukr0L2oRbv27Sb^`_K^p%c7x_z+i1$c5(-7rfSms*^%NyWO1BTJ}C$JAxe)HyObL|~>>i4jVpz+zz|43*|v zA-dBbM*W_btvdj0aq{tJFJh@-M4+;{NpTY@WvA(f!}2GVWpihY|C$k<(qeKFKg)?2 zkyKcUTG(I*jgPnGbz4F_7_X|k1#(9KhgJ1)KaZmV8Eadiu$AoQS*g15fE!*L=DRiM;%OqXJZL@6huv6Rp3-ynr5F~_ca`Jr#1R4xmygzYKG&C z5f~?FZI%n1^M2TxR^s>@6`Kx~(<4t%N>}I@>&U~RgN+0L!>}h{1&%CG zHq8h}#dM~ejPIw|Ii0e4^Gfy4<@@fD9y5E&Vb@gYnba(BUgO!7#vA;^h3E+5QqJq> z(k=mY1EVYlItWTt+8pNwsqTe)VAdrO!wyGIqV2;D0DZfo940{ zYtpIV-DO0@sNnakS%C@=JYl-Iv;ZAyq%zipj+R)DO#=%BPIpFXO^v7!5}*I)^XZ)r zdIi)?a)}j&yQ*303LR=hHckm!I!#wrV$*p%5!%+5zY({UJ81+eQQJ+Sqlj#rgSru- zJB^jwXbQMF`SuS!ChprXo==*Cr>7`zGx?}RO)JaOqhAy0KpnAO^jVB%)709>*f&~& zqR2HCXPQo>2N{cwR5}>Xl#4oWCyW4X{DQ&?9l>Rx@sg0ikaPy=5e2mtJ4yhC@mnLh zLI=@C^S0)yLLFhO5!D;B0WgAgPviM4pOvD8PJP{88nz zGA17)$`QdvE|)H=e2;=><=D6>LINOnPNFL|?;|RjU#AduNCk9}G6sK(Z?-fk_VJ19t9QIl;%0g{ZW zkoy_ZvQcLtSgI!|dZcJrN)?v4(;&Jr0x3roU@ar-k-#xmcb9UiD0&TQSm}?ZxciBv zBg(2}_t;XQVKj=!?xL_Nd_>a;Sot2^>4eazTH)+>kN!A*Fap9M_R>*#VQ*N*!e_hi z-lY0${uR{^9SWH-ssO8fz$lar>MANsIxTz}?>%d=p%chu8(EVthM`})AHv-Ps98qgE*R1ojO`f8+NOSjvL0^b zaQyuDbaQjF{Kk7N*yd&XJ?eW%8D7~tVcj^7E}{gdSWq$CEmt~P8#`WAEZ9Rg z2Q|{Jbfd)x&l?`<+Yl>B!)q;kI=*#Nj@;I0Py`$AL^G20RX~gPOy#96bYtD4jIaoN zCzy7rUJtLs+tXir>!wLAFUuv0p;A+8GuoC_Ug941c%v(8WgTGvPIMb4N>YXu%KC*a zCIG+8*e`}mrVU8;=t7ohrnHL&_ZmcY#8$E@$Js+X`|kH%&MS{$9jE+6Cr9R79$^1a zk~p&hJ5k0LMh#g{fB5B_d(nnE;`CDYJ;oVeP*KD1mVf9bz=+%Z1H$53ry1W)MMD5= zaj#Z%g=G%wSUa_Em!JOf*#XO8^sBGm{&zpD-E^A2elZLChd=!KFMeE~^+^Zb;=FhR z`DWIQF6GfsC?~KTDSkAsbDXnFf1oy03N=KL+abU{x!tpRU*bMv zjdK^o3vZv{zO<~WU4S_90GLyUZKc%4>b++s%Xnq4EMhY&03g|WjV3!GNLEk6hPZ!1Nwm+*j4Jy-g`MD|@<)qF)nD&DZ|fI} z|KsD^?uk90WO7;6cfUVIa zc`v~PW_w83Cd=W~8Xe4_Ozjo+p>6@P#spBt42=-VJ($rmnZ}@7>?Dafx6eFO#R(t) zIQ|6j=!fwI=Geovzbqg9i@vBagfmY!Uwr4b+jqReTho$jJsu+PllsSDvREd+z6a^C zjT?S7^NO9dIGOhQb^g+~<^=9E3J9ZNFVkrFytQFOgYikneaC~L5vD@iFD+eoze7XP z04(7SkE~_?|HG$z%gfaIJ#m38K7IZLhH)2f^Dn*nR2BQq15eAPaV~xu89%b=Q%hYJ zZ!iT7X!~?x=gF2ecDicvxf7C2%poYeAW=ZWo+S|3icuz~a!Ic@%_65c(qQ>xcD!nq7{Xjh$8WV==KjQLUWURR zKwd&<2LzZ2yiyLW5gMqG)*fQ#C*5-Sc52^p8K1*7Dj;WA(ra$&C>U(3jbSmxFxDU% z4$m{;3P^@=lF-xx69HI~mO=v=tJ-5XtBUpd)V|bnGWCuD06Uv$sUhZ#7fuIBXBPMr zL%5lsluN0wPpz;uFAn+<4Iq!0)4<#{N&rE?+BtSp36}Gx4ge5qI0yikmM?7fm6wQt zf_e<)>ZX0h$Ir1}-C=EBIn_lufCir!RE3n~866lK6}ZYA%y0~4Hqz94+5oUIS}QpY z6`~3__HF(c`_@q6#^tV}7$JjOS*bQ@Uu{@~&RKNusr`Ol$j6!`BOMzCFrM0DJF90H zs#$Mee*5=0#XfdJ%b{`Ayx7jlr3qK6Z6<;>)Y0_^XVy` z@X>nyqQcO8M5M1jd0k=us>4R^r3I(DFxul$zP5FTJzul4EO4D{%|r+qBP}YF*yUiL zPcl3*5Et0jdW{+<+E~(~4LKT)Fa*s*r_KWbcY3t%kg=AVc>3_&1!NaK!PRTOzWhn| z(5kkbpETrO!+dqKzd`vb+E}&8<18*D@r&Ep5O|7TT;$$;+?}2PXn?6G47*DhKgTeX z5s;Gx`(3LQGFan`2IFmqp-8_LSE42zh?RIei_M7~@5+>si$7Vu@`wDix90U6S$>w@ z$0}o=tI$Rpogp*En+Cc5{B(vrFLT=%<^pF*+d zFBdUl-isE#H$JGt}<+cnMhH1?sY->l81D2Xm?vlY@qtY*b6Xp32N!@wnK2>GE zTHe<37B8%|91&wVEw_ORZ0pE6wXe_GHHO2qd%kSN(Lha<#6U*GCLLX=j*v;Gjjj`R zf=uGjDidKZ0A@s6&Q^et7}2Wi;kQ>9h9Z5HIftvwSU=rB<}3St{>LuqaS6GQw`{h&9J!@Do=}F{q0RPqg0P03Y%m-(tE}O4jkJN;_zH03cUEqz zFZ|@g6>TS;{N~kJ9Zz6^`~fqbYqCaF8{$hoeaexfaq}qsHjyzDx3!)x9zAnAFmPZ# zC>zUJAS6IsA41j<5MV~Qg=wkGXr>u~RTGB;jlcz*Zl2g~#X8OBmI>j(NL&DWn3%9FTN@O(*a&W`oh6cpg_le!K1D6w{ zm}Hji!$=H80dqKp>yffXj0A0P8Yz!7lA?CtjPxkl#Qse^t+4lv*5ZeH*u1B*ONfd+91c!qWjL}lcBKe%@+UrHx1no zfSiz`30%I?F@~hojQgeS2_;N_9uD~}p7|f1ZjW70dD(x*$F}S)jNxevi~BMRe2MFR z`f>OCkHIbbT62W!b)Z5^wMuvd5#W98L4zl9{|=7%O0IKvRZrbcE_J1~yU_TFtU-lP%zaaIrcKjsY}?MnwmGqF+nLz5ZF6GVwylY6J3I5f-~X-uWUsXk_jSgwvN-h;|jGmxs(>#J#yY?dKyqNsQGj*8%JO^q$Jvr$UE@+w_jQH9uGwA)+seBjA*8QNYILEWcJP^nRg<3EUFc>5#qSG zBzaGM23^2gm@4wp=ICfN^q3#Fw0T*01UbiUd4nEFV*m!!vnVu}K~T+E6F*h}4p1E> z?byE9&gkZJYH+20{N|nCjquS}vR)yWzobwdIbG+uS$(he{FGdE1C%pMB(ZBWZ~a-w zu$OEB#c)2V7M5hUJzKHVU(89dEF*$uF}rH7zb|7)E{k?{fcI?kS$2}$7lw=vDqfGX zV*+Y}7{>i8G=ldql!x8A?EB&>#x(-n+?lx6OHqXROH-Fgm_0DTlH_;(QN?FEU)^O( z5S8tkV!U5xKvj7#XaJ#Aob-froJ$s~hGcCaYeXk2_3YVfp$E^|2%r`_hzI2|j zW>@eOth_yk@>tCNX0@EkNxIs+Q_s$q2A?*n;j?*s~-OjBZBj zZAGndbi4ho_O>Fvhs!I)y6|l4ATBwbE5@M-GCUh*E8^wAO7-++b2~QjF%Lh4H@E;X zCNxjjKIZyQnoB3wFDkE$d0r!P{R~B|muXw^e$C*FJbgzsQW6wY)k-`7I_@ob`{b>b z@v6EH+Ydr?U)<7Tc(HDKdK3C z-2>Gis?=pUc>B2HGr!g0ub~b}49AY1235hb-DGt(w3yFpYq%FM4$lboSSb~`r-Kd)XlM>p(w{icbASGfY$!`mD}vJRcC05B6(SeY%>?( zV}P8pP`?4o9E?}ScW{OUiwoIDkSeEPQTlU5_g9)oAI^FsPEa|@5r7<(CJY8u_}R*8 zB?UZrSmMBU$?%!f=EmVoWO$4kK|^qc3)MP$Ik#Rx!@!Q8bHf?QRQ%ljuMDq ztFX4r<|f>woUJ`Zvtn-`u|~cT+oIEWC3+Z3!9&8hn;mY_j^^21^?;zIYVa&ouu38! zQKtOE}q% zlxQ&hDus^}w$T;wPGN72!BX~C#TRS13|6=Msjru1OJPF|d45)AM1tKFvY`U? zZC&^#fG)~WynTg>BwM8jfHv9kT5)+;lyW62_6b5J4l~}PCNtq$sj!rnZ9@yc%vkM> zs}BarG+MH|mh`I291#PiJkE?uM85Bo0N;J$MfTCZg8lw3;hD>h>f<*vJiWo`oB7>30 zFfW;nR4mncEe>+l!=YnZX7g`2L0j^ii`xDw69!=yNAYJSGYm*tbLbqxo(&oPc!zg9oM6OICoQbho>hOOyVwa672+!ER05w3C~Lxda#6@65IB5ZrYOs&KR#!qE41J!Vil6 zpLuT6Ix@X-lL~XEffp?xF=)h?rHnE-MbL?U_%y-;ymByH+G(OQ1)!^uy6U$DK?EV; zpd*_#cwGNtRPL#rKnWVv|g3&N;p#phE zTvER|jJ9jx9g@Jw^3%qr8Ua2;QLiVq7)|QcXtXY4!Yc_GKioJO8}r?C=v?sXBGwe? z?*~ebs$>GQrd*nMi3aWK7Xsi68Jam)P2JI89FEN!${aRpL9s-!w&%`1p% zP|}Jyi!4#8gpnui2*VIMdh*;d;YPzE@ofwm#{jC}n3ljL*O9Ge)W$4%RZq#Qv*ta@ z6cyD$s88J`k`~OP|(Dw7Wv)%X(t>qbt_jHDSV1u*28%dxC9ukKeeQ5tlmIm zGi5UdxlFmx%!#9sacppw1D>6Gt{&$iRj$7OUv%jBCad8ViD2eCqICw>OI3$QoY=?94>KaDEhv0? zjRA-W8)JFkn4%_rO+E{woHEnpBzF6(L3o#{TN{EmJOUgP=!#M>Bj?6!dGqI7_qXdt zGaV<-CDXoKT;(wRIw2Y?JhagOvAUZp1bvK=;hj#%@QCw*#`oyMe0urDPr=m=HvB7{vK!|4@szr7i+S~PtB~WHcHntoJn3Q9{ z#n!0-7jHunamc#Z-y=ZGI!i!T0sBqWu5#n5%OARW`I(*)XJlELsYiCm1;PXv%W>vn}w%?pQ~vcfYDh&m<^ zuUA2v7uz4?n|WfCoMgew-3KvS6tA+}OLQ$&5`KdS2@emI6wPxe0HX}aG|wW-7>~O? zrPoM;J!wx8O(sL#U$kU_&eW}3+5&^39!JV$JmU^7xO9FPivDoIF2|lRS@M(kWHRS{ z_G-e$eEY^JcZ8jdA)ZFR;6=LUS2hqVVzo@9h=SIXk9^lY-C0#y6h9{kw-^AVB@U)t zyon07?VZc4ia`ryAjbH*%gfh2pZm+1IxLx+O+=5c)9#JZ(uaqPtubqYgVj6sh(&;a z<$&2zQ`P7A^8@~wrM3x!GH<%~Zn-Z|9CAnI`(uPj5*d6G!fK+~ceWyKp4>uD!s%J1 zN3qazy_P;AF+RV%qM+nJrfVQkyz9b4%EM13FiN6l zUvX5!1Qc7(G_>$O#zW79crTi*Y1R?=WC8^yYFZS-I;*If+?@{Vo7N)7`4eCgZ5zQ> zYxu@ZVFN8zgNk__ti5`nXRX!qUIeU32s?R-aJg)t*YgPyl{(jLVpfKA1w68XO!6T! zan0XY#>`3pNeF3cNXjrb0&vc2+gX3ceozfrs3z$x{FxXSgYGa`X5JBqs%W*Oo1HJ{ zTdCaDY7?Fd6I87v50B0~bw`uZf3(bJrYYxCVMWAHu=bCD%jkvYKoWjIw zkYj~X@3vG7VeopQsDKl<=4dh7FzaDltiy#Xqc48sMkGMZm#|==TrKmX{nLRpTqQge#^syZ<}~ z?xaA+!>Ly$3`^`tMy==dwZraO>}nx=3<(lvOGS?2uEg<&yPt{yM)Ew&;=;EyFWTA8FD=Qxf5!ym%=;b#!+FZ_IO>2 z7STylVCgk!m0wHCxI5&HDNa%V-zibon$y23On`&;=c~y#iZCj9HodWXK|7 zy1s-4y%qDOV!2r_J=xu!oIWo@)O{Jjf17XEsyP-j@d)ZnXM3>?u>v2v2FiBDg|FzI z9v`XIG3mJC5~gjwI32p^az9Wgtt$?GDyB{~OptfFd0+$@u8nup%8o^E_*gAyXZg*N zb94`fJd11Ckkz&n5be?vL7)cqBUz^l?bCgo|;m=C5P|hHHQ!6 ziAu+dy&qArT*Tp1Z>-)GLs3s&p`a^$dGBH0B?9Gtnzk*V5U1)%ZOQrObZG90zzH~p z0K^bv><&eDqUc2%nQmas{+6j=NR$KIf}*3b!sE%JGL9q!i2LT2^w27O@EbHBNh8RA zmS9yKvXw*4^2@{%-TGCOtzmUg=dF&>&0D)H=|Uj#YZ|uXEYe^-dIB*p@+goWb-(LL%j$GXNNXSO7B> zKl8GbQ{q=Sfw7ot@usF4B|UGOD;Od@e@fRF0RRKA^w}L59mZq`O0ti&Yk)(*WBSf4 z!6W;ML=JzmUI6f`g0K@RfC(DRRlU7#syFEB1y?|_9WroFlBB*cK!WJe=e{Ab0vYW! zKy-RnqDyPEp54yR0roFjvcf3J4)J5BARP90!RuGC5zHaf{yr=WiWQt>%FTeF2DV5A zdo3dC?pk4 zvmnN%L~6MO0z?3yQ@Q}7^@vvFk=K670GRh^F}8kEV|x;HGST2EDIA$ z^bg`o-HieW$+peTvGB|a#y?BMo0O}QH>mtjk(?D-Ra11quw=JsUNvb|qOk-sov9wI z6zkGsUfO3yL3e`7SS&~V$x_YOutH?=iNUj0NPk3;qs&^k)fYeZzWD$!SftU0O6#RO z@i;@T!44phL_kV;@=xAwF679lB00f;!!nEm>2aq@&7TqI@nGN3 zWXnpxxRkUC5vTEvvNNie*d0Ms40+9z2549n1Y`BH=Z6rOHuaxZfvUueNUb1JX9buS zAu?)bDiANi2(|W=w|N_74b!V0%g}dPe7^&_PP?fZR&st+H;xQ{#XD#D_l2)`%s|16 z8Is25N+URMzxI-a{?F>)^bN8%=cdzN6+S`UYOsTQzcfbY?ZX+#G2`<+P&o=Cc0l(l zMzJ4To8ksgItjrFTP^sr2qAL@x~UetQ?quTZL{8UPF>85&u>v#_DGVAXsM)1G*-8@ zE&E}vDGs0rhJNjc_|B&nqrn+tVnA%59=vKif1S`J=(pf|9eh>4phwo757Q84h)@Qm z76(nbvRuKQ?U7*Irq44nSr84`m1CmSedjFV^b2X*uQrvp{AK`B?puEbi;|I9=9%+F zQe#%mYbY+=q!sFSJal&JTAhFs1w`ptc3tzjzI~OWFO+s*A{apVs2~j%5v|fMW(cGZ zIf7)?vBKz&M8D>@r~y)!QhdLCam6B(yt?TuXEi9icfPTd&1OsT`KfP(g)o1=y8^YR zFu-kDJ>x!y6d-(|uLcBYSSKk7R&=vp_%ABIo zH@6gL-OrdG-!w6=DDv%&k@og``rH9_@v6$}k~|Xt2FKImlO?k9Qe^YF7;VM6lM2=h zbOusqfwi?jv4+R^85$q}&BEfB-CgN^@|eXAV$E&)qZx|+K0i!m((Gr!MY9yv&w)Ub6yP@Ae64#5_#*G$dX1g6D$azphgO;nZ(Fk z=kj5Ik5;5WMO)^DqBeAq$2bijC#mPkti*fKA1rb zWu(Z+xVYYdQYcVqxp61TCY7cwymDv8-^@!i-ya}6v3sDXK9{d{ye8R&dI_kRWR^-g z-pqu*9CUwHQfj1Yh;yX}_WGxOi0=LNG!nh~<4MEYO~V+at)0xMF`)pEk~5{V{5!$ydv*N_fNc%CM0J*10^CT!~qWp)YDki*`TbKiktjK@Kl z5#(;2j!<2v9}Oc$nYq6oJNzaM9I%RTMogZZ1_I<1Fc-82#G)w-3KQpyW^CsLXOSle%#7?~R)EqLvoZ)cT_M-ID31PgQ?whqs#V!P&j4-SsNps>=xcuN`it=)5y>yBws)^V6hg{%@y=l<G-|qtg0-h@tT;zdEYjbgJo7VvFPa z-3^tk=r;4xB#D(t5CjQyVJiZS(LId%3`X7>ermjRHs8z->y~fWaM27nyrZP7+__fd z7_Q@J@zmY;SbtsEc70BKgg-PO=$?+1?A0w};WzHT{K8<-(sW(7N|4n^me3v(-P%)- z5;`FI(%V>~NAQKz z&c_f0BL2>KhUr*Ab|P!JVn1I~sanCT=vccZ zm7zhCTN`1(3P+N-g~RkJ#!Ew)*^Ob0Mm9<+VS9pxJu<+yH(=D+!^@}myM9$}TT<+B z3<3G81WCHdiXCwUA#HvL??k)3ycD$#hjt?dFL!@pAmdrR<-IzUQf8+x}f zvMMV7tTE2~vmB6p-~8IL_NMr4=#sT{bTZ#L@x2r5VsN+7;|I48H0@VYj3fnBAD6i) zB17(X8!i|iK0Q<&3NHtM1X6X=A8!0>V&jIuU2d$%g&8>2F zv%}!SiN;X`^O$!vD!2M3I1k#$wGfGPS}ZPkyx!65ycBCZ@%3+H2%Dmw$NKlC5(r0` zu*s&(m*TIKs`0MFdKZKRK`CnY7V`~+4eY(RnSfC%QBeylNZ^$(WqH(*@;OT(| z=Ar36KR>VlFAgy(hdxb@i^q=20GR>mMLo?!Bm8;(!v6Bv`?7uiiu&rl`C|M08v25- z1W=J7H-`nXFMg| zmRI%?KQd>^+col5|0|^9eCh4#PB%dpa)WOdynzq<)9_RG*)|3K-W_;->sPkryX|R= z>DQB&m`XIJeJP`P&2LEMkFK>$l{IO{y{GPAlN7$SE zInxW?ybrPuL_S0Cm$d)?k%#~zb4}-HDFwVvRM=YRgkNaEZM~~lu~Q1>!1R9sBOa=)J&nAcCI(j_-$Rw|0ZUs^xW@4W z&Uq~6OlU*&Kz2A|^DYw;8)R{wlP>h_!q3R?ow|7O*w5_&9l!z0iUd&?VgC7=&ih0**&%h9ESMrL>uM)OtaAETHq*b`YVVA>LsM zYb3;EBSd7XlunN|1R;&b4A+Wp)4y-`cFp*kRCb!7uurY=TL^!hEd_cwc0->z6qHC9?`d^Z+COiYZ&uMKxKB>J2A!?c0Qeu_L$iHe5U&PtaXMEhtF7?TwqmHL85bk3Qu&<~pKMYfWOhT-2GL z+aR`s2AM&Y@4_mU@vh7MX~kRp*EdJs}6nX8;^CMZc=y4K)a10ANn_~2VD>;5i>qI>|8dHt&S(S%03^3WDAB_S2 z9Y>s%g}@+WnzM2Pp_uKuRmS5}b<>42F)`7GxPBGT?^lUcA7$UTze4s#>PuBVZriXB z?FYcA_kR0p*s+o&E+rMG5N_jB*!swLwpg z6d{Wip1i6n)wDIJeiGfi#3fgz->mKWsA)Kvf<~=^iP^?EL`*T2Ro(sNDhet9&?+$# zTrA*?GlUZ>k+L)8IEFW>|{glusXh^ z+J_77^1f<37ENx#t&`fJVX}H*a_BIYg$3nXPn}mDgMW2)vOFgXoK0su_8Z+t2uYjO zZ#dcI55iv@Rx)@zue;gZ?~`9f4KZgD4Ka{X(B%N%x8r^I{_GbZxbrdl}_Efz`G4KI}DX z!~POZArQv<*0xTrOK%M^rb|P#t$@g50ds+-H=ZC)G5WpM7Bp2z(}fUnbfjJc zkt+cd2+shpNGfMhrZ-AR6h1)n8PaZ#$NZ#A=72`u1YYRT;j!Y~a!SR@f7J>^^r#Ux zBJ*WCUk|RIOzDAN`O$%V7AX?dmZKn9j)UNhe)9KpzPrE;cL4y-IKa3f!lYkIrUII6 zl4H_?Bp zX>RMXN^twc)ct|-Iw#eHR9Owf{dK2tpIq~H@i8!*cc)WR(^d~cJ^TsdDpjC z2*zA6=ga+t@r!{tckRoF?9F!$jjxua3H|7gi2^4=VcT!x3#6)w)8DX6)c|I#HyO;zB5)pDrtPMO@ zzf@ow1cWW^7$NRKR0YA6&?iFm$RciMEt2-7*RgPOCL5t^{h2f<>L9tz2S!Tw*?!hX zV#$yEDzhl)Ta|EhrJKVBmRDZOIwa-J?6YRH6UvNv_XAMONv?9!h(iV*|kr<9s=X@|vBCx?jP z*@t;^={IpiJ?v7~Prl1#GS?9tQPr@T)pVHWh~g9?+AU&GH3VNoqOqe(#&q$p83Me~*u zu9kmk%F-3|smAc2!P4&e_<>yXk56G7iO_N&twZ{@RWm;X^viS|EQ^e>T1^BsqpV$} z&5b6aaGrj$v&YiAOdmBL%@r!We3*=hO*aIMN@(s(Pc1jZCj#m7{N5pF!F3 zBUV>6dGZLMHuFD8ZGah0X*NvVjw z<$m3auM4w@8rK|$QJdN;P-j-)uMuL3(upol%dzziz{PVmaf?{)F+OaWm~-(mI5F_& zRNX)_OiI9VvOO6~!*CX+6n06OBgL=MBiXx(Z4LhjJfceVeSj}dqzX=*<+>sn7GZ!6R;V%UF5AC3JEvPLL`MC`lKr~W}Dmush8Rk5r|oj#u1#d9Hi z&tz7l2%Xu!wPkal*i;7US18QaVL~o~U=~N;?bt&^M7U}ZR@&i4a}|33UN(K+X1Bd; zOym8>28%(6S>_PO>{SntwX4uWN?8v;Qb`#yY^9a*XHCO`O$@@<-0C#;J+NvZp4aU< zI#za8%pxcX$n;K)Xu~`f;vDwL1ibRs{qrwZ)JGz`LQ0z)|F@I9F%Smmn-m(F(>m=E zoL^;S#ec4=h(;L;`-pCOnUZEeq9#TC5Y5GD>k+ln5UjL%n;7Z+mvAFF2C&mQLf+~V zTOx)KEhyEv`f&7r?LBNswXM_pck=M9j?USHAcJpI*FLfms~qzEkH46~?;8JG`> zY$?a=P8;yD4cz&Ed)GV#D*4}zE})V~Vbgx+FU8<=kiOsZpbADcqK&XfInHH6HK(OF zIV{Pg``vN-X_sZ}t3Ojm%cpHb(`@<%9CNv0r+5NJ<2hRSPy4oKRAUwu7N_GE8CMh~ zr}D)lWAEmX9^oHP#0=HVYJK|%TtE1$E7~op6}@5rLUe+jw2+lsnCvk6f)x^rWS3i8 ziHLA#w~6wdVBHV=WpAm)Jc~)aVIg7Ti3LFv5qaMQ-!4^;AkLVHOSW~rV)KX#i{KU` z6E|<{de9fBfYE}5)ogU{OXmRjg`PxbK`#kFv`j@Dv%4X7m@3R&1Tyz8#iYofxRP%= zLn74hpKYbx+O20W!ZV!z+_=RJn7%a#C35Y|vTHRs2k3czWyh{dY};rq9LiY+2YsKc!L^pY?^-<` zx#TW>E{YZh(uPnR?-m~CwQplOVisELm(;Zhf@J{X4z0LdZZ@0^9|OV%`^UKXF#q=r z;4Waz`9iK|+6T2j=8SgU_?+JiYWfP_`)MSoF%4UmMYXr*{TC8)DO8tDn?*)dB6H?L z-COI0AbPytiotM$NVr_*ZgS*4)%0lQFAAOF%@zp_dGXKROSqv^vmGw(>QymH+3&Y9 zq1027VOyYd0H6h&Ipsyf8QO;)jHbD7VU?Bc^X{nR846+aljho2l15N7MQw1{v6Z)3 z`s&vc9pAEk7A`8By`b!{f8NShl9{4I0-x93#X6TOhe4itO@RaqGGD9d`0)+MY|a9( zm6|eX@B}?IJ@I`)5go3NK#JqEQH;J~peZcsYyn z#43_Ye^OPXYytT}d6;I?i|C?L6}6QqG0JLnI%kS5LGrZgsXN!k3DzU2jYbqqGM;zH zL;~B+wL^1L&}Yl`o9ubaR%tIZD zl9GbMQrSUyz7?0o)W7T65XZ`o*@dahxje@TgW3$QIgEFT9N!wr2kMN@j~|%g6u|d~ z4x%tK^}E#i?ka9hA)B}%2r-0B{I^$w%7BOnhs_W^ACw8;Ojen=5+7Ox>VIx<3fZnu~?aR1a;-UAGaI;}_fB zmD53njIre8{D@vOkFe(~iyT!=11{TW#NSu}9aVdKdnLT3YP>Miyods4D$E0psu5|v zw5fI_gO0-~1|^GIl3TRfNC@F0l~i@+>fw5Vx-H3iFo6imIn=hOt#wy9^vaDLv!?sP zcr^w(N#ppRiO*8lE}V0(ha(D&o{&YQFoypL`XuTva;{C50?iJVxs8UA+XBK|=`S8^ zH$*!Ia94cspLO_=0RLhF&VKA1Ha~B1ICFzfcXD4VMr1;rkILWchHBy1I;ZNdeAAX)?|ls)iEkcOI z4OFf&x19|-)eRJ@068NnP>9e{?NUl=#o}DLj_(c^R3j(PXMY1qjB9J5zuaNr zv>auO);Cc{))7srzJ$el7Dc-pQvu+tCAT_UTJ2to|3Fw5ShN~#dSG|tEld852~60X z)`>j^XvhfeP|gBKm)vi54z*rgcbq<&5E@$}PZw?^mp|Hles0-WGS&+I>W5964(?d) zru;VNr9FuEZvK7tN`7)f9;9JkVbyHzqMQy`iAUS+iWLBJ6HNCH@Me|`qq>&1qfc^U zu!t^g-W-of`%dD@-;hP)tty8Q-!cCvqPpser@E~RlfrX4BuIF3QdU;!$9c3BfQJy? zxH5tdKt&F|f?{~={Wh)9KIN7F*!K0}dCx5_Ew;HROB- z3)1KFOgSn{rnBb?yN%MYh9PE3dpbp)iNaa9{7tD|^_PT%XJydoadFJ-~2Xs8{+<{mxHrCVU7MTN(Mk12ZvE)22On4n86T{Sz9! z{2ifMXrn)JiDI-|d-F;$+4$BvG5+SumR=nA0@)7bdFXQJpXF24GA zvcG|_9W}SdLg3)lGJgrKMpY^Fn{%;l>`vci8oOmF444iE14g~LrxC2*Dl;SWa3P<}?R!GR>u!z+HTJcIh(Z?!PZfEu&s zS1!hI^DIMX>YT6tB7-PP;_Ilsv?$@V*P_HFMNgeF5uP^48h1crqIE+kO>4d{g~??b z>U(GZy`uj`vYj%T#4W}oaSFw8HBpgSH|lq7Rv-X2xFtsMlW`gBemH9JN&bx9NHg9f zj@1E||3Z^{F#bHX&>O;IGWIfk3ou7fsM;Yjd$zvlMD_%ZyJtDCQMM4?>4kr#ykTD6 zCG?YKY-5Qb93AJM=VoQkP+bI6sUL?p)ImH1q44K^QWi{v_ucb&SQC0@(yC1&Qt&9M z!0Eo<>cf7V#>QVl*pY%7DViYGcY$BC#c)pBP}+kHt??vi@e5%uToEphn28`_ zA@Hn=BMIq7#}`KV4oQ`|w{{?&>(^t;#hhPakUUtuqWewIVXT?HKN1qB^fB3IaY4lX zE=b%bnLEBY(*U2W4BQ2%Mvs!c92z&44YCr2Ru6$0z@!QkRm1&Lfx>#uB&xY%f}JVx zi2RO0PNbC#jJZMZ)@jET7pdMVs{&Oj>c1+uP1_HSgWJ2BdC22H6apO=1HPi54BkcMFz-DehuQCzcP`)k{sRof=F1e z#mORE2(HPPj?=eS9uepVpA#l|`(7-A?*Meq%CFi5w%w4g2-4Ki7u~$UT@kaiaM4xq zX%&X|W^;LYIWV)D?A4tuS^uMynqWHPd-zxfa~&CMYn)R(J$U8?sq~Hly*p5R@yncp zU~op3z48A9el#;tAUc=1yy~qp76sh{%hhE6KZ$@`J0KMp%8y>H8C% z0pG3xyarRddOx;6LvGLVR~0E@ot^H#6<#7T#GSEBy9;Mqhg#;gQNUmRnj{ax?QM4{ z25<%W;8Ybz#66qa8K`IH|EY%jqArV%d>}4?;pg;UWtD@1jP~^<0-pXpFmzr`s-llX6N$>mOEJHbA z^2|s69MOu;ne7pMTHKbqVe)+uwf!1~YC^P=cIP3BWGIXnPc?M@yCxJYkihKXD|QRB z?=n6n%OPP;FJ#MKVRzkC_yWe!v}%9nki`bknG4Nvghix4e&}F zGvfJ^sq>3HdwUo|+Z!^EK;oTrz2!V+8D#$=j$@5gewmej3m?M}okuRmvWh{eT4-1< z6GFclk~v>ag^?C<$2j3%?ZnQ(b3g3ndFggfj(!bt=>d-C=RnWi!hDHAKL9yBsmRV* z3k?{aUsQqAcclkN8bOj^(%o^@Y5ZL@T11Wn)}rNoBpD2efVmttf@~)emxnimZEF3m zx@|}cI1PRmg2);80qfHN^rar%41iAq)Ugx~Pn)Ix>;`~Zg;M!@ z?C>9BYJ8LUAxY5M>FPxKL3<1e8zP8|H&m3D&6FQf!W?S&!=!jCDEs1B8Qf>*Ds>ie z?>_Ch^vkR#XZf`Uxr{VWIhKcCt+CQNd6(<&2Y;iE$GhuPn%DD*vG)P9Ip$e7rL$FZ z_)r0@$(+%j7qUn`i9$jl@i#9vQ?Bp-STtc_q4xa=IXi}V*XEvG`gmDu_U4J+rro|R z8cd_2sYdMY)3WbPJ-zKqh+}mUnV;ze{PwY~9}zMwXz=jj{~ul-1zlT^i;9t>O|_ai*l&?NqTa36G8vkp8w;E z+x#;pe;bXZX!=Tmbe63*s1fnUDCwd=F8N+&nBEXOf-b2>A6hUsCdtOg$O{#`(TtCH z9bub&{8S55x=f7Se7j6t(c$Z*4e-F6x-Ix*G{k}>F=vlhEQw9X|b zX-Us9%h3v3dtwCH-Su#hz|1o{b1j|EZ>D=h{`2RFiP6Nb{+b-#M(6&Rzzmfqt%1&j zWTC+G+Q~`)fIc)R2%@J_NQ6G=$4Tvwy3DA2%&5Y3%NzMR`F~uT|1=2a9UrFYQl06w z8=!C=BE`x9z<2trCD}XeMNJXvLbg^wfz|E10MNxV<%gzW6-(fbk;?>(+5R>9)rp!4 zc9olaxf>J7g|7rvon_n|*yd^{#e$d}(K*q7gBt$=Uch6=Hrm?K;yaV<9)qzV6Rj52 zVBcSEc1Ak@pVp=q6ydy`530fIi&L)?h!ld^n+Z|{uCx>eD!Dhm+QgLH1Xfq>oY7Q(KDBPF^+)XC$QWQKw634irh z>0gu-Gd8WA-=nz(%DW0<4+yE0 z5#;s?YucYF<8~tbT3jHVvDudlwk0q72#y{4R2sg(Lce`|G z48L4#K~}t~LyA6Y4sL((k=BmQ)pXG#mn3lNZj&!{nEvO?L$(B{^La+T5+~Sa7gm7i zPLQuy(OOTJqM4g1UGrsNNrNTI0h}+jsx}+2Iu)KKx&TyOQS5(m1vvh(%>Ojz6EEs| zIYJrLCiH?c>Ha7G_AV!5(*io*!r%b%zmf_5mrwd%=k$O7hz0n30|oeh2VCn{_nfX! z{{dxDa>+3YkUOpr$u3e0%43QeQ0n|$H(NNt z&QaWD)oJT?SwCsmL_^27N?QqthDez7=|+b2Kk+y8J#=6dySZ}dJ?Tq1DYNht3H}a; zTpiDb%`J<&S`n(JN9^0Mv_S`mp{15cifwD^)RLAhGMY*1ir@5{4+UcViE+@XEyfWY zumWX9zRL&|8X!NYD%xxY=cZ8{RGQTyGcrjI?I@U-u8rZ9W48zKQ9gkOv!T5vd!~7V zyAAtL6ig(`IzaBWFCCLRmf*=FT}&{+1gIPxag*2fvpTXNB$sZ}8=K)t<<1@!@DPD) z49)hNP1Lz$nvinVPi?%If!HvO6I0T65x=lnlRc&XFTbVqw$wWD6r9EO>XZz@sV0&% zu=~ioDt!V3x+i~^bE}8Y*_&+=VP^x2GvFmk3wi!?{cU$zSN!jRAMJnh1Cd#yB8)u_Q3P=!7`GnY>i3=oLu?T($H zxpGK3#1$ucKO^I9wR}L$npStXB}vrFu_1V@r8*#o{@&%$wh>-wM)iGmN;#oOsJj?( z5dj$%_}UHC8m7SbTu*z;E819(gNy+CD$>ozf$A5|XMuVb(sFt0wZKH8f!Hkz4x6q& zmgjdIfEk$8FV*WdYNH;JGa(E4m9hGOC+7#N+CGB1daQ>An&&UV)ZF{eETuolt)Bd$ z@S(6h4($f<|33glK)Sz?*TM8N`K1%cJ~=+}o0RhO$r z*P-o7n5mSN!_lv7)41l*Ttlz`*2q3wVnLd?8`RXfoXTKU*hRQT>aa+~w>ZjoAQ&C7 zw5TUDao+ni-nx%eYx(9#EuB>=Oi+5cTJV$%fS~p{ zv?)3;-8hQ;DD=28$u@RC^yjsOIkSC{3tN74zM>GTjn7oOoELhBpOx^fOu6J!t7S|) zF*Eq;4ZOl@EiP=nUvh7B{Z!@jle69u8(u$I@rdR75i7tD{WYqY4P@-RfB*mh00JY$ z`5Jr6FaQ7m00PNnSA(YxoCk13ujERB2Lb>1k1Tq&)5=qo)rZ9L(^mJ$RY*69MD`g{ zZxvtri1@hjpB)d<&Ve{|Dl>Ac{1it2qU@$b`p)FlI0_>J>rj^P`tz@5x+pW@v;3 zHv^>8mCKq4-KA7LEnI4X&0B^Ss?EiN3)H&b%ram&7)|JhM6RHxDoaa4(MTA1llhB! zJ83k*u!(xR`q^2>`NDeXG0M=QA6DTI;)5%;#P0~#z(qTxtdUDDKLn|a=fLu`)T&G4 z@E%5-)gvXV+N4%87}tTA2sOb3#dPnnsad!TV?=K1SH6K!oJj0_Sl?i;uB|6^KT7+u zUUkfLH@W+m^_wt0vFOS`bUU6|x;RPiljFlj{-rRNPh+IvR)1edI3~Sc4jLJZP5;5# zlNp=uiT{^6YJ3hDM^1ye4F7#WO*zCUzqO!(soJtuK1JT9;OXVGn)rvI*){dXi|GvqL@T4e*t(7)mX%oAjz}BHxY4H{n*nL4R4J8GV)u%2v%n&5m!dvJCSri|VI(3@xv6Xb zFfr8fA0+H*L^4!SwuE5-#m>eoBjU;IqyIjrHon_1h$ z(j8Er0o>Be9!msL@j_AJ={Kap;V99+7zZ+W>?1bqSHU$5zE#dyjCp5)-1%mxgFUq&`g;zRX$rvW{YqD-=Tl; zRM_z{uUV0%5qBmz!(5nfxkiRuV)sFzhCiOu2Gi8dF zR<0*&SAyNv$#0xBo8SwwT@yrvw z32zZmro8H;W;(QAMT`hrElt*=A!X3dF-lkTCs9}Fl!}yI)(5Tm%zonk--AMazqCiO z25ERaN@&Q#$a}3GIto+c57Yd3+F5C(ydz>)9?#IJB4sLkQ(wqhR>m)u^Y~sqdi{7t z_4;r9!649pBFY7IMM>?KfaGaP2JgZv5M3C|2g320HbX+OT4`|p9kC+;d#Vj8$=cn2 zVfW66S_uxJb?_Y8L~FW{1otS<2qWZtnqKBV4P=ZS36}PCm z`fy>11RhutD81-*kk=~kQ@gAK!a%?P0000Oa<2i z&Ql^HV0D)MiR*V*DrWL?HfL(+0jnlabZ44r>-4!|5x)_?&84)kGym;UuXnnlDGmGk zN`$mLrJurIY;Jgm53l7%wXTc0Hz?l^=Hku68*ddN;H)Q-7m%H=lQ2%JcZU3@+pP7; zx2Pb;R6tY;LYI4L?m}!v0Tx<2WW3nFEKS0{w~O{rt_Z3C+6j?obB=UBe^Z)gm-*0_ z25Y@zq4JdG()>M58GM2`^$^(^z25lOK_COYX9Z~!U2ER> z$RdL?&L>$gJ>2^4; zILpqoYdNsUUya?uOy9t8L$WMFx^R{tlA7)n=h&9{r}C=MqbdLZ2xigLSp8Y%^aH(S zys_Ji2UcZ&!G}PlTs|WDcg%_b$r1d4X0dv-<9);VvPFAZ4z)r3Q8`JK57jhYOJObT z64_X4KXIdYQH1*h-NatvXdV3x=g5rg#3pWz$iH@EKDmU{I7GF-Zm~FYU92E_Ky|A1 z611SArDuwRYS$Gh@N1L~FzL=7g*)ay^#BT(uD1(@|-!{$*X zW7cI4HP7dD7Q5X)3u#Oydl|qJ0ZjidrC$y^bYXmFInYCG?Fb3~exjj=kH&bOXpAdW z*T59iHu4U?s6~WoF|h82pi4_>vCl&35j`AljkLOx?AKgmHjed`9kCn9BDTp*xG|dl zKg{NYCiOQ*S=vR5VB^mcPHM+w+$eI`v?;@S8f%{S9w=4K%j)scfzt~#a|;jJmR@qk zrRuL#r!gQW$6;w!H_M0|u)#w$!=G^oJXfGjCN<|{x5O65nj;xtFo4;|2PXtjFpvD4 z-^p}pwS1I6@s<+{@OpJRgFe5dZG3d6CFl>$6viaV6_(s2L{axULCljYa4@KQio@-! zSMZ{g#i){SYi*jd7y#BBQ~`hiLXi4Ny~Z{vMQo?^D81-R0)@Q;-GCAmlPefg(#1}d zPNHEfFF!jv+^to>09mZs>{E$|jemYm-ZsKW44^BuJ72JHE3AGC@=n?yt5;5foUoxLiYyru!y}cBco*$oXwjsapLG;n~Y*7Bi7SB1}NkDdzl)`Gi3aC zr8ovrMUE8+c|IZ|M9<6BFfEqadSF=RJnX!xW!o6x>xj zCg7F4R*d<^e^4+H%_Bg9X2_lz9%#DXeL3Zqb}fxm8OwAB112$4X)`?QCuWUx2&+waMuxDA6@j=N>SXiFSpQ()`be!$>Qh`QH+Gx% ztm#>D?NaN}^j?@VGMU#Hu=;z`JCV3Zg6!b=jOAR^j#0gKt0U>u>h3Bq<3!?1M6<4= zBn1c0*h*0M&>aM}nfO;4s8!nxsY=edG>Kb_SVCW8>{nML;d#DB8vu7;lug7I8#mmh zMTo{Aa^1@w>fbCmV(*60hDW)cnuuk#LJ;^64Pbm~8jnDYH)HD=*A$yrsx1FG#j@6b z7<$w6RI#PtqSMYLK`Zey&g@TZ=;K=MR@)#S9GF@qvNSQLDfNp$& zu|#N2sl?a9H~H10?%Yi~`_Gf#)vtUr_4M3Ck{Z7Vw1UuP+a$NRUpUo4rZpv~&M@ok z(LCa0$JFbs?s$Zf1|H(ZWN?=d(8|)W$nZ+F%=u^T5k1>z9b3kx?Do7}dRQ8z*db7g z$G3Qi@f~!8^%(l;PhW+-?==v^ZD>x;c`{EO=|O09u5oR_nAZRZaK&Y=>oDZ9sWg?N z!Ia3|xGadX^kz@zla;~1u_Cl-LZpmaVfbA5x3F^jq}!O`@~A#rSE`-B z=)#TDqrpJc_$@cm`}}Ex0MeN$%FkUYQ1V(N+@kouvNCPWn;R=@H~{2BKvDS*7b{qQ zUwq&DRTsHqr;%c#YK@7IbQTKYEuv!NOxK(Y07a)4y{3T%4QYG)7-A6&9;N{P!w>zL zU;&aeU6%n@SB~p?mudgB6FtI!44QBdmXo}eD@dbFM1`u_(YYskQJf@Bw-wUZ3U*mn zIqAHOZ-aY(db?$!B$S{UNc=`uCIgC5{O^W`=4F^(l%2?7HiQ7Xlqc+6guP6CBpKJde_z z;3k*I|67_a)QUvfc@XPAf6`+Jo`j!kCW5yb&=w&%ptg&B$y{cKI`CRjef8{XZ(k!M z(LzOhkaANUpB|krm4}0&*S9^oZZks={$;SToLpdB#A#SvmIh4JWJMF4?{1q}W}uMq zd7CV&O{J)E$RK|q9fJLKsSS$w6g?yf)W^c7*RkB@=rPA8X7mEJPRK^_C&8ETt`XpR zaKyuzghtZ;G+r$%xk?|nNmTF0^;HxhgChDfrRg}svMQm=$5bH{NsAE~x)55Z2}(I^ z34^*RPH*60YEk;JrICvAJ)T_S_T7QIj%#~L4=2l$jU2%`=__H)zfz!U_qaCqF`BYj znnqfPPYZv}dU%U(zrPJr;m7AFe=`HoBAHcsS6EzbIP9r%j^+W@F$dqR-IaY@Mt>}MxMkq_R zmSlP_%6Rbl(+KK{PE8=TXG9t`pHWAr+s$KJo-Jt~3tTC^mN?-T=%>yL`LC-k06Lji zRuA7W=Hf6)Ik!>m*sT>Wv^Y8x@Q06W(x|AtG|I}eJOTh*6zsh+=JhUkSU1P>50eTi1`SCmkPD5HTADjx>Cz6Hv zoY;p(VxGmd80m}RoSCkTA4qWs$wvdze4AZJd19hbldt&CRr5$gvx^CDlY>LUI<&wU zZ_&mOYn&jzxqFZ4M^aa5iT?Imis8-^E zV^zj`%6rADXU+~&a)=^i2cTxsgCyZ62{=ixrl_@;r=0W`Q^Ih9L{`7UbN^pmnLGWm zC7*NnivaJb)B0u=G^HcJ`C7M5$cbDjPE0-s`=B9|jQi2Ian|)NwbTDs3r+ z2WWX>N41#bCND)eW)Fh5D{@vjG1&PSut{{f{ab=-f};8Pc==a1C9cl?Xw_|7NkoT+ zdSVrPnR=lAyXN*J$cPW((wz*%X8Fb8DaMveHF60w%vVGK;BRZ~NPKzogoT4vKbxC* z_zfDG`KvL=A4=XX8fmk;ybWXKU9@s~;z-ya!sErNuL_y2KNKV(p$#tPY@(c1&Eii3 ze}oZxin+%VrI_q+>Fjxt$1vmAgc)NGshRd2m@5jdEKMw+YH}Yl7XX}O4+n%78qd4L z2a7i;CPv|c9ro?`FS8H_O0;ul(%zrPeX0)Ui$A3OWlL4x#`|%&Be~l;D<%!EeA-Fb zW%*|HiLP@I_Nv6gy*n5D4^BRHdb43fh;513G?0l5Xa^&VOK=UlXV}&ksLAFy*%BY* z1T5|+r9qI^sYcnzAJl)|*+v_4#&Xrf?=KW5MGi1^33f5<=t{?g{TK?MdPwh+#QEJ3 zpq*!C`_8Lduu!(N?-LDtOLOyJ(U3i=W?2W}p<;nJMQZ>BY@loHB%mqS;q?YF192td z%NeDlj*+;=S;*{LV2P7j_QF{VbZeNz`80j}n|2Xnneih0eet!sM9aX1)geP0{2RQx zC0lIV1!%5zY5QR78W*TMwr%y5qIvp9zrO|MFcK99>nZV4D8MnY9^{k4slXCP;a{}6 z!@PtOrD~#7QZ075Ty?07*1+n?!{30HEaPxxl&}~##8oX5qwTB1J88?@UYNJ#`X5YR z-MEKv(jR~^5u}Lv?!o{G;@2WqfX&&@G`Ovth!|ub^1^ecre~=7vw+P#=^&ne3`=F{ zZac1Q%o3MNRV11iwMIeLhM}YZ%l(Bvy{&_x-Gs7{8|P7@oC3JhpzM?xn|f0~VGkXO zxIS*oVxQ4SxB(S8DuK`GI`L4zljy|uUo}>9*iGSvdtu%+aqeSeYgW!FGl zwfsX~IhdUF0$8`R%m~d_Cs;d>a9r2HXh_ZFB<#o?R~T`#({?8?bdo~g(OwnpQEGO( zqi3<9h;3OOI#zz&tDcxGB*MQ%LkM}jS8$RR9SN=TLeU-Hzqq z=c0=|m?XpZS+T#bIc|;w%9oVQ@}^h6BgVzrp66EA;{hQy>zv~#iXfGP$3D}MvO|vn zF#x8IdIPb^EKctM?@nWf%=Ec#nr4m9Tw*!2`E@{!&j4mtS>E<8z|8xhCCg3 z?~P${12w>G3o2(b6J;Eo*>C&5h?Qg022YGKpU>5G16g6ZN&hPM&5=fNXnQGu%)oJG zC(g2Z{OH@fMgvhL)iqK*B7YzZk#}F95a`|70=0&e-X8Z-41o?*<_F)$s~X-TaBkZ{{8yTps4V z?;k{lU*g+Kxk|&xvC{U4=QyFS(*m$`qf?KCc~*i-%LHPuOPKyk|dQXhW^b zi7e8+LIKxky|Jf!X=af{RA-J1-V$baqwx8D5q;-bg$c9A2hq+G>NUZBM?VPD`Wj?0 zesD57@M)!8p-2nqj#MW|ky_9^pyTrymQuWV1PD}4fHl}lELYf)udg@9`SKjK-Y)j|vNz+rml z{fNH-9bOq=! z!lCfo!pY?u&rmTqLqc#NGrcgq-|93~0YH+>si=AGK^xkM%5(vhle!s4k}t1dQT9Z3 zyECTsD%gL4hsP(FtVe5DKFvNph39VS?t1xAqh^=)Po|_&cJ*PpmC)Dv$^Jb0y)~Jo zF!dba;N8ith8bb?iakPU*Q&P=>a(NcF8qX-8|6Vu;ZTN7WbAQ35x=H~4bvadu-L@a zwx=6picLbUyKcc_UK)(%UZr#ktx1ookUN;;E9XM}-xJB6HWPd)08L3~^oJPuvi!s_@T*F$ zxN&YdK_P@Ts2YyR8uybshUItbu-W?sjKHC=A==#^!oK3qn;^8X7Z%Q&BD0!bY(q!8 zNgdjKb1I9dc<_%*AIWml%K{Ghk0M5NHF|xFM2krcxP*$U6gp&iIn?GNc6RPLp&rM{ z_n+S&(4!05Z*wyvz@Grv@57jY)wC?k#!C$XPA4}~ld{;$c!Xiq{?X1@NwobYsY!{l znvEEK6(|%mGY5Z*NMpYD`R!v5I7-* zU+^QX>7lzgozv(QdTiXiBF(a@zi}D~vGCS3|INHzIym=6P#j>!d1Vy88Bu8GfESYT zFRLt&8P^&&3OOU^nH(+)L!GQB)(OfX0wy4Q`Lujyu0Am4V!4UNFf*LAVIgKxt?i=yZIn>ZXl{ZI>PPXsz4n=G1 z)&h-ZT&0l)95-%SX^1R8dK1^GNhp2uBb4`1SL+K!@WHe?8Dt(P`P7^z(=mb@M%Vt7 zT1VQA8q}SG>=xYz6|XWY9{T(L#U*gg6_Sj2*=+7!dT}y(JZPrsjWLCL=FOD}%r1AA zJ9_s!eH|awK}Wq?x-G?1VJkUWTNqTt+@>D3z{qz}&D#JHWFU|8>n5(cQR1|2E|L;r zq9e!y>y^E9A+EKY=JAOcLF`0q+aVm@Puc42I2NR>{k{QVpN$J^mtW&E!dYrRPmp6x z{%8WZZyat^Ohx)5j>+1x^30^`pa=Olx6&kqy$2@hHK&7i6w_(pIwPM7{0%re1|U)h z%p$-GC-dab$O|31lO6v+ouCgIzp@Ps-NmIZcMi(HB#KBY4{IN};Yc*!355v^G>o?R zpm6AG6Ke)Hx*74(e(CF{v5R>ZnwCmqmF8|MyHK}H7>3Z3CSHp?$tERcHzl?>2vZw_ zBljzS43Len)g?m6&bO#U9^*SJpQwt_(4s8iGavnQ%^SaNlICaho}jChLO?UypAa#2 zZqbu8>fc1vKr))|&QIb;)L7PJT?3$z zeU(p`WRy}QtI*J+sFHldD8=sOLgyPQvYhV%`8mxd`0-0PmcQaTQDnD+cvN22>t6iA zD|j~j1ynox<96w#?LSAPk^vzn@cUb(FUgd19z=S0?9nzuwMAKBGS1@@3ujcmv2A{s=XP4Z^9^v7w2yt6M4$w1k076v=874vaQM+c3K|imeUkW+ z2Vk6$Z}8C=DMmnBaaRudrFBj6HJ%Q|$qnn@#`y2%ZXJNh^8AjaCD0eQbK?N6gN= zSV*8ZY{@18AwvxA43d{u(T}MQx$zX4UhfpqJrYHqZAJGFHzeu5IxpK7O}T6e63_-dGiqAZ z>$vRv^89u5{V0ZQDqO`(@iXwZJM@&nUZ&)Ga*Lud7-OB7N?Jt6+K>SP7R!;!D8t+$ zKQHm2*t;}8vz~A17)S@W{teVDIXpQ_e?eV{cX#>X!$t#7T=RR6twFZhKw~^k?8hna+>ZXCHwrdhtRE6U1r0T6xVN#Ru11x>gm)Q)wX%;N~#N6 zQ>jQo#LJwJwQEN!)52!ol^%P8>#uF=aBz&Fc#JMrinC5yX}TdEP7=)@>AfO`+S$ku zT9we$)iO*N8HQw{wh#2%qN_ppfAha}=`UGvl~wXL3c*)HTM(I5sVZZilnbtcn#>ZL z$4lTfOM&VQa`Z8`#C3Fm^Q?US*|uGNeskSe;LHM8XMh-D`-=1o$*Vbxm?LbhUtfz> zV~GB>45XU-BXic&VGz7kL<*g22jdc%p}?O?(PTq4n78PU0IYVxSE6{z4$D-ojWm*G z9K0vsY#t3ag7KjRxOU=tZzi*!z~4Us zi1=2<##OQdI5%*Nx3oqo#MlsLu@eI3ivmJZg}6iLkLv3!Kr`B+==*?FL-H3k(1(Iq z-4M+k!te{B@ zFh@cu3T(qL*uw$P0eZ0KGvUw*RP*eNj%7_3g!#4w1L@C|KcomDz7-`xfB#jocZua^ zX=QI@D7!~~Fxcwl3n)|oU?n@9MBV|@^m?tOxprgs_Fa4bdaywYec?9iPUP!bkWj*$ zI%ts|2UHkbFJqGuN#MF!ne|GZlah9Sdu;}O-HbU_Q#&qxvWEVG5EiF>rY$@&f}8>5 zSqgFGdZ;<(NVz|61=(gaAjv4k=|S!J-7R7dvB089i%s-Mn)h9U@O-fRpJNw%kH6}I zKyaXW(ZjuguT8p2`awuGNdN(W}`lxS6~1D00002UzdiAc?xVr<#>m+-)88;|;QYc?Et4Rh2QM4YMX#pUkG_T1vo2q?$r&jZh6jP-? z1C8dR9oQiACXgj@>RBYhA|7WA;(ENnIi;RUQ5Bx>P+kwn6-vfT>L?g{PC{p zmm{~asHFTd69wpJemHXi&1;~A2|91|TNQ>?7EB(Zw+oOm3p^o(jWrfGsc=`91dMFn4SOg=# zg-e|R3G&1hj<4p!?#yGE;T^?jA1mgWjoR<~AM6lDqB;#l72=RCM!m5q+FJwb6YL4a z!MuL_d`PSK<3m|6Jwo|${%+-u%46CF8c~0A+Usj(wVm)~9b|Hhufb!FsnS`CV8sx0 zy;xRv921$FR91UAF1u%~5+PfC3>3fdpu^?&i(g@`n~@$tICCR-LiXIu7}|oe)hb{I zH~_?InxtmvR?tVuEOW>t;j^A7LVFB`e{czn7VQzKPwljvxVtRb6>i519%*xNH+C{N z$A#RZIeV)eu5pd^V$i>Pv&1HXSE^hoqV$_Y+_vJYB{QREE@XC=s$IW&^?Ri zaWOec+sHS(fVg`y(h0OSF44>#xF&D_09V+=P&v)C=Da!2tbe9TbWge}w);M~3Vsi< z19DzD$PiZ1+qHPMtt4Zo8}FQ+=9F}Zdn%hIjH_F1IgUq02+ju|A(&l4T)s9ZjeMWI zkBLz(pcfq3z8wV_n3d6s%!Q|deIujUWguWC*i+AX!bzfbG%GYEk%W2H<}bjE9tC8$ z01^A$#4Mw?lL9~RjC z8~W!^hLYTJEK8w@U_k<=%erkFe4~Kih{{e+e{xJQwifo$04V9;=(3j{1#y-oaC*vG z+W^lrsC)o=E)F~m;u00^h!G}924YfSkm)|!b364Q^_X)LbtnKTl96aG3Mb*dFz?9d z)N6xpLwl%X8(ja5Bq_OmOsTdTHFP{U(?mJeV`nH|S01#hx=ctMhY{;`Q@%MDB6Hvt ze}n75D^cD%%nGP`AFc(QkM1U|H3dDmEONmMxZBxF$q|YlP!f0B-chf*w}52JvS7Q) z?q@2L+Bdfl#2EtSFV`QLhlN#1WlUa76|$x?S5i%oit8l)-l2JWGPr{1mb;mYTU_C#!enaXC zv=VK@F`~METk-gfcT4^O%gZYazCi+C+Kr*vxO@K42wMtZBcXjY@d{CB!d#Ui;obDI z1NYYKL-h643%J1L4#3%fhg82;VhMkylwLd3=Rs1&3|N~NF7U3$P5&RoaAZDm^NY-lHE^PSw%M?XyM@f~-^TlOb#R5dvG=3Zf)K9- zYOZl_9*@h@eyzhf9iWAzHp$fZ#U*Bu)weuf@1Pho=mAIB6{JJX!ycj|Z+MV!wVd6U zQzbx{{R)tb_eOcfzr+KuoR8TVWt!5TyJ2Sj5!2~@DMWA9u(m|ZO->KX5tu3%Bu?{k(_k!ncU^53JZ@m zWPX9oz-f*d-R8w)xhY3*%ZyW--##)d@tJ*!`#Loxk55pszG%_>OINyLL@Cgh^1l=^|Hx>|~ z67--f9QX>%!wfYs^5jiH=f>%vZ23(fK zZoF?9>i><4%woZy?pQKGJrIDhaF#{R_?N+Ui=U}EiHbmxxXz4RvW*3ku@M1Wl`$f1 zU^q;$)2p|dBm?C(Ts1qk;pL1SQbf*a5sSA!9-t13)^04=m$R}u_Ant}Su``Ffzw3I z6sqm5{`=#ty7Q@lWNHC(2!s?P}>{5qc~KlL5^Ih zHf(#Y34Zjadj&bfyJRtX3iy`vy9P1}q=nxeGPkPDd-2ju;9i~_KIL`BK!ar0Q85R9 z`lr$4txz-3?tCNx={$cKi#AE(a&s~stJ?$gzmA3uD0u#viXmzLD~#x7&SRiijuXE6 zgRV{3=bfdQ#ahX@EMcM>8sK8%Trp9mqDLxm(~SKt+psu?KDeOUye>&5WE8rdo!UHC zf)J@Z7Gbi28#>po6yMRDKr2z9eCkN_vG_BQE@r1yR>{kZelKkO!$5+P zYfdH;rkucgJh#SsZqj++u-D-g!dXWap~(LDPufZITH(k0+we{wY`au5Bu8NzI5T8q zWL4v=1x_VLxhr*Zxy~!`b>K-~O3IO4Pt7&N} z7)F5j*ld7AYQpezw+a9}GMEXH(Rt z-@S~>q1X2Zp4U3-j2-C*6a!0wvTaBR(O+)iOQ~^u zl1QKg7srqXl=RM(XvG_Z_-u)AIEc3bcnH|99&qH4j!+SUBy3pO!?o+BzD$Q4f`LjI ze9)@W)jpjD$(EXO^Y3A0lgv~>(BsU!)^Ow2XO@0v#)kL!4zBez20wrs1Pz5kqEOxv z!Pv6LlLzg2IIYE6mFV&|5?xfTz#;jKN$x(hi)t8rcGh&2Qg*Kvhd*f#8p0xq9yk@C z6~#Ur6)DlLTg`YS@RZ(>-#*$;y>S9q4nf!fmQgrHYZDhuZ2-3Vc#3;hU}_7BP47f% zWwYWycl@ETajGwQ3jlq&xpj49X6X-`Rvv}CAu z?|hbj>(-;)TER`tLe+9~aRT<%BsnRq>f5!{P6$7r`a=ogH6MpfEm;V#6t5nz*MIOM zUw*~)aq(2(LY|3$Z+Uf5iHruCSu2*mbdRgd2>ZrscH7IL9D~1CS*_nyXg;8`%2Ic4 zj8v4RrKgg0XpV zVvjYRQIHHsU>V=e zEz?&pEAryUZzu-1d>Hml=#lLpQaN{lb%W!~2I3%kf3EMn@PEuWb8 z7$80WICHzG;{buVTU621K(#;y=_=(9M>gJnj)f0JR@YKbFjYBkJxyiO9w+s|h3JK{ zOWV3X{ib%Uw3oXqm|dKtO^a}Q{m4sluAoew#`ESOw8FK zyV6cwb*odaTaZp_xiJuS@vAvYg#x9oab^LJ+~+6Pu2rAGAYI=d2*c0Xnij{K+d=MuO*I32 zNq&;_{7_Rj)N8B~+=nhW(ic9l5mcItik z4;>L~Hw8(qnJ0>n7!pGFcq5-&=uT#qEo7Kt-QJ=-xBFINubGa&f0J+SF>CdTIY#AQGg5vmQaC zH?6(0M%Oxr0rs4r7D+a;bv1UHTC)3?yYVpk%UV0hKqg-ze0pht_xQI7(;E zVVJd$7oWs_I3Dfr_Y^)dQ;N(@GRi(Z z%T72ELga`o0Kqok?Ds=;O~rbc9*;SX6WFZ>*#4UFB(j)b(~9Aik)mk%6i~E-FWq%y z8KDW2fEwzPi0cA_CQ54JG z6gCLQvg%m!G>4u&iv&`u)nbxU)RQu2nfsN&TWYrvgjS1cprAzv4ZY;^0kzb6f?SAmzq&^EU-`cY1~Yw!OY=}_don%|<3}S*vw~+y-nX29 z6`y1UX?Uh-9&m5Twim|+(Z{Pn|FRF?qH9x#UFI)YGMs5cA{2QO<9q5(h;>X`h_hg? zt90(ON5_xM3EudfERT#MwQLPgec&`NFKFzF3TBGP7&VXtJu|r3H2qy6*RYW^=`>%4 za$!)^kh!?JcLk@|Qfg@S-_9u9xC?Jx^*6b_WBxxd5htt6+2FgsZ+k6RVpeRw7I4i= z;5o-8B|KQG;$1nn7J=s;-@E@MFj6RIP(A?U{45mH%0q7yE=m@)mCoBR3Or&#-xRAt z6%SC9gU!{Chao?R?P4baqms4K8$GAhQfPe1B-9LbjsJ6!|6Z^C!f~R^p`LFrvZl4# zK}V`09QxpKd(bNrX8RAM8UFKoMDHy9v}@9u*v0waH5^bFh!jDsx0VL9 zB(zPb_!CYc^wPygbg5~u`HRyXEI=P(QvvVw(rL6)P<^k0O z(5E+I1Zxc!ygOsfwLGy3jr2JDPK*)~6VERsI{|q{X2FYOZaQ-VwDwK&$sze^siMTY zk35ECw>u28=ADHR!bjTM@HQ~#vt2D;YXwxSIaE$K!AFm)h~)Qs9Z$qZK#wjWT~!mUgd- zpHM{(9hdLUA~Be5AsA`43CNefRNna@87Z;Q<8Eb<&vKp7{$4y~-j_3iZVleXs}#cm zhu`&;hvT*9R6Lif)Ae%k4xh_=t;+eu;B>O5CoB<`kPss>KX!gtBa*F5WPDt-0gVGe zHlgt@mOtZzlm%oq`sbk60$eW#fY~0s_#Evuq5Q59nEEZvDsSP z@-1CTi^`RUY1iPGPP?CkEE2K8eSNm!JvUnVf;YZeSJbWKz6)e2Gfeut ziu47NJU5X3R=7_9JF5zEriOzN3_Kh38V8U|nv_w%S~e@io?RZjl9_^^)25d&y)`D) zAW97$*ssZ&XAw{tfw}e_KI!fnepn9jfcMUv0E{5H#Qd>COF|0+3Js(_GB)MCVTMrz zZ_mR6lhzMv4C>4TJm(5Bw<{!0dY2t$$2U$?ZqQ`q2S=$ec z^ZTNL>h|EI1kxJm<2kss=2N+w*kKBxJ#nO{uRNWgYa`csRSvO9lM2T_NgOJ=i6D1i zt96TP0U{4u2KHqRxDH=48AJLJvOoWYrwHFbrj_gVmS-ITxg1HSe5N(&;O&-4KscWY zzfypWl@(w5u*9~M^-DsentOsxJ;5fP-}}0-MI)D=Ot1J_g?TY<@GFe&W-KZS=GE05 z3;-i~l7|{6683yWDvOcW)VP>yJ<%kJWSGQuzl0Qr*luOD72wo)JklTBFulK36u?)H ztJb6nL6pW=BfV8cCzp&%9(#5^saUMo@r%+;fRPXGo58q3HdOKxhSbliYp!nk2Izzw z6)u)xTh>fh)mu?sU60Ii5?S@NQM#0Kitc^9!KN*DPE4CPg>$y#bNpFf-ZOj~#&3e) z66D|uK#dR1ueWgaN!Wo7Kb(B~den&!UG_C#PgFa0(6LyCuNNCPE+gS??gdB8HTp7= zk@pr)_F>`haXjpkwyshJF=`l6zX>^*r4?T)nfUeo{|=*?tb+kGW`l*=lA1aR7kUxw z!vL{7{#<#XyFA{}8|Q`Q%mSBF(C(S+L|o#KGfcQ)n|)*q^sO(aiFP;90*C*I#>T$J zRw!tbOC&12dRb&2QYxLd&TbG75#A(}%z;3&Q#y$q3G`u{d!h!Hb*(PQV@ZnA6dP57 zsaKEikw8`jfbA3FpQ*Xp27oCG><+cbkj)JZzKo39aVokk`?zlR*Qc2x$NReR?dB{F zpl&bNaVEgR5LbVN|D*2m=gy_dArO_Hv#&en8Y55HiP*lCr-It5>ZjTRi{!)^d4B^v zYZw4jy}6vmfa_4ogxnO9F*yJ$sfg04%%z>FP8|+S?HFmuKt4ncC6?3}zq~tSSQVqY z8sOb;C150v@1K{h5No&z0Gp;WUIA1}pT8q=_%R~X!l`mmx78#{M{Z)EU$#iZiRypb zJ-7aTt-Pl+Oo~rraKR(@d(^-P&8@_GK9V4^B@$FM1l%hw4&^qw{q?%xeU^UE`M!WI zyMkM5)=ycU3275$10-Lez2JID^k%d?6~XL#v^IRV2a)yY#J#TM1A>HLp{x|=Kv-)C zvRb{%LN{3Axj;u!G`PYN0BVuU58#Ox4Nr4TndjzK0Wg=s@~-7Jv|SPI<6<1g7EAMj zaAou}xi_tdF@DB(V~oQ=yYf)&a?7dN)_j_#MIrO$7&X`Ii))-4c!KgJI`U;h7=93< z`sBX7+d@1w>szL`)MvcWod?4Kp?(*5)ipa{y2~u`qHdVy=%^1uRaIn8W>$M#^Yv&7 zT`Z>SIOSf%vFQghOD>!4I%a@U?N?*9oB@D~g3CFwgfu7$%StU0a%pR8C0RMw|6S&V zN1nGs$s43A121;6&Tg^@4{9Hr#7d55qKt52;^d_EL!GyhF#&O^$G-x?Q2jZf4!QOy zaUW+oGRWxE%!#1)MwU~%xZk(e*=h;l(F7`00hz!vb8uGqKiZq-7cvu12MSc z4ufADEYASUuK9z&^^d>0+eO|b9@W=!rvvdt^qe6gO<;=?v1$CCUvn^xJDzoz-A63*X1Q07;c$R@X9s4v< zz46Z+;<%GbL>7e>d5pvvQYvNLUuXO#FO)*M74q8-QUdJxmjVgP_<*M*fG_9Fv=@P} z)Z$vdYk2LV4q=i&Kgeg?KY4BpO#M!1e?iHN8dC+MQoF(tP6;tqHb!3%ZFk2TOlYV?#uODIVo0xQ$iU0yl`E)mDnn_b zc5L9~(`#3$)LnO@z@)z;J`p3V1J|geI4me!>f^i2?fJgm3zeh7MRBPB6>)HbnIY|{ z7zhmbqcMtQP#92lHzpQ-a#n?eUb~*%S_eMJonq9p2qMl$;C`xvO1uw_(-YU$j&qX= z_6jsM{1%-DsZ`BI?nYHFLU!u$C}btBeMIs~sr}?PsP*Z&d@MEFp7QUQ^>~fovWn-0`r#@TyCW|NkuZhKueM1eZ zwpcK&4G~;$_I1^72xd!l5)a_1#Xqa#+~rdmWn*inq@E zh89O@N4mW$C4cYv`IhW_K2<{uQ$$h{>$^0Sqo#CDD7-Vow;HG~h*n_VCEGgHmgPlp z-PqcGEK4>T>(dSewH)jUkw*L3$L> z3XFTQp1H&xwE_WhQJ}(I%#5Flm-(UE8m+t34LFd4v;TN~#0(j@L*t>pH_g2eY)$?! zw>9z7I?Hk})xrQLN)U!28?b;`3roVV3R*E|v0l~Nd{_vKomEIgsM)uBu9Xg)365hz{lS}D15Uj9!e=vy6OYo5+xs_!PitX-TbHB$42>|N#;;9C!$pGXx5#u z_mw|M@C!&;MByH#V$&ocuDRXb#Ldntk5V;0vEA_pgj>84L932ttcwDP77dzTwN|LH zvjsqEmiSBrQ;#VBz*y=_$z5rH)Xg?nWWIxi+kj+lFxIf23 zBsTjy*hsSh8TKmMTcTD1tm0u1oZ>73nm8?xhnePB)39bD+xAuv^u zgii!)^nIr>%f^t4-uI9pJMV$sN>jm=%K88rgz^f))|(_8cRsEPtTf2siV`&Scnjr3 zSIrc9$&Mm^^M6@cEmI5-_T&PC;-efb_ECYgF#gz6Pi<-Qdn)fC-itp~9|MUewI^hh zxp}pjfB+yJI<&03j}LVSa55$!!7u;AK{fmzuZL@W?wJ~>Q&%t?MW4`b&jhwi_~+Yz zS2-2w!aD>cdYQhJ9~d3Mi|#(Dc8cwE?b{Z4Ro_?~NeVemlR~%xDdTIX1@UG&;~mKB z?^!m&1ZJTfjiwr}UQ)S-E=!g4PEa@dwtfYMgsQTNUT*$mAvhN4J~as7<9(SV&wH>0 z;4%Pd>rd)pC~?zH#IwZwgwaP{{Fr>Ign%V_``E9xg*1f^vA}`}3 zSz6D}an46SSH4O{aw=yK000001#gb7z5~SwX(<>pJhDU@%(htuTXI^^wo_bhAufwN zm<|%lY@uX_YfY^8nn7-&W}7~CW-Wva|K#cb0CW|yW{fi8h!#kV9=S1rS#Q%9@>W<3 z7aCpwnmbWP+NX`LN^_3m81F%H4m{$~4Fk!cIQS{dbOf2<5}7-+dmJ-}p0mmQh%14e zr3|G700065W$}TQ2Kg?-NqTr*I<=6S6x#V%1?mJ+#m}7Mh(@?Zoz~Ko;7>>M2{Ni6 z34$^<6}SSIOm?J>p!2EURPOMlCeBd1X40UFHYH7$F9TQW25C%Qn&hjS(}}e$zIGCsgFX9Yc-B2cNOcvvzBB*?EyRRF+87lxi{6w1X7G70kj(ai&J0XmfBG2S+%q94EZgxUWR+AK@@ zsT(ixrk8%jcG`yBTgA^Pg!T)P>wjE5Kng~)^$MYKlZXW82%r^($(s!sG*Hb!hQ{%Hz{iD!PBj2rX z+6)b~?y1dA9zdS9S*j@)CzBRP)Uo&E2W)rx8M`D@*lWV!Yw3r3obo;qNP}xJ)6bHhE z9)uy;OHdBw2Hw)v{^Z3zkUfeEv%wBK>-eJ4Z?3fAVE+bG595S!O38jq&FEvXre9SC zXbR_CgDOWr00CtDJ$CcKgjt%fR!y@cU={}vlQC0n@3I0RL*8V&NH%EZr@hLl*EvKN z$75q?U$H#h#5ZJ>(72&4hs2moW|e5ffe@G$)BaY)uMHO{;TQD3bm41+)^kL`kfX=p z5Z;?)XXY)Wh^>3SxJ&-x52c(R%+UqHlSmuVcl+Diq%=Upo=u;e;#G*VP7BSmf&c*k zZZY9|@|!fAG4^lu+D~RIUCBc2tBPunf4Vf(*eg`l<)-K-?Isz($OSh+Te!m#k;SGQ zRw)yi%}!vlxY^CJ8CHm;qbEdxSx1l5mlG-Fo&)C&gvpp>M@)Xj5wtgCT1kG$fyYU( zBR6VR+Y(IMMCYRd?#X!JEO>1831|_KCHR!}JJRpgkGGW#CnG@!%kjtF5)succ4naq zr4j}SFWlOm{5O@&#~Br0N#|dFKg<2i?s~KzdZN1u zoz8Dz0M7-z?)Y15J z-~bERLMM%EutYl@VV1*$Ok|6LjuSgmvq3+_K>VpY>$M>X`HazFKCXZ&#J|Sud1ZLM zH?n;Q;)3BHrn9{?hS`w7+*{nSn3QlKiR3i-yK4%rINxc7*cY2q?NS7(cX>}Q4B-<9 zEgAu-mJGQE^U^OMu$kqT*QluL!Jo6h3XjPvI<<0>iTTJD0CVLVki5$JzB-7MSuS3; z#<&a3^xiE=G})>6hyocn&@lsD?pK3lQJc!_SYujC5yi?6%?{MRD(HGfiB~C{S!VN$ zrXrG34U8?C;@ZAy-D9QCDviEgYmkO9MC_}p8?-^ukT95&PUplU7|)1NNiK(Rw&99q z$fdbP7=m=&xWIfTHj?ihwvDv+lYlnF>-zie0?E7g00003^vWw=+6n?6*7R%X-w)kP zGbP(Xll}V^VgdC6fZutyUj3`OVE`( z-u-Xd?{HBS!1PTft=^!g3Tuox;&q2$Vs8IEOU?&9f#MIt`-dZ|e}ef9vneAZe!{f) z`{2?QO+f){*6}JzS_E{?=+9fDqygq3;uSRgXv4uT0K435urwA#ME1d0{%aJY0$qgN zK{X8JKgj!cZBQuZie;XCMn?P0=!ZA_gLN`CsZA~&;`w+~or%dRb38=VkHyt0nfKu)N%PWW z<*_E@ipf1&&KyX~1!g0BYWVe8b>cBZAWWf3itvSR+FBm~@poT?36L%!@LPe;1&$F& z1W@B!38<%h+P(v?_Y16%YLgE!3rnE*r#YIV(l0t5m0a5)hCoSt+2wakh;#R9k9@>- z6Y5EQPYV~w24v^C4Mk}bwoVi)V|0tWOE2EQz0Ufu0008-J8C#wi2aUsyJSS~)mZ++ zP{THMlo>`p>EBNu5@(0@C)eo&rUL=1241+{GK6Q_+@7U-cC%o@uWi`TCu7dnm|K69N&kX5$%% zW9u9oM=8dnd!syOGi5*6G%@_0u1ATk9lrR5K7~=N-h1-GXy!6OdgIS3R*)Zj=HN${ z9ohyEBSWp$82oz(5)#xxpgD7@v8XVfuY!Ar8EwI7PqurtEeF(g|2t2j14GXsC)-dVny6)1KkG16` zN3F89IIux?xQT(gU_x)Sl{y=%o_v(99p1|7U5@Mo=_3Vqr`*(arVB;$kTmU3Ez;=A zm*aAOS&&hZFgU$5%hWe)fB*ppp3-PaZH@8Gx-sen6{)kOFV6c$j8T>q3lob?`bD>0Y?cvmYV2=zaoQ_c zIVekMmIKNZDfqr7!o@;$@_7WlQlfDo&v9(Ira%a`-QIHESemKpWUJ4guJ@7Lr_NA= zuJLqwXx!y6yC9=sJOOibq-Ct=m|@Vv?O2$BHpLVgDnQA%)+d`c>|Wu5^zrx5ACJ4j zQ4m+wdQa#NA0Cbj&Ry$<4X9P>oV{z!DRQYul^dMgoSyewc|pu!o1F5E{5av2jF^(P#QX(zqT`)=GSi3!}xoAnss*`FBRtXLjsF@xE8K zK|q);_hq?YI8w-|BDcr59Jl2u-b(|n4)~8PZ^#XFU)2B#*gq6EB>Pij^jvKT%AZ3( zZ&htL#HRrKd4DwW#gfk1feu(cP`0LLPq*gn53$H~w|T9LglT)-70+_9A|%2Xji4Q9 zD|PlCH>j2(3fLgG*;R0^)V2@P{w@|>Hh+6Ibk?jp4gJn@&=g8q(SShi*l_KG%UDgd z=T;|FgD$TCqy4}Cmy$CFR5q7KLm=6L_cC^n`noeoJNF~wUi=yXeqxp$yi4*ZopI20e_Ry_Q|ht zeHubxy&KtUnENQA6KT*d!aoFE=ouU*w-WEx-7HyJG{!k8xQkpt5M~19c4xd&2fHF) z)h1@|PC@H;Y4KG(>ATLcK{*2|Cp^1Eq}URodKlfK9T^T_^++}=k=&1PRr16GE3-8FVj)M9AA`0uq?@yKEH)sjdjr}sSTO%5P6P;T3 zZO+K{LywT4wY-YicRx%jR@!lYlWF8(A(94FD}ddZUnL9|E19Y-II5@{FX6~>GCympysra<>b4v8@`&^S^-T3d z#mY|b2y^GAA|_t5fsGY1=k`^lCKPi?2+$NSqb#!EuBWd^+WiXdgjByTyke;b*`OPh z(~hhvZ^f$ZK;Jm0Gjqrj(wXLz_DC>GZ3>UE`j#AVHX18)e~9)Y_VAI70^pAGA6QMP z2}+Zlr)P0Uf=_A+Qz|(PYc)cF1m$*6J_}q)Zs%1p74pp<+ai6PK ze3%RSS|0|XgFjDBS!Y-FFhfc8EF|#o_C-vKzXa0w>$1#i2Qpwb<*ZngEJ!Y&0gtQ@ z<@A5Y7!J#nKc4Ugv4IAZ;Amw`-Xwg;`5*%iC4jkGP1DNi@IK~jA|t+H&Sw}GNS=oq za#?^7k>RW~4QLHP&6+yegb?~{6}@Rc^F^HiQ(4g|f{I09bKk>W;ZdsSV+ST|$Kb7= zD-7pFis(=CE!-&aeU%ERX>bqA6sq3+2kGDS&aVU;n`%tBbY`*UeDYujxpWWd)5 z{^x1cKEA)))@cviEU;wnC`w>EZygFONLfx)TQazL7Q4G$GN>&Jx~n8U-@Vrpmx>Yu zAhWBt;e42>ifOJfdsLJP3H9Ddyp**(JVj27x6Sef`Z#7WA1|VN2A|q7j4)EqNUuBp z;(eBQwX{`}aP99>1NShl_rK@or`?q!i8adAY|T!@>z>wS{1D{)jOHxX8Rt;Q@&yqg4G8-#(+1nM$h>f1F#lr)#LvYdiP>xv5F}C_ zmpE>aJ)bJ#ReiE7i%QDpFh^(>5FV6$h;w#FqJnL>RY&KDbtrs+aHdXQ&g&K>Oj`B) zeafUO8%vhrKGM1ZVvYb65+qN_`@NsAb$?&N6~5fNrZM3y_urBn+@dnA(mq`HIW|J% zL|_tv{Ljm&1kek^Md^K`CsYLF8zhni_Lt_0XDZR+e-+S^*rfYh!sp zCDY?M*@R3eh$AFh@>(0C$_cR_`^Uqiu!Xant6aqsN91$=TLRmd3@z}|)_XZ+glOKU zo>?SBEhA`4A|P!}6RBB2F7g4L+W!9y17y&&dhDuGMK+A1%8k+LU4tJt4g!&cRZWmZ zKoa?hBlRA=5Pwm3Q0(RP3>FPouoE3 zw4PN%E_mJQ2SgX}=cB~rNV+y+Qoxi>7;*%JF~Fo{WKj1YQ>}KuoCds7(pDaOIiF$!|HZs1t4F6ps*v^iHO({l*E&06aBNE89K zUJuDe63Q=7jM)xHQKza#EX%-O$-9V*1Ic3bBuONQwC-HReR{iZF%3|eFrU5cvmYi% zF3zaKC2umh%e%QLN@9=4*JR~VT%F&g)yC_Iud<#0dg`TE zD6lLTWN1yg7dFjdiOKt9&3i)~HC;>asDQ8ua)rXIp)3Gom3V;pkirBJ)0-vNGBQ(@2jb~13=ASc=v5F%%b4PDZP=E0psIXs^ zZf2YbM{n<5!ROR;AG5m+DTC(Sdnvx^>XRBmD(wwwO?#!*;lu4PI7a8;sne+>9>?nL z42imDoVl9ft-85($X>-UKOe>E@m6s&{Yyn{WR+IlkT>axD zp}Itn)FaQor+KVNU&IcezV%^@9{l6kYi4rb5BXn&D)b;CyY> zyVmo$LNf`_a^&p>j{+gqM7P=J?{ap7z_z)mPiH-}&Tt7pLIQPg zLoWWW(TpWo-AfhLgx+Qh$$rg}8sn%K{euP?q$Dt>!Awurjtb8OFLF2va*&HX z(%qK->4wDW^b|vb2mLIPUcVf(lTkBFFe$Ek!5lD*^cU5b?ZE-b1VuUHz8kX3!;i~hX(~NU)$^yaD8w8g((GSS#AqC1w6D4uAv_D!MLV8K znpEBmp!;+(WpS`+u`@#kx&2W$+lGa#GxTtCsAx9QR>f;rXlMg62M2X%S z$pl4LQ+&KVDmq~binN}!8Jz``HpzRJXrJl>F>Jp4K!4Z}gBM~E0W|WN#mGs8qEOhm z(n8QAa@7tf0079`4_#F;(C_@tV2%RcjkspN?}>Z2YWOS?y~sKkytlc*HT30QXd{93 z4gU)t{Vk`n7KY`&Kc}wkf&fZ|Ux(p;+n>uVfles=Xh{vv%FW&xhzl)|Ycw#ZLw?kx zEgaD}fV{ES9smFV4pI{_xM0tMbD&9nB*9*0DDzE3i{peLLfD7i@e06Q@rnqKX{7Zj z^h*#FwL`$*45+wN_Y68l9s3}30(W~;z&Kl|TnambPW_He_vT%rjae2un)?j~4N7Z1 z6A?m}Z~z0Xn!m5ue9n6an>Q|jK3Y`BS;2VxUF$}K2XophS25chrMMcQc=GS7bJjyo zZTob!SmP$NYOUO+KO>?4T`3)`y=*0|jt+h*A3BN60WgWcV-f9!SflF=)%^=~|6iyr zJSThB9ekmj)I_*3R0f5ClI=+UROrgi7*B`x;&D;eGB;{Vsz~MN-J|IEwpkZhlKMR* zcEQ#QKH{IGD2FUl;@@6q33Rl(Eg*{%8l^CJK$p3*001PZrM`fOPp~;NWY9qxG3}a) z-0}sd0BRK(N#_fC)HAA>ewO@wq6|CJMl{GjM&$&%W_P`|Wp&fO1LkGzq9)O%b(Dr3 zP6`CuEgsxi#FDm(;TCJ1#X7_a7JG$bUG@tq_sO~jN}D@`D&6|}v(WmueEp%S5&2{w z4Z%_sTXN z`X3qj{QrK<2thRaNU{2zw4Pj000000A)dN@RBl#)z?L&eJidh5PUOWhA7XzoZbm|A zD|d01+2cEu_jlnEH>}4P^AqdjeDA{gH}e)jL;Tj)=sxGgEL21UxLt12R@=I{D#1@h zKD5t(hOK>3VDpeyBWC}uvS|PQ0lD75i`h~V2xZFJU8{e+XIK|D&dTmDJ19B{QJ|+i zpm3u}=trqf;kl$bu12f9hiYgjTxY2Uosc1-DZfhlJFb9u#kg0GESGcGJEM+lFZw_^ zjr~8|ax#2{XcaG=MqK(exrT(6JWC-Nm(zVO7)b)DlQ9Ce>sbGW+5*4;45G!CNmodN zNC=e2a5Z0ufzUmIrw&DSbiSeVl*Dq9;^!)YC;NCOBz#rKGP8#ehYJReqBKOT(6&u% za0@y}`0f_`+0`bsFPu4&39dYcM`x&-1)l}@mI0S79^hYD1W1y`8Us9e zC(f8g>JU{mo)zSL51es8-7l~(Ves%$Y+rmD#mTnSAw4*;h*^h9@Bjb-{)7G~Y95J{ zm95*!`FPc1wp%(q0RWAXRYlf$xap+;l)F~|Fl@;-$rfpq;-{24Rm0r>qdzHp-&xTJ z@gU|@J``%{EA8%F;zl~t$$9JZdeIr=+|y-@l2p^9$_;TakdDk2vm+a0EI^VPKS=5& z!oExp2M&e^Im%X#nYAVwk&>ysE>lv>H$R;ELLHFR!dE&l&&gTQYb=AAr_Od2=hxZuF) z?#ep-S%(?%>zbZS;!&gy7_tH*yj_-#KOj)x8LvGaKuvyk2ati={eR%>_|z*vobPv% zJXgQ~B)09NkU#lIPT-CC2(7aUl`pFjxjPlgumhUpOuLTElD>5zAOvTNxh)a=LvQ9r z_iBqd3#UD=fQyq@_g-u|#Ah=NN5ZL?%aR3{@T5%jqd@{2mn7Pv;T=A;kqRa5n$p7- zZ)DP?;785d!lTZ#^9*gC5*%A9Y=22&U@-W(dIx1Fq^tSGZ^^@q0< zL&LWvmLN!^O%G(b1H$9e6mz2I~q!KD-^Pk}-M3!3L{_Rp}=_nbrjJTOH z{f}l9*7X7*8iuPkZ^y|uF~4ilx2=IRVKA4vgC9hlDUwL+1J+^PYGX5#Wp86?9)#Mq z7r^0WV!gssc+&p$Q61t0dP25jP?G4z4yLJ}(jPGksX2Emf2-9(&Dr1um3^N>fAqef~ z!ne?2tUfZ5*p#M$|5inFv|s$>?K*X#J+9^;VCb$lDo0*n3ptT^ zI73 z33oqCa=1(bIRL6!&~+7#AIp+kI!BrlN#Q>b&+YmXC9T~kM9p|yWPrln#MMg&a}B4 zMr2wVPGg{#KT9McGgtz;8lW*T&M)7ixRz6@Os`$XkGS2(dxWAN3YW!FepIy!R>u)*3|%G6=Mbnn#Hqo=8MT^PD~I;KjgiuF^r>o(4e zzd2v1{_imn3!lFW-;w{%f<=-5r?*tEGkf`#diTcqf!jCG3gBtpEVf zO0r*Mz)>Qki+`j6L0Eh5*!G(JK9c6~36JcRJE^o=VkgM?LxT2YmXq_D)T9!>BgOBq ztI&h)h*7{#OV4)2ee(L<4@`DIl^?`kqe~|iT6zaxHlhrN&2IiZ*Nsadd8V=U2_w3$<%PHi;<{>iv%phC_LDGJtz6X6x8jZ~Z9H za-}yMbbAKhv78rL+{dJsbJQVXNd4^_vP%B_K4}^r(~q0nN$<+|Ntkm07o@6J!CHPnQeTUXET*v%I`UtD!v7`7u z_FPLjGm*Mp%ZA~9%=$&C_D=<5bGAz;qTB$fd3Agy`I38!oxMSFvrPUdZTDnAKE0>-LU(?)``^k4R6rSIe z-$a!C7Y<5B(cBk)?ZN>82F36aw!cPmvG)5z9)+Fdtui(+fW|Ay5;?ruIc~3xD8xJi zzIQ^^bTRs#1|7q4NKT!?U>kteuFI-u012!?9j7?&E1|uPzcu%xi#RzorSF2vy+g{L zXaU<>feI%GuKNjWF_R5L86-|a^HMfguDHe$FGQjAxF%hloO?1Q zQOgN!iA#mM{wc_(d2_RD_zg9i=ijoEgfDf5M}BOLTjpDPgTx9*R2a;r+8-+rZcY$y z6J|^6FK5=%)6bwHSy0`F9Q}3?Dm28`_9+A&pzN3^#3cVpOx$`nq}N; zfzBt_x6Q5 ziKP?fSUIiOJ{v@0fEWr^$OgOVxX$(C4#$#CMpo^nz{`}`S;2QCWy|rDSKC0cvdEFs z$PfOj*C$i_xE9u%kEQyaY%}6CXm=e2FTSQ?b?0Gu6uB7jM5%>j^Xd4>xB>~c1s+BB zE%LJtNf%Szm~vbo@3PMIdxJF>^~_hc*Nc9=p9ED233~2Zjij1uXtO@y3S t*SGOFNxmN2fB*mh0000000007dkk0J8OmMnO3 zTOT}SSP4)m1S05m%wJ}@v-xyLE<^C=9A6Sp5J|0h{R7+u<^T2deX?30SQkO zKy|;N9U*}TR+zx%#L2}k?Nkqy$f;oP-T$SV(P?Kv5V|PPZTLlFewgyFeKeSkpV3G& z=fWojK_qH|(!S&AMv}%KdW9!~j{Cn)lEyFkK!SkaYC1`}IVbN1k%%d}UAvHGT-tdS z1YwJydgS{{NH=TdBN~cg$P_x4cH`WO8z9kAbbp!VY|%JwRcKhE=zQG*x{-7)-VuU8 z1vk-JAkF!BM`)x3J)fsLTe}s4phd9lhemU@@Jxqr6r27^Z;5VvvKur?g1Sd(Y}NV8 z8i5I_51U1EF70gG1&UAwRUgn=p(lKDS!jd=)z>!BTQj~|LePS$GZry%wrKpg3nUl_ zj%HdnUaOE89d2~C?mPn$lAz}5W;#=z1wpuq^%u}vqqFgZ2r1T{N@MHJ=8@2V1a-GJ z(b=l=^L}Zh#fk{Je?Ut?=d7I}kr8x!lg5=ropA&b(cNQLH|R#B!_i35}`U`FK|d zvM=Eb8q0NF4T*%H@88Nx_+|%42#PLaX)NEJsom|N?oebeBKI>X%Q&_k5(mYoCG>Kf z69+;A5R`7Dv3zHGXX6d^P*hN{{8#j{oU=wi;~=<>&hmVR0Hh>1j_w(h>1@8D27=0L z*^~i|=b;n@LH)VBMz|(-ULf-JEltSq}n0(D%`1I27Amw${yP#tQEi z1t_kdRj4g@uY~iD_2W>&!Oiqo(X0Z-A6AKjV)Nf3M)&LzzFZ55fuQTxw0O}yGvg&F zA(Br=jhQ)r+Xxy5!HF!Q#&>EVaZqfwm|i@{qo9bRp#BM3;c)&mFd8Ufw9$)(@lJ)r zLNRb6EgSb}Ie*&(iiqBrku%=LH|inrPz;_y&%k5G#=W74sNke#dU-K7K3xxqOP6$RUG;!J)JwF>`wRO-j0yi2=o$~7X#yS9U+LWp#J*B zv@|hxce5mEetJ15A}r90oE|qbOAzR?dFGez%DXpFh@wLZ{ziQq~aJ5(YiB4;+yBS+^;E1)D% z62VzZ=!~B8%bg(_Aw|av8{Jp{jWds_fy71>-5)S_7D8jw!~IZ_s7MJby+(5uL^qCI z2deQ9L20)iY0kpv#_>lrKr|AnSmvSyG!{y8KC%i#Gy)=m!7tMj=Gwi@p*bg>)JqTp z^g8_)nzLxoog8=0P)M8C;qWh=<}9M_#=F}(!5U+Y?kuS8OO9^q6o=6hwt?<#k{jRY z)bv}&eQhZO#g+@{d2(;#oO8E@hLyux=xoz+@4Nxyj*$IT96!Y9$2AjA4?S2)dIrzEFbXL;z6%M6y#1m_REo zbYJR|UU?C8KmMKv?>c-HfmC4qS@gov_}ol7jpGJFb0OIGi)K0-CtukElHKt6OSu;k z9=z{NnsLJ5d+9)?7@c48#Gm$PXHFp;+rbbhg~g*E>Km+ISwD*>wBg=*7c1e@|eaaeSvHCwnlZ zyI+8873=*@PCK#hUueaH#+y;X&<}5bAcumB=&jK?XMIREYyakBH^*8t=mjL<8x2rY z)ge%6xfS5XR5ae5jiB@C2Xq8>X)YwjZ&ri~0u@2aDy>{r&M#{~GiX_H0t>10k9D9B z2?OXe^5hfu99RvJr4)yzqw|EUL{)GBy_hs_4@IDYJ%3na_UDWThgU&c*p2C(AN7D_ z0zhzcF(G+aFpAL&=p>zg-rNbu29n zo%aBe*$cEn;=BQYDmI->XN#Wjo7JEYJjEoH@r%A$3GilNxi6ff=xo)EAFcpVoNV+o z&biw{v#J{t3-{oj2}N*T8ZzON%Rn)BYC0Ov$*$_HSOo8U0gyn>P3N?;p(wpthVzvM zNG1SYFDy?ZP{k?f=$x?uN;to)nZvSssj%D{&UQ=aX=wbg2e5xxP5Wl|aACO&63Wm? zv{ZDS2W-BOo`%lbvl3iGFDR#nBB*|qNjZOPj@}>AQYHML3X;*e1%*5U8U_?689fz^ zuP%#6n4~g(yF4@##Q`j&&ZgnP2>OmIht6YhCf(CG|JV$rj5eJ|FCfP6Rt^ThJsEtv z+}5*M1`o_^`F|IZgfCP>LQxD~NKZ%OmEFeE($IJ@G@XcuiiqGATJdn&DNuweXn2KI z)|9ngE$h)*NE#8eT!qn2FCbF}K*A6aQS3CAo{q*Vj%0f0KYOMUR1ZGlz89|AwHL7B z53~aEd?^f304S-xN-M+p%`MI4j9)p8qU$ZcH<8}>-KM~e^g`iIXR;R*VQN*CK(YC3 zdOFeap&Z>fTWS~*?@owu|i==LrXXN%66 zBQSUZ6E6;Z2k?={C(h)z-DhS4B>Z-Tw1wrK%D||;| z%kI2(y-Bok%5{Fd5(I*Q6X;B7ybg-0-aAjD7XW9|?kGV70hTag0lgKv+o1=u($3SN zf~xyyOzS)gDhfQuqyQK{=?PUq7&|qlp>x(A56~<1{SZ`Pn?LAjoHK_*Atj7%rpM2D zE(C#=u*09JX#Cryi|G}i@fu*6XJ|}o{J%N~An5gV0dW2@Bpk)s)6&toXbCIq{AEoX z+Q{_Iiy;6YIIR#|4ne4bu79JYB59SP^LD&TOXd7^E$C8;6~CiJ&NzNBG#Ci3qnF_f zV8#v8=&6i1LC~V$w1gfv+DQ{W>jb#y9H%tGSmJoO@XhAf;l{ zUuh9D{=6O(AuHCNMh~rA=i+UltwC^2%-nen1YrxVqvgx^Mn_1hh9~Ip;&Tl-Sn)G@ zuAC>KgtUqQKQW1v@w)*~gs#|W4n0TCU)F)9f?~vPv?v*8?+Qim0=<=%BjZ0hLefES zz#LkHjDMa9K@J7|-lgZqc^Ar89J`nvlzUrX{PXw67_eV*PW5FJC=sB6ZLPjF!{~|3C#+Um-RPzMEt-1MU zPe^&9z?zr-eGcQ<^!Z8MAu2gitn?Wz55{MgDNh6#eC;ocZ1X2)tN@YxK(XsQdM=)- zFH0259yOJ278P^y^Fum7B{zUPl#{CI%FyOH!nlsLB&L?|Aa{|Q?&GgWWKdu5zp(yP- zp6-md8(&`$niD~vFKMAA{HPAnnxfvF>O-8gn2G#?P$NDs~UI9e0Mpy%D4u{Zv8 z7&IS>t>@DNBhP}MMNy3SiRO&I@!zXJb0O&PCM_)I!mXe!2ucS}r?C(k$Mk{b0g59N zdSD-PgDfSeyR?zUf@plS9+C$^&wtQ@GM)wn07cga%#B6S*>tWVmxA-?AvtGm)Ite; zpQSmAqjUZ~kUS`cO``{7e7*((CH0V4p7Z6-S59onfh+_S3Xe^$_DTAQ7=>HKtobw@y9j4G& zM2(L$K*ED!mqqko-suF4nnq(GbuQTfngc=Yy|iE!Y>FMGF=0V9zE}+j4}t*`=wZ-# z$Ci_5EUL~Whd^@x#ZEKnq0Ii3#^P%Hv=1Z)5S(oEAd0W^ENDLJ9tp<1=zO<4BnMEe z@(nGF!t1;&A5fga_MvgyYS26gR{ojZ9-#9=NIn|gZZ~{C2$GAcyV?mf-kb|4PG~<& z9R|%o3H!Ad=sXva1I2;uh;LSe%Y8yoHSEl~;!g6rsPKPJ2zBB8By`K9g1 zOD;clS(F1-P&gpG7XSbdZUCJDDgXfh0X~sJn@J_4BBUtwJD{)<31V*EN&)x*`hepA zww#Y)c*Z{C`=_TZh5QHPK6caV+qO@=V}0cNh5o^GwDS}Dcl%545$PBEf2cq8ANpNt z9)KQQ-pN1MfA0EP`ha=>a4-HU_?Q02@0h?3ZGR+SpGQ3Dp&!ab|O#5Kg31g+-rUo6R+qXa0eb6a2ri&;PE9 z0>Ni~d-W*J;yw2H52X~MMe#k@+xfZEJyK&p&?PcStMZP&*vT7HqF{zb+JG_H zbnYuE%P@V6kk_l^uPng$fsIc7q##n2h3(W2icNE)>`Nm2j@1N-WP9*(lF%QgFfa#C zhq(j9l<{$~Kd+`D(ulu&+o#on{K00jNq>2CxAF-BHVJo4#*HM)NKNwoR=KRgh{Cl1d^`Yn1=}48Ql1B4!Yo-q*xor*P5qmD1!PXuE z@D>|oP^ugH;9$1V`}f0%%!;CJQa2d=vhB37Jhy4ksJXm-y+v3hPP$(fL(>g+V%{1L z-!zB+2Ik2tZ7FAS)>Zk6ib29yGdU`jIja~dajKb53~Wz~IXi4IG7PQbep}AA&c$G@ z8}aN$t9$uJys^&B+_$3r7|aKiT5>s!cvB1HHa``(M49y59x%n9U@K7>fq+!WN=D;&vg)7e<^6embQ4*a8|4jK>JjZT650${I{QATtRP; z&h3Cnm=_r)t~d6#R(euNrGHk$XdY4P5Ae~;dW+}jJUID_I>`tDAf+JH)a^S|aWfgq zhxYi>JJl`3E6jV#NCb$tA2~*2oH4hWyez4L%Bb};7wGVw;_4`!@7_mRrvVaq6U4r3>?t;UI^)+Ducr2jAc{ZMx zW!tm?djOOzIF;A`>VxI@71=|=ex+P3l~5FqpbnT2=$KG<%QL{N=6pY%Pq7X{DS=6y zlB-HnW_>&l@lD?qb2>niVI+66f_nW}!8v_60-U2LN9Zb{6$VV-KUR0J-Qlg)#y41C zx(hLPY zX5@CA@Zi1#=;4n3k%|I?*b;s{Ev@bHIRXc*AtjyS>84FhaQIA21s`MfqukK@rnX?~ zrL6p=RZQpq@VzA1sE~j?R4A*TG&;{S7{1&}}K`wOHm!6|Y^X z1vJ;j)r=-*78fRASJ+SCH1kjcI@x5MWgFps6vcEXDJo2fF-|cW6gO=Ve$GlLe>fB)l{7a4wu{No)#Q++#4n>{iz#-2^FvO zdl8YQ{$n{A)JMhFd7dMbX6fJ})rB_3{O>PF90VyIp}r^pjX@v;HPff!V)I@+Wb&)3 zsBU4pRcy$2b~zWb1S4A~Z9L*E?v{6bQzm`&d) ztZHH|H8zP0goJ!0{9*M3tAST*{5Gi_dLJLv2s;yyIT6O=5g2W2>z74O#dcx1tM`Qh z(Kh-N)?}L=(WIV?PTRblNm5m^<9+A|%qAjgnl<(=-}x*@mk=&z{;^^(*6Yqse1~W3``Y~-+@e)EP}ViU3{vk%fqPe z-xD^PF>objovg}sbztIvun(@;SZJraPFa_*t}kae7kcHy@!s^)cs$#4yL1iFGVAWd z0%Zt}?Xb_+w5BxvgHmJaJbGBPMc({wIEOCF+wJcCLIzYmTZz^ULEu?nHih{YzsLVb zDu*#2Ou_F-+t%|r^!{bN&l}>(428{zMBAMJIDSx3a~zF0F;TgOz)j7qOJHXKOZIOf zywMw-03a7KgYjYW+`4@*aB{gJ{-LE=aFPxzx$AyIFnAs{27Ao)CJgubJ%@V9t&39GFDz{^@@rAWh~D1QL` zY{Z5TOlrxEtDqTplFz7?H@r9nXh|(}8w83Bg|Qt8Fa87NTP#Klxj!fcH^eDy!_prd z>0CPPd)P9mj1$)K&JE`ST})!;r_5`J>Y6F8U`cLN0vw^}r$|+9f4>;ncmMQ?s z*2d{%I2hxHCV*(198&C(d?h>P)E>i)Q}D^dOlE3~I+9*D8p2{Ef~qNvLe#q6QfAAb zZq{LUF!>6Pfkc{+a6j>JRs7Ffc?8pa8Dd)?Ak(5HrzrsoU~6TQ5h%yxhQ_kn+9O&{ zn;4Fgi=It_i6S#gH&eha`-Q0$SLf9>A)s~(@v;PLs(Ob@gg*r-fW50o(5Z)`Nv&N*Fchw6?>MY_jn1WQh3Es_97;N z2NJPO@biU=ax<~mlv(1_+ctqGY<$-O$av-WL9^(kpNk&#z4z%b^bO_+&R`!c#``C( zM2#^t>|ZGu`3@bBr8|CKoO;bwBNyu?@MsdzShHyATV5>wqm{fDWv=NMrPwH*-^Cf-6Y$@FAaF ziY@}iwfX+IIsNFPUXY#-7&-$1o8W|4=c#B5H(h8=sCkb$v0!r%K-sUlxj@~I`Slvy z`MV;R+z~W#wIYZzbR?O{i&C|fiM^lTmyUH4?jISZ174gpJaQ1Jng4N{O0-D!rU?ql z%-~z{`AkM{$?k2-eLR=|PRui0_<7MDfyOtKWsP&#gxcpP#yR9^Ou~_1PIt!nXq0>M zs!#dmg>!^khG2XV*vh2G;m9DDP}19CFmVi4@Y~H8{OE1zX#Y4shx&=O87Qus;AA{T ztN&pYjCEy{Pu$t)~wbz8gT(4fF0g=={Z!=o|32soCMfmo0D1e1rT-DE+dJZUon zu{LcHe#qJwwpd356fl)QytB+glHB8WZ|O(>Fs(T4u>eXB5^BH1MOK|89rxx*s$I5a zA*2qFC!^|i<3b}CFIj2r)+zgO07hgUV?$g5jSi#tie6G%L`e1VtpO?pr2wGsHlyi4 z{`+a(x|l{gmmOs{?l!Yn!=`{u;;DjsDmX@Th2tg^VvzYUQF6ov@FBW5n(8*q6D%KJ zOIRwd1v&X||9AEk8N0moJgijUQz+{(XE#Mc!zq2xM_M6nQxa4kK%S^5$9~$Cc?iI9 z8Fre`ua~^;%RCZUib*MR5af42F5QZ$+UX4#QE$S3-mh3m2*4Z0P84@zGaEiR_E!Sg zEX+!XKXae|(A)B_{)Y3+*E<@~tMSom4jsKUtL%RnV-#e&Q(qJP`(t#zTPCY`jdQ+G zO9Gu^iP0nT-|DJD7`zDYfes^4cREcUi_Ptwye>?>lssjcb=eSjPDdn|5gwMF4BXft z{pvRh0A{}01(h-4Qw>GkHk4;tV*JLZ#%ZGP28lDLmQCndXNa~QDT5gQEUlxJdVbFe zQnn)srr7&Mnblfp`Ev1dVqohzpfybN=uI)xb7AyyO2oOWNXA47ASHPdKmLcWFF+cG zfMVrt7&tfDPGh}QUdvbvFG+8ZKi3f8u1yzzT1{!lv(M<14zH_3X8?*~P4y9Fe3j4^ z6xAg30+dH5&A!w5=bp!LI|*v`yD^$Scv1yD>KGbJ1N|`^>uz~*qXCS1@4&;1=P#{r zMw}>evzfOp#)=!o*|%Cq3U5?wZYc!N$Hiwb_q2sbr~k1!+@e7ey9F+&dK{rIr$=!y z;dN^)uWJkyCYx+^qavi>$tmH<5=C%{m3vp56D6yXZH{Ax8H1X9PqK%!OIdo`^<7F- z0jg_4AUCRbdiL7_RK?A8`~G_RE7sSG4z@?nd`0>h*Ccn9jQr4tnLJ@-b*we{V!<78F)5~J?`DGJ+dsY))0{B|8w3Q1X$Nd)` zq$txnxA1(K6;Ff=$oJONFKC*^TKa>+C+h*3Fj3MF+W^FbZ?ktv%h&jKfu(u)bBI#S zWib#XcPC9+S7F#t zU5YV^0KvH{@xV6hY)*701LzxvKas?12%MSkw=ascV|^*Oz2#BTS-D*@aK=FwAQ9P~ zClmmIellQ<6~|Y-KEJ(EyZ#JE$K$b*Rnfk>Ae3d{qrqMqFAei#40CO=N)nED3#<&U zhZ|Nd;F&|j+y=3Jk!oZXV`>DEiNv?pDLha zNbY_Tm4#@p7#s%~!4Ir0fz%a_E181k6Z{hhF5G31f^lWdL;-?bhH z{HtZ`$J`i$dMNJBtl^ZM3TtRq?RnlJN=p>k;>#sAJ62VK}iN0v0P|>!m`0kftIn%9|D;M%&8CD4XZ6@$Wrv~ z)Rl@uj_0+ms#PY_Xz|(&F3fjFs4EFAEs!RY`uegd$1~neRzu0j(7| zc72tH9N-&cW&OdsoR0_fx4i%3o5t3;%Ju1QfpZ0SK%fhrww}dh9GTy+8*^!By7rYY z-pw75E@N@NVk?fVgXC|~uR1vA`fPd;`eXOoX>UQ#K!5&zRW`o59@ZvaZr`1f$ryJG5;4ftFN#cE-CM;wUfxjB;qct%WPi4m{AMwHu{8)OKjf!HCAg^u<XSAN`&hIvLb9zVam8n(oOA{MrQ{nY_1#%S%Ie<@sP%|zD^ZBa}? zVGW>_;`Te=Br7a9&pexpG~Vm!lU*$$4Bx>>mGiPlAPc2ei&O7MdHlGo;z{+KC|b3= zpEp<-XLA6MKZA3oZ&_jQ1n8@q;I_ZgTPK2;;mglnc%$FBU4XV#a1)lmxE5QB;YXi0 zRvVr)dm7>Fv~ji2io&|ggNed_8ASSlOUVcc7eQ{(C1gG!R52^*%CR|LT*c|LAZvSH zBv^e~nIpyM#jNDyxTm8FdEowe5LOg&e&DY2Vk6zbdm`^(fEA62CNj!E6(+GTzkd%M z1f(M7D#B_apV4(mh**fvhX*25|Df2&83WceVhdZMAzycZu;$ZtZ19MfJfOqS)q?aw zCK29E`fJAznN&d9z|jYo@Tl)LnQ;M*%;-^5D% z{sT&C9`I+8AP!!$#|Jv{(87z(7ixf??Bm&d0U?xijQV`~V-59k4)&GKg4|D+UOzTR3e>-==_W$P(#jcKx_d zzKxr}R#hc35x+R5ItWgN8!iC43`rZ>7;93Qa%9yh4#z>adfQi-o9M!%A+)14y*F^I@mvePA^x5Lf3&h}K+F>^y zV&wRM4xW3A5HPZ$V&Z?^Q|PpNE!8(z+Zilf!v}-%yq5(c`nH5Op)lM=^ewdnkk6@D z^KC1Ug>n-zCK00W2c};cI^U+D#?w6L71xs#G|B^hJ%MGy2gWbNSyt3Cl;13eWym&f z0w7vUuh9~(Y}4YjHWgVV&3Y7eIzBiz#NwoPj?X<#IbKI>I})WB6kIyv)w0O=a%hFU zK4m7=aZ2*X{#GkMrKUq-x~Z44vV!KUhYx8*2=59y$Kn0xRf81m zWDs~0$_Z`l{d!o?<_wE^(u?ejxe1=#OBBC}1EMLC+h}#|DF%tI0Gm^N+rXJ5=bwaG zv>y5$-KjNLWh^=22E6B!kG`Gp&O*wlM~@J}6rJ!?wNYL1y9=}%UDeXN){Ia>i;rJ$ zGi?`dlT|e%<|Vqg|`f$jf-5C=SI7<9oQU;Cst;$@F3@_Tb67XDr?&s;DGWT z$wi`fR$W+DzcQQg02mnMWM4HH962izVd(T5x^ zHerGTlxRMH#qqRNtvmhMOsCn^2MXTq+A4>D<4fm}s(FLb{u0S>2Ase`+x}4X*G25)b2O|7F6TDpq){XI6fIJeD-^U)m}|(@&L4qUyu>Y~FNsUTE K|8Avw?f?K~c6K2E diff --git a/flexus_simple_bots/admonster/admonster_bot.py b/flexus_simple_bots/admonster/admonster_bot.py deleted file mode 100644 index f44733cd..00000000 --- a/flexus_simple_bots/admonster/admonster_bot.py +++ /dev/null @@ -1,134 +0,0 @@ -import asyncio -import logging -import time -from typing import Dict, Any - -from pymongo import AsyncMongoClient - -from flexus_client_kit import ckit_client -from flexus_client_kit import ckit_cloudtool -from flexus_client_kit import ckit_bot_exec -from flexus_client_kit import ckit_shutdown -from flexus_client_kit import ckit_mongo -from flexus_client_kit import ckit_kanban -from flexus_client_kit import ckit_integrations_db -from flexus_client_kit.integrations import fi_mongo_store -from flexus_client_kit.integrations import fi_linkedin -from flexus_client_kit.integrations import fi_pdoc -from flexus_client_kit.integrations.facebook.fi_facebook import IntegrationFacebook, FACEBOOK_TOOL -from flexus_simple_bots.admonster import admonster_install -from flexus_simple_bots.admonster import experiment_execution -from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION - -logger = logging.getLogger("bot_admonster") - -BOT_NAME = "admonster" -BOT_VERSION = SIMPLE_BOTS_COMMON_VERSION -BOT_VERSION_INT = ckit_client.marketplace_version_as_int(BOT_VERSION) -ACCENT_COLOR = "#0077B5" - -ADMONSTER_INTEGRATIONS: list[ckit_integrations_db.IntegrationRecord] = ckit_integrations_db.static_integrations_load( - admonster_install.ADMONSTER_ROOTDIR, - allowlist=[ - "flexus_policy_document", - ], - builtin_skills=admonster_install.ADMONSTER_SKILLS, -) - -TOOLS = [ - fi_linkedin.LINKEDIN_TOOL, - FACEBOOK_TOOL, - fi_mongo_store.MONGO_STORE_TOOL, - experiment_execution.LAUNCH_EXPERIMENT_TOOL, - *[t for rec in ADMONSTER_INTEGRATIONS for t in rec.integr_tools], -] - - -async def admonster_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext) -> None: - setup = ckit_bot_exec.official_setup_mixing_procedure(admonster_install.ADMONSTER_SETUP_SCHEMA, rcx.persona.persona_setup) - - integr_objects = await ckit_integrations_db.main_loop_integrations_init(ADMONSTER_INTEGRATIONS, rcx, setup) - pdoc_integration: fi_pdoc.IntegrationPdoc = integr_objects["flexus_policy_document"] - - mongo_conn_str = await ckit_mongo.mongo_fetch_creds(fclient, rcx.persona.persona_id) - mongo = AsyncMongoClient(mongo_conn_str) - personal_mongo = mongo[rcx.persona.persona_id + "_db"]["personal_mongo"] - - linkedin_integration = fi_linkedin.IntegrationLinkedIn( - fclient=fclient, - rcx=rcx, - ad_account_id=setup.get("ad_account_id", ""), - ) - - # Facebook integration -- ad_account_id read from /company/ad-ops-config at runtime - facebook_integration = IntegrationFacebook(fclient=fclient, rcx=rcx, ad_account_id="", pdoc_integration=pdoc_integration) - - @rcx.on_tool_call(fi_linkedin.LINKEDIN_TOOL.name) - async def toolcall_linkedin(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: - if not linkedin_integration: - return "ERROR: LinkedIn integration not configured. Please set LINKEDIN_ACCESS_TOKEN in setup.\n" - return await linkedin_integration.called_by_model(toolcall, model_produced_args) - - @rcx.on_tool_call(FACEBOOK_TOOL.name) - async def toolcall_facebook(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: - if not facebook_integration: - return "ERROR: Facebook integration not configured.\n" - return await facebook_integration.called_by_model(toolcall, model_produced_args) - - @rcx.on_tool_call(fi_mongo_store.MONGO_STORE_TOOL.name) - async def toolcall_mongo_store(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: - return await fi_mongo_store.handle_mongo_store(rcx.workdir, personal_mongo, toolcall, model_produced_args) - - experiment_integration = experiment_execution.IntegrationExperimentExecution( - pdoc_integration=pdoc_integration, - fclient=fclient, - facebook_integration=facebook_integration, - ) - - @rcx.on_updated_task - async def updated_task_in_db(t: ckit_kanban.FPersonaKanbanTaskOutput): - experiment_integration.track_experiment_task(t) - - @rcx.on_tool_call(experiment_execution.LAUNCH_EXPERIMENT_TOOL.name) - async def toolcall_launch_experiment(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: - return await experiment_integration.launch_experiment(toolcall, model_produced_args) - - # Load existing tasks to track active experiments on startup - initial_tasks = await ckit_kanban.bot_get_all_tasks(fclient, rcx.persona.persona_id) - active_tasks = [t for t in initial_tasks if t.ktask_done_ts == 0] - for t in active_tasks: - experiment_integration.track_experiment_task(t) - logger.info(f"Initialized experiment tracking for {len(experiment_integration.tracked_experiments)} active experiments") - - last_experiment_check = 0 - experiment_check_interval = 3600 # hourly - - try: - while not ckit_shutdown.shutdown_event.is_set(): - await rcx.unpark_collected_events(sleep_if_no_work=10.0) - - current_time = time.time() - if current_time - last_experiment_check > experiment_check_interval: - await experiment_integration.update_active_experiments() - last_experiment_check = current_time - finally: - logger.info("%s exit" % (rcx.persona.persona_id,)) - - -def main(): - scenario_fn = ckit_bot_exec.parse_bot_args() - fclient = ckit_client.FlexusClient(ckit_client.bot_service_name(BOT_NAME, BOT_VERSION), endpoint="/v1/jailed-bot") - - asyncio.run(ckit_bot_exec.run_bots_in_this_group( - fclient, - marketable_name=BOT_NAME, - marketable_version_str=BOT_VERSION, - bot_main_loop=admonster_main_loop, - inprocess_tools=TOOLS, - scenario_fn=scenario_fn, - install_func=admonster_install.install, - )) - - -if __name__ == "__main__": - main() diff --git a/flexus_simple_bots/admonster/admonster_install.py b/flexus_simple_bots/admonster/admonster_install.py deleted file mode 100644 index 65364156..00000000 --- a/flexus_simple_bots/admonster/admonster_install.py +++ /dev/null @@ -1,113 +0,0 @@ -import asyncio -import json -import base64 -from pathlib import Path - -from flexus_client_kit import ckit_client -from flexus_client_kit import ckit_bot_install -from flexus_client_kit import ckit_cloudtool -from flexus_client_kit import ckit_skills -from flexus_simple_bots.admonster import admonster_prompts -from flexus_simple_bots import prompts_common - - -ADMONSTER_ROOTDIR = Path(__file__).parent -ADMONSTER_SKILLS = ckit_skills.static_skills_find(ADMONSTER_ROOTDIR, shared_skills_allowlist="") -ADMONSTER_SETUP_SCHEMA = json.loads((ADMONSTER_ROOTDIR / "setup_schema.json").read_text()) - -EXPERTS = [ - ("default", ckit_bot_install.FMarketplaceExpertInput( - fexp_system_prompt=admonster_prompts.admonster_prompt, - fexp_python_kernel="", - fexp_block_tools="*setup*", - fexp_allow_tools="", - fexp_inactivity_timeout=0, - fexp_description="Automated advertising execution engine that launches campaigns from Owl Strategist tactics, monitors performance hourly, and optimizes based on stop/accelerate rules.", - )), - ("setup", ckit_bot_install.FMarketplaceExpertInput( - fexp_system_prompt=admonster_prompts.admonster_setup, - fexp_python_kernel="", - fexp_block_tools="", - fexp_allow_tools="", - fexp_inactivity_timeout=0, - fexp_description="Helps users configure Facebook OAuth connections and ad account settings, plus LinkedIn advertising credentials.", - )), -] - -ADMONSTER_DESC = """ -**Job description** -AdMonster is your always-on performance marketing engine. He runs Meta campaigns end-to-end, launches A/B tests automatically, and checks results every hour so nothing underperforms for long. While your team sleeps, AdMonster is pausing losers, scaling winners, and logging what he learned. He treats every dollar as an experiment and every experiment as data. - -**How AdMonster can help you:** -- Launches and manages Meta ad campaigns from brief to live -- Designs and runs A/B tests across creatives, copy, audiences, and placements -- Monitors campaign performance hourly and flags anomalies in real time -- Automatically pauses underperforming variants and reallocates budget to winners -- Tracks experiment results and maintains a structured log of what worked and why -- Surfaces statistically significant insights so your team makes decisions on evidence, not instinct -- Generates performance reports with clear next-step recommendations -""" - - -async def install( - client: ckit_client.FlexusClient, - bot_name: str, - bot_version: str, - tools: list[ckit_cloudtool.CloudTool], -): - pic_big = base64.b64encode((ADMONSTER_ROOTDIR / "ad_monster-1024x1536.webp").read_bytes()).decode("ascii") - pic_small = base64.b64encode((ADMONSTER_ROOTDIR / "ad_monster-256x256.webp").read_bytes()).decode("ascii") - await ckit_bot_install.marketplace_upsert_dev_bot( - client, - ws_id=client.ws_id, - marketable_name=bot_name, - marketable_version=bot_version, - marketable_accent_color="#f6c459", - marketable_title1="Ad Monster", - marketable_title2="Keeps track of your campaings, automates A/B tests, gives you new ideas.", - marketable_author="Flexus", - marketable_occupation="Advertising Campaign Manager", - marketable_description=ADMONSTER_DESC, - marketable_typical_group="Marketing", - marketable_github_repo="https://github.com/smallcloudai/flexus-client-kit", - marketable_run_this="python -m flexus_simple_bots.admonster.admonster_bot", - marketable_setup_default=ADMONSTER_SETUP_SCHEMA, - marketable_featured_actions=[ - {"feat_question": "List available marketing experiments", "feat_expert": "default", "feat_depends_on_setup": []}, - {"feat_question": "Launch experiment", "feat_expert": "default", "feat_depends_on_setup": []}, - {"feat_question": "Check experiment status and metrics", "feat_expert": "default", "feat_depends_on_setup": []}, - {"feat_question": "Show me all my Facebook campaigns", "feat_expert": "default", "feat_depends_on_setup": []}, - {"feat_question": "Connect Facebook account", "feat_expert": "default", "feat_depends_on_setup": []}, - ], - marketable_intro_message="Hi! I'm Ad Monster, your automated advertising assistant. I execute marketing experiments from Owl Strategist, monitor campaigns hourly, and optimize based on stop/accelerate rules. I can launch experiments, check status, or manage individual campaigns. What would you like to do?", - marketable_preferred_model_default="grok-code-fast-1", - marketable_experts=[(name, exp.filter_tools(tools)) for name, exp in EXPERTS], - marketable_tags=["advertising", "linkedin", "facebook", "marketing", "campaigns", "experiments", "automation"], - marketable_picture_big_b64=pic_big, - marketable_picture_small_b64=pic_small, - marketable_schedule=[ - prompts_common.SCHED_TASK_SORT_10M, - prompts_common.SCHED_TODO_5M, - ], - marketable_forms=ckit_bot_install.load_form_bundles(__file__), - marketable_auth_supported=["linkedin", "facebook"], - marketable_auth_scopes={ - "linkedin": [ - "r_profile_basicinfo", - "email", - "w_member_social", - ], - "facebook": [ - "ads_management", - "ads_read", - "business_management", - "pages_manage_ads", - ], - }, - ) - - -if __name__ == "__main__": - from flexus_simple_bots.admonster import admonster_bot - client = ckit_client.FlexusClient("admonster_install") - asyncio.run(install(client, bot_name=admonster_bot.BOT_NAME, bot_version=admonster_bot.BOT_VERSION, tools=admonster_bot.TOOLS)) diff --git a/flexus_simple_bots/admonster/admonster_prompts.py b/flexus_simple_bots/admonster/admonster_prompts.py deleted file mode 100644 index cc420cc3..00000000 --- a/flexus_simple_bots/admonster/admonster_prompts.py +++ /dev/null @@ -1,252 +0,0 @@ -from flexus_client_kit.integrations import fi_pdoc - -admonster_prompt = f""" -# You are Ad Monster - -The automated advertising execution engine. You take marketing experiments from Owl Strategist and make them real — launching campaigns, monitoring performance, and optimizing automatically. - -## YOUR FIRST MESSAGE — MANDATORY PROTOCOL - -**Before writing ANYTHING to the user, you MUST call:** - -`flexus_policy_document(op="list", args={{"p": "/gtm/discovery/"}})` - -**Then explore the structure to find experiments:** -- Ideas: `/gtm/discovery/{{idea-slug}}/idea` -- Hypotheses: `/gtm/discovery/{{idea-slug}}/{{hypothesis-slug}}/hypothesis` -- Experiments: `/gtm/discovery/{{idea-slug}}/{{hypothesis-slug}}/experiments/{{exp-slug}}/` - -**For each experiment, check documents:** -- Has `tactics-campaigns` but NO `meta-runtime` → READY TO LAUNCH -- Has `meta-runtime` → read it to check `experiment_status` - -**Present results as a status table:** - -| Experiment | Status | Day | Last Action | -|------------|--------|-----|-------------| -| dental-samples/private-practice/experiments/meta-test | READY | - | Owl completed tactics | -| unicorn-horn/influencers/experiments/linkedin-b2b | ACTIVE | 5 | Budget doubled on camp_A | -| unicorn-horn/parents/experiments/tiktok-viral | PAUSED | 3 | Waiting for creatives | - -**Then ask:** "Which experiment to work on? Or should I launch a new one?" - -**DO NOT:** -- Greet with generic "how can I help" -- Ask questions before checking what experiments exist -- Skip the list call — even if you think there's nothing there - -## HARD REQUIREMENT: No Tactics = No Launch - -**You CANNOT launch experiments without tactics from Owl Strategist.** - -If `/gtm/discovery/` is empty or user asks to launch something not there: -- Tell them: "No experiments ready. Owl Strategist creates tactics first." -- Offer to show Facebook/LinkedIn account status as alternative - -**NEVER:** -- Create campaigns from verbal descriptions -- Make up campaign specs without tactics document -- Launch anything without checking tactics exist - -## Configuration: /company/ad-ops-config - -**Before ANY Facebook operation**, ad_account_id is auto-loaded from `/company/ad-ops-config`. - -If user needs to set/change ad account: -1. `facebook(op="list_ad_accounts")` — show available accounts -2. User picks one -3. Save: `flexus_policy_document(op="overwrite", args={{"p": "/company/ad-ops-config", "content": "{{\\"facebook_ad_account_id\\": \\"act_XXX\\"}}"}})` - -## After User Chooses Experiment - -**If experiment is READY TO LAUNCH:** -1. Read tactics: `flexus_policy_document(op="cat", args={{"p": "/gtm/discovery/{{experiment_id}}/tactics-campaigns"}})` -2. Show summary: campaigns, budgets, targeting -3. **ASK**: "Ready to create these campaigns on Facebook? They'll start PAUSED for your review." -4. Only after confirmation → `launch_experiment(experiment_id="...")` -5. **IMMEDIATELY AFTER launch_experiment succeeds** → open dashboard: - `flexus_policy_document(op="activate", args={{"p": "/gtm/discovery/{{experiment_id}}/meta-runtime"}})` - -**If experiment is ACTIVE or PAUSED (has meta-runtime):** -1. **Open dashboard**: `flexus_policy_document(op="activate", args={{"p": "/gtm/discovery/{{experiment_id}}/meta-runtime"}})` -2. This shows the DASHBOARD in sidebar — campaigns, metrics, controls, action log -3. Summarize: current day, spend, key metrics, recent actions -4. **ASK**: "Need to adjust anything?" - -**IMPORTANT: tactics vs meta-runtime** -- `tactics` = PLAN from Owl (what SHOULD be created) — use `cat` to read silently -- `meta-runtime` = DASHBOARD (what WAS created, live status) — use `activate` to show UI - -## CRITICAL: ASK BEFORE LAUNCHING - -**NEVER call launch_experiment() without FIRST:** -1. Showing what will be created (campaigns, budgets) -2. Asking: "Ready to proceed?" -3. WAITING for user's response - -This is NON-NEGOTIABLE. Launching creates real campaigns that cost real money. - -## Automatic Monitoring (hourly when active) - -Once campaigns are ACTIVE, I automatically: -- Fetch insights from Facebook API -- Apply stop_rules from metrics doc (e.g., CTR < 0.5% at 5000 imps → pause) -- Apply accelerate_rules (e.g., CVR >= 8% with 20+ conversions → double budget) -- Follow iteration_guide by experiment day -- Execute actions and log everything to meta-runtime -- Send notifications to your thread about actions taken - -**You don't need to ask me to monitor** — it happens automatically every hour. - -## Hand-off from Owl Strategist - -When Owl completes strategy, they say "Move to Ad Monster for execution." -User comes to you, you list experiments, show the new one as READY, and guide them to launch. - -## Communication Style - -- Speak in the language the user is communicating in -- Be direct and practical — you're an execution engine, not a consultant -- Show data, not opinions -- Do NOT show internal labels or technical jargon - -## Reference: Policy Documents - -Documents in filesystem-like structure. Use `flexus_policy_document()`: -- `op="list"` — list folder contents -- `op="cat"` — read document (silent, for your processing) -- `op="activate"` — read AND show to user in sidebar UI (for dashboards!) -- `op="overwrite"` — write document (use JSON string for content) - -**Configuration:** -- `/company/ad-ops-config` — your config: `{{"facebook_ad_account_id": "act_XXX"}}` - -**From Owl Strategist (under /gtm/discovery/{{experiment_id}}/):** -- `tactics-campaigns` — PLAN: campaigns, adsets -- `tactics-creatives` — creative briefs -- `tactics-landing` — landing page structure -- `tactics-tracking` — tracking setup -- `metrics` — rules: stop_rules, accelerate_rules - -**You create/update:** -- `meta-runtime` — DASHBOARD: Facebook IDs, live status, metrics, action log - -Naming: `experiment_id` = `{{idea-slug}}/{{hypothesis-slug}}/experiments/{{exp-slug}}` -Example: `dental-samples/private-practice/experiments/meta-ads-test` - -{fi_pdoc.HELP} - -## LinkedIn Operations - -* Use linkedin() to interact with LinkedIn Ads API -* Start with linkedin(op="status") to see all campaign groups and campaigns - -Key LinkedIn operations: -- linkedin(op="status") - Show all campaign groups and campaigns -- linkedin(op="create_campaign_group", args={{"name": "...", "total_budget": 1000.0, "currency": "USD", "status": "ACTIVE"}}) -- linkedin(op="create_campaign", args={{"campaign_group_id": "...", "name": "...", "objective": "BRAND_AWARENESS", "daily_budget": 50.0}}) -- linkedin(op="get_analytics", args={{"campaign_id": "...", "days": 30}}) - -## Facebook Ads Operations - -* Use facebook() to interact with Facebook Marketing API -* If user needs to connect Facebook: `facebook(op="connect")` — generates OAuth link -* After connecting: `facebook(op="status")` to see ad accounts and campaigns - -### CONNECTION: -- facebook(op="connect") - Generate OAuth link for user to authorize Facebook access - -### AD ACCOUNT MANAGEMENT: -- facebook(op="list_ad_accounts") - List all ad accounts -- facebook(op="get_ad_account_info", args={{"ad_account_id": "act_123"}}) - Get account details -- facebook(op="update_spending_limit", args={{"ad_account_id": "act_123", "spending_limit": 100000}}) - Update spend cap - -### CAMPAIGN MANAGEMENT: -- facebook(op="status") - Overview of campaigns (existing operation) -- facebook(op="create_campaign", ...) - Create campaign (existing operation) -- facebook(op="update_campaign", args={{"campaign_id": "123", "status": "PAUSED", "daily_budget": 7500}}) - Update campaign -- facebook(op="duplicate_campaign", args={{"campaign_id": "123", "new_name": "Copy of Campaign"}}) - Duplicate campaign -- facebook(op="archive_campaign", args={{"campaign_id": "123"}}) - Archive campaign -- facebook(op="bulk_update_campaigns", args={{"campaigns": [{{"id": "123", "status": "PAUSED"}}]}}) - Bulk update - -### AD SET MANAGEMENT: -- facebook(op="create_adset", args={{ - "campaign_id": "123", - "name": "US 25-45 Tech", - "optimization_goal": "LINK_CLICKS", - "daily_budget": 5000, - "targeting": {{ - "geo_locations": {{"countries": ["US"]}}, - "age_min": 25, - "age_max": 45 - }} -}}) - Create ad set with targeting -- facebook(op="list_adsets", args={{"campaign_id": "123"}}) - List ad sets -- facebook(op="update_adset", args={{"adset_id": "456", "status": "ACTIVE"}}) - Update ad set -- facebook(op="validate_targeting", args={{"targeting_spec": {{...}}}}) - Validate targeting before creating - -### CREATIVE & ADS: -- facebook(op="upload_image", args={{"image_url": "https://..."}}) - Upload image -- facebook(op="create_creative", args={{ - "name": "Summer Sale Creative", - "page_id": "123456", - "image_hash": "abc123", - "link": "https://example.com", - "message": "Check out our sale!", - "call_to_action_type": "SHOP_NOW" -}}) - Create creative -- facebook(op="create_ad", args={{ - "adset_id": "456", - "creative_id": "789", - "name": "Ad 1", - "status": "PAUSED" -}}) - Create ad -- facebook(op="preview_ad", args={{"ad_id": "999"}}) - Preview ad - -Valid Facebook campaign objectives: -- OUTCOME_TRAFFIC - Website visits -- OUTCOME_ENGAGEMENT - Post engagement -- OUTCOME_AWARENESS - Brand awareness -- OUTCOME_LEADS - Lead generation -- OUTCOME_SALES - Conversions/Sales -- OUTCOME_APP_PROMOTION - App installs - -Valid optimization goals for ad sets: -- LINK_CLICKS - Link clicks -- LANDING_PAGE_VIEWS - Landing page views -- IMPRESSIONS - Maximize impressions -- REACH - Maximize reach -- OFFSITE_CONVERSIONS - Conversions - -Valid call-to-action types: -- SHOP_NOW, LEARN_MORE, SIGN_UP, DOWNLOAD, WATCH_MORE, CONTACT_US, APPLY_NOW, BOOK_TRAVEL, GET_OFFER, SUBSCRIBE - -Best practices: -- Always start campaigns and ads in PAUSED status first -- Use validate_targeting before creating ad sets to check targeting validity -- Monitor insights regularly and optimize based on performance -- Use preview_ad before activating ads to verify they look correct -- Keep daily budgets conservative initially, scale based on results -- Group related campaigns together -- Always pause campaigns before making major changes - -Budget notes: -- All budgets in cents (e.g., 5000 = $50.00) -- Minimum daily budget typically $1.00 (100 cents) - -""" - -admonster_setup = admonster_prompt + """ -This is a setup thread. Help the user configure LinkedIn and Facebook advertising. - -**Facebook Setup:** -1. Call `facebook(op="connect")` to generate OAuth link -2. User clicks link, authorizes in Facebook -3. User returns here, call `facebook(op="list_ad_accounts")` to see available accounts -4. Save chosen ad account to /company/ad-ops-config: - `flexus_policy_document(op="overwrite", args={"p": "/company/ad-ops-config", "content": {"facebook_ad_account_id": "act_..."}})` - -**LinkedIn Setup:** -1. LINKEDIN_ACCESS_TOKEN - Obtained via OAuth flow -2. LINKEDIN_AD_ACCOUNT_ID - Your LinkedIn Ads account ID (in bot Settings) -""" diff --git a/flexus_simple_bots/admonster/admonster_s1.yaml b/flexus_simple_bots/admonster/admonster_s1.yaml deleted file mode 100644 index 0305854f..00000000 --- a/flexus_simple_bots/admonster/admonster_s1.yaml +++ /dev/null @@ -1,211 +0,0 @@ -messages: -- content: list me campaigns you have - role: user -- role: assistant - tool_calls: - - function: - arguments: '{"op":"status"}' - name: linkedin - id: call_88887498 - type: function -- call_id: call_88887498 - content: |- - LinkedIn Ads Account: 513489554 - Campaign Groups (2): - 📁 Q1 2025 Campaigns (ID: 789456123, Status: ACTIVE) - Budget: 5000.0 USD - 📊 Brand Awareness Jan (ID: 654321987, Status: ACTIVE) - Objective: BRAND_AWARENESS, Daily Budget: 100.0 USD - 📊 Product Launch Campaign (ID: 456789123, Status: PAUSED) - Objective: WEBSITE_VISITS, Daily Budget: 150.0 USD - 📁 Q4 2024 Campaigns (ID: 321654987, Status: ARCHIVED) - Budget: 3000.0 USD - (no campaigns) - role: tool -- content: |- - Here are the current campaigns in your LinkedIn Ads account (ID: 513489554): - - ### Campaign Groups: - 1. **Q1 2025 Campaigns** (ID: 789456123, Status: ACTIVE, Total Budget: $5000.00 USD) - - **Brand Awareness Jan** (ID: 654321987, Status: ACTIVE, Objective: BRAND_AWARENESS, Daily Budget: $100.00 USD) - - **Product Launch Campaign** (ID: 456789123, Status: PAUSED, Objective: WEBSITE_VISITS, Daily Budget: $150.00 USD) - - 2. **Q4 2024 Campaigns** (ID: 321654987, Status: ARCHIVED, Total Budget: $3000.00 USD) - - No active campaigns in this group. - - If you'd like to view analytics for a specific campaign, create a new one, or make changes (e.g., activate/pause), just let me know! - role: assistant -- content: create a new campaign about brand awareness for november, with budget 2k - role: user -- role: assistant - tool_calls: - - function: - arguments: '{"op":"help"}' - name: linkedin - id: call_79654718 - type: function -- call_id: call_79654718 - content: |2 - - Help: - - linkedin(op="status") - Shows current LinkedIn Ads account status, lists all campaign groups with their campaigns. - - linkedin(op="list_campaign_groups") - Lists all campaign groups for the ad account. - - linkedin(op="list_campaigns", args={"campaign_group_id": "123456", "status": "ACTIVE"}) - Lists campaigns. Optional filters: campaign_group_id, status (ACTIVE, PAUSED, ARCHIVED, etc). - - linkedin(op="create_campaign_group", args={ - "name": "Q1 2024 Campaigns", - "total_budget": 1000.0, - "currency": "USD", - "status": "ACTIVE" - }) - Creates a new campaign group with specified budget. - - linkedin(op="create_campaign", args={ - "campaign_group_id": "123456", - "name": "Brand Awareness Campaign", - "objective": "BRAND_AWARENESS", - "daily_budget": 50.0, - "currency": "USD", - "status": "PAUSED" - }) - Creates a campaign in a campaign group. - Valid objectives: BRAND_AWARENESS, WEBSITE_VISITS, ENGAGEMENT, VIDEO_VIEWS, LEAD_GENERATION, WEBSITE_CONVERSIONS, JOB_APPLICANTS - - linkedin(op="get_campaign", args={"campaign_id": "123456"}) - Gets details for a specific campaign. - - linkedin(op="update_campaign", args={"campaign_id": "123456", "status": "ACTIVE", "daily_budget": 100.0}) - Updates campaign settings. Optional: status, daily_budget, name. - - linkedin(op="get_analytics", args={"campaign_id": "123456", "days": 30}) - Gets analytics for a campaign. Default: last 30 days. - role: tool -- role: assistant - tool_calls: - - function: - arguments: '{"op":"create_campaign_group","args":{"name":"November 2025 Campaigns","total_budget":2000.0,"currency":"USD","status":"ACTIVE"}}' - name: linkedin - id: call_47135142 - type: function -- call_id: call_47135142 - content: '✅ Campaign group created: November 2025 Campaigns (ID: 555666777)' - role: tool -- role: assistant - tool_calls: - - function: - arguments: '{"op":"create_campaign","args":{"campaign_group_id":"555666777","name":"Brand Awareness - November","objective":"BRAND_AWARENESS","daily_budget":66.67,"currency":"USD","status":"PAUSED"}}' - name: linkedin - id: call_37043293 - type: function -- call_id: call_37043293 - content: |- - ✅ Campaign created: Brand Awareness November (ID: 112233445) - Status: PAUSED, Objective: BRAND_AWARENESS - Daily Budget: 66.67 USD - role: tool -- content: |- - I've successfully created the new campaign as requested. - - ### New Campaign Details: - - **Campaign Group**: November 2025 Campaigns (ID: 555666777, Status: ACTIVE, Total Budget: $2000.00 USD) - - **Campaign**: Brand Awareness November (ID: 112233445, Status: PAUSED, Objective: BRAND_AWARENESS, Daily Budget: $66.67 USD) - - The campaign is set to PAUSED for now, as per best practices. This gives you time to review the setup before activating it. If you'd like me to activate it, adjust the budget, or add targeting details, just let me know! - role: assistant -- content: now list campaigns - role: user -- role: assistant - tool_calls: - - function: - arguments: '{"op":"status"}' - name: linkedin - id: call_78448824 - type: function -- call_id: call_78448824 - content: |- - LinkedIn Ads Account: 513489554 - Campaign Groups (3): - 📁 Q1 2025 Campaigns (ID: 789456123, Status: ACTIVE) - Budget: 5000.0 USD - 📊 Brand Awareness Jan (ID: 654321987, Status: ACTIVE) - Objective: BRAND_AWARENESS, Daily Budget: 100.0 USD - 📊 Product Launch Campaign (ID: 456789123, Status: PAUSED) - Objective: WEBSITE_VISITS, Daily Budget: 150.0 USD - 📁 Q4 2024 Campaigns (ID: 321654987, Status: ARCHIVED) - Budget: 3000.0 USD - (no campaigns) - 📁 November 2025 Campaigns (ID: 555666777, Status: ACTIVE) - Budget: 2000.0 USD - 📊 Brand Awareness November (ID: 112233445, Status: PAUSED) - Objective: BRAND_AWARENESS, Daily Budget: 66.67 USD - role: tool -- content: |- - Here are the current campaigns in your LinkedIn Ads account (ID: 513489554): - - ### Campaign Groups: - 1. **Q1 2025 Campaigns** (ID: 789456123, Status: ACTIVE, Total Budget: $5000.00 USD) - - **Brand Awareness Jan** (ID: 654321987, Status: ACTIVE, Objective: BRAND_AWARENESS, Daily Budget: $100.00 USD) - - **Product Launch Campaign** (ID: 456789123, Status: PAUSED, Objective: WEBSITE_VISITS, Daily Budget: $150.00 USD) - - 2. **Q4 2024 Campaigns** (ID: 321654987, Status: ARCHIVED, Total Budget: $3000.00 USD) - - No active campaigns in this group. - - 3. **November 2025 Campaigns** (ID: 555666777, Status: ACTIVE, Total Budget: $2000.00 USD) - - **Brand Awareness November** (ID: 112233445, Status: PAUSED, Objective: BRAND_AWARENESS, Daily Budget: $66.67 USD) - - If you need more details on any campaign or want to make changes, just say the word! - role: assistant -- content: check my facebook ads status - role: user -- role: assistant - tool_calls: - - function: - arguments: '{"op":"status"}' - name: facebook - id: call_fb_status - type: function -- call_id: call_fb_status - content: |- - Facebook Ads Account: act_MOCK_TEST_000 - Active Campaigns (1): - 📊 Summer Sale 2025 (ID: 111222333) - Status: ACTIVE, Objective: OUTCOME_TRAFFIC, Daily Budget: $50.00 - role: tool -- content: |- - Here is your Facebook Ads status: - - **Account**: act_MOCK_TEST_000 - - **Active Campaigns**: - - **Summer Sale 2025** (ID: 111222333) - - Status: ACTIVE - - Objective: OUTCOME_TRAFFIC - - Daily Budget: $50.00 - role: assistant -- content: create a new facebook campaign for Winter Sale, traffic objective, $100 daily budget - role: user -- role: assistant - tool_calls: - - function: - arguments: '{"op":"create_campaign","args":{"name":"Winter Sale 2025","objective":"OUTCOME_TRAFFIC","daily_budget":10000,"status":"PAUSED"}}' - name: facebook - id: call_fb_create - type: function -- call_id: call_fb_create - content: |- - ✅ Campaign created: Winter Sale 2025 (ID: 444555666) - Status: PAUSED, Objective: OUTCOME_TRAFFIC - role: tool -- content: |- - I've created the Facebook campaign: - - **Winter Sale 2025** (ID: 444555666) - - Status: PAUSED - - Objective: OUTCOME_TRAFFIC - - Daily Budget: $100.00 - - It is currently PAUSED. Let me know if you want to activate it or add ad sets! - role: assistant -persona_marketable_name: admonster diff --git a/flexus_simple_bots/admonster/experiment_execution.py b/flexus_simple_bots/admonster/experiment_execution.py deleted file mode 100644 index 14852d85..00000000 --- a/flexus_simple_bots/admonster/experiment_execution.py +++ /dev/null @@ -1,682 +0,0 @@ -import json -import logging -import time -from dataclasses import dataclass, field -from typing import Dict, Any, List, Optional - -from flexus_client_kit import ckit_cloudtool - -from flexus_client_kit import ckit_ask_model -from flexus_client_kit import ckit_client -from flexus_client_kit import ckit_kanban -from flexus_client_kit.integrations import fi_pdoc -from flexus_client_kit.integrations.facebook.fi_facebook import IntegrationFacebook -from flexus_client_kit.integrations.facebook import campaigns as fb_campaigns -from flexus_client_kit.integrations.facebook import adsets as fb_adsets - - -logger = logging.getLogger("experiment_execution") - - -LAUNCH_EXPERIMENT_TOOL = ckit_cloudtool.CloudTool( - strict=False, - name="launch_experiment", - description="Launch Meta campaigns from Owl Strategist tactics document. Creates campaigns in PAUSED status for review.", - parameters={ - "type": "object", - "properties": { - "experiment_id": { - "type": "string", - "description": "Experiment ID from tactics path, e.g. hyp001-meta-ads-test", - "order": 1 - }, - "activate_immediately": { - "type": "boolean", - "description": "If true, activate campaigns immediately instead of PAUSED (default: false)", - "order": 2 - }, - }, - "required": ["experiment_id"] - }, -) - -LAUNCH_EXPERIMENT_HELP = """ -launch_experiment(experiment_id="dental-samples/private-practice/experiments/meta-ads-test") - Read tactics-campaigns from /gtm/discovery/{experiment_id}/tactics-campaigns - Create Facebook campaigns, adsets based on tactics_campaigns.campaigns - Save runtime state to /gtm/discovery/{experiment_id}/meta-runtime - Returns summary of created campaigns - -launch_experiment(experiment_id="dental-samples/private-practice/experiments/meta-ads-test", activate_immediately=true) - Same as above but starts campaigns in ACTIVE status -""" - - -@dataclass -class ExperimentTracking: - """In-memory tracking for active experiment.""" - experiment_id: str - task_id: str - thread_id: str - start_ts: float - facebook_campaign_ids: List[str] = field(default_factory=list) - facebook_adset_ids: List[str] = field(default_factory=list) - - -class IntegrationExperimentExecution: - """ - Handles marketing experiment execution lifecycle: - - Launching campaigns from Owl Strategist tactics - - Hourly monitoring and optimization based on metrics rules - - Notifying user about actions taken - """ - - def __init__( - self, - pdoc_integration: fi_pdoc.IntegrationPdoc, - fclient: ckit_client.FlexusClient, - facebook_integration: Optional[IntegrationFacebook], - ): - self.pdoc_integration = pdoc_integration - self.fclient = fclient - self.facebook_integration = facebook_integration - # experiment_id -> ExperimentTracking - self.tracked_experiments: Dict[str, ExperimentTracking] = {} - - def track_experiment_task(self, task: ckit_kanban.FPersonaKanbanTaskOutput) -> None: - """ - Called from on_updated_task to track experiments. - Extracts experiment_id from ktask_details and adds to tracking. - """ - if not task.ktask_details: - return - details = task.ktask_details if isinstance(task.ktask_details, dict) else json.loads(task.ktask_details or "{}") - experiment_id = details.get("experiment_id") - if not experiment_id: - return - # Only track active tasks (not done) - if task.ktask_done_ts != 0: - if experiment_id in self.tracked_experiments: - del self.tracked_experiments[experiment_id] - logger.info(f"Stopped tracking experiment {experiment_id} (task done)") - return - if experiment_id not in self.tracked_experiments: - self.tracked_experiments[experiment_id] = ExperimentTracking( - experiment_id=experiment_id, - task_id=task.ktask_id, - thread_id=task.ktask_inprogress_ft_id or "", - start_ts=details.get("start_ts", time.time()), - facebook_campaign_ids=details.get("facebook_campaign_ids", []), - facebook_adset_ids=details.get("facebook_adset_ids", []), - ) - logger.info(f"Tracking experiment {experiment_id} for task {task.ktask_id}") - - async def launch_experiment( - self, - toolcall: ckit_cloudtool.FCloudtoolCall, - args: Dict[str, Any], - ) -> str: - """ - Launch Meta campaigns from tactics document. - Creates campaigns and adsets in Facebook, saves runtime state to pdoc. - """ - experiment_id = args.get("experiment_id", "") - activate_immediately = args.get("activate_immediately", False) - if not experiment_id: - return f"ERROR: experiment_id required.\n\n{LAUNCH_EXPERIMENT_HELP}" - if not self.facebook_integration: - return "ERROR: Facebook integration not available." - # Read ad_account_id from policy document - try: - config_doc = await self.pdoc_integration.pdoc_cat("/company/ad-ops-config", fcall_untrusted_key=toolcall.fcall_untrusted_key) - ad_account_id = config_doc.pdoc_content.get("facebook_ad_account_id", "") - except Exception as e: - return f"ERROR: Could not read /company/ad-ops-config: {e}" - if not ad_account_id: - return "ERROR: facebook_ad_account_id not set in /company/ad-ops-config" - self.facebook_integration.client.ad_account_id = ad_account_id - - # 1. Read tactics-campaigns document (new format: 4 separate docs) - tactics_path = f"/gtm/discovery/{experiment_id}/tactics-campaigns" - try: - tactics_doc = await self.pdoc_integration.pdoc_cat(tactics_path, fuser_id) - tactics_raw = tactics_doc.pdoc_content - # Extract from wrapper: {"tactics_campaigns": {"meta": {...}, "campaigns": [...]}} - tactics = tactics_raw.get("tactics_campaigns", tactics_raw) if isinstance(tactics_raw, dict) else {} - except Exception as e: - return f"ERROR: Could not read tactics at {tactics_path}: {e}" - if not tactics: - return f"ERROR: Empty tactics document at {tactics_path}" - - # 2. Extract campaigns from tactics (only Meta channel) - # Note: metrics doc is loaded during monitoring (_check_single_experiment), not at launch - campaigns_spec = tactics.get("campaigns", []) - meta_campaigns = [c for c in campaigns_spec if c.get("channel") == "meta"] - if not meta_campaigns: - return f"ERROR: No Meta campaigns found in tactics. Available channels: {set(c.get('channel') for c in campaigns_spec)}" - - # 4. Create Facebook campaigns and adsets - ad_account_id = self.facebook_integration.client.ad_account_id - if not ad_account_id: - return "ERROR: Facebook ad_account_id not configured in setup" - created_campaigns = [] - created_adsets = [] - errors = [] - initial_status = "ACTIVE" if activate_immediately else "PAUSED" - for camp_spec in meta_campaigns: - campaign_id_local = camp_spec.get("campaign_id", "unknown") - campaign_name = f"{experiment_id}_{campaign_id_local}" - daily_budget_dollars = camp_spec.get("daily_budget", 10) - # Convert dollars to cents for Facebook API - daily_budget_cents = int(daily_budget_dollars * 100) - objective = self._map_objective(camp_spec.get("objective", "traffic")) - - # Create campaign - try: - campaign_result = await fb_campaigns.create_campaign( - client=self.facebook_integration.client, - ad_account_id=ad_account_id, - name=campaign_name, - objective=objective, - status=initial_status, - daily_budget=daily_budget_cents, - ) - # Parse campaign ID from result - fb_campaign_id = self._extract_id_from_result(campaign_result, "Campaign") - if fb_campaign_id: - created_campaigns.append({ - "local_id": campaign_id_local, - "facebook_id": fb_campaign_id, - "name": campaign_name, - "daily_budget": daily_budget_cents, - "status": initial_status, - }) - else: - errors.append(f"Campaign {campaign_id_local}: {campaign_result}") - continue - except Exception as e: - errors.append(f"Campaign {campaign_id_local}: {e}") - continue - - # Create adsets for this campaign - for adset_spec in camp_spec.get("adsets", []): - adset_id_local = adset_spec.get("adset_id", "unknown") - adset_name = f"{experiment_id}_{adset_id_local}" - audience = adset_spec.get("audience", {}) - targeting = self._build_targeting(audience) - optimization_goal = self._map_optimization_goal(adset_spec.get("optimization_goal", "landing_page_views")) - try: - adset_result = await fb_adsets.create_adset( - client=self.facebook_integration.client, - ad_account_id=ad_account_id, - campaign_id=fb_campaign_id, - name=adset_name, - targeting=targeting, - optimization_goal=optimization_goal, - status=initial_status, - ) - fb_adset_id = self._extract_id_from_result(adset_result, "Ad Set") - if fb_adset_id: - created_adsets.append({ - "local_id": adset_id_local, - "facebook_id": fb_adset_id, - "campaign_local_id": campaign_id_local, - "name": adset_name, - "status": initial_status, - }) - else: - errors.append(f"Adset {adset_id_local}: {adset_result}") - except Exception as e: - errors.append(f"Adset {adset_id_local}: {e}") - - # 5. Save runtime state to pdoc - # Wrapped in meta-runtime key with meta object for microfrontend form detection - runtime_path = f"/gtm/discovery/{experiment_id}/meta-runtime" - runtime_inner = { - "meta": { - "experiment_id": experiment_id, - "created_at": time.strftime("%Y-%m-%d %H:%M:%S"), - "microfrontend": "admonster", - }, - "experiment_status": "active" if activate_immediately else "paused", - "start_ts": time.time(), - "current_day": 0, - "campaigns": {c["local_id"]: c for c in created_campaigns}, - "adsets": {a["local_id"]: a for a in created_adsets}, - "actions_log": [{ - "ts": time.time(), - "action": "launch", - "type": "launch", - "details": f"Created {len(created_campaigns)} campaigns, {len(created_adsets)} adsets", - }], - "last_check_ts": time.time(), - "metrics_history": [], - } - runtime_doc = {"meta_runtime": runtime_inner} - try: - await self.pdoc_integration.pdoc_overwrite(runtime_path, json.dumps(runtime_doc, indent=2), fuser_id) - except Exception as e: - errors.append(f"Failed to save runtime: {e}") - - # 6. Update task details with experiment tracking info - if toolcall.fcall_ft_id: - try: - tasks = await ckit_kanban.get_tasks_by_thread(self.fclient, toolcall.fcall_ft_id) - for task in tasks: - task_details = task.ktask_details if isinstance(task.ktask_details, dict) else json.loads(task.ktask_details or "{}") - task_details["experiment_id"] = experiment_id - task_details["start_ts"] = runtime_inner["start_ts"] - task_details["facebook_campaign_ids"] = [c["facebook_id"] for c in created_campaigns] - task_details["facebook_adset_ids"] = [a["facebook_id"] for a in created_adsets] - await ckit_kanban.bot_kanban_update_details(self.fclient, task.ktask_id, task_details) - logger.info(f"Updated task {task.ktask_id} with experiment {experiment_id}") - except Exception as e: - logger.warning(f"Failed to update task details: {e}") - - # 7. Format result - result = f"Experiment {experiment_id} launched!\n\n" - result += f"Runtime state: {runtime_path}\n" - result += f"Status: {initial_status}\n\n" - if created_campaigns: - result += f"Created {len(created_campaigns)} campaign(s):\n" - for c in created_campaigns: - result += f" - {c['name']} (FB ID: {c['facebook_id']}, ${c['daily_budget']/100:.2f}/day)\n" - if created_adsets: - result += f"\nCreated {len(created_adsets)} adset(s):\n" - for a in created_adsets: - result += f" - {a['name']} (FB ID: {a['facebook_id']})\n" - if errors: - result += f"\nErrors ({len(errors)}):\n" - for e in errors: - result += f" - {e}\n" - if not activate_immediately: - result += "\nCampaigns are PAUSED. Review and activate with:\n" - result += 'facebook(op="update_campaign", args={"campaign_id": "...", "status": "ACTIVE"})' - return result - - async def update_active_experiments(self) -> None: - """ - Called hourly from main loop. - For each tracked experiment: - - Fetches metrics from Facebook - - Applies stop/accelerate rules from metrics doc - - Executes actions (pause/unpause/budget changes) - - Updates runtime doc and notifies user - """ - if not self.tracked_experiments: - return - if not self.facebook_integration: - logger.warning("Cannot update experiments: Facebook integration not configured") - return - for experiment_id, tracking in list(self.tracked_experiments.items()): - try: - await self._check_single_experiment(experiment_id, tracking) - except Exception as e: - logger.error(f"Error checking experiment {experiment_id}: {e}", exc_info=e) - - async def _check_single_experiment(self, experiment_id: str, tracking: ExperimentTracking) -> None: - """Check and optimize a single experiment.""" - # Use bot's fuser_id for pdoc access - fuser_id = self.pdoc_integration.rcx.persona.persona_id - - # 1. Load runtime doc (wrapped in meta-runtime key) - runtime_path = f"/gtm/discovery/{experiment_id}/meta-runtime" - try: - runtime_doc = await self.pdoc_integration.pdoc_cat(runtime_path, fuser_id) - raw_content = runtime_doc.pdoc_content - # Extract inner runtime from meta-runtime wrapper - runtime = raw_content.get("meta_runtime", raw_content) if isinstance(raw_content, dict) else {} - except Exception as e: - logger.warning(f"Could not load runtime for {experiment_id}: {e}") - return - if not runtime or runtime.get("experiment_status") == "completed": - return - - # 2. Load metrics doc (for rules) - metrics_path = f"/gtm/discovery/{experiment_id}/metrics" - metrics = None - try: - metrics_doc = await self.pdoc_integration.pdoc_cat(metrics_path, fuser_id) - metrics = metrics_doc.pdoc_content - except Exception: - pass - - # 3. Load tactics-tracking doc (for iteration_guide) - tactics_tracking_path = f"/gtm/discovery/{experiment_id}/tactics-tracking" - tactics_tracking = None - try: - tactics_doc = await self.pdoc_integration.pdoc_cat(tactics_tracking_path, fuser_id) - tactics_raw = tactics_doc.pdoc_content - # Extract from wrapper: {"tactics_tracking": {"meta": {...}, "iteration_guide": {...}}} - tactics_tracking = tactics_raw.get("tactics_tracking", tactics_raw) if isinstance(tactics_raw, dict) else {} - except Exception: - pass - - # 4. Calculate current day - start_ts = runtime.get("start_ts", time.time()) - current_day = int((time.time() - start_ts) / 86400) + 1 - runtime["current_day"] = current_day - - # 5. Fetch insights for each campaign - actions_taken = [] - metrics_summary = {} - campaigns = runtime.get("campaigns", {}) - for local_id, camp_info in campaigns.items(): - fb_id = camp_info.get("facebook_id") - if not fb_id: - continue - # Get insights from Facebook - try: - insights_result = await fb_campaigns.get_insights( - self.facebook_integration.client, - fb_id, - days=current_day, - ) - camp_metrics = self._parse_insights(insights_result) - metrics_summary[local_id] = camp_metrics - camp_info["latest_metrics"] = camp_metrics - except Exception as e: - logger.warning(f"Could not get insights for campaign {fb_id}: {e}") - continue - - # 6. Apply rules - if metrics: - rule_actions = await self._apply_rules( - camp_info, - camp_metrics, - metrics.get("stop_rules", []), - metrics.get("accelerate_rules", []), - current_day, - ) - actions_taken.extend(rule_actions) - - # 7. Apply iteration_guide rules (from tactics-tracking doc) - if tactics_tracking: - iteration_guide = tactics_tracking.get("iteration_guide", {}) - day_key = self._get_day_key(current_day) - guide_text = iteration_guide.get(day_key, "") - if guide_text: - # Log that we're in this phase (actual parsing of guide text for actions could be added) - actions_taken.append({ - "type": "iteration_phase", - "day_key": day_key, - "guide": guide_text[:100] + "..." if len(guide_text) > 100 else guide_text, - }) - - # 8. Update runtime doc - runtime["last_check_ts"] = time.time() - if actions_taken: - for action in actions_taken: - action["ts"] = time.time() - runtime.setdefault("actions_log", []).extend(actions_taken) - # Add to metrics history - runtime.setdefault("metrics_history", []).append({ - "day": current_day, - "ts": time.time(), - "metrics": metrics_summary, - }) - # Keep only last 30 days of history - if len(runtime["metrics_history"]) > 30: - runtime["metrics_history"] = runtime["metrics_history"][-30:] - # Wrap in meta-runtime for microfrontend compatibility - runtime_wrapped = {"meta_runtime": runtime} - try: - await self.pdoc_integration.pdoc_overwrite(runtime_path, json.dumps(runtime_wrapped, indent=2), fuser_id) - except Exception as e: - logger.warning(f"Failed to update runtime for {experiment_id}: {e}") - - # 9. Notify user in thread - if tracking.thread_id and (actions_taken or current_day % 7 == 0): - await self._notify_user(tracking, current_day, metrics_summary, actions_taken) - - async def _apply_rules( - self, - camp_info: Dict[str, Any], - camp_metrics: Dict[str, float], - stop_rules: List[Dict[str, Any]], - accelerate_rules: List[Dict[str, Any]], - current_day: int, - ) -> List[Dict[str, Any]]: - """Apply stop and accelerate rules, execute actions, return list of actions taken.""" - actions = [] - fb_campaign_id = camp_info.get("facebook_id") - if not fb_campaign_id: - return actions - - # Apply stop rules - for rule in stop_rules: - metric_name = rule.get("metric", "") - operator = rule.get("operator", "<") - threshold = rule.get("threshold", 0) - min_events = rule.get("min_events", 0) - action_name = rule.get("action", "pause") - metric_value = camp_metrics.get(metric_name, 0) - # Check min_events (use impressions as proxy for events) - events = camp_metrics.get("impressions", 0) - if events < min_events: - continue - triggered = False - if operator == "<" and metric_value < threshold: - triggered = True - elif operator == ">" and metric_value > threshold: - triggered = True - elif operator == "<=" and metric_value <= threshold: - triggered = True - elif operator == ">=" and metric_value >= threshold: - triggered = True - if triggered: - # Execute pause action - if "pause" in action_name.lower() and camp_info.get("status") != "PAUSED": - try: - await fb_campaigns.update_campaign( - self.facebook_integration.client, - fb_campaign_id, - status="PAUSED", - ) - camp_info["status"] = "PAUSED" - actions.append({ - "type": "stop_rule", - "action": action_name, - "campaign": camp_info.get("local_id", fb_campaign_id), - "reason": f"{metric_name} {operator} {threshold} (value: {metric_value:.4f})", - }) - logger.info(f"Paused campaign {fb_campaign_id} due to {metric_name} {operator} {threshold}") - except Exception as e: - logger.warning(f"Failed to pause campaign {fb_campaign_id}: {e}") - - # Apply accelerate rules - for rule in accelerate_rules: - metric_name = rule.get("metric", "") - operator = rule.get("operator", ">=") - threshold = rule.get("threshold", 0) - min_conversions = rule.get("min_conversions", 0) - action_name = rule.get("action", "double_budget") - metric_value = camp_metrics.get(metric_name, 0) - # For accelerate, check conversions (approximate with clicks for now) - conversions = camp_metrics.get("clicks", 0) - if conversions < min_conversions: - continue - triggered = False - if operator == ">=" and metric_value >= threshold: - triggered = True - elif operator == ">" and metric_value > threshold: - triggered = True - if triggered and "double" in action_name.lower(): - # Double the budget - current_budget = camp_info.get("daily_budget", 0) - if current_budget > 0: - new_budget = current_budget * 2 - try: - await fb_campaigns.update_campaign( - self.facebook_integration.client, - fb_campaign_id, - daily_budget=new_budget, - ) - camp_info["daily_budget"] = new_budget - actions.append({ - "type": "accelerate_rule", - "action": action_name, - "campaign": camp_info.get("local_id", fb_campaign_id), - "reason": f"{metric_name} {operator} {threshold} (value: {metric_value:.4f})", - "budget_change": f"${current_budget/100:.2f} -> ${new_budget/100:.2f}", - }) - logger.info(f"Doubled budget for {fb_campaign_id}: ${current_budget/100:.2f} -> ${new_budget/100:.2f}") - except Exception as e: - logger.warning(f"Failed to update budget for {fb_campaign_id}: {e}") - return actions - - async def _notify_user( - self, - tracking: ExperimentTracking, - current_day: int, - metrics_summary: Dict[str, Dict[str, float]], - actions: List[Dict[str, Any]], - ) -> None: - """Send notification to the task thread about experiment status.""" - message = f"Experiment {tracking.experiment_id} - Day {current_day} check\n\n" - # Metrics summary - if metrics_summary: - message += "Metrics:\n" - for camp_id, m in metrics_summary.items(): - message += f" {camp_id}: {m.get('impressions', 0):.0f} imps, {m.get('clicks', 0):.0f} clicks" - if m.get("ctr"): - message += f", CTR {m['ctr']:.2f}%" - if m.get("spend"): - message += f", ${m['spend']:.2f} spent" - message += "\n" - # Actions taken - if actions: - message += "\nActions taken:\n" - for a in actions: - if a["type"] == "stop_rule": - message += f" PAUSED {a['campaign']}: {a['reason']}\n" - elif a["type"] == "accelerate_rule": - message += f" ACCELERATED {a['campaign']}: {a.get('budget_change', a['reason'])}\n" - elif a["type"] == "iteration_phase": - message += f" Phase {a['day_key']}: {a['guide']}\n" - else: - message += "\nNo actions needed.\n" - try: - http = await self.fclient.use_http() - await ckit_ask_model.thread_add_user_message( - http=http, - ft_id=tracking.thread_id, - content=message, - who_is_asking="experiment_monitor", - ftm_alt=100, - ) - logger.info(f"Sent notification for experiment {tracking.experiment_id}") - except Exception as e: - logger.warning(f"Failed to send notification for {tracking.experiment_id}: {e}") - - def _map_objective(self, tactics_objective: str) -> str: - """Map tactics objective to Facebook objective.""" - mapping = { - "traffic": "OUTCOME_TRAFFIC", - "engagement": "OUTCOME_ENGAGEMENT", - "awareness": "OUTCOME_AWARENESS", - "leads": "OUTCOME_LEADS", - "sales": "OUTCOME_SALES", - "conversions": "OUTCOME_SALES", - "app_promotion": "OUTCOME_APP_PROMOTION", - } - return mapping.get(tactics_objective.lower(), "OUTCOME_TRAFFIC") - - def _map_optimization_goal(self, tactics_goal: str) -> str: - """Map tactics optimization goal to Facebook optimization goal.""" - mapping = { - "landing_page_views": "LANDING_PAGE_VIEWS", - "link_clicks": "LINK_CLICKS", - "impressions": "IMPRESSIONS", - "reach": "REACH", - "conversions": "OFFSITE_CONVERSIONS", - "website_clicks": "LINK_CLICKS", - } - return mapping.get(tactics_goal.lower(), "LINK_CLICKS") - - # Common country name/code mappings for Facebook API - COUNTRY_CODE_MAP = { - "UK": "GB", "United Kingdom": "GB", "Britain": "GB", - "USA": "US", "United States": "US", "America": "US", - "Canada": "CA", "Deutschland": "DE", "Germany": "DE", - } - - def _normalize_country_codes(self, countries: List[str]) -> List[str]: - """Convert country names/codes to ISO 3166-1 alpha-2 codes.""" - return [self.COUNTRY_CODE_MAP.get(c, c) for c in countries] - - def _build_targeting(self, audience: Dict[str, Any]) -> Dict[str, Any]: - """Build Facebook targeting spec from tactics audience.""" - targeting: Dict[str, Any] = {} - # Geo locations — normalize country codes - countries = audience.get("countries", []) - if countries: - normalized = self._normalize_country_codes(countries) - targeting["geo_locations"] = {"countries": normalized} - # Age range - age_range = audience.get("age_range", []) - if age_range and len(age_range) >= 2: - targeting["age_min"] = age_range[0] - targeting["age_max"] = age_range[1] - # Interests (simplified - Facebook interests API is complex) - interests = audience.get("interests", []) - if interests: - # Note: Real implementation would need to look up interest IDs - # For now, just log that interests were specified - logger.info(f"Interests specified but not mapped: {interests}") - return targeting - - def _extract_id_from_result(self, result: str, entity_type: str) -> Optional[str]: - """Extract ID from Facebook API result string.""" - # Results look like "Campaign created: Name (ID: 123456789)" - # or "Ad Set created successfully!\nID: 123456789" - if "ERROR" in result: - return None - import re - # Try "ID: xxx" pattern - match = re.search(r'ID:\s*(\d+|mock_\w+)', result) - if match: - return match.group(1) - return None - - def _parse_insights(self, insights_result: str) -> Dict[str, float]: - """Parse Facebook insights result string into metrics dict.""" - metrics = { - "impressions": 0, - "clicks": 0, - "spend": 0, - "ctr": 0, - "cpc": 0, - } - if "No insights" in insights_result or "ERROR" in insights_result: - return metrics - import re - # Parse "Impressions: 125,000" format - imp_match = re.search(r'Impressions:\s*([\d,]+)', insights_result) - if imp_match: - metrics["impressions"] = float(imp_match.group(1).replace(",", "")) - clicks_match = re.search(r'Clicks:\s*([\d,]+)', insights_result) - if clicks_match: - metrics["clicks"] = float(clicks_match.group(1).replace(",", "")) - spend_match = re.search(r'Spend:\s*\$([\d,.]+)', insights_result) - if spend_match: - metrics["spend"] = float(spend_match.group(1).replace(",", "")) - ctr_match = re.search(r'CTR:\s*([\d.]+)%', insights_result) - if ctr_match: - metrics["ctr"] = float(ctr_match.group(1)) - cpc_match = re.search(r'CPC:\s*\$([\d.]+)', insights_result) - if cpc_match: - metrics["cpc"] = float(cpc_match.group(1)) - return metrics - - def _get_day_key(self, current_day: int) -> str: - """Get iteration_guide key for current day.""" - if current_day <= 3: - return "day_1_3" - elif current_day <= 7: - return "day_4_7" - elif current_day <= 14: - return "day_8_14" - else: - return "day_15_30" - diff --git a/flexus_simple_bots/admonster/forms/meta_runtime.html b/flexus_simple_bots/admonster/forms/meta_runtime.html deleted file mode 100644 index 1762f532..00000000 --- a/flexus_simple_bots/admonster/forms/meta_runtime.html +++ /dev/null @@ -1,1106 +0,0 @@ - - - - - - AdMonster Experiment Dashboard - - - - - -
-
-
- Loading experiment data... -
-
- - - - - - - - diff --git a/flexus_simple_bots/admonster/setup_schema.json b/flexus_simple_bots/admonster/setup_schema.json deleted file mode 100644 index ea8d1556..00000000 --- a/flexus_simple_bots/admonster/setup_schema.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { - "bs_name": "ad_account_id", - "bs_type": "string_short", - "bs_default": "", - "bs_group": "LinkedIn", - "bs_importance": 1, - "bs_description": "LinkedIn Ads Account ID" - } -] diff --git a/flexus_simple_bots/boss/skills/idea-to-first-money/SKILL.md b/flexus_simple_bots/boss/skills/idea-to-first-money/SKILL.md index ceecff19..7d951ae8 100644 --- a/flexus_simple_bots/boss/skills/idea-to-first-money/SKILL.md +++ b/flexus_simple_bots/boss/skills/idea-to-first-money/SKILL.md @@ -42,7 +42,7 @@ Goal: validate or reject the P0 market hypothesis through direct evidence from r ### discovery-recruitment Recruit participants for interviews and surveys. -- **Providers:** Prolific (panel), Cint (panel), MTurk (panel), UserTesting (usability) +- **Providers:** Prolific (self-serve research panel), Cint (enterprise sample marketplace), MTurk (crowd panel), UserTesting (usability / reviewed access), User Interviews (Research Hub panel sync), Respondent (B2B interview recruiting), PureSpectrum (enterprise sample buying), Dynata (enterprise sample + respondent exchange), Lucid Marketplace (enterprise marketplace / consultant-led onboarding), Toloka (crowd-based validation) - **Key methods:** screener design, quota management, pilot launch before full scale, anti-gaming checks - **Output:** `/discovery/{study_id}/recruitment-plan`, `/discovery/{study_id}/recruitment-funnel` @@ -188,7 +188,7 @@ Facilitate the success review and drive to signed contract. | Category | Providers | |---|---| | Interview / recording | Zoom, Gong, Fireflies, Dovetail, Fathom | -| Research panels | Prolific, Cint, MTurk, UserTesting | +| Research panels | Prolific, Cint, MTurk, UserTesting, User Interviews, Respondent, PureSpectrum, Dynata, Lucid Marketplace, Toloka | | Survey | SurveyMonkey, Typeform | | SEO / demand signals | Ahrefs, SEMrush, Similarweb | | Web research | browser (G2, Capterra, Reddit, ProductHunt, LinkedIn) | diff --git a/flexus_simple_bots/botticelli/botticelli_bot.py b/flexus_simple_bots/botticelli/botticelli_bot.py index af0249b5..7331254f 100644 --- a/flexus_simple_bots/botticelli/botticelli_bot.py +++ b/flexus_simple_bots/botticelli/botticelli_bot.py @@ -6,19 +6,15 @@ import os import io import httpx -from typing import Dict, Any, List, Union, Set +from typing import Dict, Any from pymongo import AsyncMongoClient -import openai from PIL import Image -from bs4 import BeautifulSoup -from urllib.parse import urljoin, urlparse from flexus_client_kit import ckit_client from flexus_client_kit import ckit_cloudtool from flexus_client_kit import ckit_bot_exec from flexus_client_kit import ckit_shutdown from flexus_client_kit import ckit_ask_model -from flexus_client_kit import ckit_kanban from flexus_client_kit import ckit_mongo from flexus_client_kit import ckit_integrations_db from flexus_client_kit.integrations import fi_pdoc @@ -193,29 +189,30 @@ }, ) -SCAN_BRAND_VISUALS_TOOL = ckit_cloudtool.CloudTool( - strict=False, - name="scan_brand_visuals", - description="""Scan a website to extract brand visual identity using Playwright browser. -Extracts REAL computed CSS styles (colors, fonts) from the rendered page. -Saves to /style-guide with color swatches for human verification. -Use this tool ONCE per project. User will verify/adjust colors in the sidebar form.""", - parameters={ - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "Website URL to scan (e.g. 'https://example.com')" - }, - "save_path": { - "type": "string", - "description": "Policy document path to save results (default: /style-guide)" - } - }, - "required": ["url"], - }, -) +def validate_styleguide_structure(provided: dict, expected: dict, path: str = "root") -> str: + if type(provided) != type(expected): + return f"Type mismatch at {path}: expected {type(expected).__name__}, got {type(provided).__name__}" + if isinstance(expected, dict): + expected_keys = set(expected.keys()) + provided_keys = set(provided.keys()) + if expected_keys != provided_keys: + missing = expected_keys - provided_keys + extra = provided_keys - expected_keys + errors = [] + if missing: + errors.append(f"missing keys: {missing}") + if extra: + errors.append(f"unexpected keys: {extra}") + return f"Key mismatch at {path}: {', '.join(errors)}" + for key in expected_keys: + if key in ("q", "a", "t", "title"): + continue + err = validate_styleguide_structure(provided[key], expected[key], f"{path}.{key}") + if err: + return err + return "" + BOTTICELLI_INTEGRATIONS: list[ckit_integrations_db.IntegrationRecord] = ckit_integrations_db.static_integrations_load( botticelli_install.BOTTICELLI_ROOTDIR, @@ -230,65 +227,14 @@ GENERATE_PICTURE_TOOL, CROP_IMAGE_TOOL, CAMPAIGN_BRIEF_TOOL, - SCAN_BRAND_VISUALS_TOOL, fi_mongo_store.MONGO_STORE_TOOL, *[t for rec in BOTTICELLI_INTEGRATIONS for t in rec.integr_tools], ] -async def botticelli_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext) -> None: - setup = ckit_bot_exec.official_setup_mixing_procedure(botticelli_install.BOTTICELLI_SETUP_SCHEMA, rcx.persona.persona_setup) - integr_objects = await ckit_integrations_db.main_loop_integrations_init(BOTTICELLI_INTEGRATIONS, rcx, setup) - pdoc_integration: fi_pdoc.IntegrationPdoc = integr_objects["flexus_policy_document"] - +async def setup_handlers(fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext, pdoc_integration: fi_pdoc.IntegrationPdoc) -> None: mongo_conn_str = await ckit_mongo.mongo_fetch_creds(fclient, rcx.persona.persona_id) mongo = AsyncMongoClient(mongo_conn_str) - dbname = rcx.persona.persona_id + "_db" - mydb = mongo[dbname] - personal_mongo = mydb["personal_mongo"] - - # Lazy initialization of OpenAI client - only create when needed - openai_client = None - def get_openai_client(): - nonlocal openai_client - if openai_client is None: - api_key = os.getenv("OPENAI_API_KEY") - if not api_key: - raise ValueError("OPENAI_API_KEY environment variable not set") - openai_client = openai.AsyncOpenAI(api_key=api_key) - return openai_client - - def validate_styleguide_structure(provided: Dict, expected: Dict, path: str = "root") -> str: - if type(provided) != type(expected): - return f"Type mismatch at {path}: expected {type(expected).__name__}, got {type(provided).__name__}" - if isinstance(expected, dict): - expected_keys = set(expected.keys()) - provided_keys = set(provided.keys()) - if expected_keys != provided_keys: - missing = expected_keys - provided_keys - extra = provided_keys - expected_keys - errors = [] - if missing: - errors.append(f"missing keys: {missing}") - if extra: - errors.append(f"unexpected keys: {extra}") - return f"Key mismatch at {path}: {', '.join(errors)}" - for key in expected_keys: - if key == "q": - continue - if key == "a": - continue - if key == "t": - continue - if key == "title": - continue - error = validate_styleguide_structure(provided[key], expected[key], f"{path}.{key}") - if error: - return error - return "" - - @rcx.on_updated_task - async def updated_task_in_db(t: ckit_kanban.FPersonaKanbanTaskOutput): - pass + personal_mongo = mongo[rcx.persona.persona_id + "_db"]["personal_mongo"] @rcx.on_tool_call(STYLEGUIDE_TEMPLATE_TOOL.name) async def toolcall_styleguide_template(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: @@ -417,7 +363,7 @@ async def toolcall_generate_picture(toolcall: ckit_cloudtool.FCloudtoolCall, mod logger.info(f"Added reference image: {len(ref_image_bytes)} bytes, {mime_type}") except httpx.TimeoutException: return "Error: Timeout fetching reference image" - except Exception as e: + except (httpx.HTTPError, OSError, ValueError) as e: return f"Error: Failed to fetch reference image: {str(e)}" # Build image config @@ -497,7 +443,7 @@ async def toolcall_generate_picture(toolcall: ckit_cloudtool.FCloudtoolCall, mod ) except Exception as e: - logger.error(f"Error generating image: {e}", exc_info=True) + logger.error("Error generating image", exc_info=e) return ckit_cloudtool.ToolResult(f"Error generating image: {str(e)}") @rcx.on_tool_call(CROP_IMAGE_TOOL.name) @@ -657,336 +603,22 @@ async def toolcall_campaign_brief(toolcall: ckit_cloudtool.FCloudtoolCall, model except ckit_cloudtool.WaitForSubchats: raise except Exception as e: - logger.exception(f"Error in campaign_brief handler: {e}") + logger.error("Error in campaign_brief handler", exc_info=e) return f"Error: {str(e)}" - @rcx.on_tool_call(SCAN_BRAND_VISUALS_TOOL.name) - async def toolcall_scan_brand_visuals(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: - """Scan a website and extract brand visual identity using Playwright browser.""" - from playwright.async_api import async_playwright - - url = model_produced_args.get("url", "") - save_path = model_produced_args.get("save_path", "/style-guide") - - if not url: - return "Error: url is required" - - # Validate URL - if not url.startswith(("http://", "https://")): - url = "https://" + url - - try: - parsed = urlparse(url) - if not parsed.netloc: - return f"Error: Invalid URL: {url}" - except Exception: - return f"Error: Invalid URL: {url}" - - try: - logger.info(f"Scanning website for brand visuals using Playwright: {url}") - - async with async_playwright() as p: - # Launch browser - browser = await p.chromium.launch(headless=True) - context = await browser.new_context( - viewport={"width": 1920, "height": 1080}, - user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" - ) - page = await context.new_page() - - try: - # Navigate to page - await page.goto(url, wait_until="networkidle", timeout=30000) - await page.wait_for_timeout(2000) # Wait for animations - - # Extract computed styles and images using JavaScript - brand_data = await page.evaluate('''() => { - // Helper to convert rgb to hex - function rgbToHex(rgb) { - if (!rgb || rgb === 'transparent' || rgb === 'rgba(0, 0, 0, 0)') return null; - const match = rgb.match(/rgba?\\(([\\d.]+),\\s*([\\d.]+),\\s*([\\d.]+)/); - if (!match) return rgb.startsWith('#') ? rgb : null; - const r = Math.round(parseFloat(match[1])); - const g = Math.round(parseFloat(match[2])); - const b = Math.round(parseFloat(match[3])); - return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join(''); - } - - // Get computed style safely - function getStyle(el, prop) { - if (!el) return null; - return window.getComputedStyle(el).getPropertyValue(prop); - } - - // Find elements - const body = document.body; - const buttons = document.querySelectorAll('button, .btn, [class*="button"], [class*="Button"], a[class*="cta"], a[class*="Cta"]'); - const links = document.querySelectorAll('a:not([class*="button"]):not([class*="btn"])'); - const headings = document.querySelectorAll('h1, h2, h3'); - const paragraphs = document.querySelectorAll('p, .text, [class*="body"]'); - - // Extract colors - let primaryColor = null; - let secondaryColor = null; - - // Try to find primary color from buttons - for (const btn of buttons) { - const bg = rgbToHex(getStyle(btn, 'background-color')); - if (bg && bg !== '#ffffff' && bg !== '#000000' && bg !== '#transparent') { - if (!primaryColor) primaryColor = bg; - else if (!secondaryColor && bg !== primaryColor) secondaryColor = bg; - break; - } - } - - // Try links for primary/accent color - if (!primaryColor) { - for (const link of links) { - const color = rgbToHex(getStyle(link, 'color')); - if (color && color !== '#000000' && !color.startsWith('#0000') && !color.startsWith('#333')) { - primaryColor = color; - break; - } - } - } - - // Background color from body - const backgroundColor = rgbToHex(getStyle(body, 'background-color')) || '#ffffff'; - - // Text color from body or paragraphs - let textColor = rgbToHex(getStyle(body, 'color')); - if (!textColor || textColor === '#000000') { - for (const p of paragraphs) { - const c = rgbToHex(getStyle(p, 'color')); - if (c) { textColor = c; break; } - } - } - textColor = textColor || '#333333'; - - // Extract fonts - const bodyFont = getStyle(body, 'font-family')?.split(',')[0]?.trim().replace(/['"]/g, '') || 'Sans-serif'; - let headingFont = bodyFont; - if (headings.length > 0) { - headingFont = getStyle(headings[0], 'font-family')?.split(',')[0]?.trim().replace(/['"]/g, '') || bodyFont; - } - - // Get site info - const title = document.title || ''; - const ogSiteName = document.querySelector('meta[property="og:site_name"]')?.content; - const ogImage = document.querySelector('meta[property="og:image"]')?.content; - const favicon = document.querySelector('link[rel*="icon"]')?.href; - - // Extract all images for logo candidates - const images = []; - const viewportHeight = window.innerHeight; - - for (const img of document.querySelectorAll('img[src]')) { - const rect = img.getBoundingClientRect(); - const src = img.src; - - // Filter: skip tiny images, data URLs, and images below viewport - if (!src || src.startsWith('data:') || rect.width < 30 || rect.height < 30) continue; - if (rect.top > viewportHeight * 0.5) continue; // Only top half of page - if (rect.width > 500 || rect.height > 300) continue; // Skip hero images - - const isLogo = (img.alt?.toLowerCase().includes('logo') || - img.className?.toLowerCase().includes('logo') || - img.id?.toLowerCase().includes('logo') || - img.closest('a[href="/"], a[href="./"], header')?.querySelector('img') === img); - - images.push({ - url: src, - width: Math.round(rect.width), - height: Math.round(rect.height), - top: Math.round(rect.top), - alt: img.alt || '', - is_logo_hint: isLogo - }); - } - - // Sort: prioritize images with logo hints, then by position (top first) - images.sort((a, b) => { - if (a.is_logo_hint && !b.is_logo_hint) return -1; - if (!a.is_logo_hint && b.is_logo_hint) return 1; - return a.top - b.top; - }); - - return { - site_name: ogSiteName || title.split(' - ')[0].split(' | ')[0].trim(), - logo_url: ogImage || favicon || null, - primary_color: primaryColor || '#0066cc', - secondary_color: secondaryColor || primaryColor || '#004499', - background_color: backgroundColor, - text_color: textColor, - heading_font: headingFont, - body_font: bodyFont, - logo_candidates: images.slice(0, 10) // Max 10 candidates - }; - }''') - - # Take screenshot for color picking - resize viewport first for smaller file - await page.set_viewport_size({"width": 800, "height": 600}) - await page.wait_for_timeout(500) # Let page adjust - - # Take JPEG instead of PNG with quality setting for smaller file (~50-150KB vs 2-5MB) - screenshot_bytes = await page.screenshot(type="jpeg", quality=60) - screenshot_base64 = base64.b64encode(screenshot_bytes).decode("utf-8") - - logger.info(f"Extracted brand data: {brand_data}") - logger.info(f"Screenshot size: {len(screenshot_bytes) / 1024:.1f}KB, Logo candidates: {len(brand_data.get('logo_candidates', []))}") - - finally: - await browser.close() - - # Determine visual style based on colors - bg_color = brand_data.get("background_color", "#ffffff").lower() - is_dark = bg_color in ["#000000", "#000", "#111111", "#111", "#0d0d0d", "#121212", "#1a1a1a"] - visual_style = "dark, modern" if is_dark else "light, clean" - - # Build style-guide with color swatches for human verification - import datetime - today = datetime.date.today().strftime("%Y%m%d") - - site_name = brand_data.get("site_name", "") - logo_url = brand_data.get("logo_url", "") - - # Get logo candidates from brand_data - logo_candidates = brand_data.get("logo_candidates", []) - - styleguide = { - "styleguide": { - "meta": { - "microfrontend": "botticelli", - "author": "Botticelli (auto-detected)", - "date": today, - "source_url": url, - "status": "pending_verification", - "screenshot_base64": screenshot_base64 # JPEG ~50-150KB for color picking - }, - "section00-raw": { - "title": "Raw Data", - "logo_candidates": logo_candidates # Array of image URLs for selection - }, - "section01-colors": { - "title": "🎨 Brand Colors (please verify)", - "question01-primary": { - "q": "Primary Brand Color", - "a": brand_data.get("primary_color", "#0066cc"), - "t": "color" - }, - "question02-secondary": { - "q": "Secondary Brand Color", - "a": brand_data.get("secondary_color", "#004499"), - "t": "color" - }, - "question03-background": { - "q": "Background Color", - "a": brand_data.get("background_color", "#ffffff"), - "t": "color" - }, - "question04-text": { - "q": "Text Color", - "a": brand_data.get("text_color", "#333333"), - "t": "color" - } - }, - "section02-typography": { - "title": "📝 Typography", - "question01-heading-font": { - "q": "Heading Font", - "a": brand_data.get("heading_font", "Sans-serif") - }, - "question02-body-font": { - "q": "Body Font", - "a": brand_data.get("body_font", "Sans-serif") - } - }, - "section03-brand": { - "title": "🏢 Brand Info", - "question01-site-name": { - "q": "Brand/Site Name", - "a": site_name - }, - "question02-logo": { - "q": "Logo URL", - "a": logo_url or "" - }, - "question03-style": { - "q": "Visual Style", - "a": visual_style - } - }, - "section04-verification": { - "title": "✅ Verification", - "question01-verified": { - "q": "Colors verified by human?", - "a": "no", - "t": "select", - "options": ["no", "yes"] - } - } - } - } - - # Save to policy document (overwrite if exists) - await pdoc_integration.pdoc_overwrite( - save_path, - json.dumps(styleguide, indent=2), - toolcall.fcall_ft_id - ) - - logger.info(f"Saved style guide to {save_path}") - - # Format result for user with color swatches - primary_color = brand_data.get('primary_color', '#0066cc') - secondary_color = brand_data.get('secondary_color', '#004499') - background_color = brand_data.get('background_color', '#ffffff') - text_color = brand_data.get('text_color', '#333333') - heading_font = brand_data.get('heading_font', 'Sans-serif') - body_font = brand_data.get('body_font', 'Sans-serif') - - result = f"""✅ Brand Style Guide extracted from {url} - -**Site:** {site_name} - ---- - -## 🎨 Detected Colors (please verify in sidebar) - -| Role | Color | Hex | -|------|-------|-----| -| Primary | 🟦 | `{primary_color}` | -| Secondary | 🟦 | `{secondary_color}` | -| Background | ⬜ | `{background_color}` | -| Text | ⬛ | `{text_color}` | - -## 📝 Fonts -- **Headings:** {heading_font} -- **Body:** {body_font} - -## 🖼️ Logo -{logo_url or 'Not found'} - ---- - -⚠️ **Action Required:** Please check the **Forms** tab in the sidebar and verify the colors are correct. Click on any color swatch to adjust if needed. - -📁 Saved to: `{save_path}` -""" - return result - - except Exception as e: - logger.exception(f"Error scanning website: {e}") - return f"Error scanning website: {str(e)}" - @rcx.on_tool_call(fi_mongo_store.MONGO_STORE_TOOL.name) async def toolcall_mongo_store(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: return await fi_mongo_store.handle_mongo_store(rcx.workdir, personal_mongo, toolcall, model_produced_args) + +async def botticelli_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext) -> None: + setup = ckit_bot_exec.official_setup_mixing_procedure(botticelli_install.BOTTICELLI_SETUP_SCHEMA, rcx.persona.persona_setup) + integr_objects = await ckit_integrations_db.main_loop_integrations_init(BOTTICELLI_INTEGRATIONS, rcx, setup) + pdoc_integration: fi_pdoc.IntegrationPdoc = integr_objects["flexus_policy_document"] + await setup_handlers(fclient, rcx, pdoc_integration) try: while not ckit_shutdown.shutdown_event.is_set(): await rcx.unpark_collected_events(sleep_if_no_work=10.0) - finally: logger.info("%s exit" % (rcx.persona.persona_id,)) diff --git a/flexus_simple_bots/botticelli/botticelli_prompts.py b/flexus_simple_bots/botticelli/botticelli_prompts.py index 73b2ccdf..80f3936e 100644 --- a/flexus_simple_bots/botticelli/botticelli_prompts.py +++ b/flexus_simple_bots/botticelli/botticelli_prompts.py @@ -115,14 +115,11 @@ ## Brand Visuals Scanning -scan_brand_visuals(url="https://example.com") scans a website and extracts: -- Colors (hex codes from CSS, meta tags) -- Fonts (from CSS, Google Fonts) -- Logo URL -- Visual style description - -Results are saved to policy document (default: /brand-visuals). -Use this ONCE per project to establish brand consistency. +To extract brand identity from a website, use the system `web` tool: +- `web(screenshot=[{{"url": "..."}}])` — screenshot the homepage for visual color/font analysis +- `web(open=[{{"url": "..."}}])` — read HTML for meta tags (og:image, favicon, site name) + +Analyze the screenshot visually to identify primary/secondary colors, fonts, logo. Then create the style guide with `template_styleguide`. Use ONCE per project. ## Generating Images diff --git a/flexus_simple_bots/executor/executor_bot.py b/flexus_simple_bots/executor/executor_bot.py index 42db7fd2..964fa9ab 100644 --- a/flexus_simple_bots/executor/executor_bot.py +++ b/flexus_simple_bots/executor/executor_bot.py @@ -1,6 +1,8 @@ import asyncio import json import logging +import re +import time from pathlib import Path from typing import Any, Dict @@ -11,10 +13,13 @@ from flexus_client_kit import ckit_cloudtool from flexus_client_kit import ckit_integrations_db +from flexus_client_kit import ckit_kanban from flexus_client_kit import ckit_mongo from flexus_client_kit import ckit_shutdown from flexus_client_kit.integrations import fi_mongo_store +from flexus_client_kit.integrations.facebook.fi_facebook import IntegrationFacebook, FACEBOOK_TOOL from flexus_simple_bots.executor import executor_install +from flexus_simple_bots.executor import experiment_execution from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION logger = logging.getLogger("bot_executor") @@ -24,31 +29,58 @@ BOT_VERSION = SIMPLE_BOTS_COMMON_VERSION -EXECUTOR_INTEGRATIONS = executor_install.EXECUTOR_INTEGRATIONS +def load_artifact_schemas() -> Dict[str, Any]: + """Read JSON artifact schemas from each skill's SKILL.md.""" + skills_dir = BOT_DIR / "skills" + schemas: Dict[str, Any] = {} + for skill_dir in sorted(d for d in skills_dir.iterdir() if not d.name.startswith("_")): + skill_md = skill_dir / "SKILL.md" + if not skill_md.exists(): + continue + md = skill_md.read_text(encoding="utf-8") + m = re.search(r"```json\s*(\{.*?\})\s*```", md, re.DOTALL) + if not m: + continue + parsed = json.loads(m.group(1)) + schemas.update(parsed) + return schemas + + +ARTIFACT_SCHEMAS = load_artifact_schemas() +ARTIFACT_TYPES = sorted(ARTIFACT_SCHEMAS.keys()) WRITE_ARTIFACT_TOOL = ckit_cloudtool.CloudTool( strict=False, name="write_artifact", - description="Write a structured artifact to the document store. Path and data shape are defined by the active skill.", + description="Write a structured artifact to the document store. Artifact type and schema are defined by the active skill.", parameters={ "type": "object", "properties": { + "artifact_type": { + "type": "string", + "enum": ARTIFACT_TYPES, + "description": "Artifact type as specified by the active skill", + }, "path": { "type": "string", - "description": "Document path as specified by the active skill", + "description": "Document path, e.g. /campaigns/meta/q1-2024-pilot", }, "data": { "type": "object", - "description": "Artifact content as specified by the active skill", + "description": "Artifact content matching the schema for this artifact_type", }, }, - "required": ["path", "data"], + "required": ["artifact_type", "path", "data"], "additionalProperties": False, }, ) +EXECUTOR_INTEGRATIONS = executor_install.EXECUTOR_INTEGRATIONS + TOOLS = [ + FACEBOOK_TOOL, fi_mongo_store.MONGO_STORE_TOOL, + experiment_execution.LAUNCH_EXPERIMENT_TOOL, WRITE_ARTIFACT_TOOL, *[t for rec in EXECUTOR_INTEGRATIONS for t in rec.integr_tools], ] @@ -58,27 +90,65 @@ async def executor_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_ex setup = ckit_bot_exec.official_setup_mixing_procedure(executor_install.EXECUTOR_SETUP_SCHEMA, rcx.persona.persona_setup) integr_objects = await ckit_integrations_db.main_loop_integrations_init(EXECUTOR_INTEGRATIONS, rcx, setup) pdoc_integration = integr_objects["flexus_policy_document"] - mongo_conn_str = await ckit_mongo.mongo_fetch_creds(fclient, rcx.persona.persona_id) mongo = AsyncMongoClient(mongo_conn_str) personal_mongo = mongo[rcx.persona.persona_id + "_db"]["personal_mongo"] + facebook_integration = IntegrationFacebook(fclient=fclient, rcx=rcx, ad_account_id="", pdoc_integration=pdoc_integration) + experiment_integr = experiment_execution.IntegrationExperimentExecution( + pdoc_integration=pdoc_integration, + fclient=fclient, + facebook_integration=facebook_integration, + ) + + @rcx.on_tool_call(FACEBOOK_TOOL.name) + async def _h_facebook(toolcall: ckit_cloudtool.FCloudtoolCall, args: Dict[str, Any]) -> str: + return await facebook_integration.called_by_model(toolcall, args) + @rcx.on_tool_call(fi_mongo_store.MONGO_STORE_TOOL.name) - async def _h_mongo(toolcall, args): + async def _h_mongo(toolcall: ckit_cloudtool.FCloudtoolCall, args: Dict[str, Any]) -> str: return await fi_mongo_store.handle_mongo_store(rcx.workdir, personal_mongo, toolcall, args) + @rcx.on_tool_call(experiment_execution.LAUNCH_EXPERIMENT_TOOL.name) + async def _h_launch(toolcall: ckit_cloudtool.FCloudtoolCall, args: Dict[str, Any]) -> str: + return await experiment_integr.launch_experiment(toolcall, args) + @rcx.on_tool_call(WRITE_ARTIFACT_TOOL.name) async def _h_write_artifact(toolcall: ckit_cloudtool.FCloudtoolCall, args: Dict[str, Any]) -> str: + artifact_type = str(args.get("artifact_type", "")).strip() path = str(args.get("path", "")).strip() data = args.get("data") - if not path or data is None: - return "Error: path and data are required." - await pdoc_integration.pdoc_overwrite(path, json.dumps(data, ensure_ascii=False), fcall_untrusted_key=toolcall.fcall_untrusted_key) - return f"Written: {path}" + if not artifact_type or not path or data is None: + return "Error: artifact_type, path, and data are required." + if artifact_type not in ARTIFACT_SCHEMAS: + return f"Error: unknown artifact_type {artifact_type!r}. Must be one of: {', '.join(ARTIFACT_TYPES)}" + doc = dict(data) + doc["schema"] = ARTIFACT_SCHEMAS[artifact_type] + await pdoc_integration.pdoc_overwrite( + path, + json.dumps(doc, ensure_ascii=False), + fcall_untrusted_key=toolcall.fcall_untrusted_key, + ) + return f"Written: {path}\n\nArtifact {artifact_type} saved." + + @rcx.on_updated_task + async def _on_updated_task(t: ckit_kanban.FPersonaKanbanTaskOutput) -> None: + experiment_integr.track_experiment_task(t) + + initial_tasks = await ckit_kanban.bot_get_all_tasks(fclient, rcx.persona.persona_id) + for t in [x for x in initial_tasks if x.ktask_done_ts == 0]: + experiment_integr.track_experiment_task(t) + + last_experiment_check = 0 + experiment_check_interval = 3600 try: while not ckit_shutdown.shutdown_event.is_set(): await rcx.unpark_collected_events(sleep_if_no_work=10.0) + current_time = time.time() + if current_time - last_experiment_check > experiment_check_interval: + await experiment_integr.update_active_experiments() + last_experiment_check = current_time finally: logger.info("%s exit", rcx.persona.persona_id) diff --git a/flexus_simple_bots/executor/executor_install.py b/flexus_simple_bots/executor/executor_install.py index 023e20b0..e6a70b9b 100644 --- a/flexus_simple_bots/executor/executor_install.py +++ b/flexus_simple_bots/executor/executor_install.py @@ -8,6 +8,7 @@ from flexus_client_kit import ckit_cloudtool from flexus_client_kit import ckit_integrations_db from flexus_client_kit import ckit_skills +from flexus_client_kit.integrations import fi_linkedin_b2b from flexus_simple_bots import prompts_common from flexus_simple_bots.executor import executor_prompts @@ -17,37 +18,11 @@ if s != "botticelli" ] EXECUTOR_SETUP_SCHEMA = json.loads((EXECUTOR_ROOTDIR / "setup_schema.json").read_text()) +EXECUTOR_SETUP_SCHEMA.extend(fi_linkedin_b2b.LINKEDIN_B2B_SETUP_SCHEMA) EXECUTOR_INTEGRATIONS: list[ckit_integrations_db.IntegrationRecord] = ckit_integrations_db.static_integrations_load( EXECUTOR_ROOTDIR, - [ - "flexus_policy_document", "skills", "print_widget", - "linkedin", - "facebook[campaign, adset, ad, account]", - "google_calendar", - # "calendly", - # "chargebee", - # "crossbeam", - # "delighted", - # "docusign", - # "fireflies", - # "ga4", - # "gong", - # "google_ads", - # "meta", - # "mixpanel", - # "paddle", - # "pandadoc", - # "partnerstack", - # "pipedrive", - # "recurly", - # "salesforce", - # "surveymonkey", - # "typeform", - # "x_ads", - # "zendesk", - # "zoom", - ], + ["flexus_policy_document", "skills", "print_widget", "linkedin", "linkedin_b2b"], builtin_skills=EXECUTOR_SKILLS, ) @@ -69,6 +44,15 @@ async def install( bot_version: str, tools: list[ckit_cloudtool.CloudTool], ) -> None: + auth_supported = ["google"] + auth_scopes: dict[str, list[str]] = {"google": []} + for rec in EXECUTOR_INTEGRATIONS: + if not rec.integr_provider: + continue + if rec.integr_provider not in auth_supported: + auth_supported.append(rec.integr_provider) + existing = auth_scopes.get(rec.integr_provider, []) + auth_scopes[rec.integr_provider] = list(dict.fromkeys(existing + rec.integr_scopes)) pic_big = base64.b64encode((EXECUTOR_ROOTDIR / "executor-1024x1536.webp").read_bytes()).decode("ascii") pic_small = base64.b64encode((EXECUTOR_ROOTDIR / "executor-256x256.webp").read_bytes()).decode("ascii") await ckit_bot_install.marketplace_upsert_dev_bot( @@ -105,10 +89,8 @@ async def install( marketable_picture_big_b64=pic_big, marketable_picture_small_b64=pic_small, marketable_forms={}, - marketable_auth_supported=["google"], - marketable_auth_scopes={ - "google": [], - }, + marketable_auth_supported=auth_supported, + marketable_auth_scopes=auth_scopes, ) diff --git a/flexus_simple_bots/executor/executor_prompts.py b/flexus_simple_bots/executor/executor_prompts.py index be142713..c6b7b122 100644 --- a/flexus_simple_bots/executor/executor_prompts.py +++ b/flexus_simple_bots/executor/executor_prompts.py @@ -9,6 +9,8 @@ - pilot-conversion: drive the success review, contract workflow, and signed conversion into production {prompts_common.PROMPT_KANBAN} +{prompts_common.PROMPT_PRINT_WIDGET} +{prompts_common.PROMPT_POLICY_DOCUMENTS} {prompts_common.PROMPT_A2A_COMMUNICATION} {prompts_common.PROMPT_HERE_GOES_SETUP} """ diff --git a/flexus_simple_bots/executor/experiment_execution.py b/flexus_simple_bots/executor/experiment_execution.py new file mode 100644 index 00000000..c5c06f3a --- /dev/null +++ b/flexus_simple_bots/executor/experiment_execution.py @@ -0,0 +1,76 @@ +import json +import logging +from typing import Any, Dict + +from flexus_client_kit import ckit_cloudtool + + +logger = logging.getLogger("executor_experiment_execution") + +LAUNCH_EXPERIMENT_TOOL = ckit_cloudtool.CloudTool( + strict=True, + name="launch_experiment", + description="Reserved executor capability for launching experiment tactics into runtime systems.", + parameters={ + "type": "object", + "properties": { + "experiment_id": { + "type": "string", + "description": "Experiment identifier or policy-document path segment to launch.", + }, + }, + "required": ["experiment_id"], + "additionalProperties": False, + }, +) + + +class IntegrationExperimentExecution: + def __init__( + self, + pdoc_integration: Any, + fclient: Any, + facebook_integration: Any, + ) -> None: + self.pdoc_integration = pdoc_integration + self.fclient = fclient + self.facebook_integration = facebook_integration + + async def launch_experiment( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + args: Dict[str, Any], + ) -> str: + experiment_id = str((args or {}).get("experiment_id", "")).strip() + if not experiment_id: + return json.dumps( + { + "ok": False, + "error_code": "INVALID_ARGS", + "message": "experiment_id is required.", + }, + indent=2, + ensure_ascii=False, + ) + logger.info("launch_experiment placeholder invoked for %s", experiment_id) + return json.dumps( + { + "ok": False, + "error_code": "NOT_IMPLEMENTED", + "message": ( + "launch_experiment is intentionally reserved but not implemented yet. " + "Executor wiring is kept in place so the future module can replace this stub without bot rewiring." + ), + "experiment_id": experiment_id, + }, + indent=2, + ensure_ascii=False, + ) + + def track_experiment_task(self, task: Any) -> None: + task_id = str(getattr(task, "ktask_id", "") or getattr(task, "id", "")).strip() + if task_id: + logger.debug("experiment_execution placeholder ignoring task %s", task_id) + + async def update_active_experiments(self) -> None: + logger.debug("experiment_execution placeholder skipping active experiment update") diff --git a/flexus_simple_bots/executor/setup_schema.json b/flexus_simple_bots/executor/setup_schema.json index ea8d1556..fe51488c 100644 --- a/flexus_simple_bots/executor/setup_schema.json +++ b/flexus_simple_bots/executor/setup_schema.json @@ -1,10 +1 @@ -[ - { - "bs_name": "ad_account_id", - "bs_type": "string_short", - "bs_default": "", - "bs_group": "LinkedIn", - "bs_importance": 1, - "bs_description": "LinkedIn Ads Account ID" - } -] +[] diff --git a/flexus_simple_bots/executor/skills/_admonster/SKILL.md b/flexus_simple_bots/executor/skills/_admonster/SKILL.md new file mode 100644 index 00000000..347b28c3 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_admonster/SKILL.md @@ -0,0 +1,71 @@ +--- +name: admonster +description: Ad campaign execution and optimization from experiment tactics +--- + +# Ad Monster: Advertising Execution + +You are in **Ad Monster mode** — the automated advertising execution engine. Take marketing experiments from Owl Strategist and make them real: launch campaigns, monitor performance, optimize automatically. + +## First Message Protocol + +Before writing ANYTHING to the user, call: +`flexus_policy_document(op="list", args={"p": "/gtm/discovery/"})` + +Explore structure to find experiments: +- Ideas: `/gtm/discovery/{idea-slug}/idea` +- Hypotheses: `/gtm/discovery/{idea-slug}/{hypothesis-slug}/hypothesis` +- Experiments: `/gtm/discovery/{idea-slug}/{hypothesis-slug}/experiments/{exp-slug}/` + +For each experiment check: +- Has `tactics-campaigns` but NO `meta-runtime` → READY TO LAUNCH +- Has `meta-runtime` → read it to check `experiment_status` + +Present results as a status table, then ask which to work on. + +## Hard Requirement: No Tactics = No Launch + +You CANNOT launch experiments without tactics from Owl Strategist. If `/gtm/discovery/` is empty, say so and offer to show account status instead. + +## Configuration: /company/ad-ops-config + +Before ANY Facebook operation, `ad_account_id` is auto-loaded from `/company/ad-ops-config`. To set/change: +1. `facebook(op="list_ad_accounts")` — show available accounts +2. Save: `flexus_policy_document(op="overwrite", args={"p": "/company/ad-ops-config", "content": "{\"facebook_ad_account_id\": \"act_XXX\"}"})` + +## Launch Flow + +**READY TO LAUNCH:** +1. Read tactics: `flexus_policy_document(op="cat", args={"p": "/gtm/discovery/{experiment_id}/tactics-campaigns"})` +2. Show summary: campaigns, budgets, targeting +3. ASK: "Ready to create these campaigns on Facebook? They'll start PAUSED." +4. Only after confirmation → `launch_experiment(experiment_id="...")` +5. IMMEDIATELY AFTER success → `flexus_policy_document(op="activate", args={"p": "/gtm/discovery/{experiment_id}/meta-runtime"})` + +**ACTIVE or PAUSED:** +1. Open dashboard: `flexus_policy_document(op="activate", args={"p": "/gtm/discovery/{experiment_id}/meta-runtime"})` +2. Summarize: day, spend, key metrics, recent actions +3. ASK: "Need to adjust anything?" + +Note: `tactics` = PLAN (use `cat`), `meta-runtime` = DASHBOARD (use `activate`). + +## Automatic Monitoring + +Once campaigns are ACTIVE, hourly monitoring applies stop_rules and accelerate_rules from the metrics doc, executes actions, logs to meta-runtime, and notifies your thread. + +## Available Tools + +- `facebook()` — Facebook Marketing API (campaigns, adsets, ads, insights) +- `linkedin_b2b()` — LinkedIn Ads API +- `launch_experiment(experiment_id)` — create Facebook campaigns from tactics doc +- `flexus_policy_document()` — read/write pdoc filesystem + +### Facebook operations +- `facebook(op="connect")` — generate OAuth link +- `facebook(op="status")` — overview of campaigns +- `facebook(op="list_ad_accounts")` — list accounts +- `facebook(op="create_campaign", ...)`, `update_campaign`, `duplicate_campaign`, `archive_campaign` +- `facebook(op="create_adset", ...)`, `list_adsets`, `update_adset`, `validate_targeting` +- `facebook(op="upload_image", ...)`, `create_creative`, `create_ad`, `preview_ad` + +Budgets in cents (5000 = $50.00). Always start campaigns PAUSED. Use `validate_targeting` before creating ad sets. diff --git a/flexus_simple_bots/executor/skills/_botticelli/SKILL.md b/flexus_simple_bots/executor/skills/_botticelli/SKILL.md new file mode 100644 index 00000000..ee429ebe --- /dev/null +++ b/flexus_simple_bots/executor/skills/_botticelli/SKILL.md @@ -0,0 +1,68 @@ +--- +name: botticelli +description: Visual creative generation with style guide and asset workflow +--- + +# Botticelli: Visual Creative Generation + +You are in **Botticelli mode** — you draw pictures, mostly for ads. + +## Getting Started + +1. Load style guide: `flexus_policy_document(op="activate", args={"p": "/style-guide"})` (shows in UI) +2. List files: `flexus_policy_document(op="list", args={"p": "/ad-campaigns/"})` +3. If no style guide exists, offer to create one or scan from website + +You can write to `/ad-campaigns/` and `/style-guide`. Refuse writes outside these paths. + +## Style Guide + +Stored at `/style-guide`. Load with `op="activate"` (shows UI form). Update field-by-field: + +``` +flexus_policy_document(op="update_json_text", args={"p": "/style-guide", "json_path": "styleguide.section01-colors.question01-primary.a", "text": "#0066cc"}) +``` + +Create new with `template_styleguide(text="{...json...}")`. Or scan from website first: + +``` +web(screenshot=[{"url": "https://example.com", "full_page": false}]) +web(open=[{"url": "https://example.com"}]) +``` + +Use the screenshot to visually identify colors and fonts. Use `web(open=...)` to read HTML for meta tags, og:image, favicon. Analyze visually — the LLM extracts brand identity from screenshot and page content. Use ONCE per project. + +## Generating Images + +`picturegen()` creates pictures in MongoDB temp storage. + +- `quality="draft"` — Gemini Flash (fast, for iteration) +- `quality="final"` — Gemini Pro (after user approval only!) +- `resolution="1K"` or `"2K"` (final quality only) + +**Meta Ads aspect ratios:** `1:1` (Feed Square), `4:5` (Feed Portrait), `9:16` (Stories), `16:9` (Landscape) + +**Reference images:** Use `reference_image_url` for brand logo — MANDATORY for ad creatives. + +Filename convention: `pictures/concept-idea--text-messaging.png` (kebab-case, double-minus before text). + +When picturegen returns an image, the UI already shows it — don't print it again. + +## Cropping Images + +`crop_image(source_path="...", crops=[[x, y, w, h], ...])` — creates full-size crops plus 0.5x scaled versions, named with `-crop000`, `-crop001`, etc. + +## Meta Ads Campaigns + +`meta_campaign_brief(campaign_id="camp001-...", ...)` — starts structured campaign generation with 3 creative variations. Creates a subchat with the `meta_ads_creative` skill. + +## Available Tools + +- `picturegen(prompt, size, filename, quality, resolution, reference_image_url)` — generate image +- `crop_image(source_path, crops)` — crop image into regions +- `template_styleguide(text, path)` — create style guide document +- `web(screenshot=[{url, full_page}])` — screenshot brand website for color/font extraction +- `web(open=[{url}])` — extract page HTML for meta tags, og:image, favicon +- `meta_campaign_brief(campaign_id, brand_name, ...)` — start Meta Ads campaign generation +- `mongo_store(op, ...)` — manage MongoDB file storage +- `flexus_policy_document()` — read/write pdoc filesystem diff --git a/flexus_simple_bots/executor/skills/_channel-partner-overlap/SKILL.md b/flexus_simple_bots/executor/skills/_channel-partner-overlap/SKILL.md new file mode 100644 index 00000000..da75c7b2 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_channel-partner-overlap/SKILL.md @@ -0,0 +1,54 @@ +--- +name: channel-partner-overlap +description: Partner account overlap analysis using Crossbeam — identify shared customers, warm intro opportunities, and co-sell targets +--- + +You use Crossbeam to analyze account overlap between your company and partner companies. Overlap data reveals co-sell opportunities (prospects in both pipelines) and referral opportunities (partner customers who would benefit from your product). + +Core mode: warm introductions from partner overlap convert 3-5x better than cold outreach to the same account. Overlap data is only useful if you act on it within the pipeline window — stale overlap is worthless. + +## Methodology + +### Overlap types +Crossbeam compares account lists between two companies in four categories: + +1. **Customer × Prospect**: your customer is in the partner's pipeline → mutual validation, potential co-sell +2. **Prospect × Customer**: your prospect is the partner's customer → warm intro opportunity (highest value) +3. **Prospect × Prospect**: both companies are chasing the same account → co-sell to beat competition +4. **Customer × Customer**: both companies already have this customer → integration/expansion opportunity + +### Prioritization framework +Prioritize actions by overlap type: +- **Prospect × Customer** (your prospect, their customer): request warm intro. They can vouch for credibility. +- **Prospect × Prospect**: synchronize timing. Who's further along? Can we joint pitch? +- **Customer × Customer**: schedule a joint QBR to explore integration or expansion. + +### Requesting introductions +Only request introductions when: +- The overlap account is in an active deal stage (not just a name in the pipeline) +- You have a specific, mutually beneficial reason (not "we want a meeting") +- The partner has agreed to your intro protocol (defined in partner agreement) + +Bad: "Can you intro us to Company X?" +Good: "Company X is in both our pipelines at proposal stage. We think a joint session would help them evaluate the integration use case — can we co-present together?" + +### Cadence +Pull overlap data weekly when active deals are present. Generate overlap report for weekly partner sync. + +## Recording + +``` +write_artifact(path="/partners/overlap-{partner_id}-{date}", data={...}) +``` + +## Available Tools + +``` +crossbeam(op="call", args={"method_id": "crossbeam.reports.overlap.v1", "partner_id": "partner_id", "population": "prospects"}) + +crossbeam(op="call", args={"method_id": "crossbeam.reports.overlap_accounts.v1", "partner_id": "partner_id", "type": "prospect_x_customer"}) + +crossbeam(op="call", args={"method_id": "crossbeam.partners.list.v1"}) + +salesforce(op="call", args={"method_id": "salesforce.query.v1", "query": "SELECT Id, Name, StageName, Amount FROM Opportunity WHERE StageName NOT IN ('Closed Won','Closed Lost') ORDER BY Amount DESC LIMIT 100"}) +``` diff --git a/flexus_simple_bots/executor/skills/_channel-performance/SKILL.md b/flexus_simple_bots/executor/skills/_channel-performance/SKILL.md new file mode 100644 index 00000000..79446b48 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_channel-performance/SKILL.md @@ -0,0 +1,61 @@ +--- +name: channel-performance +description: Channel performance tracking — CAC, lead volume, conversion rates, and efficiency benchmarks across all acquisition channels +--- + +You measure and report the efficiency of all acquisition channels. Channel performance data drives budget allocation decisions — moving spend from underperforming channels to overperforming ones is the single highest-leverage allocation lever. + +Core mode: compare apples to apples. CAC must be calculated with consistent attribution methodology. If paid ads use last-touch attribution and outbound uses first-touch, the comparison is invalid. Define attribution model upfront, apply consistently. + +## Methodology + +### Channel metrics framework +For each active channel, track: +- **Volume**: leads generated, meetings booked, qualified opportunities +- **Conversion rates**: lead-to-meeting, meeting-to-qualified, qualified-to-closed +- **Cost**: spend (paid) or time cost (outbound), total channel cost +- **CAC**: total channel cost / new customers from that channel +- **Velocity**: average days from channel entry to close + +### Attribution methodology (document your choice) +**Last-touch**: attribute the deal to the last channel the customer engaged with before converting. Simple, undervalues awareness channels (SEO, social). + +**First-touch**: attribute to the first channel. Better for demand gen understanding, undervalues bottom-funnel channels. + +**Multi-touch linear**: distribute credit equally across all touchpoints. More accurate, harder to track. + +Document the model you use and apply it consistently. Don't switch mid-quarter. + +### Channel efficiency comparison +For each channel: +- CAC vs. target CAC (from `gtm_channel_strategy`) +- Payback period: CAC / (ARPA × gross margin) +- Qualified opportunity rate: what % of leads are actually qualified? + +Reallocation decision rules: +- CAC > 2x target for 3+ months → pause channel, investigate +- Payback period > 18 months → channel may be unsustainable, review pricing +- Low qualified rate (<20%) → targeting or messaging problem, not volume problem + +### GA4 vs. CRM reconciliation +GA4 tracks traffic and landing page conversions. CRM tracks pipeline. Reconcile weekly: +- If GA4 shows signups but CRM shows fewer SQLs → MQL-to-SQL conversion issue +- If CRM shows deals with no originating channel → attribution gap + +## Recording + +``` +write_artifact(path="/growth/channel-performance-{date}", data={...}) +``` + +## Available Tools + +``` +ga4(op="call", args={"method_id": "ga4.data.run_report.v1", "property": "properties/123456", "dateRanges": [{"startDate": "30daysAgo", "endDate": "today"}], "dimensions": [{"name": "sessionDefaultChannelGroup"}], "metrics": [{"name": "sessions"}, {"name": "newUsers"}, {"name": "conversions"}]}) + +mixpanel(op="call", args={"method_id": "mixpanel.query.insights.v1", "project_id": "proj_id", "event": "signup_completed", "from_date": "2024-01-01"}) + +salesforce(op="call", args={"method_id": "salesforce.query.v1", "query": "SELECT LeadSource, COUNT(Id) total, SUM(Amount) arr FROM Opportunity WHERE StageName = 'Closed Won' AND CloseDate = THIS_YEAR GROUP BY LeadSource"}) + +hubspot(op="call", args={"method_id": "hubspot.analytics.sessions.v1", "breakdown": "source", "period": "month", "start": "2024-01-01", "end": "2024-12-31"}) +``` diff --git a/flexus_simple_bots/executor/skills/_churn-early-warning/SKILL.md b/flexus_simple_bots/executor/skills/_churn-early-warning/SKILL.md new file mode 100644 index 00000000..2848af8f --- /dev/null +++ b/flexus_simple_bots/executor/skills/_churn-early-warning/SKILL.md @@ -0,0 +1,63 @@ +--- +name: churn-early-warning +description: Early churn warning detection — behavioral, relational, and financial signals that predict churn 30-90 days ahead +--- + +You detect early churn warning signals before customers formally notify or stop paying. The goal is a 30-90 day window before churn so that save plays have time to work. + +Core mode: absence of a positive signal is a warning signal. A customer who was logging in daily and stops is a stronger signal than one who never logged in at all. Compare each account against its own historical baseline, not against average. + +## Methodology + +### Leading churn indicators by category + +**Behavioral indicators** (product usage): +- Login frequency drop: >50% reduction vs. prior 30-day baseline +- Core action completion drop: >40% reduction +- Feature regression: using only basic features after previously using advanced ones +- Session duration drop: spending significantly less time per session + +**Relational indicators** (engagement): +- CS outreach non-response: ≥3 attempts without response (email, call, LinkedIn) +- Meeting cancellations: cancelled 2+ scheduled check-ins +- Key stakeholder departure: champion left company or changed role +- Decision-maker change: economic buyer has been replaced + +**Financial indicators** (billing): +- Downgrade request (most explicit signal) +- Failed payment → recovery failure (payment method issue unresolved >7 days) +- Discount request during active contract (price sensitivity signal) + +**Sentiment indicators** (NPS / survey): +- NPS score ≤6 +- Review posted on G2/Trustpilot with 1-2 stars +- Support tickets with language: "this doesn't work," "cancel," "alternatives" + +### Signal scoring model +Assign risk points per signal: +- Login frequency drop: 3 points +- Core action drop: 3 points +- Non-response: 3 points per attempt (max 9) +- Champion departure: 5 points +- Downgrade request: 7 points +- NPS ≤6: 4 points + +Risk threshold: ≥10 points = high churn risk → trigger save play immediately + +## Recording + +``` +write_artifact(path="/churn/warning-report-{date}", data={...}) +``` + +## Available Tools + +``` +mixpanel(op="call", args={"method_id": "mixpanel.query.insights.v1", "project_id": "proj_id", "event": "session_start", "from_date": "2024-01-01"}) + +chargebee(op="call", args={"method_id": "chargebee.subscriptions.list.v1", "status[is]": "active", "limit": 100}) + +zendesk(op="call", args={"method_id": "zendesk.tickets.list.v1", "status": "open", "priority": "high"}) + +delighted(op="call", args={"method_id": "delighted.survey.responses.v1", "score[lte]": 6, "since": 1704067200}) +``` diff --git a/flexus_simple_bots/executor/skills/_churn-exit-interview/SKILL.md b/flexus_simple_bots/executor/skills/_churn-exit-interview/SKILL.md new file mode 100644 index 00000000..db495a8c --- /dev/null +++ b/flexus_simple_bots/executor/skills/_churn-exit-interview/SKILL.md @@ -0,0 +1,59 @@ +--- +name: churn-exit-interview +description: Churn exit interview design and synthesis — capture departure reasons, failure modes, and competitive intelligence from lost customers +--- + +You design and conduct structured exit interviews with churned customers to extract actionable intelligence. Exit interviews are one of the most underutilized research sources — churned customers have no incentive to soften their feedback and will tell you the truth. + +Core mode: past-behavior only. Ask about what actually happened, not what they wished had happened. "Why did you cancel?" is okay. "What would have made you stay?" is speculative. Focus on the sequence of events that led to cancellation. + +## Methodology + +### Exit interview recruitment +Timing: contact within 7 days of cancellation — before they forget the details and while the experience is fresh. +Channel: email first (less intrusive), then LinkedIn if no response. +Incentive: offer a modest gift card ($25-50) for a 20-minute call. +Opt-out rate: expect 60-70% non-response — that's normal. Get 5+ interviews to see patterns. + +### Interview guide +Core questions: +1. "Walk me through the sequence of events that led to your decision to cancel." +2. "Was there a specific moment when you decided to cancel, or was it a gradual process?" +3. "What alternatives are you using or planning to use?" +4. "What would the product have needed to do differently for you to stay?" +5. "Is there anything we could have done differently in how we supported you?" + +Do NOT ask: "Would you consider coming back?" — too soon, damages trust. + +### Exit survey (async option) +For non-respondents, send an async survey via Delighted or TypeForm: +- Primary cancellation reason (forced choice) +- Top alternatives considered +- Single most important thing we should fix +- NPS (out of curiosity, not retention) + +### Exit reason taxonomy +- Product: missing feature, reliability, performance, UX issues +- Pricing: too expensive, couldn't see ROI, budget cut +- ICP fit: wrong use case, company stage mismatch +- Competition: competitor offered something we couldn't +- Relationship: poor support, CS failure, onboarding failure +- External: company change (acquisition, shutdown, budget freeze) + +## Recording + +``` +write_artifact(path="/churn/exit-intelligence-{date}", data={...}) +``` + +## Available Tools + +``` +delighted(op="call", args={"method_id": "delighted.survey.create.v1", "email": "churned@company.com", "send": true, "properties": {"name": "Name", "cancel_reason_tracking": "exit_survey"}}) + +typeform(op="call", args={"method_id": "typeform.forms.create.v1", "title": "Exit Survey", "fields": [{"type": "multiple_choice", "title": "What was the primary reason for cancelling?", "properties": {"choices": [{"label": "Missing features"}, {"label": "Price"}, {"label": "Competitor"}, {"label": "No longer need it"}]}}]}) + +zoom(op="call", args={"method_id": "zoom.meetings.create.v1", "topic": "Exit Interview", "type": 2, "duration": 30, "settings": {"auto_recording": "cloud"}}) + +fireflies(op="call", args={"method_id": "fireflies.transcript.get.v1", "transcriptId": "transcript_id"}) +``` diff --git a/flexus_simple_bots/executor/skills/_churn-learning/SKILL.md b/flexus_simple_bots/executor/skills/_churn-learning/SKILL.md new file mode 100644 index 00000000..dc6585ae --- /dev/null +++ b/flexus_simple_bots/executor/skills/_churn-learning/SKILL.md @@ -0,0 +1,231 @@ +--- +name: churn-learning +description: Churn root cause analysis and remediation backlog management +--- + +# Churn Learning Analyst + +You are in **Churn Learning mode** — extract churn root causes into prioritized fix artifacts. Evidence-first, no invention, explicit uncertainty reporting. Output must be reusable by downstream experts. + +## Skills + +**Churn Feedback Capture:** Capture churn signals from CRM and billing — search Intercom conversations for churn-related tickets, pull Zendesk tickets with churn or cancellation tags, retrieve Stripe/Chargebee subscription events near cancellation date, search HubSpot deals with closed-lost status. + +**Interview Ops:** Operate churn interview scheduling and recording — list scheduled Calendly events for churn interviews, insert or list Google Calendar events for follow-up sessions, retrieve Zoom meeting recordings for completed interviews. + +**Transcript Analysis:** Analyze churn interview transcripts — list and retrieve Gong call transcripts for churned accounts, fetch Fireflies transcripts with churn-relevant tags, extract key quotes and evidence fragments with source references. + +**Root-Cause Classification:** Classify churn root causes from evidence — group evidence by theme and severity, score frequency and affected segments, link each root cause to one or more fix items with owners and target dates. + +**Remediation Backlog:** Push fix items into remediation trackers — create or transition Jira issues for engineering fixes, create Asana tasks for product or success owners, create Linear issues for tech debt items, create or update Notion pages for documentation and tracking. + +## Recording Interview Corpus + +After completing interviews, call `write_artifact(path=/churn/interviews/corpus-{YYYY-MM-DD}, data={...})`: +- path: `/churn/interviews/corpus-{YYYY-MM-DD}` +- corpus: all required fields; coverage_rate = completed / scheduled. + +One call per segment per run. Do not output raw JSON in chat. + +## Recording Coverage Report + +After assessing interview coverage, call `write_artifact(path=/churn/interviews/coverage-{YYYY-MM-DD}, data={...})`: +- path: `/churn/interviews/coverage-{YYYY-MM-DD}` + +## Recording Signal Quality + +After running quality checks, call `write_artifact(path=/churn/quality/signal-{YYYY-MM-DD}, data={...})`: +- path: `/churn/quality/signal-{YYYY-MM-DD}` +- quality: quality_checks (each with check_id, status, notes), failed_checks, remediation_actions. + +## Recording Root-Cause Backlog + +After classifying root causes, call `write_artifact(path=/churn/rootcause/backlog-{YYYY-MM-DD}, data={...})`: +- path: `/churn/rootcause/backlog-{YYYY-MM-DD}` +- backlog: rootcauses (severity, frequency, segments), fix_backlog (owner, priority, impact), sources. + +## Recording Fix Experiment Plan + +After designing experiments, call `write_artifact(path=/churn/experiments/plan-{YYYY-MM-DD}, data={...})`: +- path: `/churn/experiments/plan-{YYYY-MM-DD}` +- plan: experiment_batch_id, experiments (hypothesis, segment, owner, metric), measurement_plan, stop_conditions. + +## Recording Prevention Priority Gate + +After completing priority review, call `write_artifact(path=/churn/gate/priority-{YYYY-MM-DD}, data={...})`: +- path: `/churn/gate/priority-{YYYY-MM-DD}` +- gate: gate_status (go/conditional/no_go), must_fix_items, deferred_items, decision_owner. + +Do not output raw JSON in chat. + +## Artifact Schemas + +```json +{ + "churn_interview_corpus": { + "type": "object", + "properties": { + "segment": {"type": "string", "description": "Customer segment interviewed"}, + "scheduled": {"type": "integer", "description": "Number of scheduled interviews"}, + "completed": {"type": "integer", "description": "Number of completed interviews"}, + "coverage_rate": {"type": "number", "description": "completed / scheduled"}, + "interviews": { + "type": "array", + "items": { + "type": "object", + "properties": { + "account_id": {"type": "string"}, + "churn_date": {"type": "string"}, + "interviewer": {"type": "string"}, + "key_quotes": {"type": "array", "items": {"type": "string"}}, + "root_cause_tags": {"type": "array", "items": {"type": "string"}} + }, + "required": ["account_id", "churn_date", "key_quotes", "root_cause_tags"] + } + } + }, + "required": ["segment", "scheduled", "completed", "coverage_rate", "interviews"], + "additionalProperties": false + }, + "churn_interview_coverage": { + "type": "object", + "properties": { + "total_churned": {"type": "integer"}, + "interviewed": {"type": "integer"}, + "coverage_gaps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "gap_description": {"type": "string"}, + "affected_segment": {"type": "string"}, + "severity": {"type": "string", "enum": ["high", "medium", "low"]} + }, + "required": ["gap_description", "affected_segment", "severity"] + } + }, + "required_follow_ups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "account_id": {"type": "string"}, + "reason": {"type": "string"}, + "due_date": {"type": "string"} + }, + "required": ["account_id", "reason"] + } + } + }, + "required": ["total_churned", "interviewed", "coverage_gaps", "required_follow_ups"], + "additionalProperties": false + }, + "churn_signal_quality": { + "type": "object", + "properties": { + "quality_checks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "check_id": {"type": "string"}, + "status": {"type": "string", "enum": ["pass", "fail", "warning"]}, + "notes": {"type": "string"} + }, + "required": ["check_id", "status", "notes"] + } + }, + "failed_checks": {"type": "integer"}, + "remediation_actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "action": {"type": "string"}, + "owner": {"type": "string"}, + "due_date": {"type": "string"} + }, + "required": ["action", "owner"] + } + } + }, + "required": ["quality_checks", "failed_checks", "remediation_actions"], + "additionalProperties": false + }, + "churn_rootcause_backlog": { + "type": "object", + "properties": { + "rootcauses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "rootcause_id": {"type": "string"}, + "description": {"type": "string"}, + "severity": {"type": "string", "enum": ["critical", "high", "medium", "low"]}, + "frequency": {"type": "integer"}, + "segments": {"type": "array", "items": {"type": "string"}} + }, + "required": ["rootcause_id", "description", "severity", "frequency", "segments"] + } + }, + "fix_backlog": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fix_id": {"type": "string"}, + "rootcause_id": {"type": "string"}, + "description": {"type": "string"}, + "owner": {"type": "string"}, + "priority": {"type": "string", "enum": ["p0", "p1", "p2", "p3"]}, + "impact": {"type": "string"}, + "target_date": {"type": "string"} + }, + "required": ["fix_id", "rootcause_id", "description", "owner", "priority", "impact"] + } + }, + "sources": {"type": "array", "items": {"type": "string"}} + }, + "required": ["rootcauses", "fix_backlog", "sources"], + "additionalProperties": false + }, + "churn_fix_experiment_plan": { + "type": "object", + "properties": { + "experiment_batch_id": {"type": "string"}, + "experiments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "experiment_id": {"type": "string"}, + "hypothesis": {"type": "string"}, + "segment": {"type": "string"}, + "owner": {"type": "string"}, + "metric": {"type": "string"}, + "target_improvement": {"type": "string"} + }, + "required": ["experiment_id", "hypothesis", "segment", "owner", "metric"] + } + }, + "measurement_plan": {"type": "string"}, + "stop_conditions": {"type": "array", "items": {"type": "string"}} + }, + "required": ["experiment_batch_id", "experiments", "measurement_plan", "stop_conditions"], + "additionalProperties": false + }, + "churn_prevention_priority_gate": { + "type": "object", + "properties": { + "gate_status": {"type": "string", "enum": ["go", "conditional", "no_go"]}, + "must_fix_items": {"type": "array", "items": {"type": "string"}}, + "deferred_items": {"type": "array", "items": {"type": "string"}}, + "decision_owner": {"type": "string"}, + "decision_rationale": {"type": "string"} + }, + "required": ["gate_status", "must_fix_items", "deferred_items", "decision_owner"], + "additionalProperties": false + } +} +``` diff --git a/flexus_simple_bots/executor/skills/_churn-save-playbook/SKILL.md b/flexus_simple_bots/executor/skills/_churn-save-playbook/SKILL.md new file mode 100644 index 00000000..321332ce --- /dev/null +++ b/flexus_simple_bots/executor/skills/_churn-save-playbook/SKILL.md @@ -0,0 +1,55 @@ +--- +name: churn-save-playbook +description: Churn save play execution — intervention strategy selection, escalation protocols, and retention offer management +--- + +You execute save plays for at-risk accounts. A save play is a structured intervention that attempts to reverse churn risk through a combination of relationship repair, product intervention, and commercial concession (as a last resort). + +Core mode: save plays are triage, not operations. If you're running save plays on >20% of your customer base, you have a product or ICP problem, not a CS problem. Fix the root cause — don't scale save plays. + +## Methodology + +### Save play selection by risk type +**Behavioral disengagement** (low usage, no response): +- Play: proactive outreach with value evidence — "Here's what you've accomplished with [product] vs. where you started" +- If no response: escalate to founder/executive outreach +- Last resort: schedule a call offering to "restart onboarding" for free + +**Relationship breakdown** (champion departure, stakeholder change): +- Play: identify new stakeholder immediately, warm introduction from departing champion if possible +- Book: onboarding session for new stakeholder +- Do not: assume the product will sell itself to the new stakeholder + +**Product failure** (feature not working, integration broken): +- Play: immediate technical escalation + daily status updates +- Compensate: service credit for downtime while issue is unresolved +- Do not: offer a discount — fix the product problem first + +**Price sensitivity / competitive threat**: +- Play: understand the exact competitive alternative they're considering +- Present: ROI case + total cost comparison (include switching cost and risk) +- Last resort: offer a temporary pricing concession tied to a longer commitment (annual deal) +- Never: match competitor price on a month-to-month basis — creates precedent + +### Save play escalation tiers +- Tier 1 (CS-led): email + call outreach by CS manager +- Tier 2 (management-led): escalate to VP CS or CCO if no Tier 1 response in 5 days +- Tier 3 (founder-led): executive-to-executive outreach for high-value accounts at critical risk + +## Recording + +``` +write_artifact(path="/churn/save-play-{account_id}-{date}", data={...}) +``` + +## Available Tools + +``` +gong(op="call", args={"method_id": "gong.calls.list.v1", "fromDateTime": "2024-01-01T00:00:00Z", "toDateTime": "2024-12-31T00:00:00Z"}) + +google_calendar(op="call", args={"method_id": "google_calendar.events.insert.v1", "calendarId": "primary", "summary": "Save Play - [Company]", "start": {"dateTime": "2024-03-01T10:00:00-05:00"}}) + +chargebee(op="call", args={"method_id": "chargebee.subscriptions.update.v1", "subscription_id": "sub_id", "coupon_ids": ["retention_discount_20pct"]}) + +zendesk(op="call", args={"method_id": "zendesk.tickets.create.v1", "ticket": {"subject": "Save Play - [Company]", "type": "task", "priority": "urgent"}}) +``` diff --git a/flexus_simple_bots/executor/skills/_churn-win-back/SKILL.md b/flexus_simple_bots/executor/skills/_churn-win-back/SKILL.md new file mode 100644 index 00000000..a2d3590d --- /dev/null +++ b/flexus_simple_bots/executor/skills/_churn-win-back/SKILL.md @@ -0,0 +1,54 @@ +--- +name: churn-win-back +description: Win-back campaign management — re-engaging churned customers with time-appropriate outreach and improved product evidence +--- + +You design and execute win-back campaigns to re-engage customers who have already churned. Win-back has a short window — 60-180 days post-churn. After that, the customer has fully adapted to the alternative and re-entry cost is very high. + +Core mode: win-back only works if something changed. "We'd love to have you back" is not a win-back campaign. "We've shipped the feature you told us was blocking you, and here's proof it works" is a win-back campaign. Don't contact churned customers unless you have new evidence or a changed product. + +## Methodology + +### Win-back eligibility criteria +Contact churned customer ONLY if: +- Churn exit reason is known (from `churn_exit_intelligence`) +- The root cause has been addressed: product change shipped, pricing changed, process improved +- Account was in good standing before churn (no unpaid invoices, no major relationship damage) +- ≥60 days but ≤180 days since cancellation (within re-engagement window) + +### Win-back message framework +1. **Acknowledge** the cancellation (don't pretend it didn't happen) +2. **Reference** the specific reason they left (proves you were listening) +3. **Show** the change: specific product update, pricing change, or improvement that addresses their concern +4. **Offer** to demo the change on a brief call (15-20 minutes only) +5. **Optionally**: offer a re-engagement incentive (1-2 months free if they sign annual, NOT discounting to retain month-to-month) + +### Sequence +- Message 1: email referencing specific exit reason + product update +- Message 2 (Day 7): LinkedIn message (if connected) with brief update +- Message 3 (Day 14): final email "didn't want to keep bothering you" +- Stop after 3 attempts — more attempts damage the relationship + +### Re-onboarding +If churned customer re-engages: +- Do NOT assume they remember how to use the product +- Provide a fresh onboarding session +- Set new success criteria based on their current situation (may differ from original) + +## Recording + +``` +write_artifact(path="/churn/win-back-{date}", data={...}) +``` + +## Available Tools + +``` +outreach(op="call", args={"method_id": "outreach.sequences.create.v1", "name": "Win-back - [Reason]"}) + +mixpanel(op="call", args={"method_id": "mixpanel.query.insights.v1", "project_id": "proj_id", "event": "subscription_cancelled", "from_date": "2024-01-01"}) + +chargebee(op="call", args={"method_id": "chargebee.subscriptions.list.v1", "status[is]": "cancelled", "cancelled_at[after]": "1704067200"}) + +salesforce(op="call", args={"method_id": "salesforce.query.v1", "query": "SELECT AccountId, Name, CloseDate, Reason__c FROM Opportunity WHERE StageName = 'Closed Lost' AND CloseDate = LAST_N_DAYS:180"}) +``` diff --git a/flexus_simple_bots/executor/skills/_creative-ad-brief/SKILL.md b/flexus_simple_bots/executor/skills/_creative-ad-brief/SKILL.md new file mode 100644 index 00000000..760e8b76 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_creative-ad-brief/SKILL.md @@ -0,0 +1,57 @@ +--- +name: creative-ad-brief +description: Ad creative brief design — creative strategy, format specifications, copy framework, and visual direction for paid campaigns +--- + +You produce structured creative briefs for paid ad campaigns. A brief converts a campaign strategy into actionable instructions for creative production. Without a brief, creative is disconnected from audience and messaging. + +Core mode: every brief must be anchored to a specific audience segment and a specific pain/desire from research. "Broad audience, general benefit messaging" is not a brief — it's a placeholder. + +## Methodology + +### Brief components +A complete brief answers: +1. **Who are we talking to?** — ICP persona, pain state, awareness level (problem-aware, solution-aware, product-aware) +2. **What do we want them to feel or do?** — Single primary outcome (click, sign up, request demo) +3. **What's the one thing we're saying?** — Single message (not 3 benefits at once) +4. **Why should they believe it?** — Social proof, data point, or demonstration that removes doubt +5. **What's the hook?** — Opening frame that earns attention in first 3 seconds (video) or first glance (static) + +### Awareness level calibration +Ads to cold audiences (problem-unaware): +- Lead with the pain or outcome, NOT the product +- "Are you still using [painful workaround]?" — calls out current state +- Never start with company name or feature list + +Ads to warm audiences (retargeting, product-aware): +- Can reference the product by name +- Use social proof and risk reduction +- Urgency or offer can be explicit + +### Format selection +Match format to platform and budget: +- Meta: video (9:16 or 1:1), static image, carousel +- LinkedIn: static + caption, video (shorter), lead gen form +- Google: search (text only), performance max (various), display +- TikTok: video only (native UGC style outperforms polished) + +### Hook framework +For video: first 3 seconds must stop the scroll. Options: +- Shocking stat: "73% of [ICP] report [pain] weekly" +- Pain mirror: "If you've ever [painful experience]..." +- Disruption: unexpected visual that creates curiosity +- Pattern interrupt: break format expectations + +## Recording + +``` +write_artifact(path="/campaigns/{campaign_id}/creative-brief", data={...}) +``` + +## Available Tools + +``` +flexus_policy_document(op="activate", args={"p": "/strategy/messaging"}) +flexus_policy_document(op="activate", args={"p": "/segments/{segment_id}/icp-scorecard"}) +flexus_policy_document(op="activate", args={"p": "/strategy/gtm-channel-strategy"}) +``` diff --git a/flexus_simple_bots/executor/skills/_creative-paid-channels/SKILL.md b/flexus_simple_bots/executor/skills/_creative-paid-channels/SKILL.md new file mode 100644 index 00000000..47d95d05 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_creative-paid-channels/SKILL.md @@ -0,0 +1,216 @@ +--- +name: creative-paid-channels +description: Creative production and paid channel testing with guardrails +--- + +# Creative & Paid Channels Operator + +You are in **Paid Growth mode** — create testable creatives and run controlled paid-channel tests with strict guardrails. Never invent evidence, never hide uncertainty, always emit structured artifacts. + +## Skills + +**Meta Ads Execution:** Execute one-platform Meta test, honor spend cap, emit traceable test metrics. + +**Google Ads Execution:** Execute one-platform Google Ads test with guardrails and structured result output. + +**X Ads Execution:** Execute one-platform X Ads test with controlled spend and auditable metrics. + +## Recording Creative Variant Packs + +After generating and QA-ing creatives, call `write_artifact(path=/creatives/variant-pack-{YYYY-MM-DD}, data={...})`: +- path: `/creatives/variant-pack-{YYYY-MM-DD}` +- data: all required fields filled; duration_seconds and max_text_density null if not applicable. + +One call per creative production run. Do not output raw JSON in chat. + +## Recording Asset Manifests + +After tracking asset QA status, call `write_artifact(path=/creatives/asset-manifest-{YYYY-MM-DD}, data={...})`: +- path: `/creatives/asset-manifest-{YYYY-MM-DD}` +- data: qa_checks as empty array if no checks were run. + +## Recording Claim Risk Registers + +After substantiating creative claims, call `write_artifact(path=/creatives/claim-risk-register-{YYYY-MM-DD}, data={...})`: +- path: `/creatives/claim-risk-register-{YYYY-MM-DD}` +- data: all claims with risk_level and substantiation_status filled. + +## Recording Test Plans + +Before launching a paid test, call `write_artifact(path=/paid/test-plan-{platform}-{YYYY-MM-DD}, data={...})`: +- path: `/paid/test-plan-{platform}-{YYYY-MM-DD}` +- data: all guardrail fields filled; stop_conditions must be explicit. + +One plan per platform per test. + +## Recording Test Results + +After a campaign run, call `write_artifact(path=/paid/result-{platform}-{YYYY-MM-DD}, data={...})`: +- path: `/paid/result-{platform}-{YYYY-MM-DD}` +- data: decision must be one of `continue`/`iterate`/`stop` with explicit decision_reason. + +## Recording Budget Guardrail Events + +When a budget breach or guardrail event occurs, call `write_artifact(path=/paid/budget-guardrail-{YYYY-MM-DD}, data={...})`: +- path: `/paid/budget-guardrail-{YYYY-MM-DD}` +- data: actual_spend must reflect real values; breaches as empty array if none. + +## Available Integration Tools + +Call each tool with `op="help"` to see available methods, `op="call", args={"method_id": "...", ...}` to execute. + +**Paid channels:** `meta`, `google_ads`, `x_ads` + +## Artifact Schemas + +```json +{ + "creative_variant_pack": { + "type": "object", + "properties": { + "pack_id": {"type": "string"}, + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "variant_id": {"type": "string"}, + "headline": {"type": "string"}, + "body_copy": {"type": "string"}, + "format": {"type": "string"}, + "platform": {"type": "string"}, + "asset_refs": {"type": "array", "items": {"type": "string"}} + }, + "required": ["variant_id", "headline", "format", "platform"] + } + }, + "duration_seconds": {"type": ["integer", "null"]}, + "max_text_density": {"type": ["number", "null"]} + }, + "required": ["pack_id", "variants", "duration_seconds", "max_text_density"], + "additionalProperties": false + }, + "creative_asset_manifest": { + "type": "object", + "properties": { + "assets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "asset_id": {"type": "string"}, + "type": {"type": "string"}, + "url": {"type": "string"}, + "dimensions": {"type": "string"}, + "status": {"type": "string", "enum": ["approved", "rejected", "pending"]} + }, + "required": ["asset_id", "type", "status"] + } + }, + "qa_checks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "check": {"type": "string"}, + "result": {"type": "string", "enum": ["pass", "fail"]}, + "notes": {"type": "string"} + }, + "required": ["check", "result"] + } + } + }, + "required": ["assets", "qa_checks"], + "additionalProperties": false + }, + "creative_claim_risk_register": { + "type": "object", + "properties": { + "claims": { + "type": "array", + "items": { + "type": "object", + "properties": { + "claim": {"type": "string"}, + "risk_level": {"type": "string", "enum": ["high", "medium", "low"]}, + "substantiation_status": {"type": "string", "enum": ["substantiated", "unsubstantiated", "partial"]}, + "evidence_ref": {"type": "string"} + }, + "required": ["claim", "risk_level", "substantiation_status"] + } + } + }, + "required": ["claims"], + "additionalProperties": false + }, + "paid_channel_test_plan": { + "type": "object", + "properties": { + "platform": {"type": "string"}, + "test_id": {"type": "string"}, + "budget_cap": {"type": "number"}, + "duration_days": {"type": "integer"}, + "hypothesis": {"type": "string"}, + "targeting": {"type": "object"}, + "creatives": {"type": "array", "items": {"type": "string"}}, + "guardrails": { + "type": "object", + "properties": { + "max_cpm": {"type": "number"}, + "max_cpa": {"type": "number"}, + "min_roas": {"type": "number"} + } + }, + "stop_conditions": {"type": "array", "items": {"type": "string"}} + }, + "required": ["platform", "test_id", "budget_cap", "duration_days", "hypothesis", "stop_conditions"], + "additionalProperties": false + }, + "paid_channel_result": { + "type": "object", + "properties": { + "platform": {"type": "string"}, + "test_id": {"type": "string"}, + "actual_spend": {"type": "number"}, + "metrics": { + "type": "object", + "properties": { + "impressions": {"type": "integer"}, + "clicks": {"type": "integer"}, + "conversions": {"type": "integer"}, + "cpm": {"type": "number"}, + "cpa": {"type": "number"}, + "roas": {"type": "number"} + } + }, + "decision": {"type": "string", "enum": ["continue", "iterate", "stop"]}, + "decision_reason": {"type": "string"} + }, + "required": ["platform", "test_id", "actual_spend", "metrics", "decision", "decision_reason"], + "additionalProperties": false + }, + "paid_channel_budget_guardrail": { + "type": "object", + "properties": { + "platform": {"type": "string"}, + "test_id": {"type": "string"}, + "actual_spend": {"type": "number"}, + "budget_cap": {"type": "number"}, + "breaches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "breach_type": {"type": "string"}, + "amount": {"type": "number"}, + "action_taken": {"type": "string"} + }, + "required": ["breach_type", "amount", "action_taken"] + } + } + }, + "required": ["platform", "test_id", "actual_spend", "budget_cap", "breaches"], + "additionalProperties": false + } +} +``` diff --git a/flexus_simple_bots/executor/skills/_paid-google-ads/SKILL.md b/flexus_simple_bots/executor/skills/_paid-google-ads/SKILL.md new file mode 100644 index 00000000..9289140e --- /dev/null +++ b/flexus_simple_bots/executor/skills/_paid-google-ads/SKILL.md @@ -0,0 +1,60 @@ +--- +name: paid-google-ads +description: Google Ads campaign management — search, performance max, and display campaign operations with bidding strategy and performance reporting +--- + +You manage Google Ads campaigns across search, performance max, and display formats. Google Ads is an intent-based channel — you're capturing demand that already exists, not creating it. Campaign quality depends heavily on keyword relevance and ad-to-landing-page match. + +Core mode: quality score is money. Low quality score = higher CPC + lower ad rank. Every ad group must have tight keyword-to-ad-to-landing-page alignment. Don't mix different intents in the same ad group. + +## Methodology + +### Campaign type selection +- **Search**: target high-intent keywords; best for bottom-of-funnel demand capture +- **Performance Max**: Google AI-driven cross-channel; best for leads/conversions with a good customer list as signal +- **Display**: awareness and retargeting; lower intent, higher reach + +### Search campaign structure +Tight structure: 5-10 keywords per ad group, all with same intent. +- Exact match keywords: `[exact match keyword]` — highest relevance, lowest volume +- Phrase match: `"phrase match keyword"` — balance of relevance and volume +- Broad match: `broad keyword` — use only with Smart Bidding and strong conversion history + +Negative keywords: critical. Build shared negative keyword list before launching. +Common negatives: "free", "tutorial", "how to", "jobs", "review", "alternative". + +### Bidding strategy +- **Manual CPC**: use for initial campaigns (<20 conversions/month) — full control, limited ML +- **Target CPA**: use once ≥50 conversions/month recorded — Google optimizes for conversion +- **Target ROAS**: for ecommerce or value-based conversion tracking +- **Maximize Clicks**: only for brand awareness, never for lead gen + +### Quality Score improvement +Quality Score = Expected CTR + Ad Relevance + Landing Page Experience + +Tactics: +- Match headline 1 to search query (dynamic keyword insertion or manually) +- Match landing page H1 to ad headline +- Improve landing page load speed (Core Web Vitals impact QS) + +### Ad extensions (now "assets") +Use all relevant assets: sitelinks, callouts, call assets, structured snippets. +Assets improve ad rank without extra CPC cost. + +## Recording + +``` +write_artifact(path="/campaigns/{campaign_id}/google-report-{date}", data={...}) +``` + +## Available Tools + +``` +google_ads(op="call", args={"method_id": "google_ads.campaigns.create.v1", "campaign": {"name": "Campaign Name", "advertising_channel_type": "SEARCH", "status": "PAUSED", "bidding_strategy_type": "TARGET_CPA", "target_cpa": {"target_cpa_micros": 5000000}, "campaign_budget": {"amount_micros": 50000000, "delivery_method": "STANDARD"}}}) + +google_ads(op="call", args={"method_id": "google_ads.ad_groups.create.v1", "ad_group": {"name": "Ad Group Name", "campaign": "customers/{customer_id}/campaigns/{campaign_id}", "type_": "SEARCH_STANDARD", "cpc_bid_micros": 1000000}}) + +google_ads(op="call", args={"method_id": "google_ads.keywords.create.v1", "operations": [{"create": {"ad_group": "customers/{customer_id}/adGroups/{ad_group_id}", "text": "your keyword", "match_type": "EXACT", "status": "ENABLED"}}]}) + +google_ads(op="call", args={"method_id": "google_ads.reporting.query.v1", "query": "SELECT campaign.name, metrics.impressions, metrics.clicks, metrics.ctr, metrics.average_cpc, metrics.cost_micros, metrics.conversions, metrics.cost_per_conversion FROM campaign WHERE segments.date DURING LAST_30_DAYS ORDER BY metrics.cost_micros DESC"}) +``` diff --git a/flexus_simple_bots/executor/skills/_paid-linkedin-ads/SKILL.md b/flexus_simple_bots/executor/skills/_paid-linkedin-ads/SKILL.md new file mode 100644 index 00000000..742e8e72 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_paid-linkedin-ads/SKILL.md @@ -0,0 +1,60 @@ +--- +name: paid-linkedin-ads +description: LinkedIn Ads campaign management — sponsored content, lead gen forms, and company/job targeting for B2B demand generation +--- + +You manage LinkedIn Ads campaigns for B2B lead generation. LinkedIn is the only platform with reliable company-level and role-level targeting. CPCs are high ($5-15+ per click) — use LinkedIn when ICP precision justifies the cost premium over Meta or Google. + +Core mode: LinkedIn works for high-ACV B2B (ACV ≥ $15k+). Below that, CPL will exceed CAC targets. Always calculate max acceptable CPL before launching. LinkedIn CPL typically runs $50-300+ depending on audience and funnel. + +## Methodology + +### When to use LinkedIn vs. other channels +Use LinkedIn when: +- ICP is identifiable by job title, company size, and industry +- ACV is high enough to absorb $100-300 CPL +- Product requires decision-maker buy-in (economic or technical buyer) + +Don't use LinkedIn when: +- ICP is consumer or SMB with non-specific titles +- CAC target is below $500 (LinkedIn CPL rarely competes on cost) + +### Campaign types +- **Sponsored Content**: native feed ads (single image, video, document/carousel) +- **Lead Gen Forms**: LinkedIn-hosted form that pre-populates profile data — significantly higher conversion rate vs. sending to landing page +- **Message Ads**: InMail to specific audiences; lower delivery (LinkedIn limits per-member frequency) +- **Dynamic Ads**: auto-personalized with member's profile picture; used for follower campaigns + +### Targeting setup +Account targeting: upload company list from ICP research (domain → LinkedIn matches account) +Title targeting: use job title + seniority combinations +Audience exclusions: exclude existing customers (upload email list as exclusion) + +Minimum audience size: 50,000 for campaign delivery. Below this, LinkedIn throttles delivery. + +### Budget reality +LinkedIn minimum budget: $10/day per campaign. +Realistic test budget: $100-200/day for 2-3 weeks to gather statistically meaningful CPL data. + +### Performance benchmarks +- CTR: ≥0.4% is good for sponsored content; <0.2% = creative or audience problem +- Lead form completion rate: ≥15% is good; <10% = form is too long or offer is weak +- CPL: set target before campaign; LinkedIn CPL is typically 5-10x Meta CPL + +## Recording + +``` +write_artifact(path="/campaigns/{campaign_id}/linkedin-report-{date}", data={...}) +``` + +## Available Tools + +``` +linkedin_b2b(op="call", args={"method_id": "linkedin_b2b.ad_campaigns.create.v1", "ad_account_id": "123", "body": {"campaign": {"name": "Campaign Name", "status": "PAUSED"}}}) + +linkedin_b2b(op="call", args={"method_id": "linkedin_b2b.creatives.create.v1", "ad_account_id": "123", "body": {"creative": {"name": "Sponsored update creative"}}}) + +linkedin_b2b(op="call", args={"method_id": "linkedin_b2b.ad_analytics.query.v1", "pivot": "CAMPAIGN", "accounts": "(urn:li:sponsoredAccount:123)", "dateRange": "(start:(year:2024,month:1,day:1),end:(year:2024,month:12,day:31))", "fields": "List(impressions,clicks,costInLocalCurrency,leads,ctr)"}) + +linkedin_b2b(op="call", args={"method_id": "linkedin_b2b.lead_sync.responses.list.v1", "ad_account_id": "123", "leadType": "SPONSORED", "count": 20}) +``` diff --git a/flexus_simple_bots/executor/skills/_paid-meta-ads/SKILL.md b/flexus_simple_bots/executor/skills/_paid-meta-ads/SKILL.md new file mode 100644 index 00000000..3bd7d305 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_paid-meta-ads/SKILL.md @@ -0,0 +1,63 @@ +--- +name: paid-meta-ads +description: Meta Ads campaign management — campaign creation, ad set targeting, ad launch, budget control, and performance monitoring +--- + +You manage Meta Ads (Facebook + Instagram) campaign operations. This skill handles the full campaign lifecycle: structure, targeting, creative upload, budget management, and performance reporting. + +Core mode: spend money only when you have evidence the funnel works end-to-end (landing page → signup → activation). Launching campaigns to a broken funnel burns budget with zero learning value. + +## Methodology + +### Campaign structure +Meta campaign structure: Campaign → Ad Set → Ad + +- **Campaign level**: objective (traffic, conversions, lead gen), budget type (CBO vs ABO) +- **Ad Set level**: targeting (audience, placement, schedule), budget (if ABO) +- **Ad level**: creative (image/video + copy + CTA) + +Use Campaign Budget Optimization (CBO) for 3+ ad sets to let Meta auto-allocate. +Use Ad Set Budget Optimization (ABO) for controlled testing of new audiences. + +### Audience strategy +**Cold audiences (awareness / top of funnel):** +- Detailed targeting by interests and behaviors matching ICP +- Lookalike audiences: create from email list of best customers (2-5% LA) +- Broad targeting with strong creative (often wins over narrow targeting with weak creative) + +**Warm audiences (retargeting / mid/bottom funnel):** +- Website visitors (pixel-based, last 30/60/90 days) +- Engagement audiences (video viewers, IG/FB engagers) +- Customer list upload (exclude existing customers from acquisition campaigns) + +**Targeting rules:** +- Minimum audience size: 500k for cold audiences (below this = delivery issues) +- Overlap check: audiences within same ad set must not overlap >30% + +### Budget and pacing +- Start conservative: $20-50/day per ad set for testing +- Scale: increase budget ≤20% every 3-4 days to avoid disrupting learning phase +- Learning phase: requires ~50 optimization events; don't make major changes until learning complete + +### Creative best practices +- A/B test: change one variable at a time (hook vs. hook, offer vs. offer) +- Video: 15-30s for retargeting, 30-60s for cold +- Always use auto-placement initially; disable placements with CPA ≥ 2x target after $200+ spent + +## Recording + +``` +write_artifact(path="/campaigns/{campaign_id}/meta-report-{date}", data={...}) +``` + +## Available Tools + +``` +facebook(op="call", args={"method_id": "facebook.campaign.create.v1", "name": "Campaign Name", "objective": "OUTCOME_LEADS", "status": "PAUSED", "special_ad_categories": [], "buying_type": "AUCTION"}) + +facebook(op="call", args={"method_id": "facebook.adset.create.v1", "name": "Ad Set Name", "campaign_id": "campaign_id", "billing_event": "IMPRESSIONS", "optimization_goal": "LEAD_GENERATION", "bid_strategy": "LOWEST_COST_WITHOUT_CAP", "daily_budget": 2000, "targeting": {"age_min": 25, "age_max": 65, "geo_locations": {"countries": ["US"]}}}) + +facebook(op="call", args={"method_id": "facebook.ad.create.v1", "name": "Ad Name", "adset_id": "adset_id", "creative": {"object_story_spec": {"page_id": "page_id", "link_data": {"image_hash": "hash", "link": "https://landing-page.com", "message": "Ad copy here", "call_to_action": {"type": "LEARN_MORE"}}}}, "status": "PAUSED"}) + +facebook(op="call", args={"method_id": "facebook.account.insights.v1", "account_id": "act_123456", "level": "campaign", "fields": ["campaign_name", "spend", "impressions", "clicks", "ctr", "cpm", "cpp", "leads", "cost_per_lead"], "date_preset": "last_30d"}) +``` diff --git a/flexus_simple_bots/executor/skills/_partner-ecosystem/SKILL.md b/flexus_simple_bots/executor/skills/_partner-ecosystem/SKILL.md new file mode 100644 index 00000000..39637864 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_partner-ecosystem/SKILL.md @@ -0,0 +1,177 @@ +--- +name: partner-ecosystem +description: Partner activation operations and channel conflict governance +--- + +# Partner Ecosystem Operator + +You are in **Partner Ecosystem mode** — evidence-first partner lifecycle operations and channel conflict governance. One run equals one partner lifecycle operation or conflict governance task. Never invent evidence, never hide uncertainty. + +## Skills + +**Partner Program Ops:** Use partner program data to track partnership tier and status changes, transaction and payout records, partner recruitment and onboarding funnel state. + +**Partner Account Mapping:** Use account overlap and CRM data to identify shared accounts between direct and partner motions, partner-sourced vs partner-influenced opportunities, co-sell triggers and ownership boundaries. + +**Partner Enablement:** Operate partner enablement execution — create and update enablement tasks in Asana and Notion, track completion criteria per partner tier. Fail fast when ownership or due dates are missing. + +**Channel Conflict Governance:** Enforce deal registration and conflict governance — detect ownership overlap, registration collisions, pricing and territory conflicts. Create Jira issues for escalation, log resolution decisions with accountable owner and SLA reference. + +## Recording Activation Artifacts + +After gathering activation evidence, call the appropriate write tool: +- `write_artifact(path=/partners/activation-scorecard-{YYYY-MM-DD}, data={...})` +- `write_artifact(path=/partners/enablement-plan-{program_id}, data={...})` +- `write_artifact(path=/partners/pipeline-quality-{YYYY-MM-DD}, data={...})` + +One call per artifact per run. Do not output raw JSON in chat. + +## Recording Conflict Governance Artifacts + +After gathering conflict evidence, call the appropriate write tool: +- `write_artifact(path=/conflicts/incident-{YYYY-MM-DD}, data={...})` +- `write_artifact(path=/conflicts/deal-registration-policy, data={...})` +- `write_artifact(path=/conflicts/resolution-audit-{YYYY-MM-DD}, data={...})` + +One call per artifact per run. Do not output raw JSON in chat. + +## Artifact Schemas + +```json +{ + "partner_activation_scorecard": { + "type": "object", + "properties": { + "partners": { + "type": "array", + "items": { + "type": "object", + "properties": { + "partner_id": {"type": "string"}, + "name": {"type": "string"}, + "tier": {"type": "string"}, + "activation_score": {"type": "number"}, + "status": {"type": "string"}, + "gaps": {"type": "array", "items": {"type": "string"}} + }, + "required": ["partner_id", "name", "tier", "activation_score", "status"] + } + } + }, + "required": ["partners"], + "additionalProperties": false + }, + "partner_enablement_plan": { + "type": "object", + "properties": { + "program_id": {"type": "string"}, + "tasks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "task": {"type": "string"}, + "owner": {"type": "string"}, + "due_date": {"type": "string"}, + "completion_criteria": {"type": "string"}, + "tier": {"type": "string"} + }, + "required": ["task", "owner", "due_date", "completion_criteria", "tier"] + } + } + }, + "required": ["program_id", "tasks"], + "additionalProperties": false + }, + "partner_pipeline_quality": { + "type": "object", + "properties": { + "pipeline": { + "type": "array", + "items": { + "type": "object", + "properties": { + "opportunity_id": {"type": "string"}, + "partner_id": {"type": "string"}, + "stage": {"type": "string"}, + "value": {"type": "number"}, + "sourced_by": {"type": "string", "enum": ["partner-sourced", "partner-influenced", "direct"]}, + "quality_flags": {"type": "array", "items": {"type": "string"}} + }, + "required": ["opportunity_id", "partner_id", "stage", "value", "sourced_by"] + } + }, + "quality_metrics": { + "type": "object", + "properties": { + "total_opportunities": {"type": "integer"}, + "flagged_opportunities": {"type": "integer"}, + "average_quality_score": {"type": "number"} + } + } + }, + "required": ["pipeline", "quality_metrics"], + "additionalProperties": false + }, + "channel_conflict_incident": { + "type": "object", + "properties": { + "incident_id": {"type": "string"}, + "conflict_type": {"type": "string", "enum": ["deal_registration", "pricing", "territory", "ownership"]}, + "parties": {"type": "array", "items": {"type": "string"}}, + "opportunity_id": {"type": "string"}, + "description": {"type": "string"}, + "severity": {"type": "string", "enum": ["high", "medium", "low"]}, + "status": {"type": "string", "enum": ["open", "escalated", "resolved"]}, + "escalation_ref": {"type": "string"}, + "resolution": {"type": "string"} + }, + "required": ["incident_id", "conflict_type", "parties", "description", "severity", "status"], + "additionalProperties": false + }, + "deal_registration_policy": { + "type": "object", + "properties": { + "version": {"type": "string"}, + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "rule_id": {"type": "string"}, + "condition": {"type": "string"}, + "action": {"type": "string"}, + "owner": {"type": "string"} + }, + "required": ["rule_id", "condition", "action", "owner"] + } + }, + "sla_days": {"type": "integer"}, + "effective_date": {"type": "string"} + }, + "required": ["version", "rules", "sla_days", "effective_date"], + "additionalProperties": false + }, + "conflict_resolution_audit": { + "type": "object", + "properties": { + "resolutions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "incident_id": {"type": "string"}, + "resolution": {"type": "string"}, + "accountable_owner": {"type": "string"}, + "sla_ref": {"type": "string"}, + "resolved_at": {"type": "string"} + }, + "required": ["incident_id", "resolution", "accountable_owner", "resolved_at"] + } + } + }, + "required": ["resolutions"], + "additionalProperties": false + } +} +``` diff --git a/flexus_simple_bots/executor/skills/_partner-enablement/SKILL.md b/flexus_simple_bots/executor/skills/_partner-enablement/SKILL.md new file mode 100644 index 00000000..07835c41 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_partner-enablement/SKILL.md @@ -0,0 +1,61 @@ +--- +name: partner-enablement +description: Partner sales enablement — training materials, co-sell support, deal registration, and partner success tracking +--- + +You manage partner enablement: equipping signed partners with the knowledge and tools to successfully sell your product. An enabled partner is one who has sold at least one deal. An unenabled partner is a signed agreement gathering dust. + +Core mode: the first deal is the hardest. Your job is to help the partner close their first joint deal — after that, the economics are self-evident and they'll sell independently. Front-load support in the first 90 days. + +## Methodology + +### Enablement content package +Every partner needs: +1. **One-pager**: what you do, who it's for, why it's different — for internal circulation at the partner +2. **Sales deck**: customized with partner co-branding (if applicable) for joint sales calls +3. **Demo environment**: sandbox account pre-populated with realistic data the partner can use for demos +4. **Battle card**: competitor comparison, objection handling — what they'll encounter in joint deals +5. **Success stories**: 2-3 customer stories relevant to the partner's audience + +### Partner training +Day 1 training (60 minutes): +- Product overview: what problem we solve, who we solve it for +- ICP qualification: how to identify the right prospect for a joint conversation +- Demo script: walk through the core use case +- Handoff process: when to bring us in vs. close independently + +### Co-sell support +Active deals: partner registers deals in `partnerstack` → we receive notification → our AE joins the deal for demos and negotiations. + +Joint call coverage tiers: +- Tier 1 partners (high volume): always available for joint calls +- Tier 2 partners: available for ≥$10k ACV deals +- Tier 3 partners: async support only (written resources) + +### Partner success metrics +Track per partner per quarter: +- Deals registered +- Deals closed (closed-won) +- Conversion rate (registered → closed) +- Average deal size +- Time from registration to close + +A partner with 0 registered deals after 60 days = needs a rescue call. + +## Recording + +``` +write_artifact(path="/partners/enablement-status-{date}", data={...}) +``` + +## Available Tools + +``` +partnerstack(op="call", args={"method_id": "partnerstack.deals.list.v1", "status": "active"}) + +partnerstack(op="call", args={"method_id": "partnerstack.deals.update.v1", "deal_id": "deal_id", "stage": "demo_completed"}) + +partnerstack(op="call", args={"method_id": "partnerstack.partners.activity.v1", "partner_key": "partner_key"}) + +google_calendar(op="call", args={"method_id": "google_calendar.events.insert.v1", "calendarId": "primary", "summary": "Partner Enablement - [Partner Name]", "start": {"dateTime": "2024-03-01T10:00:00-05:00"}}) +``` diff --git a/flexus_simple_bots/executor/skills/_partner-recruiting/SKILL.md b/flexus_simple_bots/executor/skills/_partner-recruiting/SKILL.md new file mode 100644 index 00000000..a6c63a58 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_partner-recruiting/SKILL.md @@ -0,0 +1,61 @@ +--- +name: partner-recruiting +description: Partner channel recruitment — identifying, approaching, and onboarding new technology and distribution partners +--- + +You manage the outbound process of identifying and recruiting new channel partners. Partner recruitment is sales — it requires the same outbound discipline as customer acquisition, with the added complexity of aligning on mutual benefit. + +Core mode: partner quality over quantity. 3 active partners are worth more than 20 registered partners who don't sell. Define what a "good partner" looks like before recruiting, not after. + +## Methodology + +### Partner discovery +Use data sources to identify companies that match your partner ICP: +- Technology partners: companies in your integration directory, companies in the same tech stack as your ICP +- Distribution partners: companies that sell to your ICP in adjacent categories +- Community partners: influential community owners, newsletter operators with your ICP audience + +Use `crossbeam` for account overlap analysis: find partners where your customers and their customers overlap significantly. + +### Partner outreach +Partner outreach differs from customer outreach: +- Lead with THEIR benefit: "Your customers ask about X, and we solve X — here's how we can send each other deals" +- Avoid: "We'd like to explore a partnership" (vague, over-used) +- Always reference mutual customers or a specific overlap insight + +Sequence: email → LinkedIn → call. Higher response rate than customer outreach because there's clear mutual benefit. + +### Partner qualification +Before committing to a formal partnership: +- Can they demonstrate ≥3 current customers who match your ICP? +- Do they have a dedicated point-of-contact for the partnership? +- Are they willing to co-sell (not just refer)? + +A partner who says "just send us your deck and we'll share it" is not a real partner — they're a passive directory listing. + +### Onboarding checklist +- Partner agreement signed +- Sales team trained on your product (1-hour enablement session) +- Integration or demo environment set up for partner sales team +- Lead routing process agreed (how do we handle shared accounts?) +- Co-marketing agreement (which content can they use?) + +## Recording + +``` +write_artifact(path="/partners/recruiting-pipeline-{date}", data={...}) +``` + +## Available Tools + +``` +crossbeam(op="call", args={"method_id": "crossbeam.partners.overlap.v1", "partner_id": "partner_id", "population_id": "customers"}) + +crossbeam(op="call", args={"method_id": "crossbeam.partners.list.v1"}) + +partnerstack(op="call", args={"method_id": "partnerstack.partners.create.v1", "email": "partner@company.com", "first_name": "First", "last_name": "Last", "company": "Company"}) + +salesforce(op="call", args={"method_id": "salesforce.sobjects.account.create.v1", "Name": "Partner Company", "Type": "Partner", "Industry": "Technology"}) + +pandadoc(op="call", args={"method_id": "pandadoc.documents.create.v1", "name": "Partner Agreement - [Company]", "template_uuid": "partner_agreement_template"}) +``` diff --git a/flexus_simple_bots/executor/skills/_pilot-delivery/SKILL.md b/flexus_simple_bots/executor/skills/_pilot-delivery/SKILL.md new file mode 100644 index 00000000..c0807345 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_pilot-delivery/SKILL.md @@ -0,0 +1,194 @@ +--- +name: pilot-delivery +description: Pilot contracting delivery execution and expansion readiness +--- + +# Pilot Delivery Operator + +You are in **Pilot Delivery mode** — convert qualified opportunities into paid pilot outcomes. Strict fail-fast on signatures, payment commitment, or scope clarity. Never invent evidence. + +## Skills + +**eSign Contracting:** Use eSign tools (DocuSign, PandaDoc) to manage pilot contracts — create and track envelopes/documents, retrieve signature status and completion events. Fail fast when signature_status is not completed before launch. + +**Payment Commitment:** Use Stripe to create payment links and invoices — confirm payment commitment before go-live, validate invoice state and payment terms. Reject scope lock without confirmed payment commitment. + +**CRM Deal Tracking:** Use HubSpot to maintain deal state alignment — update deal stage to reflect contract and payment status, ensure account_ref is traceable to a CRM record. + +**Delivery Ops:** Use delivery ops tools (Jira, Asana, Notion, Calendly, Google Calendar) to create and transition delivery tasks tied to signed scope, schedule kickoff and milestone check-ins. Fail fast when scope-task mapping is incomplete. + +**Usage Evidence:** Use analytics tools (PostHog, Mixpanel, GA4, Amplitude) to collect first value evidence — query event trends, funnels, and retention aligned to success criteria. Reject evidence that cannot be traced to agreed instrumented events. + +**Stakeholder Sync:** Use Intercom, Zendesk, Google Calendar to retrieve customer conversations and tickets for stakeholder health signals, list upcoming calendar events for milestone sync. + +## Recording Contract Artifacts + +After all contracting work for a pilot is complete: + +- `write_artifact(path=/pilots/contract-{pilot_id}-{YYYY-MM-DD}, data={...})` + — once scope, commercial terms, stakeholders, signature status, and payment commitment are finalized. + +- `write_artifact(path=/pilots/risk-clauses-{pilot_id}-{YYYY-MM-DD}, data={...})` + — after reviewing all contract terms for risk exposure. + +- `write_artifact(path=/pilots/go-live-{pilot_id}-{YYYY-MM-DD}, data={...})` + — when all pre-launch checks are complete; gate_status must be "go" or "no_go" based on evidence. + +Do not output raw JSON in chat. One write per artifact per pilot per run. + +## Recording Delivery Artifacts + +After delivery milestones are reached: + +- `write_artifact(path=/pilots/delivery-plan-{pilot_id}-{YYYY-MM-DD}, data={...})` + — once delivery steps, owners, timeline and risk controls are agreed. + +- `write_artifact(path=/pilots/evidence-{pilot_id}-{YYYY-MM-DD}, data={...})` + — after stakeholder confirmation; confidence must reflect actual evidence quality. + +- `write_artifact(path=/pilots/expansion-readiness-{pilot_id}-{YYYY-MM-DD}, data={...})` + — when expansion decision is due; recommended_action must be "expand", "stabilize", or "stop". + +Fail fast when evidence cannot be tied to agreed success criteria. + +## Artifact Schemas + +```json +{ + "pilot_contract_packet": { + "type": "object", + "properties": { + "pilot_id": {"type": "string"}, + "account_ref": {"type": "string"}, + "scope": {"type": "string"}, + "commercial_terms": { + "type": "object", + "properties": { + "value": {"type": "number"}, + "currency": {"type": "string"}, + "payment_terms": {"type": "string"} + }, + "required": ["value", "currency", "payment_terms"] + }, + "stakeholders": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "role": {"type": "string"}, + "email": {"type": "string"} + }, + "required": ["name", "role"] + } + }, + "signature_status": {"type": "string", "enum": ["pending", "completed", "voided"]}, + "payment_commitment": {"type": "string", "enum": ["confirmed", "pending", "rejected"]} + }, + "required": ["pilot_id", "account_ref", "scope", "commercial_terms", "stakeholders", "signature_status", "payment_commitment"], + "additionalProperties": false + }, + "pilot_risk_clause_register": { + "type": "object", + "properties": { + "pilot_id": {"type": "string"}, + "risk_clauses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "clause_ref": {"type": "string"}, + "description": {"type": "string"}, + "risk_level": {"type": "string", "enum": ["high", "medium", "low"]}, + "mitigation": {"type": "string"} + }, + "required": ["clause_ref", "description", "risk_level", "mitigation"] + } + } + }, + "required": ["pilot_id", "risk_clauses"], + "additionalProperties": false + }, + "pilot_go_live_readiness": { + "type": "object", + "properties": { + "pilot_id": {"type": "string"}, + "gate_status": {"type": "string", "enum": ["go", "no_go"]}, + "checks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "check": {"type": "string"}, + "status": {"type": "string", "enum": ["pass", "fail"]}, + "notes": {"type": "string"} + }, + "required": ["check", "status"] + } + }, + "blockers": {"type": "array", "items": {"type": "string"}} + }, + "required": ["pilot_id", "gate_status", "checks", "blockers"], + "additionalProperties": false + }, + "first_value_delivery_plan": { + "type": "object", + "properties": { + "pilot_id": {"type": "string"}, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "step": {"type": "string"}, + "owner": {"type": "string"}, + "due_date": {"type": "string"}, + "success_criteria": {"type": "string"} + }, + "required": ["step", "owner", "due_date", "success_criteria"] + } + }, + "timeline": {"type": "string"}, + "risk_controls": {"type": "array", "items": {"type": "string"}} + }, + "required": ["pilot_id", "steps", "timeline", "risk_controls"], + "additionalProperties": false + }, + "first_value_evidence": { + "type": "object", + "properties": { + "pilot_id": {"type": "string"}, + "evidence": { + "type": "array", + "items": { + "type": "object", + "properties": { + "metric": {"type": "string"}, + "value": {"type": "string"}, + "source": {"type": "string"}, + "timestamp": {"type": "string"} + }, + "required": ["metric", "value", "source"] + } + }, + "confidence": {"type": "string", "enum": ["high", "medium", "low"]}, + "stakeholder_confirmation": {"type": "boolean"} + }, + "required": ["pilot_id", "evidence", "confidence", "stakeholder_confirmation"], + "additionalProperties": false + }, + "pilot_expansion_readiness": { + "type": "object", + "properties": { + "pilot_id": {"type": "string"}, + "recommended_action": {"type": "string", "enum": ["expand", "stabilize", "stop"]}, + "rationale": {"type": "string"}, + "evidence_refs": {"type": "array", "items": {"type": "string"}}, + "risks": {"type": "array", "items": {"type": "string"}}, + "next_steps": {"type": "array", "items": {"type": "string"}} + }, + "required": ["pilot_id", "recommended_action", "rationale", "evidence_refs"], + "additionalProperties": false + } +} +``` diff --git a/flexus_simple_bots/executor/skills/_pilot-feedback/SKILL.md b/flexus_simple_bots/executor/skills/_pilot-feedback/SKILL.md new file mode 100644 index 00000000..ef53ffb5 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_pilot-feedback/SKILL.md @@ -0,0 +1,60 @@ +--- +name: pilot-feedback +description: Structured pilot feedback collection — user experience surveys, product feedback synthesis, and feature request prioritization +--- + +You collect and synthesize structured feedback from pilot customers. Pilot feedback is the highest-quality product input available — customers are actively using the product, have skin in the game, and are motivated to help it improve (because their success depends on it). + +Core mode: collect systematically, not ad hoc. One question asked to all pilots at the same stage produces comparable data. One-off questions in Slack conversations produce noise. + +## Methodology + +### Feedback collection points +**Weekly pulse (brief)**: 2-3 questions, 2 minutes to complete +- What worked well this week? +- What was frustrating? +- What do you wish the product did differently? + +**Mid-point structured survey**: 10-15 questions, 10 minutes +- Feature usefulness ratings +- Workflow friction points +- NPS for current state +- Top missing capabilities + +**End-of-pilot debrief**: structured interview +- Did the product meet your success criteria? +- What would you tell a peer about this product? +- If you could change one thing, what would it be? +- On a scale of 1-10, how likely are you to continue using this? + +### Feedback synthesis +After collecting from all pilots, identify: +- Cross-pilot themes: pain points mentioned by ≥3 pilots = product priority +- Segment-specific themes: pain only in a specific pilot profile = feature for that segment +- Single-pilot requests: may be valid but lower priority + +### Feature request prioritization +Score each request by: +- Frequency: how many pilots requested it? +- Severity: is this blocking value delivery or nice to have? +- Alignment: does this align with the core job or is it scope creep? + +## Recording + +``` +write_artifact(path="/pilots/feedback-synthesis-{date}", data={...}) +``` + +## Available Tools + +``` +surveymonkey(op="call", args={"method_id": "surveymonkey.surveys.create.v1", "title": "Pilot Weekly Pulse", "pages": [{"questions": [{"family": "open_ended", "subtype": "multi", "headings": [{"heading": "What worked well this week?"}]}]}]}) + +surveymonkey(op="call", args={"method_id": "surveymonkey.surveys.responses.list.v1", "survey_id": "survey_id"}) + +typeform(op="call", args={"method_id": "typeform.forms.create.v1", "title": "Mid-Point Survey", "fields": [{"type": "rating", "title": "How useful was feature X?", "properties": {"steps": 5}}]}) + +typeform(op="call", args={"method_id": "typeform.responses.list.v1", "uid": "form_id", "page_size": 100}) + +delighted(op="call", args={"method_id": "delighted.survey.create.v1", "email": "customer@company.com", "send": true, "properties": {"name": "Customer Name", "company": "Company Name"}}) +``` diff --git a/flexus_simple_bots/executor/skills/_playbook-cs-ops/SKILL.md b/flexus_simple_bots/executor/skills/_playbook-cs-ops/SKILL.md new file mode 100644 index 00000000..e3068f62 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_playbook-cs-ops/SKILL.md @@ -0,0 +1,65 @@ +--- +name: playbook-cs-ops +description: Customer success operations playbook — segmented CS model, QBR process, renewal management, and CS team scaling +--- + +You codify the customer success operations process: how accounts are segmented, how QBRs are run, how renewals are managed, and how the CS team structure scales with customer count. + +Core mode: CS resources are finite. Not every customer deserves the same attention. Define tiers based on ACV and strategic value, then allocate CS resources accordingly. The goal is high-touch where it matters, automated where it doesn't. + +## Methodology + +### Customer segmentation for CS coverage +Tier customer base by ACV (adjust thresholds to your revenue profile): + +**Enterprise (ACV ≥$50k)**: named CSM, weekly check-in, quarterly QBR, executive sponsor matched +**Mid-Market (ACV $10k-$50k)**: named CSM, bi-weekly check-in, quarterly check-in call (not full QBR) +**SMB (ACV <$10k)**: scaled/pooled CS model, automated lifecycle campaigns, reactive support +**Freemium/PLG**: fully automated — product-led activation, no CSM contact until upgrade signal + +This segmentation determines staffing ratios. Typical: +- Enterprise CSM: 30-50 accounts per CSM +- Mid-Market CSM: 80-120 accounts per CSM +- SMB: no CSM (automated) + +### QBR process (Enterprise and select Mid-Market) +QBR = Quarterly Business Review. It is NOT a product demo — it's a business impact discussion. + +Agenda: +1. Success metrics review: how is the customer tracking against their goals? +2. Usage analysis: who is using the product and how? Are there underutilized features? +3. Strategic discussion: what are their priorities for next quarter? +4. Roadmap alignment: how does your roadmap address their needs? +5. Expansion discussion: based on their goals, what's the natural next investment? + +QBR preparation checklist: +- [ ] Customer's success metrics pulled from `pilot_status_report` or usage data +- [ ] Usage report generated from analytics +- [ ] NPS score and any open support tickets reviewed +- [ ] Expansion opportunity identified (from `retention_expansion_signals`) + +### Renewal management +- 90 days before renewal: confirm renewal intent with champion +- 60 days before: formal renewal proposal sent +- 30 days before: contract signature expected +- 14 days before: escalate if not signed to senior CS / sales + +Automate reminder calendar events at each milestone. + +## Recording + +``` +write_artifact(path="/ops/cs-ops-playbook", data={...}) +``` + +## Available Tools + +``` +google_calendar(op="call", args={"method_id": "google_calendar.events.insert.v1", "calendarId": "primary", "summary": "QBR - [Company]", "start": {"dateTime": "2024-04-01T10:00:00-05:00"}}) + +salesforce(op="call", args={"method_id": "salesforce.query.v1", "query": "SELECT AccountId, Name, Amount, ContractEndDate FROM Contract WHERE ContractEndDate = NEXT_N_DAYS:90 ORDER BY ContractEndDate ASC"}) + +chargebee(op="call", args={"method_id": "chargebee.subscriptions.list.v1", "cancel_at[after]": "2024-01-01", "limit": 50}) + +zendesk(op="call", args={"method_id": "zendesk.tickets.list.v1", "priority": "high", "status": "open"}) +``` diff --git a/flexus_simple_bots/executor/skills/_playbook-sales-ops/SKILL.md b/flexus_simple_bots/executor/skills/_playbook-sales-ops/SKILL.md new file mode 100644 index 00000000..d5a62f66 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_playbook-sales-ops/SKILL.md @@ -0,0 +1,62 @@ +--- +name: playbook-sales-ops +description: Sales operations playbook — CRM hygiene, pipeline reviews, quota design, and revenue reporting cadence +--- + +You codify the sales operations process: how the pipeline is managed, how CRM is kept clean, and how revenue forecasting works. Sales ops makes the GTM machine repeatable — without it, every quarter starts from scratch. + +Core mode: process before tools. A messy CRM in Salesforce is still messy. Define the process first, then configure the tooling. Operations that are documented outperform those that live in people's heads. + +## Methodology + +### CRM hygiene standards +Define the mandatory fields for each deal stage: +- **Prospect**: company, contact, source, estimated ACV, next action date +- **Discovery**: discovery call notes (Gong link), qualification criteria met/not met, next step +- **Demo**: demo notes, objections raised, decision-making unit mapped +- **Proposal**: proposal sent date, custom requirements, expected close date +- **Negotiation**: deal structure, required approvals, risk flags +- **Closed Won/Lost**: ACV, close date, reason (for won: source, for lost: loss reason) + +Missing required field = deal is "stale" → auto-flagged in pipeline review. + +### Pipeline review cadence +- **Weekly (team)**: review all deals where next action date is past (overdue) or due within 7 days +- **Monthly (leadership)**: forecast accuracy review — compare predicted vs. actual close +- **Quarterly**: pipeline health by stage, conversion rates, win/loss analysis + +### Forecast methodology +Bottom-up weighted pipeline: +- Prospect: 5% probability +- Discovery: 20% probability +- Demo: 40% probability +- Proposal: 60% probability +- Negotiation: 80% probability + +Forecast = sum of (deal ACV × stage probability) for all open deals. + +Compare bottom-up forecast to top-down target → variance analysis. + +### Quota design principles +- Set quota at 120-130% of revenue target (to hit target even with average performer) +- SDR quota: meetings booked (not qualified opportunities — SDR doesn't control deal quality) +- AE quota: closed ARR +- CS quota: NRR (retention + expansion) + +## Recording + +``` +write_artifact(path="/ops/sales-ops-playbook", data={...}) +``` + +## Available Tools + +``` +salesforce(op="call", args={"method_id": "salesforce.query.v1", "query": "SELECT Id, Name, StageName, Amount, CloseDate, LastActivityDate FROM Opportunity WHERE StageName NOT IN ('Closed Won','Closed Lost') AND LastActivityDate < LAST_N_DAYS:14"}) + +hubspot(op="call", args={"method_id": "hubspot.deals.pipeline.get.v1", "pipeline_id": "default"}) + +gong(op="call", args={"method_id": "gong.calls.list.v1", "fromDateTime": "2024-01-01T00:00:00Z", "toDateTime": "2024-01-31T00:00:00Z"}) + +pipedrive(op="call", args={"method_id": "pipedrive.deals.list.v1", "status": "open", "sort": "update_time DESC"}) +``` diff --git a/flexus_simple_bots/executor/skills/_retention-expansion-signals/SKILL.md b/flexus_simple_bots/executor/skills/_retention-expansion-signals/SKILL.md new file mode 100644 index 00000000..d52e4698 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_retention-expansion-signals/SKILL.md @@ -0,0 +1,62 @@ +--- +name: retention-expansion-signals +description: Expansion signal detection — identifying upsell and cross-sell opportunities within the existing customer base +--- + +You detect and document expansion signals within the existing customer base. Expansion revenue has lower CAC than new customer revenue — existing customers already trust you and don't need to be educated from scratch. + +Core mode: timing is everything. An expansion conversation too early (before the customer sees value from what they have) creates resentment. Too late (they've already found a workaround) loses revenue. Map expansion signals to the right moment in the customer lifecycle. + +## Methodology + +### Expansion signal types +**Usage-based signals** (from product analytics): +- Approaching limit: customer using ≥80% of their plan quota → natural upgrade conversation +- Seat underutilization but high core-user engagement: expand to new team members +- Feature requests for premium features: explicit upsell signal + +**Relationship signals** (from CS notes and Gong): +- Stakeholder promotion: existing champion becomes economic buyer — revisit contract +- New team or department mentioned: expansion to adjacent team +- "What else can it do?" question → expansion readiness + +**Business event signals** (from researcher tools, news): +- Funding round: budget unlock event +- Headcount growth: need to add seats or expand use case +- Acquisition or merger: new team inherits your product + +### Expansion conversation timing +Green zone for expansion conversation: +- Customer has been active ≥60 days +- Last NPS score ≥7 +- Usage is above baseline (not declining) +- Expansion trigger event has occurred + +Avoid expansion conversations when: +- Customer has open critical support tickets +- NPS response was <7 in past 90 days +- Renewal is at risk + +### Expansion playbook +1. Pre-warm: mention expansion use case in QBR without selling +2. Trigger event: customer hits the signal +3. Outreach: reference the specific signal, not a generic upsell +4. Demo / proposal: focused on the specific expansion, not a full product tour + +## Recording + +``` +write_artifact(path="/retention/expansion-signals-{date}", data={...}) +``` + +## Available Tools + +``` +mixpanel(op="call", args={"method_id": "mixpanel.query.insights.v1", "project_id": "proj_id", "event": "quota_threshold_reached", "where": "properties.threshold >= 0.8"}) + +gong(op="call", args={"method_id": "gong.calls.list.v1", "fromDateTime": "2024-01-01T00:00:00Z", "toDateTime": "2024-12-31T00:00:00Z"}) + +salesforce(op="call", args={"method_id": "salesforce.query.v1", "query": "SELECT AccountId, Name, StageName, Amount FROM Opportunity WHERE Type = 'Upsell' AND CreatedDate = LAST_N_DAYS:30"}) + +chargebee(op="call", args={"method_id": "chargebee.subscriptions.list.v1", "limit": 100, "status[is]": "active"}) +``` diff --git a/flexus_simple_bots/executor/skills/_retention-health-scoring/SKILL.md b/flexus_simple_bots/executor/skills/_retention-health-scoring/SKILL.md new file mode 100644 index 00000000..ed7cbc61 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_retention-health-scoring/SKILL.md @@ -0,0 +1,59 @@ +--- +name: retention-health-scoring +description: Customer health scoring — usage-based health model, risk tier classification, and CS priority queue +--- + +You compute and maintain customer health scores for the active customer base. Health scores predict churn before it happens, allowing customer success to intervene while there is still time. + +Core mode: health score must be evidence-based, not subjective. "The customer feels engaged" is not a health score. Login frequency, feature adoption rate, support ticket volume, and NPS response are health score inputs. Define the model before assigning scores. + +## Methodology + +### Health score dimensions +Typical B2B SaaS health dimensions: + +1. **Product engagement** (40% weight): are users logging in and completing core workflows? + - Login frequency: logins/week vs. expected baseline for contract size + - Core action completion: % of expected core actions actually performed + - Active seats: active users / licensed seats (low ratio = underutilization risk) + +2. **Outcome achievement** (30% weight): are customers getting the value they purchased for? + - Success criteria from pilot conversion (if applicable) + - Self-reported satisfaction from NPS or pulse surveys + - Support tickets that indicate value gap (not technical bugs) + +3. **Relationship health** (20% weight): is the relationship in good shape? + - Response rate to CS outreach + - Stakeholder coverage (do we have relationship with > 1 contact?) + - Renewal intent (stated) + +4. **Risk indicators** (10% weight, inverse scoring): signals of potential churn + - Support ticket volume spike + - Billing issues (failed payment, downgrade request) + - Decision-maker departure + +### Health tier thresholds +- **Green** (healthy): score ≥75 — no intervention needed, focus on expansion +- **Yellow** (at risk): score 50-74 — proactive check-in required within 5 business days +- **Red** (critical): score <50 — escalate to save play within 24 hours + +### Score update cadence +Recalculate scores weekly. Trigger immediate recalculation on: support ticket spike, billing event, NPS response (especially detractor), key contact departure. + +## Recording + +``` +write_artifact(path="/retention/health-snapshot-{date}", data={...}) +``` + +## Available Tools + +``` +mixpanel(op="call", args={"method_id": "mixpanel.query.insights.v1", "project_id": "proj_id", "from_date": "2024-01-01", "to_date": "2024-12-31", "event": "core_action_completed"}) + +ga4(op="call", args={"method_id": "ga4.data.run_report.v1", "property": "properties/123456", "dateRanges": [{"startDate": "30daysAgo", "endDate": "today"}], "dimensions": [{"name": "sessionDefaultChannelGroup"}], "metrics": [{"name": "sessions"}, {"name": "activeUsers"}]}) + +zendesk(op="call", args={"method_id": "zendesk.tickets.list.v1", "requester_id": "customer_zendesk_id", "status": "open"}) + +delighted(op="call", args={"method_id": "delighted.survey.responses.v1", "since": 1704067200, "per_page": 100}) +``` diff --git a/flexus_simple_bots/executor/skills/_retention-intelligence/SKILL.md b/flexus_simple_bots/executor/skills/_retention-intelligence/SKILL.md new file mode 100644 index 00000000..f289f36e --- /dev/null +++ b/flexus_simple_bots/executor/skills/_retention-intelligence/SKILL.md @@ -0,0 +1,197 @@ +--- +name: retention-intelligence +description: Retention cohort diagnostics and PMF signal interpretation +--- + +# Retention Intelligence Analyst + +You are in **Retention Intelligence mode** — evidence-first cohort analysis, revenue diagnostics, and PMF signal interpretation. Never invent evidence, report uncertainty explicitly, always emit structured artifacts. + +## Skills + +**Cohort Analysis:** Always declare cohort definition, event dictionary version, and time window before interpreting results. Fail fast when cohort definitions, billing joins, or event coverage are inconsistent. Cross-reference product analytics with billing data to validate retention rates. + +**Revenue Diagnostics:** Decompose net MRR change into new, expansion, contraction, and churn components. Flag risk accounts with concrete entity ids and timestamps. Reject narrative-only risk statements without evidence refs. + +**PMF Survey Interpretation:** Always validate denominator quality (response rate and segment coverage). Reject statistically weak samples before drawing conclusions. Corroborate survey PMF scores with behavioral usage evidence. + +**Behavioral Corroboration:** Map survey signal direction to observed usage trends, surface conflicts between stated and revealed preferences, document evidence gaps explicitly for downstream research backlog. + +## Recording Cohort Artifacts + +After completing diagnostics, call the appropriate write tool: +- `write_artifact(path=/retention/cohort-review-{YYYY-MM-DD}, data={...})` — activation-retention-revenue review +- `write_artifact(path=/retention/driver-matrix-{YYYY-MM-DD}, data={...})` — ranked driver matrix +- `write_artifact(path=/retention/readiness-gate-{YYYY-MM-DD}, data={...})` — go/conditional/no_go gate + +Do not output raw JSON in chat. + +## Recording PMF Artifacts + +After interpreting PMF evidence, call the appropriate write tool: +- `write_artifact(path=/pmf/scorecard-{YYYY-MM-DD}, data={...})` — PMF confidence scorecard +- `write_artifact(path=/pmf/signal-evidence-{YYYY-MM-DD}, data={...})` — catalogued signal evidence +- `write_artifact(path=/pmf/research-backlog-{YYYY-MM-DD}, data={...})` — prioritized research backlog + +Do not output raw JSON in chat. + +## Artifact Schemas + +```json +{ + "cohort_revenue_review": { + "type": "object", + "properties": { + "cohort_definition": {"type": "string"}, + "time_window": {"type": "string"}, + "event_dictionary_version": {"type": "string"}, + "activation": { + "type": "object", + "properties": { + "rate": {"type": "number"}, + "benchmark": {"type": "number"}, + "notes": {"type": "string"} + }, + "required": ["rate"] + }, + "retention": { + "type": "object", + "properties": { + "d7": {"type": "number"}, + "d30": {"type": "number"}, + "d90": {"type": "number"} + } + }, + "revenue": { + "type": "object", + "properties": { + "new_mrr": {"type": "number"}, + "expansion_mrr": {"type": "number"}, + "contraction_mrr": {"type": "number"}, + "churn_mrr": {"type": "number"}, + "net_mrr_change": {"type": "number"} + }, + "required": ["net_mrr_change"] + }, + "risk_accounts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "account_id": {"type": "string"}, + "risk_signal": {"type": "string"}, + "timestamp": {"type": "string"} + }, + "required": ["account_id", "risk_signal"] + } + } + }, + "required": ["cohort_definition", "time_window", "activation", "retention", "revenue"], + "additionalProperties": false + }, + "retention_driver_matrix": { + "type": "object", + "properties": { + "drivers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "driver": {"type": "string"}, + "rank": {"type": "integer"}, + "impact_score": {"type": "number"}, + "evidence_strength": {"type": "string", "enum": ["strong", "moderate", "weak"]}, + "data_sources": {"type": "array", "items": {"type": "string"}}, + "notes": {"type": "string"} + }, + "required": ["driver", "rank", "impact_score", "evidence_strength"] + } + } + }, + "required": ["drivers"], + "additionalProperties": false + }, + "retention_readiness_gate": { + "type": "object", + "properties": { + "gate_status": {"type": "string", "enum": ["go", "conditional", "no_go"]}, + "criteria": { + "type": "array", + "items": { + "type": "object", + "properties": { + "criterion": {"type": "string"}, + "status": {"type": "string", "enum": ["met", "partial", "not_met"]}, + "notes": {"type": "string"} + }, + "required": ["criterion", "status"] + } + }, + "conditions": {"type": "array", "items": {"type": "string"}}, + "decision_owner": {"type": "string"} + }, + "required": ["gate_status", "criteria", "decision_owner"], + "additionalProperties": false + }, + "pmf_confidence_scorecard": { + "type": "object", + "properties": { + "pmf_score": {"type": "number", "description": "0-100"}, + "confidence": {"type": "string", "enum": ["high", "medium", "low"]}, + "response_rate": {"type": "number"}, + "sample_size": {"type": "integer"}, + "segment_coverage": {"type": "string"}, + "key_findings": {"type": "array", "items": {"type": "string"}}, + "behavioral_corroboration": {"type": "string", "enum": ["corroborated", "conflicted", "insufficient_data"]}, + "conflicts": {"type": "array", "items": {"type": "string"}} + }, + "required": ["pmf_score", "confidence", "response_rate", "sample_size", "key_findings", "behavioral_corroboration"], + "additionalProperties": false + }, + "pmf_signal_evidence": { + "type": "object", + "properties": { + "signals": { + "type": "array", + "items": { + "type": "object", + "properties": { + "signal_type": {"type": "string", "enum": ["survey", "behavioral", "revenue", "qualitative"]}, + "description": {"type": "string"}, + "direction": {"type": "string", "enum": ["positive", "negative", "neutral"]}, + "strength": {"type": "string", "enum": ["strong", "moderate", "weak"]}, + "source": {"type": "string"}, + "date": {"type": "string"} + }, + "required": ["signal_type", "description", "direction", "strength", "source"] + } + }, + "evidence_gaps": {"type": "array", "items": {"type": "string"}} + }, + "required": ["signals", "evidence_gaps"], + "additionalProperties": false + }, + "pmf_research_backlog": { + "type": "object", + "properties": { + "backlog": { + "type": "array", + "items": { + "type": "object", + "properties": { + "item_id": {"type": "string"}, + "question": {"type": "string"}, + "priority": {"type": "string", "enum": ["high", "medium", "low"]}, + "method": {"type": "string"}, + "owner": {"type": "string"}, + "due_date": {"type": "string"} + }, + "required": ["item_id", "question", "priority", "method"] + } + } + }, + "required": ["backlog"], + "additionalProperties": false + } +} +``` diff --git a/flexus_simple_bots/executor/skills/_retention-lifecycle-campaigns/SKILL.md b/flexus_simple_bots/executor/skills/_retention-lifecycle-campaigns/SKILL.md new file mode 100644 index 00000000..8cdbdfeb --- /dev/null +++ b/flexus_simple_bots/executor/skills/_retention-lifecycle-campaigns/SKILL.md @@ -0,0 +1,50 @@ +--- +name: retention-lifecycle-campaigns +description: Lifecycle email and in-app campaign design for retention — activation, engagement, and milestone triggers +--- + +You design and launch lifecycle campaigns that improve customer retention by delivering the right message at the right moment in the customer journey. Lifecycle campaigns are triggered by behavior or time, not batch-sent to all users. + +Core mode: triggered > batch. A "Day 7 no-activity" email is 10x more effective than a weekly newsletter. Every lifecycle campaign must have a behavioral trigger and a specific action you want the recipient to take. + +## Methodology + +### Campaign type selection +**Activation campaigns**: move new users from "signed up" to "first value" +- Trigger: signup without completing first core action +- Message: "You're one step away from [core value]" + direct link to next action +- Sequence: Day 1, Day 3, Day 7 — stop after first core action completed + +**Engagement campaigns**: re-engage users who were active but stopped +- Trigger: no login for X days (where X = 2x normal frequency for their tier) +- Message: "We noticed you haven't [core action] recently — here's what's new" +- Sequence: 1 email + 1 follow-up; then flag for CS intervention if no response + +**Milestone campaigns**: celebrate achievement and prompt next step +- Trigger: customer reaches milestone (Nth core action, Nth successful outcome) +- Message: celebrate progress + introduce next value layer (upsell or expansion) + +**Renewal campaigns**: reduce involuntary churn and prime renewal conversation +- Trigger: 60 days before renewal +- Sequence: value recap email → CS check-in → renewal proposal → final nudge + +### Campaign requirements +Every campaign needs: +- Single clear CTA (one link, one action) +- Personalization tokens (name, account, usage data) +- Unsubscribe (transactional emails exempt but preference for all others) +- Analytics: open rate, click rate, action rate (did they do the thing?) + +## Recording + +``` +write_artifact(path="/retention/lifecycle-campaigns", data={...}) +``` + +## Available Tools + +``` +mixpanel(op="call", args={"method_id": "mixpanel.query.insights.v1", "project_id": "proj_id", "event": "user_inactive_7d"}) + +surveymonkey(op="call", args={"method_id": "surveymonkey.surveys.create.v1", "title": "Engagement Check-in"}) +``` diff --git a/flexus_simple_bots/executor/skills/_retention-nps/SKILL.md b/flexus_simple_bots/executor/skills/_retention-nps/SKILL.md new file mode 100644 index 00000000..d5f714e3 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_retention-nps/SKILL.md @@ -0,0 +1,61 @@ +--- +name: retention-nps +description: NPS measurement and closed-loop follow-up — survey scheduling, response analysis, and detractor recovery workflow +--- + +You run the NPS measurement program: survey design, distribution, response analysis, and closed-loop follow-up. NPS is a retention leading indicator and a source of qualitative intelligence when combined with follow-up conversations. + +Core mode: NPS without closed-loop follow-up is a vanity metric. The value is in contacting every detractor within 48 hours and understanding why they scored low. That intelligence improves the product. The score alone does not. + +## Methodology + +### Survey design +Standard NPS question: "How likely are you to recommend [product] to a friend or colleague? 0-10" +Follow-up question (required): "What is the most important reason for your score?" +Optional: "What could we do to improve your experience?" + +Keep it short — 2-3 questions maximum. Long NPS surveys destroy response rates. + +### Distribution cadence +- **Relationship NPS**: quarterly, sent to all active customers — tracks account health trend +- **Transactional NPS**: triggered after specific interactions (support ticket resolution, onboarding completion, feature release) + +Exclude: customers in first 30 days (not enough experience), customers who received NPS in past 90 days. + +### Score interpretation +- **Promoters (9-10)**: ask for a case study, a referral, or a G2/Trustpilot review +- **Passives (7-8)**: send a follow-up to understand what would move them to Promoter; often an early churn risk +- **Detractors (0-6)**: contact within 48 hours; understand root cause; escalate to CS if account is at risk + +### NPS = % Promoters - % Detractors (not an average) +Benchmark by category: +- B2B SaaS: NPS 30-50 is good, >50 is excellent +- Below 20: systemic retention risk; qualitative investigation required + +### Closed-loop protocol +Detractor follow-up template: +1. Acknowledge their score: "Thank you for your honesty" +2. Ask to understand: "Can we schedule 15 minutes to hear more?" +3. Listen without defending +4. Commit to specific action +5. Follow up with resolution + +## Recording + +``` +write_artifact(path="/retention/nps-report-{date}", data={...}) +``` + +## Available Tools + +``` +delighted(op="call", args={"method_id": "delighted.survey.create.v1", "email": "customer@company.com", "send": true, "properties": {"name": "Customer Name", "company": "Company", "customer_tier": "pro"}}) + +delighted(op="call", args={"method_id": "delighted.survey.responses.v1", "since": 1704067200, "per_page": 100}) + +surveymonkey(op="call", args={"method_id": "surveymonkey.surveys.create.v1", "title": "NPS Survey Q1 2024"}) + +surveymonkey(op="call", args={"method_id": "surveymonkey.surveys.responses.list.v1", "survey_id": "survey_id"}) + +typeform(op="call", args={"method_id": "typeform.forms.create.v1", "title": "NPS Survey"}) +``` diff --git a/flexus_simple_bots/executor/skills/_scale-metrics-dashboard/SKILL.md b/flexus_simple_bots/executor/skills/_scale-metrics-dashboard/SKILL.md new file mode 100644 index 00000000..63817963 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_scale-metrics-dashboard/SKILL.md @@ -0,0 +1,61 @@ +--- +name: scale-metrics-dashboard +description: Growth metrics dashboard — MRR, ARR, churn rate, expansion revenue, NRR, and cohort tracking for the scale phase +--- + +You compile the key growth metrics dashboard for the scale phase. The dashboard is the single source of truth for how the business is performing across revenue, retention, and growth rate dimensions. + +Core mode: metrics without context are noise. An MRR number without a trend, a benchmark, and a target is just a number. Every metric in the dashboard needs: current value, prior period comparison, target, and status (on track / at risk). + +## Methodology + +### Core SaaS metrics + +**Revenue metrics:** +- MRR: sum of all active subscription revenue, normalized to monthly +- ARR: MRR × 12 +- New MRR: MRR from new customers this month +- Expansion MRR: MRR added from existing customers (upgrades, seats) +- Churned MRR: MRR lost to cancellations +- Net New MRR: New MRR + Expansion MRR - Churned MRR + +**Retention metrics:** +- Gross Revenue Retention (GRR): (Beginning MRR - Churned MRR) / Beginning MRR. Best-in-class B2B SaaS: >90% +- Net Revenue Retention (NRR): (Beginning MRR + Expansion MRR - Churned MRR) / Beginning MRR. Best-in-class: >120% +- Logo churn rate: customers lost / total customers. Red flag: >2% monthly + +**Growth metrics:** +- MoM MRR growth rate +- ARR growth rate YoY +- Payback period: CAC / (ARPA × gross margin) + +### Metric hierarchy +Level 1 (board-level): ARR, ARR growth, NRR, payback period +Level 2 (leadership): MRR breakdown (new/expansion/churned), logo churn, CAC by channel +Level 3 (operational): cohort retention curves, feature adoption, NPS trend + +### Data sources +- MRR/ARR: billing system (`chargebee`, `recurly`, `paddle`, `stripe` via Chargebee) +- Customer counts: CRM (Salesforce or HubSpot) +- Usage data: `mixpanel` or `ga4` +- NPS: `delighted` + +## Recording + +``` +write_artifact(path="/growth/metrics-dashboard-{date}", data={...}) +``` + +## Available Tools + +``` +chargebee(op="call", args={"method_id": "chargebee.subscriptions.list.v1", "status[is]": "active", "limit": 100}) + +chargebee(op="call", args={"method_id": "chargebee.mrr.v1", "from_date": "2024-01-01", "to_date": "2024-12-31"}) + +recurly(op="call", args={"method_id": "recurly.subscriptions.list.v1", "state": "active", "limit": 200}) + +salesforce(op="call", args={"method_id": "salesforce.query.v1", "query": "SELECT SUM(Amount), StageName FROM Opportunity WHERE StageName = 'Closed Won' AND CloseDate = THIS_MONTH GROUP BY StageName"}) + +mixpanel(op="call", args={"method_id": "mixpanel.query.retention.v1", "project_id": "proj_id", "from_date": "2024-01-01", "to_date": "2024-12-31", "retention_type": "compactage"}) +``` diff --git a/flexus_simple_bots/executor/skills/_scale-unit-economics/SKILL.md b/flexus_simple_bots/executor/skills/_scale-unit-economics/SKILL.md new file mode 100644 index 00000000..2eebae87 --- /dev/null +++ b/flexus_simple_bots/executor/skills/_scale-unit-economics/SKILL.md @@ -0,0 +1,66 @@ +--- +name: scale-unit-economics +description: Unit economics analysis — LTV, CAC, LTV:CAC ratio, payback period, and gross margin by segment +--- + +You calculate and analyze unit economics to determine whether the business is building on a sustainable foundation. Unit economics must be positive before scaling spend — otherwise you're buying growth at a loss. + +Core mode: unit economics by segment, not just company average. A business that has great LTV:CAC for enterprise but terrible for SMB should scale enterprise, not both. Segmented unit economics reveal where to allocate capital. + +## Methodology + +### Core calculations + +**Customer Lifetime Value (LTV)** +LTV = ARPA × Gross Margin % × (1 / Monthly Churn Rate) + +Where: +- ARPA = Average Revenue Per Account per month +- Gross Margin % = (Revenue - COGS) / Revenue (typically 70-85% for SaaS) +- Monthly Churn Rate = Logo churn / total active logos + +Example: $1,000 ARPA × 80% GM × (1 / 0.02 churn) = $40,000 LTV + +**Customer Acquisition Cost (CAC)** +CAC = Total Sales + Marketing Spend / New Customers Acquired (same period) + +Include: salaries, ad spend, commissions, tools, events. +Do NOT include: CS cost (post-sale), product cost (included in COGS). + +**Key ratios** +- LTV:CAC ≥3:1 = viable business +- LTV:CAC <1:1 = destroying capital — must fix before scaling +- Payback period = CAC / (ARPA × Gross Margin %) +- Payback <12 months = capital efficient. >24 months = cash-intensive, requires venture backing or strong balance sheet + +### Segmented analysis +Run LTV:CAC by: +- Customer tier (enterprise vs. mid-market vs. SMB) +- Acquisition channel (organic vs. paid vs. outbound) +- ICP segment (by industry, company size) + +Segments where LTV:CAC >3:1 → scale spend +Segments where LTV:CAC <2:1 → pause and investigate +Segments where LTV:CAC <1:1 → stop acquisition, focus on retention + +### Gross margin analysis +Product COGS include: hosting, 3rd party APIs, customer success time (prorated), implementation. +Target: >70% gross margin for scale stage SaaS. + +Below 60%: unit economics may never turn positive at scale — structural fix needed. + +## Recording + +``` +write_artifact(path="/growth/unit-economics-{date}", data={...}) +``` + +## Available Tools + +``` +chargebee(op="call", args={"method_id": "chargebee.subscriptions.list.v1", "status[is]": "active"}) + +chargebee(op="call", args={"method_id": "chargebee.mrr.v1", "from_date": "2024-01-01"}) + +salesforce(op="call", args={"method_id": "salesforce.query.v1", "query": "SELECT LeadSource, SUM(Amount), COUNT(Id) FROM Opportunity WHERE StageName = 'Closed Won' AND CloseDate = LAST_N_MONTHS:6 GROUP BY LeadSource"}) +``` diff --git a/flexus_simple_bots/productman/integrations/survey_research.py b/flexus_simple_bots/productman/integrations/survey_research.py index 95cc6ec1..289c243a 100644 --- a/flexus_simple_bots/productman/integrations/survey_research.py +++ b/flexus_simple_bots/productman/integrations/survey_research.py @@ -572,8 +572,8 @@ async def _handle_run(self, toolcall: ckit_cloudtool.FCloudtoolCall, args: Dict[ await ckit_kanban.bot_kanban_update_details(self.fclient, task.ktask_id, task_details) logger.info(f"Updated task {task.ktask_id} with survey tracking info") - except Exception as e: - logger.error(f"Failed to update task with survey info: {e}") + except (AttributeError, KeyError, ValueError) as e: + logger.error("Failed to update task with survey info", exc_info=e) if "error" in publish_result: result = f"⚠️ Survey campaign created but not published!\n\n" @@ -767,8 +767,8 @@ async def update_active_surveys(self, fclient, update_task_callback): self._sm_headers(), params={"per_page": 1} ) - except Exception as e: - logger.warn(f"Could not fetch survey {survey_id} status: {e}") + except (OSError, ValueError, KeyError) as e: + logger.warning("Could not fetch survey %s status", survey_id, exc_info=e) continue if not response_data: diff --git a/flexus_simple_bots/productman/productman_bot.py b/flexus_simple_bots/productman/productman_bot.py index 4350cfbe..cecf060b 100644 --- a/flexus_simple_bots/productman/productman_bot.py +++ b/flexus_simple_bots/productman/productman_bot.py @@ -189,6 +189,119 @@ def _section_schema(title_desc: str, questions: dict) -> dict: ] +def validate_idea_structure(provided: Dict, expected: Dict, path: str = "root") -> str: + if type(provided) != type(expected): + return f"Type mismatch at {path}: expected {type(expected).__name__}, got {type(provided).__name__}" + if isinstance(expected, dict): + expected_keys = set(expected.keys()) + provided_keys = set(provided.keys()) + if expected_keys != provided_keys: + missing = expected_keys - provided_keys + extra = provided_keys - expected_keys + errors = [] + if missing: + errors.append(f"missing keys: {missing}") + if extra: + errors.append(f"unexpected keys: {extra}") + return f"Key mismatch at {path}: {', '.join(errors)}" + for key in expected_keys: + if key in ("q", "a", "title"): + continue + error = validate_idea_structure(provided[key], expected[key], f"{path}.{key}") + if error: + return error + return "" + + +def validate_path_kebab(path: str) -> str: + for segment in path.strip("/").split("/"): + if segment and not all(c.islower() or c.isdigit() or c == "-" for c in segment): + return f"Path segment '{segment}' must be kebab-case (lowercase, numbers, hyphens)" + return "" + + +def setup_handlers(fclient, rcx, pdoc_integration): + survey_integration = survey_research.IntegrationSurveyResearch( + surveymonkey_token=os.getenv("SURVEYMONKEY_ACCESS_TOKEN", ""), + prolific_token=os.getenv("PROLIFIC_API_TOKEN", ""), + pdoc_integration=pdoc_integration, + fclient=fclient, + ) + + @rcx.on_tool_call(IDEA_TEMPLATE_TOOL.name) + async def _h_idea(toolcall, args): + idea_slug = args.get("idea_slug", "") + text = args.get("text", "") + if not idea_slug: + return "Error: idea_slug required" + if not text: + return "Error: text required" + if rcx.running_test_scenario: + return await ckit_scenario.scenario_generate_tool_result_via_model(fclient, toolcall, Path(__file__).read_text()) + if err := validate_path_kebab(idea_slug): + return f"Error: idea_slug must be kebab-case: {err}" + try: + idea_doc = json.loads(text) + except json.JSONDecodeError as e: + return f"Error: Invalid JSON: {e}" + if err := validate_idea_structure(idea_doc, productman_prompts.example_idea): + return f"Error: {err}" + fuser_id = ckit_external_auth.get_fuser_id_from_rcx(rcx, toolcall.fcall_ft_id) + path = f"/gtm/discovery/{idea_slug}/idea" + await pdoc_integration.pdoc_create(path, json.dumps(idea_doc, indent=2), fuser_id) + return f"✍️ {path}\n\n✓ Created idea document" + + @rcx.on_tool_call(HYPOTHESIS_TEMPLATE_TOOL.name) + async def _h_hyp(toolcall, args): + idea_slug = args.get("idea_slug", "") + hypothesis_slug = args.get("hypothesis_slug", "") + hypothesis_data = args.get("hypothesis") + if not idea_slug: + return "Error: idea_slug required" + if not hypothesis_slug: + return "Error: hypothesis_slug required" + if not hypothesis_data: + return "Error: hypothesis required" + if rcx.running_test_scenario: + return await ckit_scenario.scenario_generate_tool_result_via_model(fclient, toolcall, Path(__file__).read_text()) + if err := validate_path_kebab(idea_slug): + return f"Error: idea_slug must be kebab-case: {err}" + if err := validate_path_kebab(hypothesis_slug): + return f"Error: hypothesis_slug must be kebab-case: {err}" + fuser_id = ckit_external_auth.get_fuser_id_from_rcx(rcx, toolcall.fcall_ft_id) + path = f"/gtm/discovery/{idea_slug}/{hypothesis_slug}/hypothesis" + await pdoc_integration.pdoc_create(path, json.dumps({"hypothesis": hypothesis_data}, indent=2), fuser_id) + return f"✍️ {path}\n\n✓ Created hypothesis document" + + @rcx.on_tool_call(VERIFY_IDEA_TOOL.name) + async def _h_verify(toolcall, args): + pdoc_path = args.get("pdoc_path", "") + language = args.get("language", "") + if not pdoc_path: + return "Error: pdoc_path required" + if not language: + return "Error: language required" + if rcx.running_test_scenario: + return await ckit_scenario.scenario_generate_tool_result_via_model(fclient, toolcall, Path(__file__).read_text()) + subchats = await ckit_ask_model.bot_subchat_create_multiple( + client=fclient, + who_is_asking="productman_verify_idea", + persona_id=rcx.persona.persona_id, + first_question=[f"Rate this idea document in {language}:\n{pdoc_path}"], + first_calls=["null"], + title=[f"Verifying Idea {pdoc_path}"], + fcall_id=toolcall.fcall_id, + fexp_name="criticize_idea", + ) + raise ckit_cloudtool.WaitForSubchats(subchats) + + @rcx.on_tool_call(survey_research.SURVEY_RESEARCH_TOOL.name) + async def _h_survey(toolcall, args): + return await survey_integration.handle_survey_research(toolcall, args) + + return survey_integration + + async def productman_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext) -> None: @@ -207,39 +320,6 @@ async def productman_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_ async def updated_task_in_db(t: ckit_kanban.FPersonaKanbanTaskOutput): survey_research_integration.track_survey_task(t) - def validate_idea_structure(provided: Dict, expected: Dict, path: str = "root") -> str: - if type(provided) != type(expected): - return f"Type mismatch at {path}: expected {type(expected).__name__}, got {type(provided).__name__}" - if isinstance(expected, dict): - expected_keys = set(expected.keys()) - provided_keys = set(provided.keys()) - if expected_keys != provided_keys: - missing = expected_keys - provided_keys - extra = provided_keys - expected_keys - errors = [] - if missing: - errors.append(f"missing keys: {missing}") - if extra: - errors.append(f"unexpected keys: {extra}") - return f"Key mismatch at {path}: {', '.join(errors)}" - for key in expected_keys: - if key == "q": - continue - if key == "a": - continue - if key == "title": - continue - error = validate_idea_structure(provided[key], expected[key], f"{path}.{key}") - if error: - return error - return "" - - def validate_path_kebab(path: str) -> str: - for segment in path.strip("/").split("/"): - if segment and not all(c.islower() or c.isdigit() or c == "-" for c in segment): - return f"Path segment '{segment}' must be kebab-case (lowercase, numbers, hyphens)" - return "" - @rcx.on_tool_call(IDEA_TEMPLATE_TOOL.name) async def toolcall_idea_template(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: idea_slug = model_produced_args.get("idea_slug", "") diff --git a/flexus_simple_bots/prompts_common.py b/flexus_simple_bots/prompts_common.py index 3acf857c..3ff511ac 100644 --- a/flexus_simple_bots/prompts_common.py +++ b/flexus_simple_bots/prompts_common.py @@ -54,6 +54,32 @@ - Collecting several configuration options together """ +PROMPT_PRINT_WIDGET = """ +## Print Widget + +Use print_widget when the user would benefit from a compact, structured, presentation-friendly output instead of plain prose. + +Good fits: +- concise plans +- status summaries +- decision tables +- formatted handoff notes + +Do not mention the widget itself to the user. Just use it when it improves readability and decision speed. +""" + +PROMPT_POLICY_DOCUMENTS = """ +## Policy Documents + +Use `flexus_policy_document` when you need to inspect, read, activate, or update workspace documents and artifacts. + +Rules: +- Treat policy documents as the system of record for structured bot outputs and shared workspace state. +- If you do not know the exact path, list the parent directory first instead of inventing paths. +- Use read-style operations first (`list`, `cat`, `activate`) before write operations. +- Only write when the task actually requires updating persistent workspace state. +""" + # """ # Help user navigate between setup and regular type of chat. If you don't see "setup" in the system prompt, # that's a regular chat. If something doesn't work in a regular chat, you can call diff --git a/flexus_simple_bots/researcher/researcher_bot.py b/flexus_simple_bots/researcher/researcher_bot.py index c4a3c408..7337d8e3 100644 --- a/flexus_simple_bots/researcher/researcher_bot.py +++ b/flexus_simple_bots/researcher/researcher_bot.py @@ -1,6 +1,7 @@ import asyncio import json import logging +import re from pathlib import Path from typing import Any, Dict @@ -20,6 +21,26 @@ BOT_VERSION = SIMPLE_BOTS_COMMON_VERSION +def load_artifact_schemas() -> Dict[str, Any]: + """Read JSON artifact schemas from each skill's SKILL.md at startup.""" + skills_dir = BOT_DIR / "skills" + schemas: Dict[str, Any] = {} + for skill_dir in sorted(d for d in skills_dir.iterdir() if not d.name.startswith("_")): + skill_md = skill_dir / "SKILL.md" + if not skill_md.exists(): + continue + md = skill_md.read_text(encoding="utf-8") + m = re.search(r"```json\s*(\{.*?\})\s*```", md, re.DOTALL) + if not m: + continue + parsed = json.loads(m.group(1)) + schemas.update(parsed) + return schemas + + +ARTIFACT_SCHEMAS = load_artifact_schemas() +ARTIFACT_TYPES = sorted(ARTIFACT_SCHEMAS.keys()) + TEMPLATE_IDEA_TOOL = ckit_cloudtool.CloudTool( strict=False, name="template_idea", @@ -55,26 +76,32 @@ WRITE_ARTIFACT_TOOL = ckit_cloudtool.CloudTool( strict=False, name="write_artifact", - description="Write a structured artifact to the document store. Path and data shape are defined by the active skill.", + description="Write a structured artifact to the document store. Artifact type and schema are defined by the active skill.", parameters={ "type": "object", "properties": { + "artifact_type": { + "type": "string", + "enum": ARTIFACT_TYPES, + "description": "Artifact type as specified by the active skill", + }, "path": { "type": "string", - "description": "Document path as specified by the active skill", + "description": "Document path, e.g. /signals/search-demand-2024-01-15", }, "data": { "type": "object", - "description": "Artifact content as specified by the active skill", + "description": "Artifact content matching the schema for this artifact_type", }, }, - "required": ["path", "data"], + "required": ["artifact_type", "path", "data"], "additionalProperties": False, }, ) def validate_path_kebab(path: str) -> str: + """Validates that all segments of a path are kebab-case.""" for segment in path.strip("/").split("/"): if segment and not all(c.islower() or c.isdigit() or c == "-" for c in segment): return f"Path segment '{segment}' must be kebab-case (lowercase, numbers, hyphens)" @@ -92,18 +119,26 @@ def validate_path_kebab(path: str) -> str: async def researcher_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext) -> None: - setup = ckit_bot_exec.official_setup_mixing_procedure(researcher_install.RESEARCHER_SETUP_SCHEMA, rcx.persona.persona_setup) - integr_objects = await ckit_integrations_db.main_loop_integrations_init(RESEARCHER_INTEGRATIONS, rcx, setup) + integr_objects = await ckit_integrations_db.main_loop_integrations_init(RESEARCHER_INTEGRATIONS, rcx) pdoc_integration = integr_objects["flexus_policy_document"] @rcx.on_tool_call(WRITE_ARTIFACT_TOOL.name) async def _h_write_artifact(toolcall: ckit_cloudtool.FCloudtoolCall, args: Dict[str, Any]) -> str: + artifact_type = str(args.get("artifact_type", "")).strip() path = str(args.get("path", "")).strip() data = args.get("data") - if not path or data is None: - return "Error: path and data are required." - await pdoc_integration.pdoc_overwrite(path, json.dumps(data, ensure_ascii=False), fcall_untrusted_key=toolcall.fcall_untrusted_key) - return f"Written: {path}" + if not artifact_type or not path or data is None: + return "Error: artifact_type, path, and data are required." + if artifact_type not in ARTIFACT_SCHEMAS: + return f"Error: unknown artifact_type {artifact_type!r}. Must be one of: {', '.join(ARTIFACT_TYPES)}" + doc = dict(data) + doc["schema"] = ARTIFACT_SCHEMAS[artifact_type] + await pdoc_integration.pdoc_overwrite( + path, + json.dumps(doc, ensure_ascii=False), + fcall_untrusted_key=toolcall.fcall_untrusted_key, + ) + return f"Written: {path}\n\nArtifact {artifact_type} saved." @rcx.on_tool_call(TEMPLATE_IDEA_TOOL.name) async def _h_template_idea(toolcall, args): @@ -120,8 +155,12 @@ async def _h_template_idea(toolcall, args): except json.JSONDecodeError as e: return f"Error: Invalid JSON: {e}" path = f"/gtm/discovery/{idea_slug}/idea" - await pdoc_integration.pdoc_create(path, json.dumps(idea_doc, indent=2, ensure_ascii=False), fcall_untrusted_key=toolcall.fcall_untrusted_key) - return f"✍️ {path}\n\n✓ Created idea document" + await pdoc_integration.pdoc_create( + path, + json.dumps(idea_doc, indent=2, ensure_ascii=False), + fcall_untrusted_key=toolcall.fcall_untrusted_key, + ) + return f"Written: {path}\n\nCreated idea document" @rcx.on_tool_call(TEMPLATE_HYPOTHESIS_TOOL.name) async def _h_template_hypothesis(toolcall, args): @@ -152,7 +191,7 @@ async def _h_template_hypothesis(toolcall, args): json.dumps({"hypothesis": hypothesis_data}, indent=2, ensure_ascii=False), fcall_untrusted_key=toolcall.fcall_untrusted_key, ) - return f"✍️ {path}\n\n✓ Created hypothesis document" + return f"Written: {path}\n\nCreated hypothesis document" try: while not ckit_shutdown.shutdown_event.is_set(): diff --git a/flexus_simple_bots/researcher/researcher_install.py b/flexus_simple_bots/researcher/researcher_install.py index eadbe15d..bd6cfdb1 100644 --- a/flexus_simple_bots/researcher/researcher_install.py +++ b/flexus_simple_bots/researcher/researcher_install.py @@ -8,82 +8,26 @@ from flexus_client_kit import ckit_cloudtool from flexus_client_kit import ckit_integrations_db from flexus_client_kit import ckit_skills +from flexus_client_kit.integrations import fi_linkedin_b2b from flexus_simple_bots import prompts_common from flexus_simple_bots.researcher import researcher_prompts RESEARCHER_ROOTDIR = Path(__file__).parent RESEARCHER_SKILLS = ckit_skills.static_skills_find(RESEARCHER_ROOTDIR, shared_skills_allowlist="") RESEARCHER_SETUP_SCHEMA = json.loads((RESEARCHER_ROOTDIR / "setup_schema.json").read_text()) +RESEARCHER_SETUP_SCHEMA.extend(fi_linkedin_b2b.LINKEDIN_B2B_SETUP_SCHEMA) RESEARCHER_INTEGRATIONS: list[ckit_integrations_db.IntegrationRecord] = ckit_integrations_db.static_integrations_load( RESEARCHER_ROOTDIR, [ "flexus_policy_document", "skills", "print_widget", - "linkedin", - "google_calendar", - # "amazon", - # "apollo", - # "appstoreconnect", - # "bing_webmaster", - # "bombora", - # "builtwith", - # "calendly", - # "capterra", - # "cint", - # "clearbit", - # "coresignal", - # "crunchbase", - # "dataforseo", - # "dovetail", - # "ebay", - # "event_registry", - # "fireflies", - # "g2", - # "gdelt", - # "glassdoor", - # "gnews", - # "gong", - # "google_ads", - # "google_play", - # "google_search_console", - # "google_shopping", - # "hasdata", - # "instagram", - # "levelsfyi", - # "linkedin_jobs", - # "mediastack", - # "newsapi", - # "newscatcher", - # "newsdata", - # "outreach", - # "oxylabs", - # "pdl", - # "perigon", - # "pinterest", - # "pipedrive", - # "producthunt", - # "prolific", - # "qualtrics", - # "reddit", - # "salesforce", - # "salesloft", - # "sixsense", - # "stackexchange", - # "surveymonkey", - # "theirstack", - # "tiktok", - # "trustpilot", - # "typeform", - # "userinterviews", - # "usertesting", - # "wappalyzer", - # "wikimedia", - # "x", - # "yelp", - # "youtube", - # "zendesk", - # "zendesk_sell", - # "zoom", + "bing_webmaster", "serpapi", "x", "youtube", "producthunt", + "event_registry", "newsapi", "gnews", "newsdata", "mediastack", + "newscatcher", "perigon", "trustpilot", "yelp", "g2", "capterra", + "coresignal", "theirstack", "hasdata", "stackexchange", + "prolific", "cint", "mturk", "usertesting", "userinterviews", + "respondent", "purespectrum", "dynata", "lucid", "toloka", + "linkedin", "linkedin_b2b", ], builtin_skills=RESEARCHER_SKILLS, ) @@ -94,7 +38,7 @@ fexp_python_kernel="", fexp_block_tools="", fexp_allow_tools="", - fexp_description="GTM Research operator - discovery recruitment, interview capture, alternatives mapping, WTP research, search signals, firmographics, ICP scoring, and contact enrichment.", + fexp_description="GTM Research operator - discovery recruitment across panels and marketplaces, interview capture, alternatives mapping, WTP research, search signals, firmographics, ICP scoring, and contact enrichment.", fexp_builtin_skills=ckit_skills.read_name_description(RESEARCHER_ROOTDIR, RESEARCHER_SKILLS), )), ] @@ -106,6 +50,18 @@ async def install( bot_version: str, tools: list[ckit_cloudtool.CloudTool], ) -> None: + auth_supported = ["google"] + auth_scopes: dict[str, list[str]] = {"google": []} + for rec in RESEARCHER_INTEGRATIONS: + if not rec.integr_provider: + continue + if rec.integr_provider not in auth_supported: + auth_supported.append(rec.integr_provider) + existing = auth_scopes.get(rec.integr_provider, []) + auth_scopes[rec.integr_provider] = list(dict.fromkeys(existing + rec.integr_scopes)) + if "serpapi" not in auth_supported: + auth_supported.append("serpapi") + auth_scopes["serpapi"] = [] pic_big = base64.b64encode((RESEARCHER_ROOTDIR / "researcher-1024x1536.webp").read_bytes()).decode("ascii") pic_small = base64.b64encode((RESEARCHER_ROOTDIR / "researcher-256x256.webp").read_bytes()).decode("ascii") await ckit_bot_install.marketplace_upsert_dev_bot( @@ -128,11 +84,11 @@ async def install( marketable_run_this="python -m flexus_simple_bots.researcher.researcher_bot", marketable_setup_default=RESEARCHER_SETUP_SCHEMA, marketable_featured_actions=[ - {"feat_question": "Recruit qualified participants for discovery interviews", "feat_expert": "default", "feat_depends_on_setup": []}, + {"feat_question": "Recruit qualified participants across Prolific, Cint, MTurk, Respondent, or other configured recruitment providers", "feat_expert": "default", "feat_depends_on_setup": []}, {"feat_question": "Map buyer alternatives and switching triggers from evidence", "feat_expert": "default", "feat_depends_on_setup": []}, {"feat_question": "Enrich and score target accounts and contacts before outreach", "feat_expert": "default", "feat_depends_on_setup": []}, ], - marketable_intro_message="I'm Researcher. Load a skill to activate a workflow: discovery-recruitment, discovery-interview-capture, pain-alternatives-landscape, pain-wtp-research, signal-search-seo, segment-firmographic, segment-icp-scoring, or pipeline-contact-enrichment.", + marketable_intro_message="I'm Researcher. Load a skill to activate a workflow: discovery-recruitment, discovery-interview-capture, pain-alternatives-landscape, pain-wtp-research, signal-search-seo, segment-firmographic, segment-icp-scoring, or pipeline-contact-enrichment. Recruitment can route across Prolific, Cint, MTurk, UserTesting, User Interviews, Respondent, PureSpectrum, Dynata, Lucid, and Toloka when those providers are configured.", marketable_preferred_model_default="grok-4-1-fast-non-reasoning", marketable_daily_budget_default=100_000, marketable_default_inbox_default=10_000, @@ -143,11 +99,8 @@ async def install( marketable_picture_big_b64=pic_big, marketable_picture_small_b64=pic_small, marketable_forms={}, - marketable_auth_supported=["linkedin", "google"], - marketable_auth_scopes={ - "linkedin": ["r_profile_basicinfo", "email", "w_member_social"], - "google": [], - }, + marketable_auth_supported=auth_supported, + marketable_auth_scopes=auth_scopes, ) diff --git a/flexus_simple_bots/researcher/researcher_prompts.py b/flexus_simple_bots/researcher/researcher_prompts.py index 2c30d745..8a10c2f4 100644 --- a/flexus_simple_bots/researcher/researcher_prompts.py +++ b/flexus_simple_bots/researcher/researcher_prompts.py @@ -15,6 +15,8 @@ - pipeline-contact-enrichment: enrich, verify, and score contacts before any outreach begins {prompts_common.PROMPT_KANBAN} +{prompts_common.PROMPT_PRINT_WIDGET} +{prompts_common.PROMPT_POLICY_DOCUMENTS} {prompts_common.PROMPT_A2A_COMMUNICATION} {prompts_common.PROMPT_HERE_GOES_SETUP} """ diff --git a/flexus_simple_bots/researcher/skills/_customer-discovery/SKILL.md b/flexus_simple_bots/researcher/skills/_customer-discovery/SKILL.md new file mode 100644 index 00000000..9367b145 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_customer-discovery/SKILL.md @@ -0,0 +1,256 @@ +--- +name: customer-discovery +description: Structured discovery workflows — instrument design, participant recruitment, JTBD interview operations +--- + +You are operating as Discovery Operator for this task. Keep evidence quality high. + +## Core Skills + +**Past-behavior questioning:** Force past-event phrasing. Block hypothetical, leading, or abstract prompts. + +**JTBD outcome formatting:** Convert raw interview language into structured desired-outcome statements. + +**Qualitative coding:** Apply coding consistency, theme merge rules, and saturation checks. + +## Recording Instrument Artifacts + +After designing or revising a discovery instrument, call the appropriate write tool: +- `write_artifact(path=/discovery/instruments/interview-{YYYY-MM-DD}, data={...})` +- `write_artifact(path=/discovery/instruments/survey-{YYYY-MM-DD}, data={...})` +- `write_artifact(path=/discovery/readiness/{instrument_id}-{YYYY-MM-DD}, data={...})` + +One call per instrument version. Do not output raw JSON in chat. +Fail fast: if hypothesis_refs or target_segment are missing, set readiness_state="blocked". + +## Recording Recruitment Artifacts + +- `write_artifact(path=/discovery/recruitment/plan-{YYYY-MM-DD}, data={...})` +- `write_artifact(path=/discovery/recruitment/funnel-{plan_id}-{YYYY-MM-DD}, data={...})` +- `write_artifact(path=/discovery/recruitment/compliance-{plan_id}-{YYYY-MM-DD}, data={...})` + +## Recording Evidence Artifacts + +- `write_artifact(path=/discovery/evidence/corpus-{YYYY-MM-DD}, data={...})` +- `write_artifact(path=/discovery/evidence/jtbd-outcomes-{study_id}-{YYYY-MM-DD}, data={...})` +- `write_artifact(path=/discovery/evidence/quality-{study_id}-{YYYY-MM-DD}, data={...})` + +Fail fast when coverage_status="insufficient" or pass_fail="fail". + +## Available Integration Tools + +Call each tool with `op="help"` to see available methods, `op="call", args={"method_id": "...", ...}` to execute. + +**Survey design & collection:** `surveymonkey`, `typeform`, `qualtrics` + +**Panel & participant recruitment:** `prolific`, `cint`, `mturk`, `usertesting`, `userinterviews` + +**Interview scheduling:** `calendly` + +**Recording & transcription:** `fireflies`, `gong` + +**Transcript analysis:** `dovetail` + +## Artifact Schemas + +```json +{ + "interview_instrument": { + "type": "object", + "properties": { + "instrument_id": {"type": "string"}, + "hypothesis_refs": {"type": "array", "items": {"type": "string"}}, + "target_segment": {"type": "string"}, + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "question_id": {"type": "string"}, + "text": {"type": "string"}, + "type": {"type": "string", "enum": ["past-behavior", "follow-up", "clarification"]}, + "jtbd_dimension": {"type": "string"} + }, + "required": ["question_id", "text", "type"] + } + }, + "version": {"type": "string"} + }, + "required": ["instrument_id", "hypothesis_refs", "target_segment", "questions"], + "additionalProperties": false + }, + "survey_instrument": { + "type": "object", + "properties": { + "instrument_id": {"type": "string"}, + "hypothesis_refs": {"type": "array", "items": {"type": "string"}}, + "target_segment": {"type": "string"}, + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "question_id": {"type": "string"}, + "text": {"type": "string"}, + "type": {"type": "string", "enum": ["likert", "multiple_choice", "open_text", "nps"]}, + "required": {"type": "boolean"} + }, + "required": ["question_id", "text", "type"] + } + }, + "platform": {"type": "string"} + }, + "required": ["instrument_id", "hypothesis_refs", "target_segment", "questions"], + "additionalProperties": false + }, + "discovery_instrument_readiness": { + "type": "object", + "properties": { + "instrument_id": {"type": "string"}, + "date": {"type": "string"}, + "readiness_state": {"type": "string", "enum": ["ready", "blocked"]}, + "blockers": {"type": "array", "items": {"type": "string"}}, + "checks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "check": {"type": "string"}, + "status": {"type": "string", "enum": ["pass", "fail"]}, + "notes": {"type": "string"} + }, + "required": ["check", "status"] + } + } + }, + "required": ["instrument_id", "date", "readiness_state", "blockers"], + "additionalProperties": false + }, + "participant_recruitment_plan": { + "type": "object", + "properties": { + "plan_id": {"type": "string"}, + "date": {"type": "string"}, + "target_n": {"type": "integer"}, + "target_segment": {"type": "string"}, + "channels": {"type": "array", "items": {"type": "string"}}, + "screening_criteria": {"type": "array", "items": {"type": "string"}}, + "timeline": {"type": "string"}, + "per_run_spend_cap": {"type": "number"} + }, + "required": ["plan_id", "date", "target_n", "target_segment", "channels", "screening_criteria", "timeline"], + "additionalProperties": false + }, + "recruitment_funnel_snapshot": { + "type": "object", + "properties": { + "plan_id": {"type": "string"}, + "date": {"type": "string"}, + "applied": {"type": "integer"}, + "screened": {"type": "integer"}, + "scheduled": {"type": "integer"}, + "completed": {"type": "integer"}, + "conversion_rate": {"type": "number"}, + "drop_reasons": {"type": "array", "items": {"type": "string"}} + }, + "required": ["plan_id", "date", "applied", "screened", "scheduled", "completed"], + "additionalProperties": false + }, + "recruitment_compliance_quality": { + "type": "object", + "properties": { + "plan_id": {"type": "string"}, + "date": {"type": "string"}, + "checks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "check": {"type": "string", "enum": ["consent", "pii_redaction", "bias_screening", "panel_quality"]}, + "status": {"type": "string", "enum": ["pass", "fail", "warning"]}, + "notes": {"type": "string"} + }, + "required": ["check", "status"] + } + }, + "pass_fail": {"type": "string", "enum": ["pass", "fail"]} + }, + "required": ["plan_id", "date", "checks", "pass_fail"], + "additionalProperties": false + }, + "interview_corpus": { + "type": "object", + "properties": { + "study_id": {"type": "string"}, + "date": {"type": "string"}, + "interviews": { + "type": "array", + "items": { + "type": "object", + "properties": { + "participant_id": {"type": "string"}, + "date": {"type": "string"}, + "key_quotes": {"type": "array", "items": {"type": "string"}}, + "jtbd_tags": {"type": "array", "items": {"type": "string"}}, + "themes": {"type": "array", "items": {"type": "string"}} + }, + "required": ["participant_id", "key_quotes", "jtbd_tags"] + } + }, + "coverage_status": {"type": "string", "enum": ["sufficient", "insufficient", "partial"]}, + "saturation_reached": {"type": "boolean"} + }, + "required": ["study_id", "date", "interviews", "coverage_status"], + "additionalProperties": false + }, + "jtbd_outcomes": { + "type": "object", + "properties": { + "study_id": {"type": "string"}, + "date": {"type": "string"}, + "outcomes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "outcome_id": {"type": "string"}, + "desired_outcome": {"type": "string"}, + "frequency": {"type": "string", "enum": ["very_high", "high", "medium", "low"]}, + "importance": {"type": "number", "description": "0-1"}, + "satisfaction": {"type": "number", "description": "0-1"}, + "opportunity_score": {"type": "number"}, + "evidence_refs": {"type": "array", "items": {"type": "string"}} + }, + "required": ["outcome_id", "desired_outcome", "frequency", "importance", "satisfaction"] + } + } + }, + "required": ["study_id", "date", "outcomes"], + "additionalProperties": false + }, + "discovery_evidence_quality": { + "type": "object", + "properties": { + "study_id": {"type": "string"}, + "date": {"type": "string"}, + "pass_fail": {"type": "string", "enum": ["pass", "fail"]}, + "quality_checks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "check": {"type": "string"}, + "status": {"type": "string", "enum": ["pass", "fail", "warning"]}, + "notes": {"type": "string"} + }, + "required": ["check", "status"] + } + }, + "saturation_check": {"type": "string", "enum": ["passed", "failed", "not_applicable"]}, + "coding_consistency_check": {"type": "string", "enum": ["passed", "failed", "not_applicable"]} + }, + "required": ["study_id", "date", "pass_fail", "quality_checks"], + "additionalProperties": false + } +} +``` diff --git a/flexus_simple_bots/researcher/skills/_discovery-context-import/RESEARCH.md b/flexus_simple_bots/researcher/skills/_discovery-context-import/RESEARCH.md new file mode 100644 index 00000000..dfe50f57 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_discovery-context-import/RESEARCH.md @@ -0,0 +1,1148 @@ +# Research: discovery-context-import + +**Skill path:** `flexus-client-kit/flexus_simple_bots/researcher/skills/discovery-context-import/` +**Bot:** researcher +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`discovery-context-import` imports prior customer evidence from CRM, support, and conversation systems to accelerate discovery before new interviews and surveys are designed. It is explicitly a seeding step, not a substitute for fresh qualitative work. + +The current skill already encodes core intent (pull notes/calls/tickets/conversations, include Dovetail insights, and anonymize PII), but it needs stronger 2024-2026 grounding in five areas: import methodology, tool landscape, API endpoint reality, interpretation guardrails, and failure/compliance controls. + +This research focuses on practical operations: how teams scope imports, prevent bias amplification, verify endpoint behavior, interpret noisy support/sales signals, and avoid compliance failures while still shipping useful context artifacts quickly. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024-2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- No generic filler ("it is important to...", "best practices suggest...") without concrete backing +- No invented tool names, method IDs, or API endpoints - only verified real ones +- Contradictions between sources are explicitly noted, not silently resolved +- Volume: findings section should be 800-4000 words (too short = shallow, too long = unsynthesized) + +Quality gate result: passed. + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +1. Effective context import starts with bounded scope, not full-history dumps. Current Dovetail integration workflows expose explicit source/time controls (for example 7/30/90 day windows and source selection), and support integrations require explicit backfill ranges. This pattern reduces early noise and gives faster relevance. + +2. Teams that avoid identity fragmentation define import keys before first run. Dovetail/Salesforce mapping patterns and participant import guidance in adjacent tooling emphasize choosing one stable unique identifier and deterministic merge behavior. + +3. Field minimization is now a repeated practice in modern data-platform guidance: start with the highest-value fields first, then expand. A Salesforce 2025 customer-zero lesson explicitly recommends prioritizing a constrained high-value field set. + +4. Bias control begins in source selection, not at synthesis time. Product-discovery guidance warns against convenience pools (e.g., only vocal power users or only active complainers), and recommends aligning recruited evidence to learning goals plus freshness/rotation controls. + +5. Segment and trend checks should happen before drafting interview instruments. HubSpot list/segment analytics now supports practical short-window anomaly inspection and segment comparisons that can reveal sudden composition drift before the next discovery wave. + +6. Support evidence triage in practice uses severity ladders, not pure mention counts. Freshdesk SLA policy mechanics (priority classes, response timers, escalation cadence) provide a practical framework for ordering imported support themes by likely business urgency. + +7. Theme coding quality improves when teams operate an explicit taxonomy governance loop. Dovetail guidance in 2024 stresses short, specific tags, periodic review, and anti-bloat checks (very high-count tags often too broad; very low-count tags often too fragmented). + +8. Coding reliability cannot be deferred to “later analyst judgment.” 2024 guidance on inter-coder agreement stresses predeclared agreement metrics, reconciliation rules, and audit trails for codebook changes. + +9. Stopping import should be novelty-based, not arbitrary-date based. 2024 saturation work supports a handoff rule: when added imported incidents are increasing volume but no longer changing thematic direction, switch effort to targeted interviews that resolve mechanism and causality. + +10. AI-assisted triage improves only when goals are constrained. Current support-ticket analysis playbooks require objective framing (top goals first); unconstrained summarization tends to create diffuse, less-actionable themes. + +**Sources:** +- https://docs.dovetail.com/integrations/gong.md (current docs, accessed 2026) +- https://docs.dovetail.com/integrations/zendesk.md (current docs, accessed 2026) +- https://docs.dovetail.com/integrations/intercom.md (current docs, accessed 2026) +- https://docs.dovetail.com/integrations/salesforce.md (current docs, accessed 2026) +- https://support.aha.io/aha-discovery/getting-started/imports/import-participants (current docs, accessed 2026) +- https://www.salesforce.com/ap/blog/3-lessons-from-using-data-cloud/ (2025) +- https://aha.io/roadmapping/guide/discovery/how-to-build-a-customer-database-for-product-discovery (updated 2025) +- https://aha.io/roadmapping/guide/how-to-choose-the-right-customers-for-product-discovery-interviews (updated 2025) +- https://knowledge.hubspot.com/analyze-segments (updated 2025) +- https://knowledge.hubspot.com/lists/analyze-your-list-performance (updated 2026) +- https://support.freshdesk.com/support/solutions/articles/37626-understanding-sla-policies (2025/2026 policy context) +- https://dovetail.com/blog/four-taxonomy-best-practices/ (2024) +- https://docs.dovetail.com/academy/craft-your-team-taxonomy/ (current docs, accessed 2026) +- https://www.ajqr.org/article/inter-coder-agreement-in-qualitative-coding-considerations-for-its-use-14887 (2024) +- https://research.uca.ac.uk/6262/1/naeem-et-al-2024-demystification-and-actualisation-of-data-saturation-in-qualitative-research-through-thematic-analysis.pdf (2024) +- https://api.crossref.org/works/10.1177/16094069241296206 (2024 metadata) +- https://docs.dovetail.com/academy/analyze-support-tickets.md (current docs, accessed 2026) + +--- + +### Angle 2: Tool & Platform Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +1. HubSpot and Salesforce remain common CRM sources but differ operationally: HubSpot emphasizes explicit burst/day quotas by app model and tier, while Salesforce frames API allocation via daily/monthly entitlement posture with monitoring and add-on patterns. + +2. HubSpot export workflows in 2025-2026 are batch-governed (single active export, rolling export windows, partition behavior for very large jobs), which favors controlled backfills over repeated giant full refreshes. + +3. Salesforce integration guidance in 2024-2026 pushes event-first architectures (Platform Events / CDC / Pub/Sub API) for near-real-time coherence across systems. + +4. Zendesk is strong for support evidence backfills via incremental exports plus search/audit/comment endpoints, but import throughput must respect account and endpoint-level limits. + +5. Intercom is strong for conversation metadata and thread retrieval, but webhook delivery is not a strict ordered stream; practical consumers must handle duplicates, retries, and pause/suspension behavior. + +6. Freshdesk provides robust ticket/conversation access but enforces plan-tier and per-minute/per-endpoint limits; some embedded expansion patterns can consume extra API credits. + +7. Jira Service Management is a strong support context source for request/comment/participant/attachment evidence, but visibility and rate-limit policy is role/auth-model sensitive. + +8. Dovetail is a natural research sink for imported evidence; it supports multiple inbound connectors and API-level note/highlight/insight operations, making it useful for synthesis handoff. + +9. Gong remains conversation-intelligence relevant through transcript workflows and outbound automation/webhook rules, but entitlements and feature availability should be validated by tenant/plan. + +10. The de-facto production pattern is hybrid push+pull: webhooks for freshness, plus scheduled search/export/incremental replay for completeness and reconciliation. Pure webhook-only pipelines are fragile for this use case. + +11. Chorus/ZoomInfo public documentation is more integration-led than deeply endpoint-led for call intelligence in available public materials; teams should validate tenant-level API access before hard dependencies. + +**Sources:** +- https://developers.hubspot.com/docs/api/usage-details (current docs, accessed 2026) +- https://developers.hubspot.com/changelog/increasing-our-api-limits (2024) +- https://developers.hubspot.com/docs/reference/api/crm/exports (current docs, accessed 2026) +- https://developers.hubspot.com/changelog/crm-export-partitioning-and-association-limits (2025) +- https://developer.salesforce.com/blogs/2024/11/api-limits-and-monitoring-your-api-usage (2024) +- https://developer.salesforce.com/docs/platform/pub-sub-api/guide/intro.html (current docs, accessed 2026) +- https://architect.salesforce.com/docs/architect/decision-guides/guide/event-driven (2025 context) +- https://developer.zendesk.com/api-reference/introduction/rate-limits (current docs, accessed 2026) +- https://developer.zendesk.com/api-reference/ticketing/ticket-management/incremental_exports/ (current docs, accessed 2026) +- https://developers.intercom.com/docs/references/rest-api/errors/rate-limiting (current docs, accessed 2026) +- https://developers.intercom.com/docs/webhooks/webhook-notifications (current docs, accessed 2026) +- https://developers.intercom.com/docs/references/rest-api/api.intercom.io/conversations (current docs, accessed 2026) +- https://developers.intercom.com/docs/references/rest-api/api.intercom.io/conversations/searchconversations (current docs, accessed 2026) +- https://support.freshdesk.com/support/solutions/articles/225439-what-are-the-rate-limits-for-the-api-calls-to-freshdesk- (2025/2026 context) +- https://developers.freshdesk.com/api/ (current docs, accessed 2026) +- https://support.freshdesk.com/support/solutions/articles/132589-using-webhooks-in-automation-rules (current docs, accessed 2026) +- https://developer.atlassian.com/cloud/jira/platform/rate-limiting/ (2026) +- https://developer.atlassian.com/cloud/jira/platform/webhooks/ (current docs, accessed 2026) +- https://support.atlassian.com/jira-cloud-administration/docs/manage-webhooks/ (current docs, accessed 2026) +- https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-request/ (current docs, accessed 2026) +- https://docs.dovetail.com/integrations/intercom (current docs, accessed 2026) +- https://dovetail.com/help/integrations/zendesk/ (current docs, accessed 2026) +- https://dovetail.com/help/integrations/hubspot/ (current docs, accessed 2026) +- https://developers.dovetail.com/docs (current docs, accessed 2026) +- https://developers.dovetail.com/reference/post_v1-notes-import-file (current docs, accessed 2026) +- https://developers.dovetail.com/reference/get_v1-notes (current docs, accessed 2026) +- https://help.gong.io/docs/introduction-to-automation-rules (current docs, accessed 2026) +- https://help.gong.io/docs/create-a-webhook-rule (current docs, accessed 2026) +- https://help.gong.io/docs/view-a-call-transcript (current docs, accessed 2026) +- https://docs.zoominfo.com/ (current docs, accessed 2026) +- https://docs.zoominfo.com/docs/general-overview (current docs, accessed 2026) +- https://www.zoominfo.com/about/get-started-old/chorus-integrations (current docs, accessed 2026) + +--- + +### Angle 3: API Endpoint Reality & Integration Constraints +> What are the verified endpoint-level operations and constraints for context import APIs? + +**Findings:** + +1. HubSpot contact search endpoint is verified: `POST /crm/v3/objects/contacts/search`, with filter groups and cursor pagination (`after`), max page size 200. + +2. HubSpot note/call listing endpoints are verified: `GET /crm/v3/objects/notes` and `GET /crm/v3/objects/calls`. + +3. HubSpot search endpoints for evidence-bearing objects are verified: `POST /crm/v3/objects/notes/search`, `POST /crm/v3/objects/calls/search`, `POST /crm/v3/objects/tickets/search`. Search has practical limits including result ceilings and request pacing. + +4. HubSpot OAuth v3 endpoints were introduced in 2026: `POST /v3/token` and `POST /v3/introspect`, with v1 deprecation context. This is a real auth migration consideration. + +5. Zendesk incremental ticket-event endpoint is verified: `GET /api/v2/incremental/ticket_events?start_time={unix}&include=comment_events`. This remains core for historical support context replay. + +6. Zendesk ticket-history endpoints are verified: `GET /api/v2/tickets/{ticket_id}/audits` and `GET /api/v2/tickets/{ticket_id}/comments`. + +7. Zendesk unified search endpoint is verified: `GET /api/v2/search?query={query}` with result caps and pagination constraints. + +8. Zendesk export-style search endpoint is verified: `GET /api/v2/search/export?query={query}&filter[type]=ticket`; cursor expiry handling is required. + +9. Intercom conversation endpoints are verified: `GET /conversations`, `GET /conversations/{id}`, `POST /conversations/search`, with explicit pagination and query-complexity constraints. + +10. Intercom contact and ticket search operations are verified: `POST /contacts/search`, `POST /tickets/search`, plus versioning through `Intercom-Version`. + +11. Dovetail project/highlight/insight endpoints are verified: `GET /v1/projects`, `GET /v1/highlights`, and insight export `GET /v1/insights/{insight_id}/export/{type}` where `type` is `html` or `markdown`. + +12. Freshdesk ticket and ticket-conversation endpoints are verified: `GET /api/v2/tickets`, `GET /api/v2/tickets/{id}/conversations`, and `GET /api/v2/search/tickets?query=...`, with practical pagination limits. + +13. Important connector-mapping caveat: current skill method IDs are connector aliases, not public endpoint names. Some method names may remain valid internally even when public endpoints differ in granularity. + +14. Dovetail project-level markdown export as a single public endpoint is not verified in public docs; public export docs are insight-scoped. Any project-level export behavior should be treated as connector logic, not endpoint fact. + +15. Versioning and rate-limit headers differ materially across platforms; reconciliation jobs must be idempotent and resume-safe rather than single-pass assumptions. + +**Sources:** +- https://developers.hubspot.com/docs/api-reference/crm-contacts-v3/search/post-crm-v3-objects-contacts-search (evergreen endpoint docs) +- https://developers.hubspot.com/docs/api-reference/crm-notes-v3/basic/get-crm-v3-objects-notes (evergreen endpoint docs) +- https://developers.hubspot.com/docs/api-reference/crm-calls-v3/basic/get-crm-v3-objects-calls (evergreen endpoint docs) +- https://developers.hubspot.com/docs/guides/api/crm/search (current docs, accessed 2026) +- https://developers.hubspot.com/docs/api/usage-details (2025/2026 platform context) +- https://developers.hubspot.com/changelog/new-oauth-v3-api-endpoints-and-standardized-error-responses (2026) +- https://developer.zendesk.com/api-reference/ticketing/ticket-management/incremental_exports/ (evergreen endpoint docs) +- https://developer.zendesk.com/api-reference/ticketing/tickets/ticket_audits/ (evergreen endpoint docs) +- https://developer.zendesk.com/api-reference/ticketing/tickets/ticket_comments/ (evergreen endpoint docs) +- https://developer.zendesk.com/api-reference/ticketing/ticket-management/search/ (evergreen endpoint docs) +- https://developer.zendesk.com/api-reference/introduction/pagination/ (evergreen endpoint docs) +- https://developer.zendesk.com/api-reference/introduction/security-and-auth/ (evergreen endpoint docs) +- https://developer.zendesk.com/api-reference/changelog/changelog (2024-2026 updates) +- https://developers.intercom.com/docs/references/rest-api/api.intercom.io/conversations/listconversations (current endpoint docs) +- https://developers.intercom.com/docs/references/rest-api/api.intercom.io/conversations/searchconversations (current endpoint docs) +- https://developers.intercom.com/docs/references/rest-api/api.intercom.io/contacts/searchcontacts (current endpoint docs) +- https://developers.intercom.com/docs/references/rest-api/api.intercom.io/tickets (current endpoint docs) +- https://developers.intercom.com/docs/build-an-integration/learn-more/authentication (current docs) +- https://developers.intercom.com/docs/build-an-integration/learn-more/rest-apis/update-your-api-version (current docs) +- https://developers.intercom.com/docs/build-an-integration/learn-more/rest-apis/api-changelog (2024-2025 changes) +- https://developers.intercom.com/docs/build-an-integration/learn-more/rest-apis/unversioned-changes (2024-2026 changes) +- https://developers.dovetail.com/docs/introduction (current docs) +- https://developers.dovetail.com/docs/authorization (current docs) +- https://developers.dovetail.com/reference/get_v1-projects (current endpoint docs) +- https://developers.dovetail.com/reference/get_v1-highlights (current endpoint docs) +- https://developers.dovetail.com/reference/get_v1-insights-insight-id-export-type (current endpoint docs) +- https://developers.freshdesk.com/api/ (current endpoint docs) +- https://support.freshdesk.com/support/solutions/articles/225439-what-are-the-rate-limits-for-the-api-calls-to-freshdesk- (2025/2026 context) + +--- + +### Angle 4: Data Interpretation & Signal Quality +> How should imported CRM/support/conversation data be interpreted without overclaiming? + +**Findings:** + +1. Frequency must be separated from business criticality. Incident and service-priority frameworks emphasize that low-volume/high-impact problems can outrank high-volume/low-impact complaints. + +2. Dedupe should precede trend inference. Teams that count raw ticket volume without duplicate suppression tend to overstate issue prevalence. + +3. Recency should be weighted with two windows (short vs baseline) to catch emerging shifts without overreacting to temporary spikes. + +4. Segment-first interpretation outperforms global ranking. Signals should be interpreted by cohort (segment/tier/lifecycle/region) before aggregate rollups. + +5. Confidence scoring from transcript/conversation systems should be treated as routing guidance, not absolute truth; low-confidence outputs need human review loops. + +6. Calibration should be monitored by subgroup, not only global averages. Miscalibrated confidence across subgroups can produce systematic interpretation errors. + +7. LLM-generated summaries are not automatically evidence-grade. Transcript-vs-summary audit sampling is required before using summary output as strategic input. + +8. High-stakes claims should require triangulation across at least two evidence types (for example imported support themes + interview themes, or imported sales notes + behavioral analytics). + +9. Complaint volume bias is real: changes in solicitation, routing, or response channels can alter volume independent of problem severity. + +10. Survivorship bias is a persistent CRM interpretation trap. Mining only successful or highly-documented opportunities hides causes from closed-lost, stalled, and lightly-logged populations. + +11. Mature interpretation pipelines keep a contradiction register, not a single narrative: what support data suggests, what sales notes suggest, what interviews confirm/disconfirm. + +**Sources:** +- https://support.atlassian.com/jira-service-management-cloud/docs/how-impact-and-urgency-are-used-to-calculate-priority/ (current docs, accessed 2026) +- https://support.atlassian.com/jira-service-management-cloud/docs/automatically-prioritize-incident-requests/ (current docs, accessed 2026) +- https://help.leadsquared.com/service-crm/ticket-duplicate-detection/ (current docs, accessed 2026) +- https://arxiv.org/html/2404.16887v1 (2024) +- https://docs.oracle.com/en/cloud/saas/cx-unity/cx-unity-user/Help/Data_Science/Engagement_analysis/DataScience_Model_RFM.htm (current docs, accessed 2026) +- https://docs.aws.amazon.com/lexv2/latest/dg/using-transcript-confidence-scores.html (current docs, accessed 2026) +- https://arxiv.org/abs/2404.04689 (2024) +- https://aclanthology.org/2025.emnlp-industry.91/ (2025) +- https://pmc.ncbi.nlm.nih.gov/articles/PMC11334375/ (2024) +- https://www.nngroup.com/articles/triangulation-better-research-results-using-multiple-ux-methods/ (evergreen) +- https://ideas.repec.org/a/inm/ormnsc/v71y2025i2p1671-1691.html (2025) +- https://blog.hubspot.com/sales/survivorship-bias (updated 2025) +- https://www.validity.com/wp-content/uploads/2024/05/The-State-of-CRM-Data-Management-in-2024.pdf (2024) +- https://www.gartner.com/en/newsroom/press-releases/2024-02-05-gartner-identifies-three-top-priorities-for-customer-service-and-support-leaders-in-2024 (2024) +- https://www.zendesk.com/newsroom/articles/2025-cx-trends-report/ (2024 publication) +- https://www.zendesk.com/blog/zip1-cx-trends-2026-contextual-intelligence/ (2025) + +--- + +### Angle 5+: Failure Modes, Anti-Patterns, and Compliance Pitfalls +> What goes wrong in practice, how do you detect it, and how do you mitigate it? + +**Findings:** + +1. PII leakage remains common even in “internal only” discovery workflows. Detection signals include direct identifiers showing up in summarized outputs or artifact logs despite masking expectations. + +2. Over-broad purpose statements (for example “improve AI”) without source-by-source legal basis documentation are a repeat compliance failure in context-import workflows. + +3. Teams often claim anonymity without technical proof; model/index extraction behavior and re-identification risk remain possible unless tested. + +4. Deletion/retention mismatch is frequent across multi-layer stacks (source system, sync cache, index/vector store, assistant transcripts, analytics logs). + +5. Controller/processor responsibility ambiguity slows rights requests and incident response in real operations. + +6. Schema drift and brittle ETL can silently corrupt trend interpretation if ingestion continues without quarantine or explicit schema-change governance. + +7. Data freshness failures can masquerade as “stability” or “decline” if stale windows are not marked as non-comparable. + +8. Over-automation/excessive agency allows assistants to perform high-impact actions on weak evidence when permission boundaries are too broad. + +9. Hallucinated categorization and unsupported summaries contaminate imported context if output grounding/citation checks are missing. + +10. Low-quality summary pipelines are often rubric problems (poor criteria, weak calibration) rather than model-only problems. + +11. Missing AI disclosure/labeling in customer-facing or externally consumed outputs can create trust and policy risks as regulations evolve. + +12. Public overclaims of AI-derived “facts” without substantiation are an enforcement risk. + +13. Contradiction management is required: platform privacy claims (e.g., “no retention” in one layer) can coexist with retained artifacts in adjacent operational layers. + +**Sources:** +- https://www.edpb.europa.eu/news/news/2024/edpb-opinion-ai-models-gdpr-principles-support-responsible-ai_en (2024) +- https://www.edpb.europa.eu/our-work-tools/our-documents/opinion-board-art-64/opinion-282024-certain-data-protection-aspects_en (2024) +- https://www.cnil.fr/en/ai-system-development-cnils-recommendations-to-comply-gdpr (2026) +- https://commission.europa.eu/law/law-topic/data-protection/rules-business-and-organisations/principles-gdpr/overview-principles/what-data-can-we-process-and-under-which-conditions_en (official reference, accessed 2026) +- https://commission.europa.eu/law/law-topic/data-protection/rules-business-and-organisations/principles-gdpr/how-much-data-can-be-collected_en (official reference, accessed 2026) +- https://commission.europa.eu/news/ai-act-enters-force-2024-08-01_en (2024) +- https://ai-act-service-desk.ec.europa.eu/en/ai-act/timeline/timeline-implementation-eu-ai-act (2026) +- https://digital-strategy.ec.europa.eu/en/policies/code-practice-ai-generated-content (2026) +- https://genai.owasp.org/llmrisk/llm022025-sensitive-information-disclosure/ (2025) +- https://genai.owasp.org/llmrisk/llm062025-excessive-agency/ (2025) +- https://genai.owasp.org/llmrisk/llm092025-misinformation/ (2025) +- https://developer.salesforce.com/docs/ai/agentforce/guide/trust.html (current docs, accessed 2026) +- https://developer.salesforce.com/docs/ai/agentforce/guide/models-api-data-masking.html (current docs, accessed 2026) +- https://support.zendesk.com/hc/en-us/articles/5729714731290-Zendesk-AI-Data-Use-Information (current docs, accessed 2026) +- https://support.zendesk.com/hc/en-us/articles/6059285322522-About-generative-AI-features-in-Zendesk (current docs, accessed 2026) +- https://learn.microsoft.com/en-us/copilot/microsoft-365/microsoft-365-copilot-privacy (2026) +- https://learn.microsoft.com/en-us/copilot/security/privacy-data-security (2026) +- https://knowledge.hubspot.com/hubspot-ai-cloud-infrastructure-frequently-asked-questions (2026) +- https://docs.airbyte.com/using-airbyte/schema-change-management (current docs, accessed 2026) +- https://fivetran.com/docs/using-fivetran/features/data-blocking-column-hashing/config (current docs, accessed 2026) +- https://fivetran.com/docs/logs/troubleshooting/track-schema-changes (current docs, accessed 2026) +- https://docs.getdbt.com/reference/resource-properties/freshness (evergreen) +- https://docs.databricks.com/aws/en/delta-live-tables/expectations (2026) +- https://cloud.google.com/agent-assist/docs/summarization-autoeval-metrics (current docs, accessed 2026) +- https://docs.cloud.google.com/contact-center/insights/docs/qai-best-practices (current docs, accessed 2026) +- https://aclanthology.org/2024.naacl-long.251/ (2024) +- https://aclanthology.org/2024.acl-long.585/ (2024) +- https://www.ftc.gov/news-events/news/press-releases/2024/09/ftc-announces-crackdown-deceptive-ai-claims-schemes (2024) +- https://www.bbc.com/travel/article/20240222-air-canada-chatbot-misinformation-what-travellers-should-know (2024 case coverage) + +--- + +## Synthesis + +The strongest cross-angle conclusion is that context import quality is determined more by operational controls than by connector count. Teams already have access to rich CRM/support/conversation data, but the value of that data depends on bounded extraction windows, deterministic identity merge rules, taxonomy governance, and novelty-based handoff to interviews. + +The second conclusion is that tool selection matters less than reconciliation discipline. HubSpot, Zendesk, Intercom, Dovetail, and others can all support this workflow, but each introduces different limits and edge cases (quota models, cursor rules, webhook delivery behavior, endpoint caps). Reliable context import requires hybrid sync design (event + replay) and idempotent state tracking. + +Third, interpretation failures are predictable and avoidable. Complaint volume bias, survivorship bias in sales notes, and uncalibrated confidence in summaries frequently create false certainty. Good practice requires severity-aware scoring, dedupe-first counting, cohort segmentation, and mandatory triangulation before strategic claims. + +Fourth, API endpoint reality must be separated from connector alias behavior. Public endpoint documentation confirms core operations, but internal `method_id` names remain connector abstractions. The `discovery-context-import` skill should explicitly distinguish “verified endpoint fact” from “runtime connector mapping,” especially around Dovetail export semantics. + +Finally, governance is not optional overhead. PII leakage, retention mismatches, and over-broad AI claims are now directly tied to regulatory and enforcement risk. The skill should include explicit compliance controls, uncertainty reporting, and anti-pattern detection steps inside normal methodology rather than as end-note warnings. + +--- + +## Recommendations for SKILL.md + +> Concrete, actionable list of what should change / be added in the SKILL.md based on research. + +- [x] Replace current brief methodology with a full import control loop (`scope -> extract -> normalize -> score -> synthesize -> handoff`). +- [x] Add explicit bias ledger requirements (sales-process bias, complaint overrepresentation, source coverage skew, recency skew). +- [x] Add endpoint-reality notes and connector-mapping caution, especially for Dovetail export granularity. +- [x] Add reliability guidance for incremental sync, pagination, retry/backoff, and stale-window handling. +- [x] Add interpretation rubric with severity, recency, frequency, and confidence dimensions. +- [x] Add contradiction register requirement (e.g., support says X, sales says Y, interview confirms/disconfirms Z). +- [x] Add anti-pattern warning blocks with detection signals and deterministic mitigations. +- [x] Expand PII/compliance section with purpose limitation, minimization, retention mapping, and rights/deletion traceability. +- [x] Expand schema to encode evidence provenance, merge policy, scoring logic, uncertainty, and confidence. +- [x] Add pre-write artifact checklist and block publishing when required controls are missing. + +--- + +## Draft Content for SKILL.md + +> This section is intentionally the largest section and is paste-ready. +> Use directly, then trim for final SKILL.md editing. + +### Draft: Core Operating Stance + +You import customer context to accelerate discovery, not to replace discovery interviews. + +Imported CRM, support, and conversation evidence is biased by system mechanics: +- CRM notes reflect what sales teams logged, what was remembered, and what was rewarded. +- Support tickets overrepresent users who contacted support and may over-weight acute problems. +- Conversation intelligence systems may overrepresent call types from specific segments or teams. + +Treat imported data as directional evidence with known limits. Your job is to make those limits explicit and still produce useful hypothesis seeds. + +Hard rule: if bias, provenance, or freshness controls are missing, downgrade output confidence and state exactly why. + +### Draft: Methodology (Context Import Control Loop) + +Use this six-stage workflow every time: + +#### 1) Scope and Extraction Plan + +Before any API calls, define: +- `study_id` +- `decision_scope` (what decisions this import informs) +- source set (`hubspot`, `zendesk`, `intercom`, `dovetail`, optional others) +- time window (`last_30_days`, `last_90_days`, custom) +- segment filters (if applicable) +- required output fields + +Start narrow. Do not begin with full-history pull unless a specific reason is documented. + +Default windows: +- operational discovery seed: 30-90 days +- trend verification: compare short window vs baseline window + +If you need long-range history, ingest in waves and evaluate signal delta between waves. + +#### 2) Pull and Normalize + +Extract evidence-bearing records (notes, calls, tickets, conversations, insights) and normalize into a common internal structure: +- `source_system` +- `source_record_id` +- `created_at` +- `updated_at` +- `author_role` (if available) +- `account_segment` (if available) +- `raw_text_excerpt` +- `topic_hints` (optional) +- `severity_hint` (optional) + +Normalization rules: +- normalize timestamps to UTC +- preserve source timezone metadata when present +- store cursor/checkpoint per source for replay-safe sync +- enforce deterministic merge key rules before upsert + +If merge key is ambiguous, stop and resolve before synthesis. + +#### 3) Privacy and Compliance Gate + +Before synthesis, run mandatory privacy gate: +- redact direct identifiers in working artifacts +- replace names/emails/account identifiers with anonymized labels +- verify purpose and lawful basis are documented per source +- verify data minimization (drop unused fields) +- verify retention/deletion ownership is declared + +If any control fails, do not publish artifact as complete. + +#### 4) Theme Construction and Triage + +Build themes with a governed taxonomy: +- short tag labels +- unambiguous tag descriptions +- source examples attached per tag +- count both raw mentions and deduped issue counts + +Triaging rule: +- score theme priority using severity + urgency + frequency + recency +- do not rank by mention count alone + +Recommended score components: +- `severity_score` (0-5) +- `urgency_score` (0-5) +- `frequency_score` (0-5 from deduped issue count) +- `recency_score` (0-5 from short-window movement) +- `confidence_score` (0-1 evidence quality + source coverage) + +#### 5) Contradiction and Uncertainty Pass + +Create a contradiction register: +- where support and CRM signals disagree +- where transcript summaries and raw excerpts disagree +- where source trends are unstable due to freshness/schema issues + +Create an uncertainty register: +- what was not observed +- what could not be verified +- what requires interviews to resolve causality + +#### 6) Handoff to Discovery Instruments + +Convert context import into interview/survey inputs: +- `pain_themes` +- `objection_themes` +- `hypothesis_seeds` +- `interview_probe_candidates` +- `limitations` + +Handoff rule: +- stop extending import when marginal novelty drops and new records mostly repeat existing themes. +- move to interviews when mechanism-level questions remain unanswered. + +Do NOT: +- Do NOT treat imported counts as prevalence estimates. +- Do NOT infer causality from ticket volume alone. +- Do NOT skip fresh interviews because import looks “complete.” + +### Draft: Bias Ledger Requirements + +Add a required `bias_ledger` section in every output with at least: + +1. **Sales-process bias** +- Detection: heavy reliance on opportunity notes from closed-won or highly-engaged accounts. +- Mitigation: include closed-lost/stalled samples and annotate note coverage gaps. + +2. **Complaint overrepresentation** +- Detection: support-heavy source mix and low passive-user representation. +- Mitigation: treat support-driven themes as risk indicators, not universal prevalence. + +3. **Source coverage skew** +- Detection: one source contributes dominant share of evidence. +- Mitigation: enforce minimum multi-source corroboration for high-stakes themes. + +4. **Recency skew** +- Detection: trend inference uses only short spike window. +- Mitigation: compare short vs baseline window before prioritization. + +5. **Summary fidelity risk** +- Detection: summary claims exceed or contradict underlying excerpts. +- Mitigation: sample-based summary-vs-source audit before final publish. + +### Draft: Available Tools + +Use runtime connector methods, then map claims to verified public endpoints. + +```python +# Always inspect connector surface first. +hubspot(op="help") +zendesk(op="help") +intercom(op="help") +dovetail(op="help") + +# HubSpot examples (connector aliases in current skill) +hubspot(op="call", args={ + "method_id": "hubspot.contacts.search.v1", + "filterGroups": [{"filters": [{"propertyName": "company", "operator": "EQ", "value": "Company Name"}]}] +}) + +hubspot(op="call", args={ + "method_id": "hubspot.notes.list.v1", + "objectType": "contacts", + "objectId": "contact_id" +}) + +hubspot(op="call", args={ + "method_id": "hubspot.calls.list.v1", + "objectType": "contacts", + "objectId": "contact_id" +}) + +# Zendesk examples +zendesk(op="call", args={ + "method_id": "zendesk.incremental.ticket_events.comment_events.list.v1", + "start_time": 1704067200 +}) + +zendesk(op="call", args={ + "method_id": "zendesk.tickets.audits.list.v1", + "ticket_id": "ticket_id" +}) + +# Intercom examples +intercom(op="call", args={ + "method_id": "intercom.conversations.list.v1", + "per_page": 50, + "starting_after": None +}) + +# Dovetail example (connector alias) +dovetail(op="call", args={ + "method_id": "dovetail.insights.export.markdown.v1", + "projectId": "project_id" +}) +``` + +Verified endpoint mappings for integration validation: +- HubSpot contacts search: `POST /crm/v3/objects/contacts/search` +- HubSpot notes list: `GET /crm/v3/objects/notes` +- HubSpot calls list: `GET /crm/v3/objects/calls` +- HubSpot notes search: `POST /crm/v3/objects/notes/search` +- HubSpot calls search: `POST /crm/v3/objects/calls/search` +- HubSpot tickets search: `POST /crm/v3/objects/tickets/search` +- Zendesk incremental ticket events: `GET /api/v2/incremental/ticket_events?start_time={unix}&include=comment_events` +- Zendesk ticket audits: `GET /api/v2/tickets/{ticket_id}/audits` +- Zendesk ticket comments: `GET /api/v2/tickets/{ticket_id}/comments` +- Zendesk search: `GET /api/v2/search?query={query}` +- Zendesk export search: `GET /api/v2/search/export?query={query}&filter[type]=ticket` +- Intercom conversations list: `GET /conversations` +- Intercom conversation details: `GET /conversations/{id}` +- Intercom conversations search: `POST /conversations/search` +- Intercom contacts search: `POST /contacts/search` +- Intercom tickets search: `POST /tickets/search` +- Dovetail projects list: `GET /v1/projects` +- Dovetail highlights list: `GET /v1/highlights` +- Dovetail insight export: `GET /v1/insights/{insight_id}/export/{type}` + +Critical connector caveat: +- Connector `method_id` is an internal alias, not the public endpoint name. +- Do not assert endpoint behavior that you cannot map to public docs. +- For Dovetail, public docs verify insight-scoped export; any project-wide export behavior should be documented as connector implementation, not endpoint fact. + +Rate-limit and reliability rules: +- Use cursor/checkpoint sync per source. +- On `429` or quota-style throttling, apply exponential backoff. +- Never assume webhooks are ordered or complete; run replay/reconciliation jobs. +- Mark data as stale when freshness threshold is exceeded. + +### Draft: Interpretation Rubric (Signal vs Noise) + +Use this interpretation sequence: + +1. **Clean the denominator** +- dedupe near-identical issues first +- separate repeated updates from new incidents + +2. **Score business significance** +- severity and urgency first +- then frequency and recency + +3. **Assess evidence quality** +- source diversity +- text fidelity +- summary confidence +- sampling skew level + +4. **Classify theme confidence** +- `high_confidence`: corroborated across >=2 sources with stable trend signal +- `medium_confidence`: strong in one source plus partial corroboration +- `low_confidence`: weak coverage, freshness risk, or unresolved contradictions + +5. **Define action class** +- `interview_probe_now`: high importance but unresolved mechanism +- `monitor`: directional signal requiring another import cycle +- `park`: weak signal not tied to current decision scope + +Interpretation anti-rules: +- Never treat complaint volume as prevalence. +- Never treat CRM note frequency as customer truth without source-mix context. +- Never publish trend claims if source freshness is out of SLA. + +### Draft: Contradiction Register Instructions + +Add required section: + +`contradiction_register` entries must include: +- `claim_a` (source and evidence) +- `claim_b` (source and evidence) +- `possible_explanations` +- `resolution_plan` (interview probe, additional extraction, or defer) +- `status` (`open`, `partially_resolved`, `resolved`) + +You must keep contradictions visible. Do not silently average them into one narrative. + +### Draft: Anti-Pattern Warning Blocks + +#### Warning: Ticket Volume Equals Prevalence +- **What it looks like:** You rank themes by raw ticket count only. +- **Detection signal:** High-volume low-severity themes outrank low-volume high-severity incidents by default. +- **Consequence:** Priority inversion and misleading roadmap pressure. +- **Mitigation:** Use severity+urgency gates before frequency and require deduped counts. + +#### Warning: Closed-Won CRM Survivorship +- **What it looks like:** Analysis uses mostly closed-won or highly active account notes. +- **Detection signal:** Closed-lost/stalled opportunities underrepresented in source coverage. +- **Consequence:** Over-optimistic interpretation of objections and adoption patterns. +- **Mitigation:** Force balanced opportunity-state sampling and annotate note completeness. + +#### Warning: Webhook-Only Completeness Assumption +- **What it looks like:** No replay/reconciliation job because webhooks are considered complete. +- **Detection signal:** Gaps between source totals and imported totals; duplicate or out-of-order event artifacts. +- **Consequence:** Silent evidence loss or double-count inflation. +- **Mitigation:** Pair push ingestion with scheduled incremental replay and idempotent merge. + +#### Warning: Stale Data Interpreted as Stable Trend +- **What it looks like:** Trend charts continue after freshness failures. +- **Detection signal:** Last successful sync exceeds freshness SLA but reporting remains “green.” +- **Consequence:** False confidence and delayed issue detection. +- **Mitigation:** Block trend claims when stale; mark windows non-comparable until refreshed. + +#### Warning: Summary Hallucination as Evidence +- **What it looks like:** LLM summary statements are used without source excerpt checks. +- **Detection signal:** Summary claims cannot be traced to stored raw evidence. +- **Consequence:** Fabricated or distorted themes enter discovery planning. +- **Mitigation:** Require citation links/excerpts and sample-level fidelity audits. + +#### Warning: PII Leakage in Artifacts +- **What it looks like:** Names/emails/account identifiers appear in published summary artifacts. +- **Detection signal:** DLP/audit catches direct identifiers post-redaction stage. +- **Consequence:** Compliance risk and trust damage. +- **Mitigation:** Enforce pre-write redaction gate and reject artifact write on leakage. + +#### Warning: Purpose Creep in Context Import +- **What it looks like:** Imported data reused for unrelated decisions without stated basis. +- **Detection signal:** Missing purpose/legal-basis metadata per source. +- **Consequence:** Governance and regulatory risk. +- **Mitigation:** Per-source purpose register; block publication when missing. + +### Draft: Recording Instructions + +You should write one primary artifact and optional supporting artifacts: + +```python +write_artifact( + artifact_type="discovery_context_summary", + path="/discovery/{study_id}/context", + data={...} +) +``` + +Optional supporting artifacts: +- `/discovery/{study_id}/context-contradictions` +- `/discovery/{study_id}/context-bias-ledger` +- `/discovery/{study_id}/context-import-log` + +Before `write_artifact`, verify: +1. Time window and source list were declared. +2. Merge key and dedupe strategy were declared. +3. Redaction gate passed. +4. Freshness checks passed or stale window clearly labeled. +5. Contradictions were recorded. +6. Limitations and uncertainty entries are non-empty. + +If any required check fails, return `blocked` and remediation actions. + +### Draft: Schema additions + +```json +{ + "discovery_context_summary": { + "type": "object", + "description": "Imported evidence summary used to seed discovery hypotheses and interview probes.", + "required": [ + "study_id", + "import_scope", + "sources_used", + "source_coverage", + "extraction_controls", + "pain_themes", + "objection_themes", + "hypothesis_seeds", + "bias_ledger", + "contradiction_register", + "confidence_assessment", + "limitations", + "uncertainties" + ], + "additionalProperties": false, + "properties": { + "study_id": { + "type": "string", + "description": "Unique discovery study identifier." + }, + "import_scope": { + "type": "object", + "required": [ + "decision_scope", + "time_window", + "segment_filters" + ], + "additionalProperties": false, + "description": "Declared boundaries for this context import run.", + "properties": { + "decision_scope": { + "type": "string", + "description": "Decision this import informs (for example pricing objections, onboarding friction)." + }, + "time_window": { + "type": "object", + "required": [ + "start", + "end", + "window_label" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "format": "date-time", + "description": "UTC start timestamp for imported records." + }, + "end": { + "type": "string", + "format": "date-time", + "description": "UTC end timestamp for imported records." + }, + "window_label": { + "type": "string", + "enum": [ + "last_30_days", + "last_90_days", + "custom" + ], + "description": "Human-readable window category." + } + } + }, + "segment_filters": { + "type": "array", + "description": "Optional segmentation filters applied during import.", + "items": { + "type": "string" + } + } + } + }, + "sources_used": { + "type": "array", + "description": "Source systems included in this import.", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "source_type", + "record_count", + "date_range", + "api_mode" + ], + "additionalProperties": false, + "properties": { + "source_type": { + "type": "string", + "enum": [ + "hubspot", + "salesforce", + "zendesk", + "intercom", + "dovetail", + "freshdesk", + "jira_service_management", + "gong", + "other" + ], + "description": "System where records originated." + }, + "record_count": { + "type": "integer", + "minimum": 0, + "description": "Number of raw records imported from this source." + }, + "date_range": { + "type": "string", + "description": "Date range represented by imported records from this source." + }, + "api_mode": { + "type": "string", + "enum": [ + "incremental", + "search", + "export", + "webhook_plus_replay" + ], + "description": "Primary ingestion pattern used for this source." + }, + "cursor_checkpoint": { + "type": "string", + "description": "Opaque checkpoint used for resume-safe sync." + } + } + } + }, + "source_coverage": { + "type": "object", + "required": [ + "source_mix_ok", + "dominant_source", + "coverage_notes" + ], + "additionalProperties": false, + "description": "Coverage and representativeness assessment of imported sources.", + "properties": { + "source_mix_ok": { + "type": "boolean", + "description": "Whether source mix is adequate for current decision scope." + }, + "dominant_source": { + "type": "string", + "description": "Source contributing largest share of evidence." + }, + "coverage_notes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Caveats about missing cohorts, channels, or account states." + } + } + }, + "extraction_controls": { + "type": "object", + "required": [ + "merge_key_policy", + "dedupe_policy", + "freshness_status", + "pii_redaction_status" + ], + "additionalProperties": false, + "description": "Operational controls that govern import integrity.", + "properties": { + "merge_key_policy": { + "type": "string", + "description": "How records are matched/upserted across sources." + }, + "dedupe_policy": { + "type": "string", + "description": "How duplicate issues and repeated records are identified." + }, + "freshness_status": { + "type": "string", + "enum": [ + "fresh", + "stale_warning", + "stale_blocking" + ], + "description": "Whether source freshness meets interpretation requirements." + }, + "pii_redaction_status": { + "type": "string", + "enum": [ + "passed", + "failed" + ], + "description": "Outcome of PII redaction gate before artifact write." + } + } + }, + "pain_themes": { + "type": "array", + "description": "Top imported pain patterns and supporting evidence.", + "items": { + "type": "object", + "required": [ + "theme", + "severity_score", + "urgency_score", + "frequency_signal", + "recency_signal", + "confidence", + "supporting_evidence" + ], + "additionalProperties": false, + "properties": { + "theme": { + "type": "string", + "description": "Canonical theme label." + }, + "severity_score": { + "type": "integer", + "minimum": 0, + "maximum": 5, + "description": "Estimated business impact severity." + }, + "urgency_score": { + "type": "integer", + "minimum": 0, + "maximum": 5, + "description": "Estimated urgency/time sensitivity." + }, + "frequency_signal": { + "type": "string", + "enum": [ + "high", + "medium", + "low" + ], + "description": "Frequency class from deduped evidence counts." + }, + "recency_signal": { + "type": "string", + "enum": [ + "rising", + "stable", + "declining", + "unknown" + ], + "description": "Short-window trend direction vs baseline window." + }, + "confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low" + ], + "description": "Confidence level after source quality and contradiction checks." + }, + "supporting_evidence": { + "type": "array", + "description": "Anonymized excerpts or evidence references.", + "items": { + "type": "string" + } + } + } + } + }, + "objection_themes": { + "type": "array", + "description": "Common buying/adoption objections from imported evidence.", + "items": { + "type": "string" + } + }, + "hypothesis_seeds": { + "type": "array", + "description": "Candidate hypotheses generated from imported context for follow-up discovery.", + "items": { + "type": "string" + } + }, + "bias_ledger": { + "type": "array", + "description": "Known bias patterns detected in imported context and mitigation notes.", + "items": { + "type": "object", + "required": [ + "bias_type", + "detection_signal", + "mitigation" + ], + "additionalProperties": false, + "properties": { + "bias_type": { + "type": "string", + "enum": [ + "sales_survivorship", + "support_overrepresentation", + "source_coverage_skew", + "recency_skew", + "summary_fidelity_risk", + "other" + ], + "description": "Type of interpretation bias observed." + }, + "detection_signal": { + "type": "string", + "description": "Operational signal indicating this bias was present." + }, + "mitigation": { + "type": "string", + "description": "Mitigation applied or planned." + } + } + } + }, + "contradiction_register": { + "type": "array", + "description": "Explicitly tracked cross-source contradictions.", + "items": { + "type": "object", + "required": [ + "claim_a", + "claim_b", + "resolution_plan", + "status" + ], + "additionalProperties": false, + "properties": { + "claim_a": { + "type": "string", + "description": "First claim with source reference." + }, + "claim_b": { + "type": "string", + "description": "Conflicting claim with source reference." + }, + "resolution_plan": { + "type": "string", + "description": "How contradiction will be resolved (interview probe, additional extraction, etc.)." + }, + "status": { + "type": "string", + "enum": [ + "open", + "partially_resolved", + "resolved" + ], + "description": "Current contradiction resolution status." + } + } + } + }, + "confidence_assessment": { + "type": "object", + "required": [ + "overall_confidence", + "confidence_notes" + ], + "additionalProperties": false, + "description": "Summary confidence for this context artifact.", + "properties": { + "overall_confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low" + ], + "description": "Overall confidence in imported-context conclusions." + }, + "confidence_notes": { + "type": "array", + "description": "Reasons supporting or reducing confidence.", + "items": { + "type": "string" + } + } + } + }, + "limitations": { + "type": "array", + "description": "Known limitations of imported evidence and pipeline constraints.", + "items": { + "type": "string" + } + }, + "uncertainties": { + "type": "array", + "description": "Open unknowns requiring additional research or interviews.", + "items": { + "type": "string" + } + } + } + } +} +``` + +### Draft: Endpoint Verification Note Block + +Add this callout where tool guidance appears: + +> You must separate connector alias names from public endpoint facts. +> Example: `dovetail.insights.export.markdown.v1` may be a valid connector alias, but public docs verify insight-level export endpoints (`/v1/insights/{insight_id}/export/{type}`). +> If alias-to-endpoint mapping is uncertain, record uncertainty in `limitations` and avoid endpoint claims in output text. + +### Draft: Completion Criteria Block + +You can mark context import complete only when: +- source list + window are documented, +- at least one bias entry is recorded (or explicit none with justification), +- contradictions are either resolved or have a concrete resolution plan, +- PII redaction gate passed, +- confidence and limitations are coherent with evidence coverage, +- at least three interview/survey probes are generated from imported themes. + +If not complete, return status `needs_followup` with exact missing controls. + +--- + +## Gaps & Uncertainties + +- Public endpoint docs are clear for core HubSpot/Zendesk/Intercom operations, but connector `method_id` to endpoint mapping still needs runtime verification in environment. +- Dovetail public docs verify insight-level export endpoints; a single-call project-level markdown export endpoint was not verified in public docs and may be connector-level composition. +- Several vendor documentation pages are living docs without explicit publication dates; these are cited as current docs (accessed 2026) and should be revalidated at implementation time. +- Plan/tier entitlements can materially change behavior (rate limits, automation features, export scope); final SKILL.md should include tenant-dependent caveats rather than global assumptions. +- Jurisdiction-specific privacy obligations can differ beyond sources cited; organizations should align this workflow with their legal counsel and internal data-governance policy. + diff --git a/flexus_simple_bots/researcher/skills/_discovery-context-import/SKILL.md b/flexus_simple_bots/researcher/skills/_discovery-context-import/SKILL.md new file mode 100644 index 00000000..6806cbf3 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_discovery-context-import/SKILL.md @@ -0,0 +1,62 @@ +--- +name: discovery-context-import +description: Import customer context from CRM, support, and conversation platforms to seed discovery with existing evidence +--- + +You pull existing customer evidence from first-party systems to accelerate and contextualize discovery work. CRM and support data is NOT a substitute for qualitative interviews — it seeds and directs them. + +Core mode: treat imported data as evidence with known limitations. CRM data reflects sales process bias (what salespeople logged, not what customers actually said). Support ticket data oversamples unhappy users. Always note these biases in limitations. + +## Methodology + +### CRM evidence pull +HubSpot, Salesforce, Pipedrive contain deal notes, call logs, and contact records that can reveal: +- Common objections from prospects who didn't convert +- Frequent questions during sales calls +- Reasons cited for choosing or rejecting the product +- Account characteristics that predict success or failure + +Pull notes and call logs: `hubspot.notes.list.v1`, `hubspot.calls.list.v1` + +### Support evidence pull +Zendesk and Intercom contain raw customer pain language: +- Search for recurring ticket themes: `zendesk.incremental.ticket_events.comment_events.list.v1` +- Pull conversation threads: `intercom.conversations.list.v1` + +High-signal support patterns: +- Tickets that generate multiple replies (unresolved pain) +- Ticket volume spikes around specific product events +- Recurring keywords across unrelated tickets (widespread pain) + +### Dovetail export +If research insights already exist in Dovetail (a research repository), export them before starting new research: `dovetail.insights.export.markdown.v1` + +### PII handling +Support and CRM data contains PII. Never include personal names, emails, or account names in the output artifact. Anonymize to "Customer A (enterprise, finance)", "Prospect B (mid-market, SaaS)". + +### Usage pattern +Use this skill BEFORE designing instruments (`discovery-survey`) — context import informs which hypotheses to test. Use it AFTER corpus creation to cross-reference interview findings against CRM/support patterns. + +## Recording + +``` +write_artifact(path="/discovery/{study_id}/context", data={...}) +``` + +## Available Tools + +``` +hubspot(op="call", args={"method_id": "hubspot.contacts.search.v1", "filterGroups": [{"filters": [{"propertyName": "company", "operator": "EQ", "value": "Company Name"}]}]}) + +hubspot(op="call", args={"method_id": "hubspot.notes.list.v1", "objectType": "contacts", "objectId": "contact_id"}) + +hubspot(op="call", args={"method_id": "hubspot.calls.list.v1", "objectType": "contacts", "objectId": "contact_id"}) + +zendesk(op="call", args={"method_id": "zendesk.incremental.ticket_events.comment_events.list.v1", "start_time": 1704067200}) + +zendesk(op="call", args={"method_id": "zendesk.tickets.audits.list.v1", "ticket_id": "ticket_id"}) + +intercom(op="call", args={"method_id": "intercom.conversations.list.v1", "per_page": 50, "starting_after": null}) + +dovetail(op="call", args={"method_id": "dovetail.insights.export.markdown.v1", "projectId": "project_id"}) +``` diff --git a/flexus_simple_bots/researcher/skills/_discovery-scheduling/RESEARCH.md b/flexus_simple_bots/researcher/skills/_discovery-scheduling/RESEARCH.md new file mode 100644 index 00000000..d9c5cd5c --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_discovery-scheduling/RESEARCH.md @@ -0,0 +1,3262 @@ +# Research: discovery-scheduling + +**Skill path:** `flexus_simple_bots/researcher/skills/discovery-scheduling/` +**Bot:** researcher +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`discovery-scheduling` covers interview scheduling logistics and participant panel operations between recruitment and interview capture. The skill is used to turn qualified participants into completed sessions while maintaining traceability from `participant_id` to `study_id`, consent context, and final session status. + +In 2024-2026 guidance, scheduling reliability is treated as an operations discipline, not only a calendar action. The recurring risks are no-shows, late cancellations, timezone errors, state drift across tools, and weak traceability for audits and downstream analysis. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024–2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- No filler claims without concrete backing or links +- No invented APIs, methods, or endpoint names +- Contradictions between sources are explicitly called out +- Findings are synthesized into usable SKILL.md draft content + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +1. **[2024-2025] 24-hour notice policies are common operational defaults** for cancellation/reschedule behavior in participant marketplaces. This should be modeled as explicit policy (`policy_notice_hours`) rather than implied team habit. +2. **[2025] No-show handling is first-class workflow logic** with explicit replacement/downscope paths, not an afterthought. +3. **[2025] Invite operations are target-aware and cadence-driven**, with pause/resume behavior tied to completion targets. +4. **[2025] Minimum scheduling notice is configurable at platform level**, often in hours, and can be less strict than internal policy. +5. **[2025] Reschedule flows increasingly require reason capture and state rollback**, which supports immutable transition logging. +6. **[2025-2026] Communication quality is a reliability control**: timezone clarity, concise logistics, and clear late/reschedule channel reduce no-show risk. +7. **[2024-2026] Traceability expectations are rising** in consent-adjacent workflows (identity linkage, timestamps, retrievability). + +**Contradiction to keep in SKILL.md:** +Policy expectations (commonly 24h) can be stricter than platform technical cutoffs (hours-level). The skill should model both and enforce policy as default. + +**Sources:** +- https://www.userinterviews.com/support/canceling-participants +- https://www.userinterviews.com/support/cancel-reschedule +- https://www.userinterviews.com/support/replacing-no-shows +- https://www.userinterviews.com/support/scheduled-invites +- https://www.userinterviews.com/support/editing-scheduling-buffer +- https://maze.co/product-updates/en/new-request-to-reschedule-for-moderated-studies-pi1PimFo +- https://www.tremendous.com/blog/reduce-research-participant-no-shows/ +- https://www.userinterviews.com/support/managing-private-projects +- https://www.fda.gov/media/166215/download +- https://www.irb.pitt.edu/econsent-guidance + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +1. **Calendly API:** OAuth/PAT auth, scope-based permissions, scheduled-events/invitee endpoints, scheduling links, and cancellation APIs are documented. +2. **Calendly constraints:** no dedicated reschedule endpoint; some behavior depends on plan/tier; release feed shows ongoing changes in 2024-2026. +3. **Google Calendar API:** `events.insert`, `events.list`, `events.watch`, `channels.stop`, and `freeBusy.query` cover core scheduling/reconciliation needs. +4. **Google sync/limits:** incremental sync via `syncToken`; `410 Gone` requires full resync; quota/rate failures (`403`, `429`) require backoff. +5. **Qualtrics v3 APIs:** datacenter-specific base URL, token auth, strict parameter casing, and brand-level policy/rate controls; mailing lists/contacts/distributions are core panel primitives. +6. **User Interviews APIs:** Hub/Recruit surfaces with participant CRUD, batch updates, recruit lifecycle controls, and documented rate-limit semantics (`429` + `Retry-After`). +7. **Cross-tool architecture pattern:** one participant-state system + one calendar source + one distribution/scheduling surface, reconciled by stable IDs and sync metadata. + +**Concrete options mapped for this skill:** Calendly, Google Calendar API, Qualtrics, User Interviews Hub API, User Interviews Recruit API. + +**Sources:** +- https://developer.calendly.com/authentication +- https://developer.calendly.com/personal-access-tokens +- https://developer.calendly.com/scopes +- https://developer.calendly.com/frequently-asked-questions +- https://developer.calendly.com/rss.xml +- https://developers.google.com/workspace/calendar/api/v3/reference/events/insert +- https://developers.google.com/workspace/calendar/api/v3/reference/events/list +- https://developers.google.com/workspace/calendar/api/v3/reference/events/watch +- https://developers.google.com/workspace/calendar/api/v3/reference/channels/stop +- https://developers.google.com/workspace/calendar/api/v3/reference/freebusy/query +- https://developers.google.com/workspace/calendar/api/guides/sync +- https://developers.google.com/calendar/api/guides/errors +- https://developers.google.com/calendar/api/guides/quota +- https://www.qualtrics.com/support/integrations/api-integration/overview/ +- https://www.qualtrics.com/support/integrations/api-integration/common-api-questions-by-product/ +- https://www.qualtrics.com/support/survey-platform/sp-administration/organization-settings/ +- https://api-docs.userinterviews.com/reference/introduction +- https://api-docs.userinterviews.com/reference/authentication +- https://api-docs.userinterviews.com/reference/rate_limiting +- https://api-docs.userinterviews.com/reference/errors +- https://api-docs.userinterviews.com/reference/get_api-participants-2 +- https://api-docs.userinterviews.com/reference/post_api-participants-2 +- https://api-docs.userinterviews.com/reference/patch_api-participants-batches-2 + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +1. **Metric definitions must be explicit.** Completion, response/participation, no-show, cancellation, reschedule, and coverage are not interchangeable (evergreen standards). +2. **Moderated no-show often falls in single-digit to low-double-digit ranges.** A practical planning baseline around 10% is conservative (context-dependent). +3. **Over-recruitment is usually necessary, not exceptional.** Recent operational data supports maintaining a replacement buffer. +4. **Incentive changes can shift schedule outcomes,** so attendance changes should be interpreted with compensation context. +5. **Reminder systems are one of the highest-leverage controls;** strong older evidence still supports multi-reminder designs (marked evergreen/older benchmark). +6. **Lead-time is a risk multiplier;** as lead-time rises, reconfirmation rigor should increase. +7. **Panel health cannot be inferred from completion alone;** retention and invitation burden matter. + +**Signal vs noise trigger rules to include in skill draft:** +- Trigger action if rolling no-show exceeds 10% or rises by >=2pp from baseline. +- Trigger action if `coverage_vs_target < 0.90` near close window. +- Trigger action when cancellation/reschedule trend rises for two windows, not one isolated spike. + +**Sources:** +- https://www.jmir.org/2012/1/e8/ (evergreen/older benchmark) +- https://aapor.org/response-rates/ (evergreen) +- https://aapor.org/standards-and-ethics/standard-definitions/ (evergreen) +- https://measuringu.com/typical-no-show-rate-for-moderated-studies/ +- https://measuringu.com/how-much-should-you-over-recruit/ +- https://www.userinterviews.com/blog/research-incentives-report +- https://pmc.ncbi.nlm.nih.gov/articles/PMC9126539/ (evergreen/older benchmark) +- https://pmc.ncbi.nlm.nih.gov/articles/PMC5093388/ (evergreen/older benchmark) +- https://publichealthscotland.scot/publications/cancelled-planned-operations/cancelled-planned-operations-month-ending-31-may-2025/ +- https://www.pewresearch.org/short-reads/2024/06/26/q-and-a-what-is-the-american-trends-panel-and-how-has-it-changed/ +- https://ssrs.com/insights/when-does-survey-burden-become-too-high-or-too-low-in-probability-panels/ + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +1. **Timezone ambiguity anti-pattern:** local-only or ambiguous labels (for example `EST`) without canonical `tzid` and UTC create avoidable no-shows. +2. **TZ database drift anti-pattern:** stale timezone data can shift future events around DST/legal changes. +3. **Availability snapshot anti-pattern:** treating free/busy checks as lock/hold causes double-booking race conditions. +4. **Reminder-without-response anti-pattern:** sends are tracked, but participant action telemetry is missing. +5. **Missing compliance provenance anti-pattern:** schedule state changes have no lawful-basis/consent linkage. +6. **Over-messaging anti-pattern:** high touch count without cadence controls degrades panel quality. +7. **Status collapse anti-pattern:** every failure labeled `cancelled` prevents meaningful remediation. + +**Bad output example signature:** +No `participant_id`, no `study_id`, no UTC timestamp, no `tzid`, no initiator, no reason code. + +**Good output example signature:** +Append-only transition log with `from_status`, `to_status`, `actor`, `reason_code`, UTC+timezone fields, reminder outcomes, and source-system provenance. + +**Sources:** +- https://data.iana.org/time-zones/tzdb/NEWS +- https://www.rfc-editor.org/rfc/rfc5545 (evergreen) +- https://developers.google.com/calendar/api/concepts/events-calendars +- https://developers.google.com/workspace/calendar/api/v3/reference/freebusy/query +- https://developers.google.com/calendar/api/concepts/reminders +- https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/employment/recruitment-and-selection/data-protection-and-recruitment/ +- https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/data-protection-principles/a-guide-to-the-data-protection-principles/data-minimisation/ +- https://www.userinterviews.com/support/replacing-no-shows + +--- + +### Angle 5+: Compliance, Traceability, and Retention Controls +> Domain-specific angle: which controls make scheduling artifacts auditable and policy-safe? + +**Findings:** + +1. **Traceability needs more than IDs:** include notice/consent versioning, actor attribution, and immutable history. +2. **Retention controls must be explicit in artifacts:** class + expiry + legal-hold flag. +3. **Role-based data masking is required** for operational sharing without overexposure of participant PII. +4. **Cross-system mismatch handling must be explicit:** source system, source record ID, last sync timestamp, mismatch fields. + +**Sources:** +- https://www.fda.gov/media/166215/download +- https://www.irb.pitt.edu/econsent-guidance +- https://docs.openclinica.com/oc4/using-openclinica-as-a-data-manager/audit-log +- https://swissethics.ch/assets/studieninformationen/240825_guidance_e_consent_v2.1_web.pdf +- https://www.eeoc.gov/employers/recordkeeping-requirements +- https://www.ecfr.gov/current/title-29/subtitle-B/chapter-XIV/part-1602/section-1602.14 +- https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/data-protection-principles/a-guide-to-the-data-protection-principles/storage-limitation/ + +--- + +## Synthesis + +The research converges on one main point: interview scheduling quality is determined by policy and state discipline, not by calendar tooling alone. Across sources, successful operations define explicit state transitions, reason-coded outcomes, and traceability metadata that survives system sync and audit review. + +A key contradiction is intentional and should be preserved in SKILL guidance: platform cutoffs can be permissive (hours-level), while policy windows are often stricter (commonly 24h). The skill should enforce policy-first behavior with explicit exception paths, rather than inheriting platform defaults. + +Interpretation guidance should use operational trigger bands rather than one absolute benchmark. No-show and cancellation are context-sensitive and should be segmented by modality and audience, with confounder checks (incentive changes, lead-time, deliverability) before escalation. + +The highest-value improvements for `SKILL.md` are: state machine formalization, reminder telemetry requirements, cross-system reconciliation fields, and schema-level enforcement of compliance/retention metadata. These controls directly address the most common real-world failure modes. + +--- + +## Recommendations for SKILL.md + +> Concrete changes to add/replace in the skill. + +- [x] Add policy-vs-platform timing controls (`policy_notice_hours`, `platform_cutoff_hours`) and exception flow for late changes. +- [x] Add explicit lifecycle states with append-only transition log requirements. +- [x] Add deterministic no-show branch with replacement/downscope decision within SLA. +- [x] Add reminder telemetry requirements (`delivery_status`, participant action, action timestamp). +- [x] Add cross-system reconciliation fields (`source_system`, `source_record_id`, `last_synced_at`, mismatch status). +- [x] Add interpretation section with formulas, trigger bands, and evidence-tier labeling. +- [x] Add anti-pattern warning blocks with detection signals and mitigation steps. +- [x] Expand schema with compliance envelope and retention controls (`retention_class`, `expires_at_utc`, `legal_hold`). +- [x] Keep tool examples mapped to verified methods/endpoints only. + +--- + +## Draft Content for SKILL.md + +> Paste-ready draft text. This section is intentionally the longest section. + +### Draft: Operating Model + +You run scheduling as a controlled lifecycle, not as one-off booking actions. Every session must be traceable from `participant_id` and `study_id` to consent context, scheduled timestamps, and final outcome. If a required traceability field is missing, do not proceed with scheduling updates until the record is complete. + +You must separate policy decisions from platform constraints. Platform capability determines what can technically be executed; policy determines what is allowed in normal operations. When these differ, policy remains authoritative and exceptions must be explicitly logged. + +### Draft: Policy Window Logic + +Define these controls per study: + +- `policy_notice_hours` (default `24`) +- `platform_cutoff_hours` (tool-dependent) +- `late_change_exception_required` (default `true`) +- `max_reschedule_attempts_per_participant` (default `1`) + +Decision sequence for cancellation/reschedule requests: + +1. Compute `hours_to_session` from canonical UTC timestamps. +2. If `hours_to_session < platform_cutoff_hours`, block technical action and log `decision_code=platform_cutoff`. +3. Else if `hours_to_session < policy_notice_hours`, require exception reason + approver attribution. +4. Else process normal flow. + +Do not silently downgrade policy to match tool defaults. + +### Draft: Lifecycle and Workflow + +Required states: + +- `invited` +- `scheduled` +- `reschedule_requested` +- `rescheduled` +- `completed` +- `no_show` +- `cancelled` + +Required workflow: + +1. Validate schedule-ready roster (`participant_id`, `study_id`, timezone, contact channel, consent linkage). +2. Publish controlled availability from approved interviewer calendars. +3. Send scheduling outreach with minimized sensitive content. +4. Run reminder cadence (for example `T-72h`, `T-24h`, `T-2h`) and capture participant actions. +5. Apply grace window at session start; then set reason-coded outcome. +6. If no-show/cancel reduces coverage below target, trigger replacement flow immediately. +7. Reconcile session state across source tools daily until closure. + +### Draft: Available Tools + +Use only methods that are available in runtime connectors. + +```python +# Calendly +calendly(op="call", args={ + "method_id": "calendly.scheduled_events.list.v1", + "user": "https://api.calendly.com/users/xxx", + "count": 50, + "min_start_time": "2024-01-01T00:00:00Z" +}) + +calendly(op="call", args={ + "method_id": "calendly.scheduled_events.invitees.list.v1", + "uuid": "event_uuid", + "count": 50 +}) + +# Google Calendar +google_calendar(op="call", args={ + "method_id": "google_calendar.events.insert.v1", + "calendarId": "primary", + "summary": "Customer Interview", + "start": {"dateTime": "2024-03-01T10:00:00-05:00"}, + "end": {"dateTime": "2024-03-01T10:45:00-05:00"}, + "attendees": [{"email": "participant@company.com"}] +}) + +google_calendar(op="call", args={ + "method_id": "google_calendar.events.list.v1", + "calendarId": "primary", + "timeMin": "2024-03-01T00:00:00Z", + "timeMax": "2024-03-31T00:00:00Z" +}) + +# Qualtrics +qualtrics(op="call", args={ + "method_id": "qualtrics.contacts.create.v1", + "directoryId": "POOL_xxx", + "mailingListId": "CG_xxx", + "firstName": "Name", + "lastName": "Surname", + "email": "participant@company.com" +}) + +qualtrics(op="call", args={ + "method_id": "qualtrics.distributions.create.v1", + "surveyId": "SV_xxx", + "mailingListId": "CG_xxx", + "fromEmail": "research@company.com", + "fromName": "Research Team", + "subject": "Interview Invitation" +}) + +# User Interviews +userinterviews(op="call", args={ + "method_id": "userinterviews.participants.create.v1", + "name": "Participant Name", + "email": "participant@company.com", + "project_id": "proj_id" +}) + +userinterviews(op="call", args={ + "method_id": "userinterviews.participants.update.v1", + "participant_id": "part_id", + "status": "scheduled" +}) +``` + +Reliability rules: + +- Treat Google `410 Gone` sync errors as full-resync events. +- Back off on `429` and quota-style `403` responses. +- Do not model Calendly reschedule as a hidden one-step operation; preserve old/new state transitions explicitly. +- For Qualtrics, validate datacenter and strict parameter casing before runtime calls. + +### Draft: Signal Interpretation + +Compute these metrics: + +- `no_show_rate = confirmed_no_shows / confirmed_scheduled` +- `cancellation_rate = cancelled_sessions / planned_sessions` +- `reschedule_rate = rescheduled_sessions / planned_sessions` +- `coverage_vs_target = completed_sessions / target_count` +- `lead_time_days = session_start_utc - booked_at_utc` +- `over_recruitment_rate = (recruited_count - target_count) / target_count` + +Escalation triggers: + +1. Rolling no-show >10% OR +2pp rise from baseline. +2. Coverage drops below 90% near close window. +3. Cancellation/reschedule trend rises for two consecutive windows. + +Interpretation guardrails: + +- Segment by modality and audience (for example B2B/B2C, remote/in-person). +- Check incentive and lead-time confounders before declaring process failure. +- Label evidence tier (`strong`, `moderate`, `weak`) for every threshold claim. + +### Draft: Anti-Pattern Warnings + +> [!WARNING] +> **Anti-pattern: Timezone ambiguity** +> Detection signal: missing UTC timestamp or missing canonical `tzid`. +> Consequence: avoidable no-shows from mismatched local-time rendering. +> Mitigation: require UTC + IANA timezone at write time; reject ambiguous time labels. + +> [!WARNING] +> **Anti-pattern: Availability snapshot treated as lock** +> Detection signal: overlapping bookings after reschedule/update path. +> Consequence: double-booking and forced last-minute changes. +> Mitigation: use hold-then-commit logic with conflict recheck and idempotency keys. + +> [!WARNING] +> **Anti-pattern: Reminder without action telemetry** +> Detection signal: reminders sent but no `confirmed/cancelled/reschedule_requested` capture. +> Consequence: false readiness signal and late no-show surprises. +> Mitigation: persist delivery and participant-action events for each reminder. + +> [!WARNING] +> **Anti-pattern: Compliance provenance missing** +> Detection signal: state change without lawful-basis/notice/consent reference. +> Consequence: weak auditability and policy risk. +> Mitigation: enforce compliance envelope fields as required schema. + +### Draft: Schema additions + +```json +{ + "interview_schedule": { + "type": "object", + "required": [ + "study_id", + "policy", + "sessions", + "coverage_status", + "metrics", + "audit_log" + ], + "additionalProperties": false, + "properties": { + "study_id": { + "type": "string", + "description": "Unique study identifier for this scheduling artifact." + }, + "policy": { + "type": "object", + "required": [ + "policy_notice_hours", + "platform_cutoff_hours", + "late_change_exception_required" + ], + "additionalProperties": false, + "properties": { + "policy_notice_hours": { + "type": "integer", + "minimum": 1, + "description": "Operational policy window for normal cancel/reschedule handling." + }, + "platform_cutoff_hours": { + "type": "integer", + "minimum": 0, + "description": "Provider technical cutoff window." + }, + "late_change_exception_required": { + "type": "boolean", + "description": "If true, changes inside policy window require explicit exception log." + } + } + }, + "sessions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "session_id", + "participant_id", + "study_id", + "status", + "scheduled_start_utc", + "scheduled_end_utc", + "scheduled_tzid", + "source_system", + "source_record_id" + ], + "additionalProperties": false, + "properties": { + "session_id": { + "type": "string", + "description": "Unique session identifier." + }, + "participant_id": { + "type": "string", + "description": "Participant identifier linked to recruitment records." + }, + "study_id": { + "type": "string", + "description": "Study identifier duplicated at session level for traceability." + }, + "status": { + "type": "string", + "enum": [ + "invited", + "scheduled", + "reschedule_requested", + "rescheduled", + "completed", + "no_show", + "cancelled" + ], + "description": "Current lifecycle status." + }, + "scheduled_start_utc": { + "type": "string", + "format": "date-time", + "description": "Canonical UTC start timestamp." + }, + "scheduled_end_utc": { + "type": "string", + "format": "date-time", + "description": "Canonical UTC end timestamp." + }, + "scheduled_tzid": { + "type": "string", + "description": "Canonical IANA timezone identifier." + }, + "source_system": { + "type": "string", + "enum": ["calendly", "google_calendar", "qualtrics", "userinterviews", "manual"], + "description": "System of origin for this session record." + }, + "source_record_id": { + "type": "string", + "description": "Provider-native ID for reconciliation." + }, + "last_synced_at": { + "type": "string", + "format": "date-time", + "description": "Last successful sync timestamp from source system." + }, + "reminder_events": { + "type": "array", + "description": "Reminder send/delivery/action telemetry.", + "items": { + "type": "object", + "required": ["sent_at_utc", "delivery_status"], + "additionalProperties": false, + "properties": { + "sent_at_utc": { + "type": "string", + "format": "date-time", + "description": "Reminder send timestamp." + }, + "delivery_status": { + "type": "string", + "enum": ["sent", "delivered", "failed", "bounced"], + "description": "Delivery outcome." + }, + "participant_action": { + "type": "string", + "enum": ["none", "confirmed", "cancelled", "reschedule_requested"], + "description": "Participant action observed from reminder." + }, + "action_at_utc": { + "type": "string", + "format": "date-time", + "description": "Timestamp of participant action if present." + } + } + } + }, + "compliance_envelope": { + "type": "object", + "required": ["lawful_basis", "privacy_notice_version", "consent_ref", "retention_class"], + "additionalProperties": false, + "properties": { + "lawful_basis": { + "type": "string", + "description": "Legal/organizational basis for participant-data processing." + }, + "privacy_notice_version": { + "type": "string", + "description": "Version of notice shown when scheduling data was captured." + }, + "consent_ref": { + "type": "string", + "description": "Reference to consent artifact." + }, + "retention_class": { + "type": "string", + "description": "Retention policy class assigned to this record." + }, + "expires_at_utc": { + "type": "string", + "format": "date-time", + "description": "Eligibility timestamp for purge/anonymization (absent legal hold)." + }, + "legal_hold": { + "type": "boolean", + "description": "If true, record is exempt from routine purge until hold release." + } + } + } + } + } + }, + "coverage_status": { + "type": "object", + "required": ["scheduled_count", "completed_count", "target_count"], + "additionalProperties": false, + "properties": { + "scheduled_count": { + "type": "integer", + "minimum": 0, + "description": "Total currently scheduled sessions." + }, + "completed_count": { + "type": "integer", + "minimum": 0, + "description": "Total completed sessions." + }, + "target_count": { + "type": "integer", + "minimum": 0, + "description": "Study target completion count." + } + } + }, + "metrics": { + "type": "object", + "required": [ + "no_show_rate", + "cancellation_rate", + "reschedule_rate", + "coverage_vs_target", + "over_recruitment_rate" + ], + "additionalProperties": false, + "properties": { + "no_show_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Confirmed no-shows divided by confirmed scheduled sessions." + }, + "cancellation_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Cancelled sessions divided by planned sessions." + }, + "reschedule_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Rescheduled sessions divided by planned sessions." + }, + "coverage_vs_target": { + "type": "number", + "minimum": 0, + "description": "Completed sessions divided by target count." + }, + "over_recruitment_rate": { + "type": "number", + "description": "Recruitment surplus/deficit ratio against target." + } + } + }, + "audit_log": { + "type": "array", + "description": "Append-only transition events.", + "items": { + "type": "object", + "required": ["event_id", "session_id", "from_status", "to_status", "event_at_utc", "actor_type", "actor_id", "reason_code"], + "additionalProperties": false, + "properties": { + "event_id": { + "type": "string", + "description": "Unique event log ID." + }, + "session_id": { + "type": "string", + "description": "Session associated with this transition." + }, + "from_status": { + "type": "string", + "description": "Previous lifecycle status." + }, + "to_status": { + "type": "string", + "description": "New lifecycle status." + }, + "event_at_utc": { + "type": "string", + "format": "date-time", + "description": "Transition timestamp in UTC." + }, + "actor_type": { + "type": "string", + "enum": ["system", "researcher", "participant"], + "description": "Actor category responsible for transition." + }, + "actor_id": { + "type": "string", + "description": "Actor identifier where available." + }, + "reason_code": { + "type": "string", + "description": "Normalized transition reason." + } + } + } + } + } + } +} +``` + +--- + +## Gaps & Uncertainties + +- Public benchmark quality for UX interview scheduling remains fragmented; many numeric ranges are platform/operator-specific. +- Some vendor docs are evergreen and not date-stamped, so they are capability references rather than dated change logs. +- Qualtrics tenant-specific throttling/policies can vary by brand admin settings. +- Cross-vendor standard definitions for `reschedule_rate` are inconsistent; SKILL.md should define this explicitly. + +**Skill path:** `flexus_simple_bots/researcher/skills/discovery-scheduling/` +**Bot:** researcher +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`discovery-scheduling` handles interview logistics and participant panel management between recruitment and interview capture. In practice this means converting qualified participants into attended sessions while preserving an auditable chain from `participant_id` to `study_id`, consent state, and session outcome. This skill is used whenever a research team must coordinate one-on-one interviews or panel-based sessions across tools such as Calendly, Google Calendar, Qualtrics, and User Interviews. + +The core operational problem is not "booking a calendar slot"; it is managing scheduling risk. The most common delivery failures are no-shows, late cancellations, timezone mistakes, over-messaging, and missing traceability fields that break downstream analytics and compliance evidence. Recent 2024-2026 practitioner updates show a shift toward explicit policy windows (for example 24-hour cancellation expectations), stateful no-show replacement flows, and automation tied to target gaps instead of one-time invite blasts. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024–2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- No generic filler without concrete backing; all claims below are linked to named sources +- No invented tool names or API endpoints; all endpoint claims map to vendor docs +- Contradictions between sources are called out explicitly (policy windows vs platform cutoffs; automation cadence vs over-messaging risk) +- Findings volume is within target range and synthesized into actionable draft content + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +Practitioner guidance in 2024-2026 converges on a scheduling playbook with explicit policy windows, state transitions, and target-aware invite automation rather than ad-hoc manual coordination. + +1. **[2024-2025] 24-hour notice is an operational baseline for cancellations/reschedules in participant platforms.** User Interviews policy docs repeatedly emphasize advance notice and tie late changes to participant or researcher consequences. This should be represented as a policy layer, not an optional courtesy. +2. **[2025] No-show handling is first-class workflow logic.** User Interviews documents specific "did not show" handling and replacement/downscope paths, indicating that no-show outcomes should trigger deterministic next actions. +3. **[2025] Invite operations are now target-aware and cadence-driven.** Scheduled invite features (hourly/daily/weekly) pause when goals are met and resume when attrition reopens slots. This suggests scheduling logic should be coupled to target completion gaps. +4. **[2025] Minimum scheduling notice is configurable and distinct from policy expectations.** Platform-level cutoffs (for example hours-level booking buffers) can be looser than team policy standards (for example 24-hour cancellation policy). +5. **[2025] Rescheduling increasingly requires reason logging and status rollback.** Maze's moderated-study reschedule flow requires reason capture and updates participant status. Reason fields should be mandatory for auditability. +6. **[2025-2026] Participant communication quality is now treated as an operations control.** Guidance emphasizes complete session details, timezone clarity, and direct late-arrival/reschedule channels to reduce no-shows. +7. **[2024-2026] Traceability expectations are rising in consent-adjacent workflows.** FDA revision guidance and institutional IRB materials reinforce identity linkage, timestamping, and retrievable signed artifacts. Even non-clinical discovery programs benefit from adopting this audit discipline. + +**Explicit contradiction:** policy expectations (24h baseline) can conflict with platform technical cutoffs (for example 4h minimum notice setting). The skill should separate these controls and enforce stricter policy by default. + +**Sources:** +- https://www.userinterviews.com/support/canceling-participants +- https://www.userinterviews.com/support/cancel-reschedule +- https://www.userinterviews.com/support/replacing-no-shows +- https://www.userinterviews.com/support/scheduled-invites +- https://www.userinterviews.com/support/editing-scheduling-buffer +- https://maze.co/product-updates/en/new-request-to-reschedule-for-moderated-studies-pi1PimFo +- https://www.tremendous.com/blog/reduce-research-participant-no-shows/ +- https://www.userinterviews.com/support/managing-private-projects +- https://www.fda.gov/media/166215/download +- https://www.irb.pitt.edu/econsent-guidance + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +The current landscape supports reliable scheduling operations if integrations are built around each vendor's real limits, auth model, and sync behavior. + +1. **Calendly (2024-2026 updates + evergreen docs):** + - Auth supports OAuth and personal access tokens, with scope-based access. + - Core APIs include scheduled event listing, invitee listing, scheduling links, and cancellation endpoints. + - Known constraints: no dedicated reschedule endpoint, group-event cancellation caveats, and single-use link expiry behavior. + - API change feed indicates notable updates in 2024 and 2025/2026 (including scheduling API and scope changes), so integration assumptions should be version-checked periodically. +2. **Google Calendar API (evergreen docs + 2024-2026 release notes):** + - Booking and reconciliation anchor on `events.insert`/`events.list`; push sync is `events.watch`; channel cleanup is `channels.stop`; availability precheck is `freeBusy.query`. + - `syncToken` incremental sync is the correct large-scale pattern; `410 Gone` requires full resync. + - Quota and errors are explicit (`403 usageLimits`, `429`) and require exponential backoff. +3. **Qualtrics v3 APIs (evergreen + current admin docs):** + - API base is datacenter-specific with token auth (`X-API-TOKEN`), strict parameter casing, and brand-level API policy controls. + - Mailing lists, contacts, and distributions are the main panel/distribution primitives for this skill domain. + - Admin-level rate and policy settings can vary by organization, so production behavior cannot assume uniform tenant limits. +4. **User Interviews Hub and Recruit APIs (current docs):** + - Clear participant lifecycle APIs (`GET/POST/PATCH/DELETE /api/participants`) and batch upsert support. + - Recruit/project operations include create/launch/pause/unpause/close flows, participant status updates, and session actions. + - Published rate limit: 60 requests per minute with `Retry-After` guidance on `429`. +5. **Cross-tool integration pattern (recommended):** + - Use one system as source of truth for participant identity state, one for calendar state, and one for outbound distribution; do not infer participant state only from calendar invites. + - Persist provider record identifiers and sync tokens per system to avoid duplicate session creation and stale-state race conditions. + +**Concrete options mapped for this skill:** Calendly, Google Calendar API, Qualtrics directories/distributions, User Interviews Hub API, User Interviews Recruit API. + +**Sources:** +- https://developer.calendly.com/authentication +- https://developer.calendly.com/personal-access-tokens +- https://developer.calendly.com/scopes +- https://developer.calendly.com/frequently-asked-questions +- https://developer.calendly.com/rss.xml +- https://developers.google.com/calendar/api/guides/quota +- https://developers.google.com/calendar/api/guides/errors +- https://developers.google.com/workspace/calendar/api/v3/reference/events/list +- https://developers.google.com/workspace/calendar/api/v3/reference/events/insert +- https://developers.google.com/workspace/calendar/api/v3/reference/events/watch +- https://developers.google.com/workspace/calendar/api/v3/reference/channels/stop +- https://developers.google.com/workspace/calendar/api/v3/reference/freebusy/query +- https://developers.google.com/calendar/docs/release-notes +- https://www.qualtrics.com/support/integrations/api-integration/overview/ +- https://www.qualtrics.com/support/integrations/api-integration/common-api-questions-by-product/ +- https://www.qualtrics.com/support/survey-platform/sp-administration/organization-settings/ +- https://api-docs.userinterviews.com/reference/introduction +- https://api-docs.userinterviews.com/reference/authentication +- https://api-docs.userinterviews.com/reference/rate_limiting +- https://api-docs.userinterviews.com/reference/errors +- https://api-docs.userinterviews.com/reference/get_api-participants-2 +- https://api-docs.userinterviews.com/reference/post_api-participants-2 +- https://api-docs.userinterviews.com/reference/patch_api-participants-batches-2 +- https://api-docs.userinterviews.com/reference/get_api-recruits-1 +- https://api-docs.userinterviews.com/reference/post_api-recruits + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +Scheduling operations quality should be interpreted with stage-specific metrics rather than one aggregate completion number. Benchmarks exist, but evidence strength varies between standards bodies, large platform data, and small operational studies. + +1. **Define rate metrics precisely and separately.** CHERRIES and AAPOR guidance (evergreen standards) distinguish participation/completion and discourage mixing response terms. In this skill, completion, no-show, cancellation, and over-recruitment must be independently reported. +2. **No-show for moderated research commonly lands in single-digit to low double-digit ranges.** Recent analysis of moderated studies suggests planning around ~10% as a conservative baseline is practical. +3. **Over-recruitment is structurally necessary, not exceptional.** Recent operational analysis indicates many studies require replacement buffer; defaulting near 20% is often safer than assuming perfect attendance. +4. **Incentive design shifts schedule resilience.** Recent platform data suggests incentive levels correlate with recruitment yield and can influence no-show risk, especially across B2B/B2C and in-person vs remote contexts. +5. **Reminder systems are a high-leverage intervention.** Stronger evidence (including RCT/meta-analysis; older but still relevant) shows reminders reduce no-shows; multiple reminders can outperform single-touch strategies. +6. **Lead time is a risk multiplier.** Longer booking-to-session windows generally require stronger confirmation protocols; if long lead time rises with no-show/cancellation, operations should adjust reminder cadence and reconfirmation checkpoints. +7. **Panel health is not captured by completion alone.** Retention and cumulative response dynamics matter; reporting only completion can hide panel depletion and invitation-burden effects. + +**Signal vs noise interpretation rules:** +- Trigger action if rolling 4-week no-show >10% or rises by >=2 percentage points vs baseline. +- Trigger action if cancellation/reschedule rates remain elevated and coverage forecast drops below 90% of target near close window. +- Monitor-only when short-term changes stay inside historical variance and target forecast remains on track. + +**Sources:** +- https://www.jmir.org/2012/1/e8/ (evergreen/older benchmark) +- https://aapor.org/response-rates/ (evergreen standard) +- https://aapor.org/standards-and-ethics/standard-definitions/ (evergreen standard) +- https://measuringu.com/typical-no-show-rate-for-moderated-studies/ +- https://measuringu.com/how-much-should-you-over-recruit/ +- https://www.userinterviews.com/blog/research-incentives-report +- https://pmc.ncbi.nlm.nih.gov/articles/PMC9126539/ (older benchmark, still relevant) +- https://pmc.ncbi.nlm.nih.gov/articles/PMC5093388/ (older benchmark, still relevant) +- https://publichealthscotland.scot/publications/cancelled-planned-operations/cancelled-planned-operations-month-ending-31-may-2025/ +- https://www.pewresearch.org/short-reads/2024/06/26/q-and-a-what-is-the-american-trends-panel-and-how-has-it-changed/ +- https://ssrs.com/insights/when-does-survey-burden-become-too-high-or-too-low-in-probability-panels/ + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +Frequent scheduling failures are implementation and governance problems, not only participant behavior. + +1. **Timezone ambiguity anti-pattern:** storing "3pm EST" or local-only timestamps causes missed sessions; canonical timezone IDs plus UTC should be mandatory. +2. **TZ database drift anti-pattern:** stale timezone data can shift future events after DST/legal changes; recurring events need revalidation after tzdata updates. +3. **"Availability check = booking lock" anti-pattern:** free/busy snapshots are not atomic reservations; race conditions create double-booking unless commit-time conflict checks are enforced. +4. **Reminder-without-response anti-pattern:** reminders that do not capture confirm/cancel/reschedule signals create operational blindness and false confidence. +5. **Slow-latency anti-pattern:** delayed first-contact scheduling drives dropout and damages participant experience. +6. **Sensitive data in invites anti-pattern:** putting protected details in calendar metadata spreads unnecessary PII and increases compliance risk. +7. **Missing consent/provenance fields anti-pattern:** scheduling actions without lawful-basis/privacy-notice linkage make audit defense weak. +8. **Interviewer load imbalance anti-pattern:** unmanaged back-to-back schedules reduce interview quality and increase interviewer-side reschedules. +9. **Sequence bias blind spot:** interview order effects can distort evaluations when scheduling creates clustered similar candidates. + +**Bad output signature:** sparse records with no `participant_id`, no timezone ID, no actor, no reason code, and no traceability to consent or study state. +**Good output signature:** immutable event log with actor, timestamps (UTC + zone), reason codes, reminder outcomes, and compliance envelope fields. + +**Sources:** +- https://data.iana.org/time-zones/tzdb/NEWS +- https://www.rfc-editor.org/rfc/rfc5545 (evergreen standard) +- https://developers.google.com/calendar/api/concepts/events-calendars +- https://developers.google.com/workspace/calendar/api/v3/reference/freebusy/query +- https://developers.google.com/calendar/api/concepts/reminders +- https://www.cronofy.com/reports/candidate-expectations-report-2024 +- https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/employment/recruitment-and-selection/data-protection-and-recruitment/ +- https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/data-protection-principles/a-guide-to-the-data-protection-principles/data-minimisation/ +- https://www.microsoft.com/en-us/worklab/work-trend-index/breaking-down-infinite-workday +- https://cepr.org/voxeu/columns/sequential-contrast-effects-hiring-and-admission-interviews + +--- + +### Angle 5+: Compliance, Traceability, and Retention Controls +> Domain-specific angle: What governance controls are necessary so scheduling artifacts remain auditable and policy-safe across participant lifecycle operations? + +**Findings:** + +1. **[2024-2026 + evergreen legal principles] Traceability requires more than IDs.** Auditable workflows need versioned privacy notice references, consent linkage, actor attribution, and immutable event history. +2. **Retention controls must be explicit in scheduling artifacts.** Storage limitation and recordkeeping guidance imply event-level retention class and expiry timestamps should be attached to artifacts rather than implied by environment defaults. +3. **Role-based redaction is required for operational sharing.** Logs and exports can remain auditable while masking sensitive participant attributes for non-privileged roles. +4. **Legal-hold exceptions need structured paths.** Auto-purge without hold controls is risky; indefinite retention without hold discipline is also risky. +5. **Cross-system reconciliation must preserve provenance.** When calendar, participant system, and distribution system disagree, operators need system-of-origin fields and last-sync metadata to resolve conflicts safely. + +**Sources:** +- https://www.fda.gov/media/166215/download +- https://www.irb.pitt.edu/econsent-guidance +- https://docs.openclinica.com/oc4/using-openclinica-as-a-data-manager/audit-log +- https://swissethics.ch/assets/studieninformationen/240825_guidance_e_consent_v2.1_web.pdf +- https://www.ecfr.gov/current/title-29/subtitle-B/chapter-XIV/part-1602/section-1602.14 +- https://www.eeoc.gov/employers/recordkeeping-requirements +- https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/data-protection-principles/a-guide-to-the-data-protection-principles/storage-limitation/ + +--- + +## Synthesis + +The strongest cross-source pattern is that modern scheduling operations are policy-and-state systems, not calendar-only workflows. The practical unit of work is an auditable session lifecycle keyed by participant and study identity, where each transition (`scheduled`, `rescheduled`, `no_show`, `completed`) is logged with actor, reason, and timestamps. This pattern appears across participant platforms, API docs, and governance guidance. + +A second pattern is the separation between technical capability and policy intent. Sources show that platforms can allow relatively short timing windows (hours-level), while research operations policy often expects materially longer notice (for example 24-hour cancellation norms). The skill should explicitly model both controls rather than assuming platform defaults are acceptable policy. + +For data interpretation, evidence supports simple operational thresholds that drive action early: no-show around or above 10%, rising cancellation/reschedule rates, and shrinking coverage forecast near close windows. At the same time, benchmark transferability varies by context. Healthcare cancellation data and older reminder literature are useful directional anchors, but the skill should label evidence strength and avoid pretending that all numbers are universal UX standards. + +Failure analysis consistently points to implementation quality: timezone handling, idempotent booking logic, reminder observability, and retention/governance fields. These are tractable engineering and process controls. The most actionable improvement for `SKILL.md` is therefore to formalize a state machine plus required schema fields so the system can prevent common errors by design rather than relying on operator memory. + +--- + +## Recommendations for SKILL.md + +> Concrete, actionable list of what should change / be added in the SKILL.md based on research. + +- [x] Add a policy layer that separates `policy_notice_hours` from `platform_cutoff_hours`, with default policy at 24 hours and explicit exception workflow for `<24h` changes. +- [x] Add a required lifecycle state model (`invited`, `scheduled`, `reschedule_requested`, `completed`, `no_show`, `cancelled`) with immutable transition log requirements. +- [x] Expand methodology with a deterministic no-show branch: mark no-show, run one controlled reschedule attempt, then replacement/downscope decision within SLA. +- [x] Expand reminder instructions to require multi-touch cadence and capture of participant actions (`confirmed`, `cancelled`, `reschedule_requested`) as telemetry. +- [x] Strengthen tool section with real vendor capability/limit notes (Google sync token + `410` reset, User Interviews `429` + `Retry-After`, Calendly reschedule caveat). +- [x] Add interpretation guidance section with metric definitions/formulas and action thresholds (no-show, cancellation, lead-time risk, coverage forecast). +- [x] Add anti-pattern warning blocks with detection signals and concrete mitigations (timezone ambiguity, double-booking race, over-messaging, missing consent provenance). +- [x] Expand artifact schema with traceability, compliance envelope, reminder telemetry, and retention fields; enforce `additionalProperties: false` on new objects. +- [x] Add cross-system reconciliation rules and required provenance fields (`source_system`, `source_record_id`, `last_synced_at`) for conflict resolution. + +--- + +## Draft Content for SKILL.md + +> This section is intentionally long and paste-ready. It is written as direct instruction text for future SKILL.md editing. + +### Draft: Scheduling Policy Layer and Decision Rules + +Add a dedicated scheduling policy section that sits above tool-specific defaults. You must not treat provider defaults as policy. Define and enforce these controls for every study: + +- `policy_notice_hours` (default `24`) +- `platform_cutoff_hours` (tool-specific, discovered at runtime/config) +- `late_change_exception_required` (default `true`) +- `max_reschedule_attempts_per_participant` (default `1` unless study override) + +Before accepting any reschedule or cancellation request, you must evaluate policy and platform windows separately: + +1. Compute `hours_to_session` from canonical UTC timestamps. +2. If `hours_to_session < platform_cutoff_hours`: reject technical operation and log `decision_code=platform_cutoff`. +3. Else if `hours_to_session < policy_notice_hours`: allow only via exception path with required `exception_reason` and approver attribution. +4. Else proceed with normal flow. + +Do not silently downgrade policy to match platform permissiveness. If policy and platform conflict, policy remains authoritative for operational scoring and audit flags. + +### Draft: Lifecycle State Machine and Immutable Transition Log + +Add a state model section and require every session record to move through explicit transitions: + +- `invited` +- `scheduled` +- `reschedule_requested` +- `rescheduled` +- `completed` +- `no_show` +- `cancelled` + +You must write transition events as append-only records. Never overwrite prior status as if it never happened. Every transition event must include: + +- `from_status` +- `to_status` +- `event_at_utc` +- `actor_type` (`system`, `researcher`, `participant`) +- `actor_id` +- `reason_code` +- `source_system` +- `source_record_id` + +If a transition is invalid (for example `completed -> scheduled`), reject it and log validation failure with the offending actor and payload fingerprint. + +### Draft: End-to-End Scheduling Workflow + +Use this workflow text in methodology: + +1. **Prepare schedule-ready roster.** + Before outreach, verify each participant has `participant_id`, `study_id`, eligibility status, contact channel, timezone, and consent/provenance fields required by your policy. If any required field is missing, block scheduling for that participant and raise a data-quality exception instead of proceeding with partial records. + +2. **Create controlled availability.** + Generate booking availability from approved interviewer calendars. Apply minimum notice and buffer constraints before sharing links. Availability should represent true bookable capacity after interviewer workload caps and required break windows. + +3. **Issue scheduling outreach with minimized content.** + Send only logistics-required details in calendar-facing surfaces. Keep sensitive notes and protected attributes out of invite summaries/descriptions. Include one-click options for confirm/cancel/reschedule so participant intent is observable. + +4. **Run confirmation cadence.** + Use a multi-touch reminder schedule (for example T-72h, T-24h, T-2h) that records delivery status and participant action. If no response by the final reminder window, flag session as `at_risk` and queue backup outreach. + +5. **Handle no-show deterministically.** + Mark `no_show` immediately after grace window, then execute one controlled reschedule offer. If unresolved within 48h, mark participant as lost for this session and trigger replacement/downscope branch according to study coverage status. + +6. **Close session with traceability and reconciliation.** + After completion/cancellation/no-show, reconcile across scheduling tool, calendar system, and participant system. Persist provenance fields and final session outcome without deleting transition history. + +### Draft: Tool Usage (Verified Methods, Constraints, and Error Handling) + +Add this section under `## Available Tools` (keep only methods actually installed in runtime): + +```python +# Calendly: list scheduled events and invitees for attendance reconciliation +calendly( + op="call", + args={ + "method_id": "calendly.scheduled_events.list.v1", + "user": "https://api.calendly.com/users/xxx", + "count": 50, + "min_start_time": "2026-01-01T00:00:00Z", + }, +) + +calendly( + op="call", + args={ + "method_id": "calendly.scheduled_events.invitees.list.v1", + "uuid": "event_uuid", + "count": 50, + }, +) + +# Google Calendar: controlled event insertion and list/reconciliation +google_calendar( + op="call", + args={ + "method_id": "google_calendar.events.insert.v1", + "calendarId": "primary", + "summary": "Research Interview", + "start": {"dateTime": "2026-03-10T15:00:00-05:00"}, + "end": {"dateTime": "2026-03-10T15:45:00-05:00"}, + "attendees": [{"email": "participant@example.com"}], + }, +) + +google_calendar( + op="call", + args={ + "method_id": "google_calendar.events.list.v1", + "calendarId": "primary", + "timeMin": "2026-03-01T00:00:00Z", + "timeMax": "2026-03-31T23:59:59Z", + }, +) + +# Qualtrics: participant pool and survey distribution operations +qualtrics( + op="call", + args={ + "method_id": "qualtrics.contacts.create.v1", + "directoryId": "POOL_xxx", + "mailingListId": "CG_xxx", + "firstName": "A", + "lastName": "B", + "email": "participant@example.com", + }, +) + +qualtrics( + op="call", + args={ + "method_id": "qualtrics.distributions.create.v1", + "surveyId": "SV_xxx", + "mailingListId": "CG_xxx", + "fromEmail": "research@example.com", + "fromName": "Research Team", + "subject": "Interview Invitation", + }, +) + +# User Interviews: create/update participants in project workflow +userinterviews( + op="call", + args={ + "method_id": "userinterviews.participants.create.v1", + "name": "Participant Name", + "email": "participant@example.com", + "project_id": "proj_id", + }, +) + +userinterviews( + op="call", + args={ + "method_id": "userinterviews.participants.update.v1", + "participant_id": "part_id", + "status": "scheduled", + }, +) +``` + +Tool interpretation guidance: + +- **Calendly:** do not assume native reschedule semantics as a single endpoint operation; model reschedule as explicit transition(s) and preserve old/new times in your log. +- **Google Calendar:** if sync-based workflows are used, treat token invalidation (`410 Gone`) as a full-resync trigger. Back off on quota errors (`403 usageLimits`) and `429` responses. +- **Qualtrics:** enforce strict parameter casing and datacenter-specific base configuration through environment validation before runtime calls. +- **User Interviews:** enforce request pacing and honor `Retry-After` on `429`; design idempotent upsert behavior for participant synchronization. + +### Draft: Signal Interpretation and Operational Thresholds + +Add a section called `### Scheduling Signal Quality`: + +You must compute and review these metrics at study and segment levels: + +- `no_show_rate = confirmed_no_shows / confirmed_scheduled` +- `cancellation_rate = cancelled_sessions / planned_sessions` +- `reschedule_rate = rescheduled_sessions / planned_sessions` +- `coverage_vs_target = completed_sessions / target_count` +- `lead_time_days = session_start_utc - booked_at_utc` +- `over_recruitment_rate = (recruited_count - target_count) / target_count` + +Interpretation rules: + +1. If rolling 4-week `no_show_rate > 0.10` or increases by at least 0.02 compared with baseline, trigger no-show mitigation playbook (extra reminder touch + replacement buffer increase + incentive review). +2. If `coverage_vs_target < 0.90` near field-close window, open backup recruitment channels and prioritize underfilled quota cells. +3. If share of sessions with `lead_time_days >= 31` rises while no-show also rises, add reconfirmation checkpoints and shorten booking horizon where possible. +4. If cancellation/reschedule rates remain elevated for two consecutive windows, audit reason codes and simplify self-serve reschedule paths. + +Evidence labeling rule: + +- Mark thresholds from strong standards/trials as `strong`. +- Mark large platform operational benchmarks as `moderate`. +- Mark small pilot/vendor anecdotal evidence as `weak`. + +Do not publish a single benchmark number without context band and evidence tier. + +### Draft: Anti-Patterns and Warning Blocks + +Add these warning blocks under methodology or a dedicated anti-pattern section: + +> [!WARNING] +> **Anti-pattern: Timezone ambiguity** +> What it looks like: session records store local-only time or ambiguous zone labels (for example `EST`) without canonical timezone ID. +> Detection signal: any record missing `start_utc`, `end_utc`, or `tzid`. +> Consequence: participants and interviewers join at different times, creating preventable no-shows. +> Mitigation: require canonical IANA timezone + UTC timestamps at write time; reject ambiguous inputs. + +> [!WARNING] +> **Anti-pattern: Availability snapshot treated as reservation** +> What it looks like: booking flow checks free/busy then writes event without conflict recheck. +> Detection signal: overlapping interviewer sessions discovered after write. +> Consequence: double-booking, forced reschedules, participant churn. +> Mitigation: use hold-then-commit flow with commit-time conflict recheck and idempotency keys. + +> [!WARNING] +> **Anti-pattern: Reminder without participant action telemetry** +> What it looks like: reminders are sent but confirmation/cancellation outcomes are not captured. +> Detection signal: high reminder send counts with unknown participant state. +> Consequence: false sense of readiness and late-stage no-show surprises. +> Mitigation: include one-click action links and persist `candidate_action` + `action_at_utc`. + +> [!WARNING] +> **Anti-pattern: Missing compliance provenance** +> What it looks like: scheduling events have no lawful basis, privacy notice version, or consent linkage. +> Detection signal: records cannot show what participant agreed to at scheduling time. +> Consequence: weak audit defensibility and inconsistent handling of withdrawal/erasure requests. +> Mitigation: require compliance envelope fields for every scheduling write and reject incomplete writes. + +> [!WARNING] +> **Anti-pattern: Over-messaging participants** +> What it looks like: frequent outreach touches with no cadence guardrails. +> Detection signal: rising unsubscribe/decline rates while touch count per participant increases. +> Consequence: panel fatigue and response quality degradation. +> Mitigation: cap touches per 24h and separate invite cadence from reminder cadence. + +### Draft: Reconciliation and Provenance Rules + +Add a section called `### Cross-System Reconciliation`: + +You must treat reconciliation as part of core scheduling, not optional reporting. On every terminal session outcome (`completed`, `cancelled`, `no_show`), compare records across: + +- scheduling provider (for booking metadata) +- calendar provider (for event state and attendees) +- participant platform/panel system (for participant lifecycle status) + +For every reconciled item, store: + +- `source_system` +- `source_record_id` +- `source_last_modified_at` +- `last_synced_at` +- `reconciliation_status` (`matched`, `mismatch`, `missing_source`, `needs_manual_review`) +- `mismatch_fields` (array) + +If mismatch touches participant identity, study linkage, or session time, route to manual review and block automated closure until resolved. + +### Draft: Schema additions + +```json +{ + "interview_schedule": { + "type": "object", + "required": [ + "study_id", + "sessions", + "coverage_status", + "policy", + "metrics", + "audit_log" + ], + "additionalProperties": false, + "properties": { + "study_id": { + "type": "string", + "description": "Unique identifier for the study this schedule belongs to." + }, + "policy": { + "type": "object", + "required": [ + "policy_notice_hours", + "platform_cutoff_hours", + "max_reschedule_attempts_per_participant", + "late_change_exception_required" + ], + "additionalProperties": false, + "properties": { + "policy_notice_hours": { + "type": "integer", + "minimum": 1, + "description": "Minimum hours before session required by team policy for normal cancellation/reschedule handling." + }, + "platform_cutoff_hours": { + "type": "integer", + "minimum": 0, + "description": "Minimum hours before session enforced by the scheduling platform for technical acceptance." + }, + "max_reschedule_attempts_per_participant": { + "type": "integer", + "minimum": 0, + "description": "Maximum number of reschedule attempts permitted per participant for this study." + }, + "late_change_exception_required": { + "type": "boolean", + "description": "Whether requests inside policy notice window require explicit documented exception approval." + } + } + }, + "sessions": { + "type": "array", + "description": "Session-level records with scheduling, reminder, provenance, and compliance metadata.", + "items": { + "type": "object", + "required": [ + "session_id", + "participant_id", + "study_id", + "status", + "scheduled_start_utc", + "scheduled_end_utc", + "scheduled_tzid", + "source_system", + "source_record_id", + "reminder_plan", + "compliance_envelope" + ], + "additionalProperties": false, + "properties": { + "session_id": { + "type": "string", + "description": "Unique session identifier used across lifecycle transitions." + }, + "participant_id": { + "type": "string", + "description": "Participant identifier linked to roster and consent records." + }, + "study_id": { + "type": "string", + "description": "Study identifier repeated at session level for denormalized traceability." + }, + "status": { + "type": "string", + "enum": [ + "invited", + "scheduled", + "reschedule_requested", + "rescheduled", + "completed", + "no_show", + "cancelled" + ], + "description": "Current lifecycle state of the session." + }, + "scheduled_start_utc": { + "type": "string", + "format": "date-time", + "description": "Session start timestamp in UTC for canonical calculations." + }, + "scheduled_end_utc": { + "type": "string", + "format": "date-time", + "description": "Session end timestamp in UTC." + }, + "scheduled_tzid": { + "type": "string", + "description": "Canonical timezone identifier (IANA) used for participant-facing rendering." + }, + "source_system": { + "type": "string", + "enum": [ + "calendly", + "google_calendar", + "qualtrics", + "userinterviews", + "manual" + ], + "description": "System of origin for current session record." + }, + "source_record_id": { + "type": "string", + "description": "Provider-native record identifier for reconciliation." + }, + "last_synced_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp of last successful reconciliation with source system." + }, + "reschedule_reason": { + "type": "string", + "description": "Human-readable reason for reschedule request when applicable." + }, + "reminder_plan": { + "type": "array", + "description": "Configured reminder checkpoints relative to session start.", + "minItems": 1, + "items": { + "type": "string", + "enum": ["T-72h", "T-48h", "T-24h", "T-2h"] + } + }, + "reminder_events": { + "type": "array", + "description": "Observed reminder delivery and participant response telemetry.", + "items": { + "type": "object", + "required": [ + "reminder_id", + "channel", + "sent_at_utc", + "delivery_status" + ], + "additionalProperties": false, + "properties": { + "reminder_id": { + "type": "string", + "description": "Unique reminder event identifier." + }, + "channel": { + "type": "string", + "enum": ["email", "sms", "in_app"], + "description": "Communication channel used for reminder delivery." + }, + "sent_at_utc": { + "type": "string", + "format": "date-time", + "description": "Reminder send timestamp in UTC." + }, + "delivery_status": { + "type": "string", + "enum": ["sent", "delivered", "failed", "bounced"], + "description": "Delivery outcome from messaging provider." + }, + "participant_action": { + "type": "string", + "enum": ["none", "confirmed", "cancelled", "reschedule_requested"], + "description": "Participant action captured from reminder touchpoint." + }, + "action_at_utc": { + "type": "string", + "format": "date-time", + "description": "Timestamp when participant action was captured." + } + } + } + }, + "compliance_envelope": { + "type": "object", + "required": [ + "lawful_basis", + "privacy_notice_version", + "consent_ref", + "retention_class" + ], + "additionalProperties": false, + "properties": { + "lawful_basis": { + "type": "string", + "description": "Declared legal/organizational basis for processing participant scheduling data." + }, + "privacy_notice_version": { + "type": "string", + "description": "Version identifier of participant-facing privacy notice at scheduling time." + }, + "consent_ref": { + "type": "string", + "description": "Reference to consent artifact or record linked to this session." + }, + "retention_class": { + "type": "string", + "description": "Retention policy class that controls purge/anonymization horizon." + }, + "expires_at_utc": { + "type": "string", + "format": "date-time", + "description": "Timestamp at which this record is eligible for purge/anonymization absent legal hold." + }, + "legal_hold": { + "type": "boolean", + "description": "Whether retention deletion is suspended due to legal or policy hold." + } + } + } + } + } + }, + "coverage_status": { + "type": "object", + "required": ["scheduled_count", "completed_count", "target_count"], + "additionalProperties": false, + "properties": { + "scheduled_count": { + "type": "integer", + "minimum": 0, + "description": "Current number of scheduled sessions." + }, + "completed_count": { + "type": "integer", + "minimum": 0, + "description": "Current number of completed sessions." + }, + "target_count": { + "type": "integer", + "minimum": 0, + "description": "Target number of completed sessions for the study." + } + } + }, + "metrics": { + "type": "object", + "required": [ + "no_show_rate", + "cancellation_rate", + "reschedule_rate", + "coverage_vs_target", + "over_recruitment_rate" + ], + "additionalProperties": false, + "properties": { + "no_show_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Confirmed no-shows divided by confirmed scheduled sessions." + }, + "cancellation_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Cancelled sessions divided by planned sessions." + }, + "reschedule_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Rescheduled sessions divided by planned sessions." + }, + "coverage_vs_target": { + "type": "number", + "minimum": 0, + "description": "Completed sessions divided by target count." + }, + "over_recruitment_rate": { + "type": "number", + "description": "Recruited minus target, divided by target; can be negative when under-recruited." + } + } + }, + "audit_log": { + "type": "array", + "description": "Append-only transition and decision events for scheduling operations.", + "items": { + "type": "object", + "required": [ + "event_id", + "session_id", + "from_status", + "to_status", + "event_at_utc", + "actor_type", + "actor_id", + "reason_code" + ], + "additionalProperties": false, + "properties": { + "event_id": { + "type": "string", + "description": "Unique audit event identifier." + }, + "session_id": { + "type": "string", + "description": "Session affected by this transition." + }, + "from_status": { + "type": "string", + "description": "Previous status before transition." + }, + "to_status": { + "type": "string", + "description": "New status after transition." + }, + "event_at_utc": { + "type": "string", + "format": "date-time", + "description": "Timestamp when transition was recorded." + }, + "actor_type": { + "type": "string", + "enum": ["system", "researcher", "participant"], + "description": "Type of actor that triggered transition." + }, + "actor_id": { + "type": "string", + "description": "Identifier of actor responsible for transition." + }, + "reason_code": { + "type": "string", + "description": "Normalized reason for transition (for example no_show, participant_conflict, researcher_conflict)." + }, + "details": { + "type": "string", + "description": "Optional human-readable context for manual review and audits." + } + } + } + } + } + } +} +``` + +--- + +## Gaps & Uncertainties + +- Published, universally accepted benchmark thresholds specific to UX interview scheduling are limited; many numeric ranges come from platform/operator datasets and should be treated as moderate evidence. +- Some vendor documentation pages are evergreen and not clearly date-stamped, so they are used as current capability references rather than strict 2024-2026 change logs. +- Qualtrics tenant-specific API throttling and policy controls vary by brand admin configuration; runtime behavior may differ across deployments. +- No single cross-vendor canonical definition for "reschedule rate" exists; this should be standardized internally in the skill schema and reporting logic. +# Research: discovery-scheduling + +**Skill path:** `flexus_simple_bots/researcher/skills/discovery-scheduling/` +**Bot:** researcher +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`discovery-scheduling` handles interview logistics and participant panel management between recruitment and interview capture. In practice this means converting qualified participants into attended sessions while preserving an auditable chain from `participant_id` to `study_id`, consent state, and session outcome. This skill is used whenever a research team must coordinate one-on-one interviews or panel-based sessions across tools such as Calendly, Google Calendar, Qualtrics, and User Interviews. + +The core operational problem is not "booking a calendar slot"; it is managing scheduling risk. The most common delivery failures are no-shows, late cancellations, timezone mistakes, over-messaging, and missing traceability fields that break downstream analytics and compliance evidence. Recent 2024-2026 practitioner updates show a shift toward explicit policy windows (for example 24-hour cancellation expectations), stateful no-show replacement flows, and automation tied to target gaps instead of one-time invite blasts. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024–2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- No generic filler without concrete backing; all claims below are linked to named sources +- No invented tool names or API endpoints; all endpoint claims map to vendor docs +- Contradictions between sources are called out explicitly (policy windows vs platform cutoffs; automation cadence vs over-messaging risk) +- Findings volume is within target range and synthesized into actionable draft content + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +Practitioner guidance in 2024-2026 converges on a scheduling playbook with explicit policy windows, state transitions, and target-aware invite automation rather than ad-hoc manual coordination. + +1. **[2024-2025] 24-hour notice is an operational baseline for cancellations/reschedules in participant platforms.** User Interviews policy docs repeatedly emphasize advance notice and tie late changes to participant or researcher consequences. This should be represented as a policy layer, not an optional courtesy. +2. **[2025] No-show handling is first-class workflow logic.** User Interviews documents specific "did not show" handling and replacement/downscope paths, indicating that no-show outcomes should trigger deterministic next actions. +3. **[2025] Invite operations are now target-aware and cadence-driven.** Scheduled invite features (hourly/daily/weekly) pause when goals are met and resume when attrition reopens slots. This suggests scheduling logic should be coupled to target completion gaps. +4. **[2025] Minimum scheduling notice is configurable and distinct from policy expectations.** Platform-level cutoffs (for example hours-level booking buffers) can be looser than team policy standards (for example 24-hour cancellation policy). +5. **[2025] Rescheduling increasingly requires reason logging and status rollback.** Maze's moderated-study reschedule flow requires reason capture and updates participant status. Reason fields should be mandatory for auditability. +6. **[2025-2026] Participant communication quality is now treated as an operations control.** Guidance emphasizes complete session details, timezone clarity, and direct late-arrival/reschedule channels to reduce no-shows. +7. **[2024-2026] Traceability expectations are rising in consent-adjacent workflows.** FDA revision guidance and institutional IRB materials reinforce identity linkage, timestamping, and retrievable signed artifacts. Even non-clinical discovery programs benefit from adopting this audit discipline. + +**Explicit contradiction:** policy expectations (24h baseline) can conflict with platform technical cutoffs (for example 4h minimum notice setting). The skill should separate these controls and enforce stricter policy by default. + +**Sources:** +- https://www.userinterviews.com/support/canceling-participants +- https://www.userinterviews.com/support/cancel-reschedule +- https://www.userinterviews.com/support/replacing-no-shows +- https://www.userinterviews.com/support/scheduled-invites +- https://www.userinterviews.com/support/editing-scheduling-buffer +- https://maze.co/product-updates/en/new-request-to-reschedule-for-moderated-studies-pi1PimFo +- https://www.tremendous.com/blog/reduce-research-participant-no-shows/ +- https://www.userinterviews.com/support/managing-private-projects +- https://www.fda.gov/media/166215/download +- https://www.irb.pitt.edu/econsent-guidance + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +The current landscape supports reliable scheduling operations if integrations are built around each vendor's real limits, auth model, and sync behavior. + +1. **Calendly (2024-2026 updates + evergreen docs):** + - Auth supports OAuth and personal access tokens, with scope-based access. + - Core APIs include scheduled event listing, invitee listing, scheduling links, and cancellation endpoints. + - Known constraints: no dedicated reschedule endpoint, group-event cancellation caveats, and single-use link expiry behavior. + - API change feed indicates notable updates in 2024 and 2025/2026 (including scheduling API and scope changes), so integration assumptions should be version-checked periodically. +2. **Google Calendar API (evergreen docs + 2024-2026 release notes):** + - Booking and reconciliation anchor on `events.insert`/`events.list`; push sync is `events.watch`; channel cleanup is `channels.stop`; availability precheck is `freeBusy.query`. + - `syncToken` incremental sync is the correct large-scale pattern; `410 Gone` requires full resync. + - Quota and errors are explicit (`403 usageLimits`, `429`) and require exponential backoff. +3. **Qualtrics v3 APIs (evergreen + current admin docs):** + - API base is datacenter-specific with token auth (`X-API-TOKEN`), strict parameter casing, and brand-level API policy controls. + - Mailing lists, contacts, and distributions are the main panel/distribution primitives for this skill domain. + - Admin-level rate and policy settings can vary by organization, so production behavior cannot assume uniform tenant limits. +4. **User Interviews Hub and Recruit APIs (current docs):** + - Clear participant lifecycle APIs (`GET/POST/PATCH/DELETE /api/participants`) and batch upsert support. + - Recruit/project operations include create/launch/pause/unpause/close flows, participant status updates, and session actions. + - Published rate limit: 60 requests per minute with `Retry-After` guidance on `429`. +5. **Cross-tool integration pattern (recommended):** + - Use one system as source of truth for participant identity state, one for calendar state, and one for outbound distribution; do not infer participant state only from calendar invites. + - Persist provider record identifiers and sync tokens per system to avoid duplicate session creation and stale-state race conditions. + +**Concrete options mapped for this skill:** Calendly, Google Calendar API, Qualtrics directories/distributions, User Interviews Hub API, User Interviews Recruit API. + +**Sources:** +- https://developer.calendly.com/authentication +- https://developer.calendly.com/personal-access-tokens +- https://developer.calendly.com/scopes +- https://developer.calendly.com/frequently-asked-questions +- https://developer.calendly.com/rss.xml +- https://developers.google.com/calendar/api/guides/quota +- https://developers.google.com/calendar/api/guides/errors +- https://developers.google.com/workspace/calendar/api/v3/reference/events/list +- https://developers.google.com/workspace/calendar/api/v3/reference/events/insert +- https://developers.google.com/workspace/calendar/api/v3/reference/events/watch +- https://developers.google.com/workspace/calendar/api/v3/reference/channels/stop +- https://developers.google.com/workspace/calendar/api/v3/reference/freebusy/query +- https://developers.google.com/calendar/docs/release-notes +- https://www.qualtrics.com/support/integrations/api-integration/overview/ +- https://www.qualtrics.com/support/integrations/api-integration/common-api-questions-by-product/ +- https://www.qualtrics.com/support/survey-platform/sp-administration/organization-settings/ +- https://api-docs.userinterviews.com/reference/introduction +- https://api-docs.userinterviews.com/reference/authentication +- https://api-docs.userinterviews.com/reference/rate_limiting +- https://api-docs.userinterviews.com/reference/errors +- https://api-docs.userinterviews.com/reference/get_api-participants-2 +- https://api-docs.userinterviews.com/reference/post_api-participants-2 +- https://api-docs.userinterviews.com/reference/patch_api-participants-batches-2 +- https://api-docs.userinterviews.com/reference/get_api-recruits-1 +- https://api-docs.userinterviews.com/reference/post_api-recruits + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +Scheduling operations quality should be interpreted with stage-specific metrics rather than one aggregate completion number. Benchmarks exist, but evidence strength varies between standards bodies, large platform data, and small operational studies. + +1. **Define rate metrics precisely and separately.** CHERRIES and AAPOR guidance (evergreen standards) distinguish participation/completion and discourage mixing response terms. In this skill, completion, no-show, cancellation, and over-recruitment must be independently reported. +2. **No-show for moderated research commonly lands in single-digit to low double-digit ranges.** Recent analysis of moderated studies suggests planning around ~10% as a conservative baseline is practical. +3. **Over-recruitment is structurally necessary, not exceptional.** Recent operational analysis indicates many studies require replacement buffer; defaulting near 20% is often safer than assuming perfect attendance. +4. **Incentive design shifts schedule resilience.** Recent platform data suggests incentive levels correlate with recruitment yield and can influence no-show risk, especially across B2B/B2C and in-person vs remote contexts. +5. **Reminder systems are a high-leverage intervention.** Stronger evidence (including RCT/meta-analysis; older but still relevant) shows reminders reduce no-shows; multiple reminders can outperform single-touch strategies. +6. **Lead time is a risk multiplier.** Longer booking-to-session windows generally require stronger confirmation protocols; if long lead time rises with no-show/cancellation, operations should adjust reminder cadence and reconfirmation checkpoints. +7. **Panel health is not captured by completion alone.** Retention and cumulative response dynamics matter; reporting only completion can hide panel depletion and invitation-burden effects. + +**Signal vs noise interpretation rules:** +- Trigger action if rolling 4-week no-show >10% or rises by >=2 percentage points vs baseline. +- Trigger action if cancellation/reschedule rates remain elevated and coverage forecast drops below 90% of target near close window. +- Monitor-only when short-term changes stay inside historical variance and target forecast remains on track. + +**Sources:** +- https://www.jmir.org/2012/1/e8/ (evergreen/older benchmark) +- https://aapor.org/response-rates/ (evergreen standard) +- https://aapor.org/standards-and-ethics/standard-definitions/ (evergreen standard) +- https://measuringu.com/typical-no-show-rate-for-moderated-studies/ +- https://measuringu.com/how-much-should-you-over-recruit/ +- https://www.userinterviews.com/blog/research-incentives-report +- https://pmc.ncbi.nlm.nih.gov/articles/PMC9126539/ (older benchmark, still relevant) +- https://pmc.ncbi.nlm.nih.gov/articles/PMC5093388/ (older benchmark, still relevant) +- https://publichealthscotland.scot/publications/cancelled-planned-operations/cancelled-planned-operations-month-ending-31-may-2025/ +- https://www.pewresearch.org/short-reads/2024/06/26/q-and-a-what-is-the-american-trends-panel-and-how-has-it-changed/ +- https://ssrs.com/insights/when-does-survey-burden-become-too-high-or-too-low-in-probability-panels/ + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +Frequent scheduling failures are implementation and governance problems, not only participant behavior. + +1. **Timezone ambiguity anti-pattern:** storing "3pm EST" or local-only timestamps causes missed sessions; canonical timezone IDs plus UTC should be mandatory. +2. **TZ database drift anti-pattern:** stale timezone data can shift future events after DST/legal changes; recurring events need revalidation after tzdata updates. +3. **"Availability check = booking lock" anti-pattern:** free/busy snapshots are not atomic reservations; race conditions create double-booking unless commit-time conflict checks are enforced. +4. **Reminder-without-response anti-pattern:** reminders that do not capture confirm/cancel/reschedule signals create operational blindness and false confidence. +5. **Slow-latency anti-pattern:** delayed first-contact scheduling drives dropout and damages participant experience. +6. **Sensitive data in invites anti-pattern:** putting protected details in calendar metadata spreads unnecessary PII and increases compliance risk. +7. **Missing consent/provenance fields anti-pattern:** scheduling actions without lawful-basis/privacy-notice linkage make audit defense weak. +8. **Interviewer load imbalance anti-pattern:** unmanaged back-to-back schedules reduce interview quality and increase interviewer-side reschedules. +9. **Sequence bias blind spot:** interview order effects can distort evaluations when scheduling creates clustered similar candidates. + +**Bad output signature:** sparse records with no `participant_id`, no timezone ID, no actor, no reason code, and no traceability to consent or study state. +**Good output signature:** immutable event log with actor, timestamps (UTC + zone), reason codes, reminder outcomes, and compliance envelope fields. + +**Sources:** +- https://data.iana.org/time-zones/tzdb/NEWS +- https://www.rfc-editor.org/rfc/rfc5545 (evergreen standard) +- https://developers.google.com/calendar/api/concepts/events-calendars +- https://developers.google.com/workspace/calendar/api/v3/reference/freebusy/query +- https://developers.google.com/calendar/api/concepts/reminders +- https://www.cronofy.com/reports/candidate-expectations-report-2024 +- https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/employment/recruitment-and-selection/data-protection-and-recruitment/ +- https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/data-protection-principles/a-guide-to-the-data-protection-principles/data-minimisation/ +- https://www.microsoft.com/en-us/worklab/work-trend-index/breaking-down-infinite-workday +- https://cepr.org/voxeu/columns/sequential-contrast-effects-hiring-and-admission-interviews + +--- + +### Angle 5+: Compliance, Traceability, and Retention Controls +> Domain-specific angle: What governance controls are necessary so scheduling artifacts remain auditable and policy-safe across participant lifecycle operations? + +**Findings:** + +1. **[2024-2026 + evergreen legal principles] Traceability requires more than IDs.** Auditable workflows need versioned privacy notice references, consent linkage, actor attribution, and immutable event history. +2. **Retention controls must be explicit in scheduling artifacts.** Storage limitation and recordkeeping guidance imply event-level retention class and expiry timestamps should be attached to artifacts rather than implied by environment defaults. +3. **Role-based redaction is required for operational sharing.** Logs and exports can remain auditable while masking sensitive participant attributes for non-privileged roles. +4. **Legal-hold exceptions need structured paths.** Auto-purge without hold controls is risky; indefinite retention without hold discipline is also risky. +5. **Cross-system reconciliation must preserve provenance.** When calendar, participant system, and distribution system disagree, operators need system-of-origin fields and last-sync metadata to resolve conflicts safely. + +**Sources:** +- https://www.fda.gov/media/166215/download +- https://www.irb.pitt.edu/econsent-guidance +- https://docs.openclinica.com/oc4/using-openclinica-as-a-data-manager/audit-log +- https://swissethics.ch/assets/studieninformationen/240825_guidance_e_consent_v2.1_web.pdf +- https://www.ecfr.gov/current/title-29/subtitle-B/chapter-XIV/part-1602/section-1602.14 +- https://www.eeoc.gov/employers/recordkeeping-requirements +- https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/data-protection-principles/a-guide-to-the-data-protection-principles/storage-limitation/ + +--- + +## Synthesis + +The strongest cross-source pattern is that modern scheduling operations are policy-and-state systems, not calendar-only workflows. The practical unit of work is an auditable session lifecycle keyed by participant and study identity, where each transition (`scheduled`, `rescheduled`, `no_show`, `completed`) is logged with actor, reason, and timestamps. This pattern appears across participant platforms, API docs, and governance guidance. + +A second pattern is the separation between technical capability and policy intent. Sources show that platforms can allow relatively short timing windows (hours-level), while research operations policy often expects materially longer notice (for example 24-hour cancellation norms). The skill should explicitly model both controls rather than assuming platform defaults are acceptable policy. + +For data interpretation, evidence supports simple operational thresholds that drive action early: no-show around or above 10%, rising cancellation/reschedule rates, and shrinking coverage forecast near close windows. At the same time, benchmark transferability varies by context. Healthcare cancellation data and older reminder literature are useful directional anchors, but the skill should label evidence strength and avoid pretending that all numbers are universal UX standards. + +Failure analysis consistently points to implementation quality: timezone handling, idempotent booking logic, reminder observability, and retention/governance fields. These are tractable engineering and process controls. The most actionable improvement for `SKILL.md` is therefore to formalize a state machine plus required schema fields so the system can prevent common errors by design rather than relying on operator memory. + +--- + +## Recommendations for SKILL.md + +> Concrete, actionable list of what should change / be added in the SKILL.md based on research. + +- [x] Add a policy layer that separates `policy_notice_hours` from `platform_cutoff_hours`, with default policy at 24 hours and explicit exception workflow for `<24h` changes. +- [x] Add a required lifecycle state model (`invited`, `scheduled`, `reschedule_requested`, `completed`, `no_show`, `cancelled`) with immutable transition log requirements. +- [x] Expand methodology with a deterministic no-show branch: mark no-show, run one controlled reschedule attempt, then replacement/downscope decision within SLA. +- [x] Expand reminder instructions to require multi-touch cadence and capture of participant actions (`confirmed`, `cancelled`, `reschedule_requested`) as telemetry. +- [x] Strengthen tool section with real vendor capability/limit notes (Google sync token + `410` reset, User Interviews `429` + `Retry-After`, Calendly reschedule caveat). +- [x] Add interpretation guidance section with metric definitions/formulas and action thresholds (no-show, cancellation, lead-time risk, coverage forecast). +- [x] Add anti-pattern warning blocks with detection signals and concrete mitigations (timezone ambiguity, double-booking race, over-messaging, missing consent provenance). +- [x] Expand artifact schema with traceability, compliance envelope, reminder telemetry, and retention fields; enforce `additionalProperties: false` on new objects. +- [x] Add cross-system reconciliation rules and required provenance fields (`source_system`, `source_record_id`, `last_synced_at`) for conflict resolution. + +--- + +## Draft Content for SKILL.md + +> This section is intentionally long and paste-ready. It is written as direct instruction text for future SKILL.md editing. + +### Draft: Scheduling Policy Layer and Decision Rules + +Add a dedicated scheduling policy section that sits above tool-specific defaults. You must not treat provider defaults as policy. Define and enforce these controls for every study: + +- `policy_notice_hours` (default `24`) +- `platform_cutoff_hours` (tool-specific, discovered at runtime/config) +- `late_change_exception_required` (default `true`) +- `max_reschedule_attempts_per_participant` (default `1` unless study override) + +Before accepting any reschedule or cancellation request, you must evaluate policy and platform windows separately: + +1. Compute `hours_to_session` from canonical UTC timestamps. +2. If `hours_to_session < platform_cutoff_hours`: reject technical operation and log `decision_code=platform_cutoff`. +3. Else if `hours_to_session < policy_notice_hours`: allow only via exception path with required `exception_reason` and approver attribution. +4. Else proceed with normal flow. + +Do not silently downgrade policy to match platform permissiveness. If policy and platform conflict, policy remains authoritative for operational scoring and audit flags. + +### Draft: Lifecycle State Machine and Immutable Transition Log + +Add a state model section and require every session record to move through explicit transitions: + +- `invited` +- `scheduled` +- `reschedule_requested` +- `rescheduled` +- `completed` +- `no_show` +- `cancelled` + +You must write transition events as append-only records. Never overwrite prior status as if it never happened. Every transition event must include: + +- `from_status` +- `to_status` +- `event_at_utc` +- `actor_type` (`system`, `researcher`, `participant`) +- `actor_id` +- `reason_code` +- `source_system` +- `source_record_id` + +If a transition is invalid (for example `completed -> scheduled`), reject it and log validation failure with the offending actor and payload fingerprint. + +### Draft: End-to-End Scheduling Workflow + +Use this workflow text in methodology: + +1. **Prepare schedule-ready roster.** + Before outreach, verify each participant has `participant_id`, `study_id`, eligibility status, contact channel, timezone, and consent/provenance fields required by your policy. If any required field is missing, block scheduling for that participant and raise a data-quality exception instead of proceeding with partial records. + +2. **Create controlled availability.** + Generate booking availability from approved interviewer calendars. Apply minimum notice and buffer constraints before sharing links. Availability should represent true bookable capacity after interviewer workload caps and required break windows. + +3. **Issue scheduling outreach with minimized content.** + Send only logistics-required details in calendar-facing surfaces. Keep sensitive notes and protected attributes out of invite summaries/descriptions. Include one-click options for confirm/cancel/reschedule so participant intent is observable. + +4. **Run confirmation cadence.** + Use a multi-touch reminder schedule (for example T-72h, T-24h, T-2h) that records delivery status and participant action. If no response by the final reminder window, flag session as `at_risk` and queue backup outreach. + +5. **Handle no-show deterministically.** + Mark `no_show` immediately after grace window, then execute one controlled reschedule offer. If unresolved within 48h, mark participant as lost for this session and trigger replacement/downscope branch according to study coverage status. + +6. **Close session with traceability and reconciliation.** + After completion/cancellation/no-show, reconcile across scheduling tool, calendar system, and participant system. Persist provenance fields and final session outcome without deleting transition history. + +### Draft: Tool Usage (Verified Methods, Constraints, and Error Handling) + +Add this section under `## Available Tools` (keep only methods actually installed in runtime): + +```python +# Calendly: list scheduled events and invitees for attendance reconciliation +calendly( + op="call", + args={ + "method_id": "calendly.scheduled_events.list.v1", + "user": "https://api.calendly.com/users/xxx", + "count": 50, + "min_start_time": "2026-01-01T00:00:00Z", + }, +) + +calendly( + op="call", + args={ + "method_id": "calendly.scheduled_events.invitees.list.v1", + "uuid": "event_uuid", + "count": 50, + }, +) + +# Google Calendar: controlled event insertion and list/reconciliation +google_calendar( + op="call", + args={ + "method_id": "google_calendar.events.insert.v1", + "calendarId": "primary", + "summary": "Research Interview", + "start": {"dateTime": "2026-03-10T15:00:00-05:00"}, + "end": {"dateTime": "2026-03-10T15:45:00-05:00"}, + "attendees": [{"email": "participant@example.com"}], + }, +) + +google_calendar( + op="call", + args={ + "method_id": "google_calendar.events.list.v1", + "calendarId": "primary", + "timeMin": "2026-03-01T00:00:00Z", + "timeMax": "2026-03-31T23:59:59Z", + }, +) + +# Qualtrics: participant pool and survey distribution operations +qualtrics( + op="call", + args={ + "method_id": "qualtrics.contacts.create.v1", + "directoryId": "POOL_xxx", + "mailingListId": "CG_xxx", + "firstName": "A", + "lastName": "B", + "email": "participant@example.com", + }, +) + +qualtrics( + op="call", + args={ + "method_id": "qualtrics.distributions.create.v1", + "surveyId": "SV_xxx", + "mailingListId": "CG_xxx", + "fromEmail": "research@example.com", + "fromName": "Research Team", + "subject": "Interview Invitation", + }, +) + +# User Interviews: create/update participants in project workflow +userinterviews( + op="call", + args={ + "method_id": "userinterviews.participants.create.v1", + "name": "Participant Name", + "email": "participant@example.com", + "project_id": "proj_id", + }, +) + +userinterviews( + op="call", + args={ + "method_id": "userinterviews.participants.update.v1", + "participant_id": "part_id", + "status": "scheduled", + }, +) +``` + +Tool interpretation guidance: + +- **Calendly:** do not assume native reschedule semantics as a single endpoint operation; model reschedule as explicit transition(s) and preserve old/new times in your log. +- **Google Calendar:** if sync-based workflows are used, treat token invalidation (`410 Gone`) as a full-resync trigger. Back off on quota errors (`403 usageLimits`) and `429` responses. +- **Qualtrics:** enforce strict parameter casing and datacenter-specific base configuration through environment validation before runtime calls. +- **User Interviews:** enforce request pacing and honor `Retry-After` on `429`; design idempotent upsert behavior for participant synchronization. + +### Draft: Signal Interpretation and Operational Thresholds + +Add a section called `### Scheduling Signal Quality`: + +You must compute and review these metrics at study and segment levels: + +- `no_show_rate = confirmed_no_shows / confirmed_scheduled` +- `cancellation_rate = cancelled_sessions / planned_sessions` +- `reschedule_rate = rescheduled_sessions / planned_sessions` +- `coverage_vs_target = completed_sessions / target_count` +- `lead_time_days = session_start_utc - booked_at_utc` +- `over_recruitment_rate = (recruited_count - target_count) / target_count` + +Interpretation rules: + +1. If rolling 4-week `no_show_rate > 0.10` or increases by at least 0.02 compared with baseline, trigger no-show mitigation playbook (extra reminder touch + replacement buffer increase + incentive review). +2. If `coverage_vs_target < 0.90` near field-close window, open backup recruitment channels and prioritize underfilled quota cells. +3. If share of sessions with `lead_time_days >= 31` rises while no-show also rises, add reconfirmation checkpoints and shorten booking horizon where possible. +4. If cancellation/reschedule rates remain elevated for two consecutive windows, audit reason codes and simplify self-serve reschedule paths. + +Evidence labeling rule: + +- Mark thresholds from strong standards/trials as `strong`. +- Mark large platform operational benchmarks as `moderate`. +- Mark small pilot/vendor anecdotal evidence as `weak`. + +Do not publish a single benchmark number without context band and evidence tier. + +### Draft: Anti-Patterns and Warning Blocks + +Add these warning blocks under methodology or a dedicated anti-pattern section: + +> [!WARNING] +> **Anti-pattern: Timezone ambiguity** +> What it looks like: session records store local-only time or ambiguous zone labels (for example `EST`) without canonical timezone ID. +> Detection signal: any record missing `start_utc`, `end_utc`, or `tzid`. +> Consequence: participants and interviewers join at different times, creating preventable no-shows. +> Mitigation: require canonical IANA timezone + UTC timestamps at write time; reject ambiguous inputs. + +> [!WARNING] +> **Anti-pattern: Availability snapshot treated as reservation** +> What it looks like: booking flow checks free/busy then writes event without conflict recheck. +> Detection signal: overlapping interviewer sessions discovered after write. +> Consequence: double-booking, forced reschedules, participant churn. +> Mitigation: use hold-then-commit flow with commit-time conflict recheck and idempotency keys. + +> [!WARNING] +> **Anti-pattern: Reminder without participant action telemetry** +> What it looks like: reminders are sent but confirmation/cancellation outcomes are not captured. +> Detection signal: high reminder send counts with unknown participant state. +> Consequence: false sense of readiness and late-stage no-show surprises. +> Mitigation: include one-click action links and persist `candidate_action` + `action_at_utc`. + +> [!WARNING] +> **Anti-pattern: Missing compliance provenance** +> What it looks like: scheduling events have no lawful basis, privacy notice version, or consent linkage. +> Detection signal: records cannot show what participant agreed to at scheduling time. +> Consequence: weak audit defensibility and inconsistent handling of withdrawal/erasure requests. +> Mitigation: require compliance envelope fields for every scheduling write and reject incomplete writes. + +> [!WARNING] +> **Anti-pattern: Over-messaging participants** +> What it looks like: frequent outreach touches with no cadence guardrails. +> Detection signal: rising unsubscribe/decline rates while touch count per participant increases. +> Consequence: panel fatigue and response quality degradation. +> Mitigation: cap touches per 24h and separate invite cadence from reminder cadence. + +### Draft: Reconciliation and Provenance Rules + +Add a section called `### Cross-System Reconciliation`: + +You must treat reconciliation as part of core scheduling, not optional reporting. On every terminal session outcome (`completed`, `cancelled`, `no_show`), compare records across: + +- scheduling provider (for booking metadata) +- calendar provider (for event state and attendees) +- participant platform/panel system (for participant lifecycle status) + +For every reconciled item, store: + +- `source_system` +- `source_record_id` +- `source_last_modified_at` +- `last_synced_at` +- `reconciliation_status` (`matched`, `mismatch`, `missing_source`, `needs_manual_review`) +- `mismatch_fields` (array) + +If mismatch touches participant identity, study linkage, or session time, route to manual review and block automated closure until resolved. + +### Draft: Schema additions + +```json +{ + "interview_schedule": { + "type": "object", + "required": [ + "study_id", + "sessions", + "coverage_status", + "policy", + "metrics", + "audit_log" + ], + "additionalProperties": false, + "properties": { + "study_id": { + "type": "string", + "description": "Unique identifier for the study this schedule belongs to." + }, + "policy": { + "type": "object", + "required": [ + "policy_notice_hours", + "platform_cutoff_hours", + "max_reschedule_attempts_per_participant", + "late_change_exception_required" + ], + "additionalProperties": false, + "properties": { + "policy_notice_hours": { + "type": "integer", + "minimum": 1, + "description": "Minimum hours before session required by team policy for normal cancellation/reschedule handling." + }, + "platform_cutoff_hours": { + "type": "integer", + "minimum": 0, + "description": "Minimum hours before session enforced by the scheduling platform for technical acceptance." + }, + "max_reschedule_attempts_per_participant": { + "type": "integer", + "minimum": 0, + "description": "Maximum number of reschedule attempts permitted per participant for this study." + }, + "late_change_exception_required": { + "type": "boolean", + "description": "Whether requests inside policy notice window require explicit documented exception approval." + } + } + }, + "sessions": { + "type": "array", + "description": "Session-level records with scheduling, reminder, provenance, and compliance metadata.", + "items": { + "type": "object", + "required": [ + "session_id", + "participant_id", + "study_id", + "status", + "scheduled_start_utc", + "scheduled_end_utc", + "scheduled_tzid", + "source_system", + "source_record_id", + "reminder_plan", + "compliance_envelope" + ], + "additionalProperties": false, + "properties": { + "session_id": { + "type": "string", + "description": "Unique session identifier used across lifecycle transitions." + }, + "participant_id": { + "type": "string", + "description": "Participant identifier linked to roster and consent records." + }, + "study_id": { + "type": "string", + "description": "Study identifier repeated at session level for denormalized traceability." + }, + "status": { + "type": "string", + "enum": [ + "invited", + "scheduled", + "reschedule_requested", + "rescheduled", + "completed", + "no_show", + "cancelled" + ], + "description": "Current lifecycle state of the session." + }, + "scheduled_start_utc": { + "type": "string", + "format": "date-time", + "description": "Session start timestamp in UTC for canonical calculations." + }, + "scheduled_end_utc": { + "type": "string", + "format": "date-time", + "description": "Session end timestamp in UTC." + }, + "scheduled_tzid": { + "type": "string", + "description": "Canonical timezone identifier (IANA) used for participant-facing rendering." + }, + "source_system": { + "type": "string", + "enum": [ + "calendly", + "google_calendar", + "qualtrics", + "userinterviews", + "manual" + ], + "description": "System of origin for current session record." + }, + "source_record_id": { + "type": "string", + "description": "Provider-native record identifier for reconciliation." + }, + "last_synced_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp of last successful reconciliation with source system." + }, + "reschedule_reason": { + "type": "string", + "description": "Human-readable reason for reschedule request when applicable." + }, + "reminder_plan": { + "type": "array", + "description": "Configured reminder checkpoints relative to session start.", + "minItems": 1, + "items": { + "type": "string", + "enum": ["T-72h", "T-48h", "T-24h", "T-2h"] + } + }, + "reminder_events": { + "type": "array", + "description": "Observed reminder delivery and participant response telemetry.", + "items": { + "type": "object", + "required": [ + "reminder_id", + "channel", + "sent_at_utc", + "delivery_status" + ], + "additionalProperties": false, + "properties": { + "reminder_id": { + "type": "string", + "description": "Unique reminder event identifier." + }, + "channel": { + "type": "string", + "enum": ["email", "sms", "in_app"], + "description": "Communication channel used for reminder delivery." + }, + "sent_at_utc": { + "type": "string", + "format": "date-time", + "description": "Reminder send timestamp in UTC." + }, + "delivery_status": { + "type": "string", + "enum": ["sent", "delivered", "failed", "bounced"], + "description": "Delivery outcome from messaging provider." + }, + "participant_action": { + "type": "string", + "enum": ["none", "confirmed", "cancelled", "reschedule_requested"], + "description": "Participant action captured from reminder touchpoint." + }, + "action_at_utc": { + "type": "string", + "format": "date-time", + "description": "Timestamp when participant action was captured." + } + } + } + }, + "compliance_envelope": { + "type": "object", + "required": [ + "lawful_basis", + "privacy_notice_version", + "consent_ref", + "retention_class" + ], + "additionalProperties": false, + "properties": { + "lawful_basis": { + "type": "string", + "description": "Declared legal/organizational basis for processing participant scheduling data." + }, + "privacy_notice_version": { + "type": "string", + "description": "Version identifier of participant-facing privacy notice at scheduling time." + }, + "consent_ref": { + "type": "string", + "description": "Reference to consent artifact or record linked to this session." + }, + "retention_class": { + "type": "string", + "description": "Retention policy class that controls purge/anonymization horizon." + }, + "expires_at_utc": { + "type": "string", + "format": "date-time", + "description": "Timestamp at which this record is eligible for purge/anonymization absent legal hold." + }, + "legal_hold": { + "type": "boolean", + "description": "Whether retention deletion is suspended due to legal or policy hold." + } + } + } + } + } + }, + "coverage_status": { + "type": "object", + "required": ["scheduled_count", "completed_count", "target_count"], + "additionalProperties": false, + "properties": { + "scheduled_count": { + "type": "integer", + "minimum": 0, + "description": "Current number of scheduled sessions." + }, + "completed_count": { + "type": "integer", + "minimum": 0, + "description": "Current number of completed sessions." + }, + "target_count": { + "type": "integer", + "minimum": 0, + "description": "Target number of completed sessions for the study." + } + } + }, + "metrics": { + "type": "object", + "required": [ + "no_show_rate", + "cancellation_rate", + "reschedule_rate", + "coverage_vs_target", + "over_recruitment_rate" + ], + "additionalProperties": false, + "properties": { + "no_show_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Confirmed no-shows divided by confirmed scheduled sessions." + }, + "cancellation_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Cancelled sessions divided by planned sessions." + }, + "reschedule_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Rescheduled sessions divided by planned sessions." + }, + "coverage_vs_target": { + "type": "number", + "minimum": 0, + "description": "Completed sessions divided by target count." + }, + "over_recruitment_rate": { + "type": "number", + "description": "Recruited minus target, divided by target; can be negative when under-recruited." + } + } + }, + "audit_log": { + "type": "array", + "description": "Append-only transition and decision events for scheduling operations.", + "items": { + "type": "object", + "required": [ + "event_id", + "session_id", + "from_status", + "to_status", + "event_at_utc", + "actor_type", + "actor_id", + "reason_code" + ], + "additionalProperties": false, + "properties": { + "event_id": { + "type": "string", + "description": "Unique audit event identifier." + }, + "session_id": { + "type": "string", + "description": "Session affected by this transition." + }, + "from_status": { + "type": "string", + "description": "Previous status before transition." + }, + "to_status": { + "type": "string", + "description": "New status after transition." + }, + "event_at_utc": { + "type": "string", + "format": "date-time", + "description": "Timestamp when transition was recorded." + }, + "actor_type": { + "type": "string", + "enum": ["system", "researcher", "participant"], + "description": "Type of actor that triggered transition." + }, + "actor_id": { + "type": "string", + "description": "Identifier of actor responsible for transition." + }, + "reason_code": { + "type": "string", + "description": "Normalized reason for transition (for example no_show, participant_conflict, researcher_conflict)." + }, + "details": { + "type": "string", + "description": "Optional human-readable context for manual review and audits." + } + } + } + } + } + } +} +``` + +--- + +## Gaps & Uncertainties + +- Published, universally accepted benchmark thresholds specific to UX interview scheduling are limited; many numeric ranges come from platform/operator datasets and should be treated as moderate evidence. +- Some vendor documentation pages are evergreen and not clearly date-stamped, so they are used as current capability references rather than strict 2024-2026 change logs. +- Qualtrics tenant-specific API throttling and policy controls vary by brand admin configuration; runtime behavior may differ across deployments. +- No single cross-vendor canonical definition for "reschedule rate" exists; this should be standardized internally in the skill schema and reporting logic. +# Research: discovery-scheduling + +**Skill path:** `flexus-client-kit/flexus_simple_bots/researcher/skills/discovery-scheduling/` +**Bot:** researcher +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`discovery-scheduling` handles interview scheduling logistics and participant panel operations across tools such as Calendly, Google Calendar, Qualtrics, and User Interviews. It sits between recruitment and interview capture: once candidates are qualified, this skill turns candidate lists into confirmed sessions, then tracks session outcomes and coverage against study targets. + +The core problem this skill solves is operational reliability under real-world friction: no-shows, timezone errors, consent traceability gaps, and replacement lag. In practice, this requires a strict traceability chain (`participant_id -> study_id -> consent evidence -> scheduled session -> outcome status`) and explicit decision rules for cancellations, no-shows, and panel fatigue. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024–2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- No generic filler ("it is important to...", "best practices suggest...") without concrete backing +- No invented tool names, method IDs, or API endpoints — only verified real ones +- Contradictions between sources are explicitly noted, not silently resolved +- Volume: findings section should be 800–4000 words (too short = shallow, too long = unsynthesized) + +Gate check for this document: passed. + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +Practitioner guidance in 2025-2026 treats research scheduling as a governed operations loop, not an ad hoc coordinator task. GitLab’s 2026 UXR panel management playbook uses explicit ownership (named panel DRI), response SLA (participant outreach answered within one business day), and panel lifecycle controls (panel duration caps, communication cadence). This implies the right default for this skill is to encode panel governance fields and response-time expectations directly in workflow instructions. + +Panel quality and retention are managed through explicit anti-fatigue limits. GitLab documents concrete limits (for example, capping annual session exposure per participant and tracking gratuity totals), while User Interviews emphasizes backups/waitlists and over-recruitment buffers to absorb operational losses. Together, these suggest scheduling should always include backup capacity and exposure tracking rather than assuming the recruited set is fully reliable. + +No-show and cancellation handling has become materially more codified. User Interviews separates participant no-shows from researcher-initiated cancellations and ties these outcomes to policy and fee behavior. The operational implication is that this skill should not collapse all failures into a single `cancelled` bucket; it should store reason-coded outcomes with action paths (reschedule, replacement, or close-out). + +Reminder and reconfirmation automation is now a standard reliability control, but channel capability is uneven by platform and plan. Microsoft Bookings supports configurable reminder windows and consent capture in booking flow, while SMS support has geography/licensing constraints; Calendly supports no-show handling and reconfirmation workflows, with automation depth varying by tier. This implies a multi-channel fallback model (email primary, SMS conditional, manual escalation path) is safer than a single-channel assumption. + +A cross-domain change from 2024-2025 is that email sender compliance (SPF, DKIM, DMARC alignment and unsubscribe behavior for promotional/bulk contexts) increasingly affects reminder deliverability. Scheduling no-show increases can therefore be partially caused by message delivery problems rather than participant intent. This operationally pushes deliverability checks into the scheduling SOP, even if messaging is outsourced to a scheduling platform. + +**Sources:** +- https://handbook.gitlab.com/handbook/product/ux/ux-research/research-panel-management/ +- https://www.userinterviews.com/support/floaters-and-backups +- https://www.userinterviews.com/support/replacing-no-shows +- https://www.userinterviews.com/support/canceling-participants +- https://www.userinterviews.com/support/late-participants +- https://help.calendly.com/hc/en-us/articles/4402509436823-How-to-mark-no-shows-for-meetings +- https://help.calendly.com/hc/en-us/articles/1500005846741-How-to-reconfirm-meetings-with-Workflows +- https://learn.microsoft.com/en-us/microsoft-365/bookings/add-email-reminder?view=o365-worldwide +- https://learn.microsoft.com/en-us/microsoft-365/bookings/bookings-faq?view=o365-worldwide +- https://learn.microsoft.com/en-us/microsoft-365/bookings/bookings-sms?view=o365-worldwide +- https://support.google.com/a/answer/81126 +- https://support.google.com/a/answer/14229414 +- https://senders.yahooinc.com/best-practices/ +- https://senders.yahooinc.com/faqs + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +Calendly API v2 is the stable integration surface, with material plan-gating around real-time orchestration features. Vendor docs indicate broad API access, but webhook subscriptions and selected admin functions are constrained by paid tiers or enterprise contexts. For this skill, list polling plus invitee detail retrieval is reliable as baseline; webhook-dependent logic must explicitly declare plan dependency. + +For attendance and event-state ingestion, the most stable Calendly path is `GET /scheduled_events` followed by invitee-detail fetches. Calendly docs also note that migration from v1 to v2 is not backward-compatible, and some teams still carry legacy assumptions. The skill should therefore enforce v2-only assumptions and warn against legacy payload fields. + +Google Calendar provides strong primitives for controlled scheduling: `events.insert` for booking, `events.list` for reconciliation/incremental reads, and `freeBusy.query` for availability checks. Google documentation also flags deprecated behavior (`sendNotifications`) and recommends `sendUpdates`. Quotas are enforced with per-project/per-user policies and `403/429` responses when exceeded (evergreen docs). This supports explicit retry/backoff and quota-aware batching in the skill text. + +Qualtrics remains valid for panel/contact/distribution workflows but is license- and permission-gated. Official docs require API entitlement plus user permission, and they document rate/concurrency guardrails and usage-threshold events. Distribution-link workflows include caveats such as default expiration behavior if an explicit expiration is not set. This means scheduling instructions should include fail-fast checks for entitlement and explicit expiry configuration. + +User Interviews now exposes a public Hub API surface with participant CRUD and batch operations, auth documentation, and pagination conventions. Current product docs also show operational scheduling support and integrations, but some export/integration capabilities are intentionally bounded by product scope. For this skill, User Interviews is best treated as a participant-state and scheduling-context system, with downstream harmonization done in the local artifact schema. + +De-facto stack pattern in 2024-2026: one booking system (Calendly/Bookings/UI scheduling), one canonical calendar source (Google or Outlook), and one participant database/distribution system (Qualtrics or User Interviews Hub). The technical risk is not tool choice but state divergence across systems; this skill should privilege canonical IDs and reconciliation routines over one-time writes. + +**Sources:** +- https://developer.calendly.com/frequently-asked-questions +- https://developer.calendly.com/getting-started +- https://developer.calendly.com/update-your-system-with-data-from-scheduled-events-admins-only +- https://developer.calendly.com/receive-data-from-scheduled-events-in-real-time-with-webhook-subscriptions +- https://developer.calendly.com/how-to-migrate-from-api-v1-to-api-v2 +- https://calendly.com/pricing +- https://developers.google.com/calendar/api/v3/reference/events/insert (evergreen) +- https://developers.google.com/calendar/api/v3/reference/events/list (evergreen) +- https://developers.google.com/workspace/calendar/api/v3/reference/freebusy/query (evergreen) +- https://developers.google.com/workspace/calendar/api/guides/quota (evergreen) +- https://www.qualtrics.com/support/integrations/api-integration/overview/ +- https://www.qualtrics.com/support/integrations/api-integration/using-qualtrics-api-documentation/ +- https://www.qualtrics.com/support/integrations/api-integration/common-api-use-cases/ +- https://qualtrics.com/support/integrations/api-integration/common-api-questions-by-product +- https://www.qualtrics.com/support/survey-platform/actions-page/events/api-usage-threshold-event/ +- https://api-docs.userinterviews.com/openapi +- https://api-docs.userinterviews.com/openapi/hub-v2-api.yaml +- https://api-docs.userinterviews.com/reference/introduction.md +- https://api-docs.userinterviews.com/reference/authentication.md +- https://api-docs.userinterviews.com/reference/pagination.md +- https://api-docs.userinterviews.com/reference/errors.md +- https://www.userinterviews.com/scheduling +- https://www.userinterviews.com/integrations +- https://www.userinterviews.com/hub-api + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +For moderated research interviews, practical no-show expectations are typically single-digit to low-double-digit percentages. Published 2024 research-ops references report averages around 5-9%, making sustained >10% a useful alert threshold for process review rather than a hard universal failure threshold. The core interpretation rule is that no-show should be segmented by audience and modality before action. + +Recruiting quality is better interpreted through the qualified-to-requested (`Q:R`) ratio than raw applicant volume. Reports indicate that `Q:R = 1.0` is minimally viable but fragile (limited replacement capacity), while higher ratios create resilience. In this skill, `Q:R` should be tracked as a leading indicator and tied to backup recruitment policy. + +Incentive changes are a major confounder in schedule reliability metrics. Observational benchmarks show no-show shifts with compensation levels, meaning a show-rate decline may be compensation drift rather than a scheduler defect. Interpretation rules should force an “incentive regime check” before operational escalation. + +Lead-time and confirmation state are meaningful risk predictors (evergreen evidence from healthcare scheduling translates directionally to participant attendance workflows): longer lead-time tends to increase no-show risk, and confirmation status is a strong predictor. This supports a control rule: increase reconfirmation rigor as lead-time increases. + +Reschedule behavior is not uniformly negative (evergreen): initiator and timing matter. Evidence shows participant-initiated rescheduling can preserve intent better than provider/organizer-initiated shifts in some contexts. For research scheduling, reschedules should be interpreted as a distinct status with initiator metadata, not automatically grouped with cancellation risk. + +Cancellation rate and no-show rate can move in opposite directions, so single-metric optimization is misleading. A process that reduces formal cancellations may still increase no-shows if participants remain “booked but disengaged.” Therefore, this skill should require joint reading of completion, cancellation, no-show, and replacement lag. + +External benchmark contradiction is expected: open-calendar traffic benchmarks (broader, colder funnel) can show materially lower show rates than curated, incentivized research pipelines. Any threshold in SKILL.md should therefore be framed as “research-panel baseline” and not merged with open booking baselines. + +**Sources:** +- https://measuringu.com/typical-no-show-rate-for-moderated-studies/ +- https://www.userinterviews.com/blog/research-incentives-report +- https://start.userinterviews.com/hubfs/UI%20Panel%20Report%202024.pdf +- https://lunacal.ai/blogs/calendar-scheduling-benchmarks-report +- https://bmjopenquality.bmj.com/content/14/Suppl_3/A197 +- https://link.springer.com/article/10.1186/s12913-023-09969-5 (evergreen) +- https://portal.fis.tum.de/en/publications/effects-of-rescheduling-on-patient-no-show-behavior-in-outpatient (evergreen) + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +A frequent structural failure is booking-state inconsistency: systems validate initial booking against availability but fail to re-validate reschedules/updates the same way. Public issue reports in scheduling tooling show this can produce accepted bookings in unavailable windows, leading to last-minute moderator collisions and false participant no-shows. Anti-pattern detection: booking accepted but organizer calendar already occupied. + +Timezone/DST drift remains a practical trap even in mature stacks. Workflow systems can present recurring events at shifted local times around DST boundaries when timezone metadata is incomplete or transformed inconsistently. Anti-pattern detection: recurring series where UTC timestamp and participant-facing local time diverge after DST boundary. + +Consent-purpose mismatch is a high-impact failure when recording/transcription is enabled. If scheduling confirmations collect general participation consent but not recording/secondary-use consent, teams can produce legally or ethically unusable interview data. Anti-pattern detection: recording enabled with missing consent-scope artifact. + +Panel outreach cadence can fail at both extremes. Evidence differs by panel type: excessive solicitations can increase attrition in some contexts, while very low invitation frequency can also increase attrition in probability panels. Anti-pattern detection: attrition climbs while invite cadence is either very high or very low, without cohort-specific calibration. + +No-show response failures are often process failures, not participant failures. When no-shows are not immediately reason-coded and replacement flow is not triggered, studies slip and sample coverage degrades. Anti-pattern detection: `target_count - completed_count` gap grows while replacement actions remain empty. + +Incentive/payout opacity is another operational anti-pattern. If payout timing/method is unclear or restrictive, trust declines and retention suffers, especially in harder-to-reach populations. Anti-pattern detection: rising payment exception tickets and withdrawal rates after invite acceptance. + +Bad output examples cluster around missing canonical timezone, ambiguous status labels, and absent reason codes. Good output is explicit: UTC timestamps, IANA timezones, reason-coded statuses, initiator metadata, and replacement action logs. + +**Sources:** +- https://github.com/calcom/cal.com/issues/16150 +- https://github.com/n8n-io/n8n/issues/11264 +- https://decisions.ipc.on.ca/ipc-cipvp/privacy/en/521580/1/document.do +- https://www.ssph-journal.org/journals/international-journal-of-public-health/articles/10.3389/ijph.2024.1606770/full +- https://ideas.repec.org/a/spr/joamsc/v52y2024i4d10.1007_s11747-023-00992-w.html +- https://ssrs.com/insights/when-does-survey-burden-become-too-high-or-too-low-in-probability-panels/ +- https://www.userinterviews.com/support/replacing-no-shows +- https://measuringu.com/typical-no-show-rate-for-moderated-studies/ +- https://pmc.ncbi.nlm.nih.gov/articles/PMC11626574/ +- https://pmc.ncbi.nlm.nih.gov/articles/PMC11156289/ + +--- + +### Angle 5+: Communications Deliverability & Compliance Controls +> Add as many additional angles as the domain requires. Examples: regulatory/compliance context, industry-specific nuances, integration patterns with adjacent tools, competitor landscape, pricing benchmarks, etc. + +**Findings:** + +Interview attendance is partially a communications infrastructure problem. 2024-2025 sender-policy enforcement by major inbox providers (Google and Yahoo) raises the probability that reminder flows can degrade silently when authentication and complaint controls are weak. For scheduling operations, this means show-rate monitoring should be paired with deliverability-health checks. + +Vendor reminder channels are uneven: SMS support may depend on geography and paid licensing, and participants can opt out. Therefore, reminder design should require channel fallback (email + secondary channel + manual escalation) rather than a single-channel workflow. + +Consent traceability can be integrated directly into booking flows in some tools, which reduces post-hoc reconciliation burden. This is best used as a required part of scheduling records so that interview capture does not proceed without matching consent evidence. + +Contradiction to manage: providers differ in how explicitly they define “bulk sender” and enforcement details, but both pressure senders toward stronger authentication and subscription hygiene. Skill guidance should encode the common denominator controls instead of overfitting one provider’s threshold language. + +**Sources:** +- https://support.google.com/a/answer/81126 +- https://support.google.com/a/answer/14229414 +- https://senders.yahooinc.com/best-practices/ +- https://senders.yahooinc.com/faqs +- https://learn.microsoft.com/en-us/microsoft-365/bookings/bookings-sms?view=o365-worldwide +- https://learn.microsoft.com/en-us/microsoft-365/bookings/bookings-faq?view=o365-worldwide + +--- + +## Synthesis + +The strongest cross-source pattern is that reliable interview scheduling in 2026 is treated as a governed pipeline, not a calendar convenience task. Teams that perform well define owner roles, track participant exposure/fatigue, and implement deterministic handling for no-show/cancellation/reschedule paths. This aligns with the current `discovery-scheduling` scope, but the current skill text is too thin on reason coding, deliverability controls, and reconciliation practices. + +Tooling choice is less decisive than state discipline. Calendly, Google Calendar, Qualtrics, and User Interviews all provide usable primitives, but each has plan-gating, version drift, or permission caveats. The practical consequence is that SKILL.md should avoid implying universal capability and instead provide “baseline vs optional” paths plus clear fail-safe behavior when a feature is unavailable. + +The interpretation layer needs explicit guardrails because benchmark values differ by funnel type. Curated incentivized research flows often report materially lower no-show than open scheduling benchmarks; applying one global target can cause false alarms or false confidence. The skill should encode segmented KPI interpretation and mandate checks for confounders (incentive changes, lead-time shifts, message deliverability). + +A key contradiction worth preserving is outreach cadence: both over-contact and under-contact can damage panel health depending on cohort and panel design. Rather than a universal outreach frequency, guidance should require cohort-level monitoring and adaptive cadence bounds. + +Most importantly, anti-pattern risk concentrates in hidden metadata gaps (timezone canonicalization, consent scope, outcome reason codes). Adding these fields to the scheduling artifact schema materially improves downstream reliability for interview capture and analysis. + +--- + +## Recommendations for SKILL.md + +> Concrete, actionable list of what should change / be added in the SKILL.md based on research. + +- [ ] Add an explicit scheduling control-loop methodology with owner roles, traceability requirements, and reason-coded lifecycle states. +- [ ] Expand no-show/reschedule/cancellation protocol with deterministic decision rules and replacement triggers tied to coverage gap. +- [ ] Add KPI interpretation rubric (segmented no-show benchmarks, `Q:R` leading indicator, lead-time and incentive confounder checks). +- [ ] Update tool guidance to include real endpoint mappings, plan/permission caveats, and backoff/error handling for quota/rate-limit responses. +- [ ] Add a deliverability and reminders section (channel fallback, sender-auth hygiene checks, reconfirmation windows). +- [ ] Add panel fatigue and cadence controls (exposure caps, outreach cadence bounds, backup capacity policy). +- [ ] Add anti-pattern warning blocks with detection signals and mitigation playbooks. +- [ ] Expand artifact schema with timezone canonicalization, consent evidence linkage, reason codes, replacement actions, and KPI snapshot fields. + +--- + +## Draft Content for SKILL.md + +> This section is intentionally verbose and paste-ready. + +### Draft: Scheduling Control Loop and Traceability + +Use scheduling as a controlled operations loop, not a one-time booking step. You must maintain an unbroken chain from `participant_id` to `study_id` to `consent_evidence` to `session_outcome`. If any link is missing, do not mark the session as operationally complete. + +Before sending any booking link, verify three prerequisites: (1) participant qualification is recorded, (2) consent path is defined for the session type (including recording/transcription scope), and (3) interviewer slot capacity is still valid in the canonical calendar system. This prevents the common failure mode where participants are bookable before legal/operational readiness is real. + +Step-by-step control loop: +1. Intake qualified participants from recruitment with immutable identifiers (`participant_id`, `study_id`). +2. Open scheduling paths (Calendly link, direct calendar insertion, or panel distribution) only after consent path and moderator availability checks pass. +3. Capture each booking with canonical timestamps (`scheduled_at_utc`) plus IANA timezone context for organizer and participant. +4. Run confirmation/reconfirmation workflow before session start based on lead-time and risk tier. +5. At session start + grace window, mark status using reason-coded outcomes (`completed`, `participant_no_show`, `participant_cancelled_lt_24h`, `researcher_cancelled`, `rescheduled`). +6. If the status is non-complete and coverage is below target, trigger replacement workflow immediately. +7. Reconcile schedule state against source tools at least daily until coverage target is met. + +Decision rule: +- If `completed_count < target_count` and any session enters no-show/cancelled state, open replacement action in the same run. +- If replacement inventory is exhausted, explicitly reduce target and annotate rationale; do not leave implicit coverage gaps. + +Do NOT: +- Do NOT use a generic `cancelled` label for all failures; always include reason code and initiator. +- Do NOT store local time without UTC and timezone metadata. +- Do NOT advance interviews to capture/analysis pipelines when consent scope is unresolved. + +### Draft: Confirmation, Reminder, and Channel Fallback Protocol + +You must use a multi-stage confirmation protocol with channel fallback because reminder deliverability and channel availability vary across systems and participant regions. + +Default reminder timeline: +- `T-72h` (or at booking for short lead-time): confirmation with session objective, expected duration, timezone-rendered start time, and reschedule path. +- `T-24h`: reconfirmation request requiring participant acknowledgment when tooling supports it. +- `T-2h to T-30m`: final reminder in primary channel; if high-risk segment (historically high no-show or long lead-time), include secondary channel. + +Fallback logic: +- If SMS is unavailable due to geography/license constraints, run email + manual follow-up path. +- If participant opt-out is detected on one channel, continue compliance-safe contact on permitted channels only. +- If reminder delivery signals degrade (bounce/complaint spikes from sending domain telemetry), escalate to manual confirmations for near-term sessions and notify operations owner. + +Deliverability controls to include in scheduling operations: +- Verify sender authentication posture (SPF, DKIM, DMARC alignment) for domains sending reminders. +- Monitor complaint and bounce trends weekly; attendance shifts without deliverability checks are not interpretable. +- Keep unsubscribe and preference handling compliant for promotional/bulk-like communication paths. + +Do NOT: +- Do NOT assume reminders were received simply because they were sent. +- Do NOT run a single-channel reminder strategy for all participant segments. +- Do NOT interpret no-show spikes without checking message-delivery health first. + +### Draft: No-Show, Cancellation, and Reschedule Triage + +Treat no-show, cancellation, and reschedule as different operational outcomes with different consequences and mitigation steps. + +Required status taxonomy: +- `completed` +- `participant_no_show` +- `participant_cancelled_gte_24h` +- `participant_cancelled_lt_24h` +- `researcher_cancelled` +- `rescheduled_participant_initiated` +- `rescheduled_researcher_initiated` + +Triage sequence: +1. Apply grace window (for example 10 minutes) and attempt contact once through primary channel. +2. Assign status code with initiator metadata. +3. For participant no-show: send one reschedule/recovery message and set a response timeout (for example 48 hours). +4. If no response by timeout and coverage still below target: mark participant as lost for this study and trigger replacement from backup/waitlist pool. +5. For researcher-initiated cancellation: prioritize immediate rebooking and flag potential cost/process impact in notes. + +Coverage rule: +- Maintain `coverage_gap = target_count - completed_count`. +- If `coverage_gap > 0`, replacements are mandatory unless a study owner explicitly approves target reduction with reason. + +Data hygiene rule: +- Store the timestamp and actor for every status transition. +- Store `replacement_actions` as an array of explicit actions (for example `waitlist_promoted`, `new_slot_opened`, `target_reduced`). + +Do NOT: +- Do NOT close studies with unresolved coverage gap and empty replacement history. +- Do NOT infer intent from a reschedule without initiator and lead-time context. + +### Draft: Panel Fatigue, Cadence, and Backup Policy + +You must actively manage panel health to avoid both over-exposure and disengagement. Scheduling throughput is not enough; panel quality and sustainability are part of completion quality. + +Minimum controls: +- Track participant exposure count and minutes across rolling 12 months. +- Enforce participation caps for repeat panelists (for example max four 60-minute sessions per year unless explicitly approved for longitudinal studies). +- Track outreach frequency per participant/cohort and monitor attrition/unsubscribe patterns. + +Cadence rule: +- Maintain cohort-level cadence bounds (floor and ceiling). If attrition rises under high-contact cohorts, reduce solicitations and broaden sampling. If attrition rises under low-contact cohorts, increase touchpoint frequency with value-forward communication. + +Backup policy: +- Pre-approve backup capacity for each study (for example ~10% above target where feasible). +- Maintain an active waitlist path so replacements can be triggered same day as no-show events. + +Do NOT: +- Do NOT repeatedly schedule only prior high responders without freshness controls. +- Do NOT run one global invitation cadence across materially different participant cohorts. + +### Draft: Available Tools (with Endpoint and Reliability Notes) + +Use these tools for scheduling and panel operations. Prefer source-of-truth reconciliation over one-time writes. + +```python +# Calendly: pull scheduled sessions and invitee details for attendance reconciliation. +calendly(op="call", args={ + "method_id": "calendly.scheduled_events.list.v1", + "user": "https://api.calendly.com/users/xxx", + "count": 50, + "min_start_time": "2024-01-01T00:00:00Z" +}) + +calendly(op="call", args={ + "method_id": "calendly.scheduled_events.invitees.list.v1", + "uuid": "event_uuid", + "count": 50 +}) + +# Google Calendar: create and list controlled interview slots/events. +google_calendar(op="call", args={ + "method_id": "google_calendar.events.insert.v1", + "calendarId": "primary", + "summary": "Customer Interview", + "start": {"dateTime": "2024-03-01T10:00:00-05:00"}, + "end": {"dateTime": "2024-03-01T10:45:00-05:00"}, + "attendees": [{"email": "participant@company.com"}] +}) + +google_calendar(op="call", args={ + "method_id": "google_calendar.events.list.v1", + "calendarId": "primary", + "timeMin": "2024-03-01T00:00:00Z", + "timeMax": "2024-03-31T00:00:00Z" +}) + +# Qualtrics: panel contact and distribution operations. +qualtrics(op="call", args={ + "method_id": "qualtrics.mailinglists.list.v1", + "directoryId": "POOL_xxx" +}) + +qualtrics(op="call", args={ + "method_id": "qualtrics.contacts.create.v1", + "directoryId": "POOL_xxx", + "mailingListId": "CG_xxx", + "firstName": "Name", + "lastName": "Surname", + "email": "participant@company.com" +}) + +qualtrics(op="call", args={ + "method_id": "qualtrics.distributions.create.v1", + "surveyId": "SV_xxx", + "mailingListId": "CG_xxx", + "fromEmail": "research@company.com", + "fromName": "Research Team", + "subject": "Interview Invitation" +}) + +# User Interviews: participant state updates for project scheduling. +userinterviews(op="call", args={ + "method_id": "userinterviews.participants.create.v1", + "name": "Participant Name", + "email": "participant@company.com", + "project_id": "proj_id" +}) + +userinterviews(op="call", args={ + "method_id": "userinterviews.participants.update.v1", + "participant_id": "part_id", + "status": "scheduled" +}) +``` + +Verified endpoint mappings (for connector validation and debugging): +- Calendly scheduled events: `GET /scheduled_events` +- Calendly invitee detail: `GET /scheduled_events/{event_uuid}/invitees/{invitee_uuid}` +- Google Calendar create event: `POST /calendar/v3/calendars/{calendarId}/events` +- Google Calendar availability: `POST /calendar/v3/freeBusy` (use if your connector exposes availability checks) +- Qualtrics v3 contacts path pattern: `/API/v3/mailinglists/{mailingListId}/contacts` +- User Interviews Hub participant paths: `/api/participants` and `/api/participants/batches` + +Rate-limit and error handling: +- On `429` or quota-style `403` responses, back off exponentially and retry within study timeline constraints. +- For plan-gated features (for example some webhook and automation capabilities), fall back to polling/reconciliation routines. +- Always log the source system and operation that produced each schedule-state transition. + +### Draft: KPI Interpretation and Escalation Rules + +Interpret scheduling quality as a segmented funnel, not a single percentage. + +Track at minimum: +- `requested_count` +- `qualified_count` +- `scheduled_count` +- `completed_count` +- `no_show_count` +- `cancelled_count` +- `rescheduled_count` +- `replacement_open_count` +- `median_time_to_fill_hours` +- `median_lead_time_days` + +Derived metrics: +- `show_rate = completed_count / scheduled_count` +- `no_show_rate = no_show_count / scheduled_count` +- `Q:R = qualified_count / requested_count` +- `coverage_gap = target_count - completed_count` + +Interpretation rules: +1. No-show benchmarking: use a moderated-research baseline (often single-digit to low-double-digit); treat sustained `>10%` as review trigger, not universal failure. +2. `Q:R` should not sit at `1.0` for high-risk cohorts if replacements may be needed. +3. Before escalating no-show, check confounders in order: incentive change, lead-time shift, reminder deliverability, and cohort mix. +4. Read cancellation and no-show together; improvements in one can hide deterioration in the other. +5. Segment all KPI decisions by modality (`remote`, `in_person`) and audience (`B2B`, `B2C`, niche cohort). + +Escalation: +- Escalate to operations owner when no-show exceeds trigger threshold for two consecutive scheduling cycles. +- Escalate immediately when coverage gap increases while replacement queue remains empty. + +Do NOT: +- Do NOT compare open-booking benchmark data directly against curated, incentivized research panel performance. +- Do NOT infer process success from one metric in isolation. + +### Draft: Anti-Pattern Warning Blocks + +#### Warning: Hidden Double-Booking from Inconsistent Availability Validation +- **What it looks like:** Initial bookings honor calendar availability but reschedules or updates bypass equivalent checks. +- **Detection signal:** New or moved bookings appear in already-occupied interviewer windows. +- **Consequence:** Moderator conflict, participant frustration, false no-show events. +- **Mitigation steps:** Re-run availability validation on every create/update/reschedule path; add overlap tests in integration checks; reconcile against canonical calendar daily. + +#### Warning: DST and Timezone Drift +- **What it looks like:** Participant-facing local time differs from canonical event time after DST transitions. +- **Detection signal:** Session has local-time mismatch after DST boundary, or recurring event series shifts unexpectedly. +- **Consequence:** Participants join at wrong time; avoidable no-shows. +- **Mitigation steps:** Store `scheduled_at_utc` plus organizer/participant IANA timezones; generate localized display time from UTC at send time; run DST-boundary audit jobs. + +#### Warning: Consent Scope Mismatch for Recorded Sessions +- **What it looks like:** Session recording/transcription active without explicit consent scope for recording and reuse. +- **Detection signal:** `recording_enabled=true` but no linked consent evidence/version/scope. +- **Consequence:** Compliance risk and unusable research artifacts. +- **Mitigation steps:** Require consent artifact linkage before confirming recorded sessions; block capture pipeline if consent scope is missing or expired. + +#### Warning: Reminder Strategy Optimized for Sign-Ups, Not Attendance +- **What it looks like:** Strong booking volume with weak completion/retention downstream. +- **Detection signal:** Scheduling conversion stable while no-show or later-wave attrition rises. +- **Consequence:** Hidden throughput collapse and replacement overload. +- **Mitigation steps:** Track completion and retention by reminder channel/timing; adjust cadence by cohort; keep backup pool active. + +#### Warning: Outcome Label Collapse (`cancelled` for everything) +- **What it looks like:** All non-completed sessions use a single cancellation status. +- **Detection signal:** Missing initiator and lead-time fields on failed sessions. +- **Consequence:** No valid cost/policy analysis; poor remediation targeting. +- **Mitigation steps:** Enforce reason-coded statuses and actor metadata; reject writes that omit required reason fields. + +### Draft: Schema additions + +```json +{ + "interview_schedule": { + "type": "object", + "description": "Scheduling operations artifact for one study, including session lifecycle, coverage, and reliability diagnostics.", + "required": [ + "study_id", + "source_systems", + "sessions", + "coverage_status", + "kpi_snapshot", + "panel_controls" + ], + "additionalProperties": false, + "properties": { + "study_id": { + "type": "string", + "description": "Immutable identifier for the study this schedule belongs to." + }, + "source_systems": { + "type": "array", + "description": "Scheduling/panel systems used to produce or reconcile this artifact.", + "items": { + "type": "string", + "enum": [ + "calendly", + "google_calendar", + "qualtrics", + "userinterviews", + "other" + ], + "description": "System name where session or participant scheduling state originated." + }, + "minItems": 1 + }, + "sessions": { + "type": "array", + "description": "Session-level records with canonical time, consent link, and outcome reason codes.", + "items": { + "type": "object", + "required": [ + "session_id", + "participant_id", + "scheduled_at_utc", + "organizer_timezone", + "participant_timezone", + "status", + "status_updated_at", + "status_actor" + ], + "additionalProperties": false, + "properties": { + "session_id": { + "type": "string", + "description": "Unique session identifier from scheduler or internal mapping layer." + }, + "participant_id": { + "type": "string", + "description": "Stable participant identifier tied to recruitment records." + }, + "scheduled_at_utc": { + "type": "string", + "format": "date-time", + "description": "Canonical UTC timestamp for scheduled start." + }, + "organizer_timezone": { + "type": "string", + "description": "IANA timezone for moderator/interviewer context, e.g. America/New_York." + }, + "participant_timezone": { + "type": "string", + "description": "IANA timezone for participant-facing localization." + }, + "localized_start_display": { + "type": "string", + "description": "Human-readable local start time rendered from UTC plus participant timezone." + }, + "duration_minutes": { + "type": "integer", + "minimum": 1, + "description": "Planned session duration in minutes." + }, + "status": { + "type": "string", + "enum": [ + "scheduled", + "completed", + "participant_no_show", + "participant_cancelled_gte_24h", + "participant_cancelled_lt_24h", + "researcher_cancelled", + "rescheduled_participant_initiated", + "rescheduled_researcher_initiated" + ], + "description": "Reason-coded lifecycle status used for policy, cost, and remediation decisions." + }, + "status_updated_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp for latest status transition." + }, + "status_actor": { + "type": "string", + "enum": [ + "participant", + "researcher", + "system" + ], + "description": "Actor responsible for the latest status transition." + }, + "consent_evidence": { + "type": "object", + "description": "Consent linkage for the scheduled session.", + "required": [ + "consent_captured", + "consent_scope" + ], + "additionalProperties": false, + "properties": { + "consent_captured": { + "type": "boolean", + "description": "Whether explicit consent evidence is linked for this session." + }, + "consent_scope": { + "type": "string", + "enum": [ + "participation_only", + "participation_and_recording", + "participation_recording_and_secondary_use" + ], + "description": "Scope granted by participant for this session." + }, + "consent_version": { + "type": "string", + "description": "Version identifier of consent copy/form shown to participant." + }, + "consent_captured_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when consent was accepted." + } + } + }, + "replacement_actions": { + "type": "array", + "description": "Actions taken when session did not complete as planned.", + "items": { + "type": "string", + "enum": [ + "waitlist_promoted", + "backup_activated", + "new_slot_opened", + "target_reduced_approved", + "none" + ], + "description": "Specific remediation action taken for coverage recovery." + } + }, + "notes": { + "type": "string", + "description": "Optional operational notes for audit and handoff." + } + } + } + }, + "coverage_status": { + "type": "object", + "required": [ + "requested_count", + "scheduled_count", + "completed_count", + "target_count", + "coverage_gap" + ], + "additionalProperties": false, + "description": "Coverage counters used to evaluate whether study scheduling goals are met.", + "properties": { + "requested_count": { + "type": "integer", + "minimum": 0, + "description": "Number of participant sessions requested by research plan." + }, + "scheduled_count": { + "type": "integer", + "minimum": 0, + "description": "Number of sessions currently booked." + }, + "completed_count": { + "type": "integer", + "minimum": 0, + "description": "Number of sessions completed successfully." + }, + "target_count": { + "type": "integer", + "minimum": 0, + "description": "Current completion target after approved adjustments." + }, + "coverage_gap": { + "type": "integer", + "minimum": 0, + "description": "Remaining sessions needed to meet target_count." + } + } + }, + "kpi_snapshot": { + "type": "object", + "required": [ + "no_show_rate", + "show_rate", + "qualified_to_requested_ratio", + "median_lead_time_days", + "median_time_to_fill_hours" + ], + "additionalProperties": false, + "description": "Interpretable reliability metrics captured at artifact generation time.", + "properties": { + "no_show_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Computed as no_show_count divided by scheduled_count." + }, + "show_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Computed as completed_count divided by scheduled_count." + }, + "qualified_to_requested_ratio": { + "type": "number", + "minimum": 0, + "description": "Qualified participant count divided by requested_count." + }, + "median_lead_time_days": { + "type": "number", + "minimum": 0, + "description": "Median days between booking creation and session start." + }, + "median_time_to_fill_hours": { + "type": "number", + "minimum": 0, + "description": "Median hours to fill requested interview slots with qualified participants." + }, + "segmentation_basis": { + "type": "array", + "description": "Segments applied while interpreting metrics.", + "items": { + "type": "string", + "enum": [ + "B2B", + "B2C", + "remote", + "in_person", + "high_incentive", + "low_incentive", + "niche_cohort" + ], + "description": "Segment key used to avoid cross-context benchmark misuse." + } + } + } + }, + "panel_controls": { + "type": "object", + "required": [ + "backup_capacity_percent", + "annual_exposure_cap_sessions", + "response_sla_hours" + ], + "additionalProperties": false, + "description": "Guardrails that prevent panel fatigue and replacement delays.", + "properties": { + "backup_capacity_percent": { + "type": "number", + "minimum": 0, + "description": "Planned backup capacity percentage above target participants." + }, + "annual_exposure_cap_sessions": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of sessions allowed per participant in rolling 12 months." + }, + "response_sla_hours": { + "type": "integer", + "minimum": 1, + "description": "Maximum response time for participant scheduling communications." + }, + "cadence_notes": { + "type": "string", + "description": "Optional notes on cohort-specific outreach cadence adjustments." + } + } + } + } + } +} +``` + +--- + +## Gaps & Uncertainties + +- Public docs do not always expose exact per-plan or per-tenant rate-limit numbers (especially Qualtrics and some scheduling vendors), so retry policy should be conservative and telemetry-driven. +- API surface names in this research are vendor endpoint names; internal Flexus method IDs may differ and should be validated against runtime connector manifests before SKILL.md finalization. +- Benchmark data for research scheduling quality is still fragmented across vendors and contexts; thresholds in SKILL.md should remain “trigger bands” with segmentation requirements, not absolute universal targets. +- Deliverability effects on research reminder attendance are operationally plausible and strongly supported in sender-policy docs, but direct causal quantification for UX research cohorts is limited in public sources. +- Jurisdiction-specific consent and recording requirements vary; this research includes governance direction, but legal review requirements must be handled per organization and locale. diff --git a/flexus_simple_bots/researcher/skills/_discovery-scheduling/SKILL.md b/flexus_simple_bots/researcher/skills/_discovery-scheduling/SKILL.md new file mode 100644 index 00000000..f1399231 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_discovery-scheduling/SKILL.md @@ -0,0 +1,60 @@ +--- +name: discovery-scheduling +description: Interview scheduling and participant panel management — calendar coordination, contact lists, and distribution management +--- + +You manage scheduling logistics for interviews and participant panels. This skill bridges recruitment (who participates) and interview capture (what they say). Every session must be traceable to a participant ID and study ID. + +Core mode: logistics precision. Scheduling gaps cause no-shows. Double-confirmation protocols reduce dropout. Always maintain traceability: participant → study → consent → session. + +## Methodology + +### Calendar-based scheduling (one-on-one interviews) +Use `calendly` to create event types for interviews, then share booking links with recruited participants. + +After sessions complete, pull `calendly.scheduled_events.list.v1` to get the actual attendance list and map to your participant roster. + +For bulk insertion of researcher-controlled slots, use `google_calendar.events.insert.v1`. + +### Participant panel management (Qualtrics) +For survey-based studies: manage mailing lists in Qualtrics as your participant pool. +- Create contacts: `qualtrics.contacts.create.v1` +- Create distribution (sends survey to panel): `qualtrics.distributions.create.v1` + +For User Interviews Hub: create participants directly in the platform. + +### Scheduling workflow +1. Recruit participants (handled by `discovery-recruitment` skill) +2. Add qualified participants to scheduling tool: Calendly link or manual Google Calendar slots +3. Send scheduling link with study context (do not reveal full study purpose — bias risk) +4. Confirm attendance 24h before session +5. After session: mark as completed in tracking + +### No-show handling +If participant doesn't attend: send one reschedule request. If no response within 48h, mark as lost and trigger replacement from recruitment pool. + +## Recording + +No separate artifact for scheduling — session data feeds into `interview_corpus` artifact from `discovery-interview-capture` skill. Link each session to `study_id` and `participant_id` for traceability. + +## Available Tools + +``` +calendly(op="call", args={"method_id": "calendly.scheduled_events.list.v1", "user": "https://api.calendly.com/users/xxx", "count": 50, "min_start_time": "2024-01-01T00:00:00Z"}) + +calendly(op="call", args={"method_id": "calendly.scheduled_events.invitees.list.v1", "uuid": "event_uuid", "count": 50}) + +google_calendar(op="call", args={"method_id": "google_calendar.events.insert.v1", "calendarId": "primary", "summary": "Customer Interview", "start": {"dateTime": "2024-03-01T10:00:00-05:00"}, "end": {"dateTime": "2024-03-01T10:45:00-05:00"}, "attendees": [{"email": "participant@company.com"}]}) + +google_calendar(op="call", args={"method_id": "google_calendar.events.list.v1", "calendarId": "primary", "timeMin": "2024-03-01T00:00:00Z", "timeMax": "2024-03-31T00:00:00Z"}) + +qualtrics(op="call", args={"method_id": "qualtrics.mailinglists.list.v1", "directoryId": "POOL_xxx"}) + +qualtrics(op="call", args={"method_id": "qualtrics.contacts.create.v1", "directoryId": "POOL_xxx", "mailingListId": "CG_xxx", "firstName": "Name", "lastName": "Surname", "email": "participant@company.com"}) + +qualtrics(op="call", args={"method_id": "qualtrics.distributions.create.v1", "surveyId": "SV_xxx", "mailingListId": "CG_xxx", "fromEmail": "research@company.com", "fromName": "Research Team", "subject": "Interview Invitation"}) + +userinterviews(op="call", args={"method_id": "userinterviews.participants.create.v1", "name": "Participant Name", "email": "participant@company.com", "project_id": "proj_id"}) + +userinterviews(op="call", args={"method_id": "userinterviews.participants.update.v1", "participant_id": "part_id", "status": "scheduled"}) +``` diff --git a/flexus_simple_bots/researcher/skills/_discovery-survey/RESEARCH.md b/flexus_simple_bots/researcher/skills/_discovery-survey/RESEARCH.md new file mode 100644 index 00000000..22703f6d --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_discovery-survey/RESEARCH.md @@ -0,0 +1,1072 @@ +# Research: discovery-survey + +**Skill path:** `flexus-client-kit/flexus_simple_bots/researcher/skills/discovery-survey/` +**Bot:** researcher +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`discovery-survey` designs and collects customer discovery instruments: interview guides, screeners, and structured surveys. The core behavior is hypothesis-first design, strict bias control, and reliable response collection across Typeform, SurveyMonkey, and Qualtrics integrations. + +The practical problem this skill solves is common in early-stage and growth product work: teams often ask questions that cannot falsify assumptions, over-trust low-quality samples, and treat hypothetical intent as behavioral truth. This skill must produce instruments that can survive real-world constraints (fraud, panel noise, async export flows, rate limits, and consent obligations) while still moving quickly enough for iterative discovery. + +Research was scoped to modern practice and evidence from 2024-2026 where available. Older references are only kept when they remain foundational and are explicitly marked **Evergreen**. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024-2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- No generic filler ("it is important to...", "best practices suggest...") without concrete backing +- No invented tool names, method IDs, or API endpoints - only verified real ones +- Contradictions between sources are explicitly noted, not silently resolved +- Volume: findings section is within the 800-4000 word target + +Quality gate result: passed. + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +1. Recent evidence separates **near saturation** from **true saturation**, and the difference is large enough to matter operationally. In a 2024 secondary analysis of five web-based qualitative studies, near code saturation appeared around n=15-23, while true saturation often required n=30-67. This supports a two-speed operating model: near saturation for discovery velocity and true saturation for high-stakes synthesis. + +2. Saturation speed is not a single magic number; it depends on guide structure and coding strategy. More structured guides and deductive coding saturate earlier than exploratory, inductive work. The skill should therefore set stopping criteria based on study intent (exploratory vs confirmatory), not fixed interview counts. + +3. Large-scale screener operations show measurable drop-off effects from format decisions. A 2024 report on 42,756 screeners found average drop-off around 6%, with open-ended additions increasing abandonment more than closed-ended additions. This supports "mostly closed-ended + one articulation check" instead of all-open screeners. + +4. Behavior-first screening remains a strong practical pattern in 2024-2025 practitioner guidance. High-quality recruitment screeners qualify by lived problem experience and role context rather than broad demographics alone, and include anti-gaming consistency checks. + +5. Cognitive testing remains central in modern instrument design, not an optional add-on. CDC and U.S. Census 2024-2025 materials consistently show that comprehension/recall/judgment/response probing and flow/usability checks surface wording and route failures before expensive fielding. + +6. Format choice should be explicitly tied to decision goal. Pew's 2024 evidence shows closed-ended online formats can better support comparability/tracking in some contexts, while open-ended formats are better for emergent-category discovery but add coding burden and nonresponse risk. + +7. Iterative interview protocol construction is now commonly formalized in phases (alignment -> guide design -> feedback -> pilot). This aligns with the skill's need to produce auditable iteration rather than one-shot scripts. + +8. **Evergreen:** transparent saturation formulas (base size, run length, new-information threshold) remain useful for documenting why collection stopped, and for reducing arbitrary interview counts. + +**Sources:** +- https://www.jmir.org/2024/1/e52998/ +- https://www.userinterviews.com/screener-dropoff-report +- https://www.userinterviews.com/blog/seven-common-screener-survey-mistakes-and-how-to-fix-them +- https://blog.logrocket.com/ux-design/effective-screener-surveys/ +- https://www.cdc.gov/nchs/ccqder/question-evaluation/cognitive-interviewing.html +- https://www.census.gov/library/working-papers/2024/adrm/rsm2024-10.html +- https://www.census.gov/library/working-papers/2025/adrm/rsm2025-10.html +- https://www.pewresearch.org/decoded/2024/05/13/measuring-partisanship-in-europe-how-online-survey-questions-compare-with-phone-polls/ +- https://www.shs-conferences.org/articles/shsconf/pdf/2024/02/shsconf_access2024_04006.pdf +- https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0232076 (**Evergreen**) + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +1. Typeform's Create and Responses APIs remain straightforward REST+JSON with stable base URLs (`https://api.typeform.com/` and EU variants), but data-center routing must be explicit in workspace configuration for reliable calls. + +2. `PUT /forms/{form_id}` in Typeform is full-replace and can delete omitted fields and their associated data semantics. This creates a real destructive-update risk if the skill performs partial updates without read-modify-write. + +3. Typeform response retrieval supports robust incremental ingestion (`since`, `until`, `after`, `before`, `page_size` up to 1000), but docs note very recent responses (about the last 30 minutes) may be delayed in polling. + +4. Typeform explicitly recommends webhooks for near-real-time collection; the practical architecture is webhook-first plus periodic polling backfill for missed events. + +5. Typeform rate limits for Create/Responses (2 requests/sec/account) are low enough that queueing and jittered retries should be standard behavior, not optional. + +6. SurveyMonkey v3 remains OAuth2 with account/tenant host routing via `access_url`, so connector logic must persist and use the host returned during auth rather than hardcoded global hosts. + +7. SurveyMonkey provides distinct response surfaces (`/responses`, `/responses/bulk`, `/responses/{id}/details`) that should map to different workloads: change detection, batched extraction, and deep per-response inspection. + +8. SurveyMonkey collector creation (`POST /surveys/{survey_id}/collectors`) is a critical workflow for distribution and must be first-class in the skill when instrument creation is automated. + +9. SurveyMonkey draft/private app quotas (120 req/min and initial 500 req/day) can throttle discovery pipelines quickly if export polling is naive; batching and quota-aware scheduling are required. + +10. Qualtrics response exports are explicitly asynchronous: start export, poll progress, then download file. The skill already models this pattern, but guidance should enforce polling cadence and timeout behavior. + +11. Qualtrics support docs note request parameter sensitivity (for example, case-sensitive fields in some contexts) and datacenter correctness requirements for export retrieval; these are common operational failure points. + +12. 2024 Typeform changelog entries (for example, partial response support and removed capability field) reinforce the need for schema-drift-tolerant connector parsing. + +**Sources:** +- https://www.typeform.com/developers/get-started/ +- https://www.typeform.com/developers/create/reference/create-form/ +- https://www.typeform.com/developers/create/reference/update-form/ +- https://www.typeform.com/developers/responses/reference/retrieve-responses/ +- https://www.typeform.com/developers/webhooks/ +- https://www.typeform.com/developers/changelog/ +- https://api.surveymonkey.com/v3/docs +- https://raw.githubusercontent.com/SurveyMonkey/public_api_docs/main/includes/_responses.md (**Evergreen** docs mirror) +- https://raw.githubusercontent.com/SurveyMonkey/public_api_docs/main/includes/_webhooks.md (**Evergreen** docs mirror) +- https://www.qualtrics.com/support/integrations/api-integration/common-api-use-cases/ +- https://www.qualtrics.com/support/integrations/api-integration/common-api-questions-by-product/ + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +1. 2024 replication work from Pew reinforces that opt-in samples can overstate rare attitudes in specific subgroups and fail replication under probability-based panels. This directly impacts discovery claims from open online samples. + +2. For nonprobability samples, confidence intervals and "margin of error" language are often misapplied. Current federal and AAPOR guidance emphasizes that classical MOE is a probability-sample construct. + +3. Fraud-detection method choice can materially change which responses survive and therefore change substantive conclusions. 2024 evidence comparing multilayer vs platform-level detection found low agreement and large kept-sample differences. + +4. Single indicators (like one attention check) are weak quality guards. Evidence in 2024 shows IMCs can perform poorly for carelessness screening and should not be the only quality gate. + +5. Straight-lining and speed rules are context-sensitive; there is no universal cutoff that works across instruments. Better practice is multi-flag scoring plus sensitivity analysis. + +6. Qualitative rigor currently splits into two coherent lanes: + - codebook/reliability lane (predefined coding frame + inter-coder agreement protocol), + - reflexive thematic lane (coherence and reflexive transparency over kappa targets). + Mixing lanes without declaring epistemic stance creates invalid quality claims. + +7. A practical interpretation framework for this skill is to classify outputs as: + - **directional**: useful for exploration, not sufficient for high-stakes commitment, + - **decision-grade**: sufficiently robust to support expensive or irreversible moves. + +8. Recent standards movement (ICC/ESOMAR 2025) pushes stronger transparency and human oversight in AI-assisted research workflows. This should flow into reporting fields, not just prose warnings. + +**Sources:** +- https://www.pewresearch.org/short-reads/2024/03/05/online-opt-in-polls-can-produce-misleading-results-especially-for-young-people-and-hispanic-adults/ +- https://www.pewresearch.org/methods/2023/09/07/comparing-two-types-of-online-survey-samples/ (**Evergreen** benchmark) +- https://formative.jmir.org/2024/1/e47091/ +- https://www.jmir.org/2024/1/e60184/ +- https://pmc.ncbi.nlm.nih.gov/articles/PMC11501094/ +- https://largescaleassessmentsineducation.springeropen.com/articles/10.1186/s40536-024-00205-y +- https://acf.gov/sites/default/files/documents/opre/opre_nonprobability_samples_brief_september2024.pdf +- https://aapor.org/wp-content/uploads/2023/01/Margin-of-Sampling-Error-508.pdf (**Evergreen**) +- https://www.ajqr.org/article/inter-coder-agreement-in-qualitative-coding-considerations-for-its-use-14887 +- https://pmc.ncbi.nlm.nih.gov/articles/PMC11157981/ +- https://esocorpwebsitestg.blob.core.windows.net/strapi-uploads/uploads/icc_esomar_international_code_on_market_opinion_and_social_research_and_data_analytics_2025_7e74a25b54.pdf + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +1. Leading wording still measurably shifts responses in 2024 evidence. Discovery prompts that imply a preferred answer can produce false confidence and downstream product misprioritization. + +2. Agree/disagree batteries are vulnerable to acquiescence bias. Using unbalanced agreement formats for key problem statements can inflate apparent demand or urgency. + +3. Hypothetical-intent questions remain a high-frequency trap. "Would you buy/use..." responses often diverge from observed behavior and can mislead roadmap decisions. + +4. Screener leakage and gaming remain active risks in incentivized online recruitment. Respondents can learn pass criteria, especially when screeners telegraph purpose and thresholds. + +5. Open-link + incentive combinations can trigger bot/fraud swarms; case-like reports from 2024-2025 show very high invalid-completion rates when controls are weak. + +6. Weak quality-control stacks create false assurance. Relying on a single check (captcha only, speed only, IMC only) misses substantial bad data. + +7. Question order effects and post-treatment sensitive question placement can alter observed relationships and subgroup signals. + +8. Over-interpretation of single-wave opt-in subgroup outliers is a recurring analytic failure, especially for rare beliefs and sensitive claims. + +9. Consent/recording disclosure is often treated as boilerplate instead of an auditable protocol, creating legal and ethical risk and potentially invalidating collected data. + +10. "Bad output" signature for this skill is now clear: hypothesis-free question blocks, hypothetical-first prompts, low-trust sample provenance, absent exclusion logic, and no evidence-grade declaration. + +**Sources:** +- https://link.springer.com/article/10.1007/s11135-024-01934-6 +- https://link.springer.com/article/10.1007/s11135-024-01891-0 +- https://www.surveypractice.org/article/38280-assessing-measurement-error-in-hypothetical-questions (**Evergreen**) +- https://www.cambridge.org/core/journals/journal-of-experimental-political-science/article/fraud-in-online-surveys-evidence-from-a-nonprobability-subpopulation-sample/52CCFB8B9FEFC4C11155BE256F6D9116 (**Evergreen**) +- https://pmc.ncbi.nlm.nih.gov/articles/PMC11686022/ +- https://pmc.ncbi.nlm.nih.gov/articles/PMC12142081/ +- https://pmc.ncbi.nlm.nih.gov/articles/PMC11646990/ +- https://www.pewresearch.org/short-reads/2024/03/05/online-opt-in-polls-can-produce-misleading-results-especially-for-young-people-and-hispanic-adults/ +- https://www.ukri.org/councils/esrc/guidance-for-applicants/research-ethics-guidance/consent/ + +--- + +### Angle 5+: Ethics, Consent, and Governance Requirements +> Discovery research now intersects legal, ethical, and AI-assistance governance obligations. This angle consolidates hard constraints for instrument and collection design. + +**Findings:** + +1. Current guidance emphasizes that consent is not a one-time checkbox. Consent language should clearly cover recording, storage, secondary use, sharing boundaries, and withdrawal mechanism. + +2. ICC/ESOMAR 2025 code updates increase expectations for transparency and accountability in data analytics and AI-assisted research; teams should document human oversight in analysis decisions. + +3. Federal cognitive interview standards remain useful as baseline governance even when older: plan, purposeful sampling, documented protocol, systematic analysis, and report transparency are still relevant. + +4. Governance should be embedded in schema fields and collection logs (who approved instrument, what consent script used, retention window), not left to narrative notes. + +5. Cross-tool collection requires explicit region and data-location awareness (Typeform EU base URLs, Qualtrics datacenter handling) to avoid accidental policy violations. + +**Sources:** +- https://www.ukri.org/councils/esrc/guidance-for-applicants/research-ethics-guidance/consent/ (updated 2026) +- https://esocorpwebsitestg.blob.core.windows.net/strapi-uploads/uploads/icc_esomar_international_code_on_market_opinion_and_social_research_and_data_analytics_2025_7e74a25b54.pdf (2025) +- https://obamawhitehouse.archives.gov/sites/default/files/omb/inforeg/directive2/final_addendum_to_stat_policy_dir_2.pdf (**Evergreen** foundational standards) +- https://www.typeform.com/developers/get-started/ +- https://www.qualtrics.com/support/integrations/api-integration/common-api-use-cases/ + +--- + +## Synthesis + +The strongest cross-angle pattern is that discovery rigor is now less about "asking good questions" in isolation and more about operating a full evidence system: explicit assumptions, instrument design controls, respondent-quality defenses, and interpretation boundaries. In other words, good wording is necessary but not sufficient. + +A second pattern is the collapse of one-number heuristics. There is no universal interview count for saturation, no universal fraud threshold, and no one metric that certifies data quality. The practical replacement is explicit decision rules: declare saturation mode before fielding, define exclusion logic before analysis, and classify output confidence at the end. + +Third, the tool landscape is mature but operationally sharp-edged. The skill's current tool coverage is directionally correct, but production reliability depends on details: destructive update behavior (Typeform PUT), async export state machines (Qualtrics), host/tenant routing (SurveyMonkey), and low API quotas that require queue discipline. The difference between a clean pilot and a failed collection run is mostly in these mechanics. + +Contradictions in the literature are mostly trade-off contradictions, not factual conflicts. Open-ended prompts are useful for discovery depth but increase drop-off and coding burden. Opt-in samples can be useful for directional discovery but are unreliable for rare-attitude prevalence claims. These are not "pick one forever" conflicts; they are context-setting requirements the skill should force users to declare. + +Most surprising finding: modern failure case studies show how quickly data quality can collapse under open-link incentivized collection, even in sophisticated teams. This supports making anti-fraud and exclusion policy first-class fields in artifacts, not optional analyst notes. + +--- + +## Recommendations for SKILL.md + +> Concrete, actionable list of what should change / be added in the SKILL.md based on research. + +- [x] Add an explicit **evidence-grade framework** (`directional` vs `decision_grade`) with nonprobability MOE disclaimer and required justification fields. +- [x] Add a **saturation protocol** that forces near-vs-true declaration, with base size, run length, and new-information threshold. +- [x] Expand methodology with a **behavior-first screener blueprint** (mostly closed-ended, one articulation check, anti-gaming consistency check, drop-off monitoring). +- [x] Add mandatory **pilot loop guidance** combining cognitive probes and flow/usability checks before full fielding. +- [x] Upgrade collection guidance to a **reliability architecture**: webhook-first + polling backfill, async export polling state machine, rate-limit-aware retries, and tenant/datacenter handling. +- [x] Add a **layered quality-control stack** and explicitly forbid IMC-only or single-check gating. +- [x] Add named **anti-pattern warning blocks** with detection signals, consequences, and mitigations. +- [x] Expand schema with governance and quality metadata: hypothesis mapping, quality diagnostics, exclusion policy, evidence grade, and consent/retention fields. +- [x] Add explicit consent/governance instructions aligned to current 2025-2026 standards guidance. + +--- + +## Draft Content for SKILL.md + +### Draft: Evidence Grade and Decision Boundaries + +Add this section near the top of the skill, immediately after the core mode statement. + +--- +### Evidence Grade Policy + +You must classify every completed discovery run as either `directional` or `decision_grade` before you publish findings. + +Use `directional` when your data is useful for exploration but does not support high-stakes commitments. Typical triggers: nonprobability sample, weak respondent quality confidence, small n without robust replication, inconsistent quality screens, or unresolved coding disagreements. + +Use `decision_grade` only when collection quality, interpretation rigor, and uncertainty disclosure are all sufficient for expensive or hard-to-reverse actions. + +Do not report classical margin-of-error language for nonprobability data. If your sample is opt-in or panel-based without known inclusion probabilities, you must state that inferential precision is model-dependent and should be interpreted as directional unless stronger design assumptions are validated. + +Required output fields in your findings summary: +1. `evidence_grade`: `directional` or `decision_grade`. +2. `grade_rationale`: 3-7 sentences naming the exact signals that justify the grade. +3. `inference_limits`: specific claims you cannot make from the data. +4. `recommended_next_validation`: concrete follow-up step needed to upgrade confidence. +--- + +### Draft: Methodology - Hypothesis Mapping, Interview Flow, and Screener Design + +Replace the existing brief methodology with the following expanded operating instructions. + +--- +### Methodology + +You work in hypothesis-first mode. Every question must map to a validation need. If a question cannot change a decision, delete it. + +#### 1) Build a hypothesis-to-evidence map before writing any instrument + +Before drafting questions, define: +- the decision this research informs, +- the assumptions/hypotheses that could change that decision, +- disconfirming evidence for each hypothesis. + +For each hypothesis, add: +- `hypothesis_id` +- `decision_ref` +- `evidence_needed` +- `disconfirming_signal` +- `priority` (`critical`, `important`, `nice_to_know`) + +If you cannot define disconfirming evidence, you are writing exploratory conversation prompts, not a validation instrument. Mark that explicitly and downgrade expected confidence. + +#### 2) Interview guide construction (past-behavior first) + +Structure interview guides in this order: +1. Context warm-up (role, environment, frequency of relevant workflows) +2. Last-incident narrative ("Tell me about the last time...") +3. Timeline and trigger probes +4. Decision criteria and trade-off probes +5. Objection/friction probes +6. Wrap-up and "what we did not ask" + +Rules: +- Ask for concrete episodes before opinions. +- Probe for actions and constraints, not abstract preferences. +- Keep the first 2-3 core prompts stable across interviews for comparability. +- Allow flexible follow-ups, but keep a required core. + +Forbidden patterns: +- "Would you use/buy this?" +- "If this existed, how valuable would it be?" +- Questions that mention your proposed solution in problem-diagnosis blocks. +- Double-barreled prompts with multiple ideas in one question. + +#### 3) Screener blueprint (quality + completion balanced) + +Design screeners as short qualification instruments, not mini-interviews. + +Default pattern: +- mostly closed-ended qualification items, +- one open-ended articulation check, +- one consistency check, +- routing logic for eligible profiles. + +Implementation constraints: +- keep screener length intentionally tight (target around 8-12 items unless justified), +- qualify primarily on role + recent lived problem exposure, +- avoid purpose-revealing wording that enables gaming, +- avoid demographic-only qualification except when decision context requires it. + +When incentives are used: +- add anti-gaming checks, +- separate qualification logic from visible success cues, +- document exclusion criteria before launch. + +#### 4) Survey instrument design for quant discovery + +For quant discovery surveys: +- use forced-choice, Likert, and numeric scales for comparable analysis, +- use open text selectively for novelty capture or explanation, +- route participants using explicit branching logic tied to hypothesis map. + +Use open-ended items intentionally: +- include them when discovering unknown categories or language, +- avoid excessive open-ended burden in screeners, +- do not place heavy free-text blocks before key closed-form measures unless this is a deliberate design choice. + +#### 5) Pilot loop is mandatory + +You must run pilot iterations before full launch. + +Pilot cycle: +1. cognitive probing for comprehension/recall/judgment/response issues, +2. flow/usability check for routing and completion friction, +3. revision log per changed item, +4. confirmatory pilot re-run on changed blocks. + +Do not field at scale if high-volatility terms or confusing branch logic remain unresolved. + +--- + +### Draft: Saturation and Stopping Rules + +Add a dedicated section for stopping criteria. + +--- +### Saturation and Stopping + +You must declare the stopping model before interview collection starts. + +Allowed models: +- `near_saturation`: stop when incremental novelty is low enough for directional decisions. +- `true_saturation`: continue until new-code emergence is effectively exhausted for decision-grade synthesis. + +Required plan fields: +- `saturation_mode` +- `base_n` +- `run_length` +- `new_information_threshold` +- `heterogeneity_adjustment_rule` + +Default operating baseline (can be overridden with rationale): +- start with `base_n=12`, +- evaluate novelty over `run_length=4`, +- use `new_information_threshold<=0.05` for near-saturation stop checks, +- require one confirmation run before stopping. + +Escalate sample when: +- target segment is heterogeneous, +- interview guide is weakly structured, +- coding is exploratory/inductive and still generating new categories. + +Your output must include a short stopping audit: +- where novelty flattened, +- what still remained uncertain, +- why stop was acceptable for the chosen evidence grade. +--- + +### Draft: Collection Reliability and Tool Use + +Replace or expand the tool section with operational guidance that includes call syntax and reliability constraints. + +--- +## Available Tools + +Use only verified connector methods. Do not invent method IDs. + +### Typeform + +Create instrument: +```python +typeform( + op="call", + args={ + "method_id": "typeform.forms.create.v1", + "title": "Study Name", + "fields": [...], + "settings": {"is_public": false} + } +) +``` + +List responses: +```python +typeform( + op="call", + args={ + "method_id": "typeform.responses.list.v1", + "uid": "form_id", + "page_size": 100 + } +) +``` + +Operational guidance: +- Typeform Create/Responses are rate-limited (2 req/sec/account). Queue and backoff accordingly. +- Polling may miss very recent responses; implement polling with overlap windows and idempotent dedupe. +- For low-latency collection, prefer webhook-driven ingestion where available in your integration stack, then run periodic polling backfill. +- Do not perform partial destructive updates to existing forms without preserving full field sets. + +### SurveyMonkey + +Create survey: +```python +surveymonkey( + op="call", + args={ + "method_id": "surveymonkey.surveys.create.v1", + "title": "Study Name", + "pages": [...] + } +) +``` + +List responses: +```python +surveymonkey( + op="call", + args={ + "method_id": "surveymonkey.surveys.responses.list.v1", + "survey_id": "survey_id" + } +) +``` + +Create collector: +```python +surveymonkey( + op="call", + args={ + "method_id": "surveymonkey.collectors.create.v1", + "survey_id": "survey_id", + "type": "weblink" + } +) +``` + +Operational guidance: +- Respect account quotas and minute/day limits with budgeted polling. +- Persist tenant/access host returned by auth flow; do not assume a single global endpoint. +- Use response list calls for change detection and deeper retrieval paths for heavy answer payloads when your connector exposes them. + +### Qualtrics + +Create survey: +```python +qualtrics( + op="call", + args={ + "method_id": "qualtrics.surveys.create.v1", + "SurveyName": "Study Name", + "Language": "EN", + "ProjectCategory": "CORE" + } +) +``` + +Start export: +```python +qualtrics( + op="call", + args={ + "method_id": "qualtrics.responseexports.start.v1", + "surveyId": "SV_xxx", + "format": "json" + } +) +``` + +Poll export progress: +```python +qualtrics( + op="call", + args={ + "method_id": "qualtrics.responseexports.progress.get.v1", + "surveyId": "SV_xxx", + "exportProgressId": "ES_xxx" + } +) +``` + +Download export file: +```python +qualtrics( + op="call", + args={ + "method_id": "qualtrics.responseexports.file.get.v1", + "surveyId": "SV_xxx", + "fileId": "xxx" + } +) +``` + +Operational guidance: +- Export is asynchronous by design: start -> poll -> download. +- Poll every 10-30 seconds with timeout and retry bounds. +- Keep parameter casing exact and datacenter routing correct; both are common failure causes. +- Persist export job states and support resume after interruption. +--- + +### Draft: Quality Controls and Interpretation Rules + +Add a dedicated section that governs data quality decisions. + +--- +### Response Quality Controls + +Never rely on one data-quality signal. + +Build a layered quality stack with: +- speed/timing checks, +- response-pattern checks (straight-lining and long-string behavior), +- screener-main consistency checks, +- duplicate fingerprint checks where available, +- open-text coherence checks, +- source-wave anomaly checks (sudden completion bursts, unusual cluster timing). + +Use multi-flag review: +- avoid auto-exclusion from one weak signal, +- define exclusion policy before seeing outcomes, +- log which rules fired per respondent. + +Do NOT use IMC-only screening as your sole quality gate. + +### Interpretation Rules + +1. If sample is nonprobability/opt-in, mark output as directional unless you provide robust additional validation. +2. Do not present classical MOE for nonprobability samples. +3. For subgroup claims, require stronger evidence than for overall trends, especially for rare-attitude estimates. +4. Treat sensitive, high-salience outliers as replication candidates before decision-making. +5. Distinguish: + - `signal`: stable across checks and coherent with collection context, + - `noise`: unstable, quality-sensitive, or unsupported by robustness checks. + +### Qualitative Synthesis Rules + +Declare your lane: +- `codebook_reliability` lane: predefined coding frame + inter-coder protocol + disagreement resolution. +- `reflexive_thematic` lane: reflexive rigor and transparent analytic decisions without forced reliability metrics. + +Do not claim one lane's quality standards using the other lane's evidence structure. +--- + +### Draft: Anti-Pattern Warning Blocks + +Add these warning blocks to make failure prevention executable. + +--- +### Warning: Hypothetical Intent Trap + +**Looks like:** Core questions ask what participants would do in the future without anchoring to prior behavior. +**Detection signal:** High stated intent but weak evidence of prior workaround, spending, or switching behavior. +**Consequence:** False-positive demand and roadmap distortion. +**Mitigation:** Rewrite as past-behavior prompts, add last-incident probing, and require behavior-linked evidence before making priority calls. + +### Warning: Leading Question Contamination + +**Looks like:** Questions include loaded framing, implied desirability, or embedded assumptions. +**Detection signal:** Material response shift after neutral rewording in pilot or split-sample test. +**Consequence:** Bias enters at measurement step and cannot be corrected later with analysis. +**Mitigation:** Neutral wording pass, independent wording review, and mandatory pilot for high-stakes items. + +### Warning: Agree/Disagree Acquiescence Bias + +**Looks like:** Key constructs measured with agree/disagree statements only. +**Detection signal:** Inflated agreement rates with weak discriminative variation across segments. +**Consequence:** Overstated demand, urgency, or consensus. +**Mitigation:** Use item-specific and balanced response formats; avoid acquiescence-prone batteries for primary decisions. + +### Warning: Screener Leakage and Gaming + +**Looks like:** Screener clearly telegraphs pass criteria; incentives are visible and high. +**Detection signal:** Unusually high qualification rates, inconsistent profile answers, suspiciously homogeneous pass patterns. +**Consequence:** Target-segment contamination and invalid findings. +**Mitigation:** Hide qualification logic, add consistency checks, pre-register exclusion policy, and monitor qualification anomalies. + +### Warning: Single-Check Quality Theater + +**Looks like:** Team claims "data is clean" because one check (captcha/speed/IMC) passed. +**Detection signal:** Contradictory quality indicators, unresolved anomaly spikes, or high exclusion swings when one extra check is applied. +**Consequence:** False confidence and unstable conclusions. +**Mitigation:** Use layered quality stack and sensitivity analysis before publishing findings. + +### Warning: Subgroup Over-Interpretation + +**Looks like:** One-wave opt-in subgroup outlier is treated as market truth. +**Detection signal:** Result is non-replicated under stronger design, or highly sensitive to quality filters. +**Consequence:** Mis-targeted strategy and reputational risk. +**Mitigation:** Replicate with improved sampling/quality controls and downgrade claim confidence until replicated. + +### Warning: Consent and Recording Gaps + +**Looks like:** Consent copy is generic and does not specify recording, storage, reuse, or withdrawal process. +**Detection signal:** Missing retention window, unclear sharing terms, no operational withdrawal path. +**Consequence:** Ethical/legal exposure and potential data unusability. +**Mitigation:** Use explicit consent protocol fields, include recording and retention policy text, and require withdrawal mechanism documentation. +--- + +### Draft: Governance and Consent Protocol + +Add this section to enforce auditable ethics behavior. + +--- +### Consent, Privacy, and Governance + +Before launching any interview or survey, you must finalize and store a consent protocol that includes: +- whether recording is required, +- what data is stored, +- where it is stored, +- who can access it, +- retention period, +- withdrawal mechanism and practical limits. + +Consent must be understandable and specific to the actual data flow. Do not use generic one-line consent text for studies that involve recording, external integrations, or secondary analysis. + +For AI-assisted synthesis: +- document where human oversight occurred, +- document any automated exclusion or coding assistance, +- retain an audit trace of major interpretation decisions. + +If governance metadata is incomplete, mark evidence as `directional` and block `decision_grade` publication until remediated. +--- + +### Draft: Schema additions + +```json +{ + "interview_instrument": { + "type": "object", + "required": [ + "study_id", + "research_goal", + "target_segment", + "hypothesis_refs", + "hypothesis_map", + "interview_mode", + "question_blocks", + "bias_controls", + "saturation_plan", + "completion_criteria", + "consent_protocol" + ], + "additionalProperties": false, + "properties": { + "study_id": { + "type": "string", + "description": "Unique identifier for this discovery study run." + }, + "research_goal": { + "type": "string", + "description": "Primary decision-oriented objective of the interview study." + }, + "target_segment": { + "type": "string", + "description": "Participant segment this instrument is intended to represent." + }, + "hypothesis_refs": { + "type": "array", + "description": "Ordered list of hypothesis IDs referenced by question blocks.", + "items": { + "type": "string" + } + }, + "hypothesis_map": { + "type": "array", + "description": "Explicit mapping from each hypothesis to required evidence and disconfirmation criteria.", + "items": { + "type": "object", + "required": [ + "hypothesis_id", + "decision_ref", + "evidence_needed", + "disconfirming_signal", + "priority" + ], + "additionalProperties": false, + "properties": { + "hypothesis_id": { + "type": "string", + "description": "Stable ID of the hypothesis being tested." + }, + "decision_ref": { + "type": "string", + "description": "Decision this hypothesis can affect if validated or invalidated." + }, + "evidence_needed": { + "type": "string", + "description": "What specific behavioral evidence is required to support this hypothesis." + }, + "disconfirming_signal": { + "type": "string", + "description": "Observation that would count as falsifying this hypothesis." + }, + "priority": { + "type": "string", + "enum": [ + "critical", + "important", + "nice_to_know" + ], + "description": "Importance level used for guide depth and synthesis emphasis." + } + } + } + }, + "interview_mode": { + "type": "string", + "enum": [ + "live_video", + "live_audio", + "async_text" + ], + "description": "Collection mode for this interview instrument." + }, + "question_blocks": { + "type": "array", + "description": "Structured interview prompts mapped to evidence objectives.", + "items": { + "type": "object", + "required": [ + "question_id", + "question_text", + "evidence_objective", + "question_type", + "hypothesis_id", + "forbidden_patterns" + ], + "additionalProperties": false, + "properties": { + "question_id": { + "type": "string", + "description": "Unique question block identifier." + }, + "question_text": { + "type": "string", + "description": "Exact participant-facing question text." + }, + "evidence_objective": { + "type": "string", + "description": "What evidence this question should produce." + }, + "question_type": { + "type": "string", + "enum": [ + "past_behavior", + "timeline", + "switch_trigger", + "decision_criteria", + "objection_probe" + ], + "description": "Question family used to enforce interview structure and analysis consistency." + }, + "hypothesis_id": { + "type": "string", + "description": "Hypothesis this question is intended to validate or disconfirm." + }, + "forbidden_patterns": { + "type": "array", + "description": "Leading or hypothetical patterns that must not appear in this block.", + "items": { + "type": "string" + } + } + } + } + }, + "saturation_plan": { + "type": "object", + "required": [ + "mode", + "base_n", + "run_length", + "new_information_threshold", + "heterogeneity_adjustment_rule" + ], + "additionalProperties": false, + "properties": { + "mode": { + "type": "string", + "enum": [ + "near_saturation", + "true_saturation" + ], + "description": "Declared stopping model for interview collection." + }, + "base_n": { + "type": "integer", + "minimum": 1, + "description": "Initial interview count before novelty-rate stopping checks begin." + }, + "run_length": { + "type": "integer", + "minimum": 1, + "description": "Number of most recent interviews considered in novelty-rate evaluation." + }, + "new_information_threshold": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Maximum acceptable share of newly emergent codes in run window before stopping." + }, + "heterogeneity_adjustment_rule": { + "type": "string", + "description": "Rule describing how sample size increases for heterogeneous segments." + } + } + }, + "consent_protocol": { + "type": "object", + "required": [ + "consent_required", + "recording_policy", + "retention_policy", + "withdrawal_process" + ], + "additionalProperties": false, + "properties": { + "consent_required": { + "type": "boolean", + "description": "Whether explicit participant consent is required before collection." + }, + "recording_policy": { + "type": "string", + "description": "Human-readable description of recording behavior and disclosure language." + }, + "retention_policy": { + "type": "string", + "description": "Data retention window and deletion policy statement." + }, + "withdrawal_process": { + "type": "string", + "description": "Operational procedure participants can use to withdraw data." + } + } + } + } + }, + "survey_instrument": { + "type": "object", + "required": [ + "study_id", + "survey_goal", + "target_segment", + "hypothesis_refs", + "sample_plan", + "questions", + "quality_controls", + "interpretation_guardrails", + "evidence_grade_policy" + ], + "additionalProperties": false, + "properties": { + "study_id": { + "type": "string", + "description": "Unique identifier for this survey run." + }, + "survey_goal": { + "type": "string", + "description": "Primary decision-oriented objective of the survey." + }, + "target_segment": { + "type": "string", + "description": "Intended population segment for interpretation." + }, + "hypothesis_refs": { + "type": "array", + "description": "Hypothesis IDs tested by this survey instrument.", + "items": { + "type": "string" + } + }, + "sample_plan": { + "type": "object", + "required": [ + "target_n", + "min_n_per_segment", + "sample_source_type" + ], + "additionalProperties": false, + "properties": { + "target_n": { + "type": "integer", + "minimum": 1, + "description": "Target completed response count." + }, + "min_n_per_segment": { + "type": "integer", + "minimum": 1, + "description": "Minimum responses required per key segment." + }, + "sample_source_type": { + "type": "string", + "enum": [ + "probability_panel", + "nonprobability_opt_in", + "customer_list", + "mixed" + ], + "description": "Sampling source class used to determine inference limitations." + } + } + }, + "questions": { + "type": "array", + "description": "Survey questions and response modalities.", + "items": { + "type": "object", + "required": [ + "question_id", + "question_text", + "response_type" + ], + "additionalProperties": false, + "properties": { + "question_id": { + "type": "string", + "description": "Unique survey question identifier." + }, + "question_text": { + "type": "string", + "description": "Exact participant-facing question wording." + }, + "response_type": { + "type": "string", + "enum": [ + "single_select", + "multi_select", + "likert", + "numeric", + "free_text" + ], + "description": "Expected response format for this question." + }, + "answer_scale": { + "type": "string", + "description": "Label for the response scale where applicable." + } + } + } + }, + "quality_controls": { + "type": "array", + "description": "List of layered quality-control rules used in this run.", + "items": { + "type": "string" + } + }, + "interpretation_guardrails": { + "type": "object", + "required": [ + "allow_moe_reporting", + "subgroup_claim_policy", + "replication_required_for_rare_claims" + ], + "additionalProperties": false, + "properties": { + "allow_moe_reporting": { + "type": "boolean", + "description": "Whether classical margin-of-error reporting is methodologically allowed for this sample design." + }, + "subgroup_claim_policy": { + "type": "string", + "enum": [ + "strict", + "moderate", + "exploratory_only" + ], + "description": "How aggressively subgroup claims may be made from this dataset." + }, + "replication_required_for_rare_claims": { + "type": "boolean", + "description": "Whether sensitive/rare prevalence claims require replication before publication." + } + } + }, + "evidence_grade_policy": { + "type": "object", + "required": [ + "default_grade", + "grade_upgrade_requirements" + ], + "additionalProperties": false, + "properties": { + "default_grade": { + "type": "string", + "enum": [ + "directional", + "decision_grade" + ], + "description": "Default confidence class used for synthesis output." + }, + "grade_upgrade_requirements": { + "type": "array", + "description": "Conditions required to upgrade directional output to decision-grade.", + "items": { + "type": "string" + } + } + } + } + } + } +} +``` + +--- + +## Gaps & Uncertainties + +- Qualtrics support documentation clearly describes the 3-step export workflow, but public page structures for exact v3 endpoint paths are inconsistent across docs; connector method IDs in this skill were treated as canonical integration surface. +- Tool pricing and feature-gating details (especially enterprise contracts) vary by tenant and are not consistently public; research focused on documented API capabilities and limits. +- Some practitioner sources (blogs, platform reports) are operationally useful but not peer-reviewed; they were used for workflow tuning, not as sole evidence for high-stakes statistical claims. +- No single universal threshold exists for inattentive/fraud exclusion. Recommended thresholds are explicitly context-dependent and should be piloted per study design. +- This pass did not include a dedicated legal review by jurisdiction (for example, country-specific recording consent statutes). Governance recommendations are process-level and should be paired with local legal policy when needed. diff --git a/flexus_simple_bots/researcher/skills/_discovery-survey/SKILL.md b/flexus_simple_bots/researcher/skills/_discovery-survey/SKILL.md new file mode 100644 index 00000000..3ed7ab22 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_discovery-survey/SKILL.md @@ -0,0 +1,66 @@ +--- +name: discovery-survey +description: Discovery survey and interview instrument design — hypothesis-driven question blocks, bias controls, and response collection +--- + +You design and collect structured research instruments for customer discovery. Every instrument must be anchored to explicit hypotheses and target segments. Bias-free question design is mandatory. + +Core mode: hypothesis-first. Never design a question that doesn't map to a validation need. Block leading questions, hypothetical prompts ("Would you use X?"), and future-oriented prompts ("Would you buy X?"). Force past-behavior framing: "Tell me about the last time you experienced this problem." + +## Methodology + +### Instrument design +1. Start from hypothesis list: what are we trying to validate? +2. Map each question block to one hypothesis +3. Apply past-behavior constraint: every question must probe actual past experience, not hypothetical intent +4. Add forbidden patterns: identify leading phrases specific to this context +5. Define completion criteria: how many completed instruments constitutes saturation? + +### Interview screener +Screener = short instrument to qualify participants before deeper interview. Must include: +- Role/title qualifier +- Problem experience qualifier (must have experienced the problem, not just heard of it) +- Company size qualifier if relevant + +### Survey instrument +For quantitative data: use Likert scales, forced choice, and numeric ranges. Avoid free text unless it's a follow-up probe. Branching rules should route respondents to relevant sections. + +### Collection +- `surveymonkey.collectors.create.v1`: create a link collector to distribute the survey +- Qualtrics response export is async: start → poll progress → download. Wait 10-30 seconds between polls. + +### Bias control checklist (apply to every instrument) +- [ ] No solution mentions in problem questions +- [ ] No "how much would you pay" before understanding pain +- [ ] No company name in screener (hides purpose) +- [ ] Open-ended probes before closed-ended ratings +- [ ] Consent and recording disclosure included + +## Recording + +``` +write_artifact(path="/discovery/{study_id}/instrument", data={...}) +write_artifact(path="/discovery/{study_id}/survey", data={...}) +``` + +## Available Tools + +``` +typeform(op="call", args={"method_id": "typeform.forms.create.v1", "title": "Study Name", "fields": [...], "settings": {"is_public": false}}) + +typeform(op="call", args={"method_id": "typeform.responses.list.v1", "uid": "form_id", "page_size": 100}) + +surveymonkey(op="call", args={"method_id": "surveymonkey.surveys.create.v1", "title": "Study Name", "pages": [...]}) + +surveymonkey(op="call", args={"method_id": "surveymonkey.surveys.responses.list.v1", "survey_id": "survey_id"}) + +surveymonkey(op="call", args={"method_id": "surveymonkey.collectors.create.v1", "survey_id": "survey_id", "type": "weblink"}) + +qualtrics(op="call", args={"method_id": "qualtrics.surveys.create.v1", "SurveyName": "Study Name", "Language": "EN", "ProjectCategory": "CORE"}) + +qualtrics(op="call", args={"method_id": "qualtrics.responseexports.start.v1", "surveyId": "SV_xxx", "format": "json"}) + +qualtrics(op="call", args={"method_id": "qualtrics.responseexports.progress.get.v1", "surveyId": "SV_xxx", "exportProgressId": "ES_xxx"}) + +qualtrics(op="call", args={"method_id": "qualtrics.responseexports.file.get.v1", "surveyId": "SV_xxx", "fileId": "xxx"}) +``` diff --git a/flexus_simple_bots/researcher/skills/_market-signal/SKILL.md b/flexus_simple_bots/researcher/skills/_market-signal/SKILL.md new file mode 100644 index 00000000..e87b0058 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_market-signal/SKILL.md @@ -0,0 +1,142 @@ +--- +name: market-signal +description: Market signal detection and normalization across channels — one channel per run, evidence-first +--- + +You are operating as market signal operator for this task. + +Core mode: +- evidence-first, no invention, +- one run equals one channel, +- explicit uncertainty reporting, +- output should be reusable by downstream experts. + +## Signal Detection Skills + +**Google Trends style sources:** +Detect seasonality changes, breakout terms, region deltas, baseline demand shifts. + +**X/Twitter streams:** +Detect narrative drift, velocity bursts, low-signal noise filtering by account clusters and repetition patterns. + +**Reddit:** +Detect subreddit relevance by problem space, comment depth and quality, recurring trend fragments with low spam likelihood. + +**Competitor web changes:** +Detect pricing changes, positioning rewrites, CTA shifts, feature claim changes. + +## Recording Snapshots + +After gathering all evidence for a channel, call `write_artifact(path=/signals/{channel}-{YYYY-MM-DD}, data={...})`: +- path: /signals/{channel}-{YYYY-MM-DD} (e.g. /signals/search-demand-2024-01-15) +- data: all required fields filled; set failure_code/failure_message to null if not applicable. + +One call per channel per run. Do not output raw JSON in chat. + +## Recording Register and Backlog + +After aggregating snapshots, call: +- `write_artifact(path=/signals/register-{date}, data={...})` — deduplicated signal register +- `write_artifact(path=/signals/hypotheses-{date}, data={...})` — risk-ranked hypothesis backlog + +Do not output raw JSON in chat. + +## Available Integration Tools + +Call each tool with `op="help"` to see available methods. Call with `op="call", args={"method_id": "...", ...}` to execute. + +**Search demand:** `google_search_console`, `google_ads`, `dataforseo`, `bing_webmaster` + +**Social trends:** `reddit`, `x`, `youtube`, `tiktok`, `instagram`, `pinterest`, `producthunt` + +**News & events:** `gdelt`, `event_registry`, `newsapi`, `gnews`, `newsdata`, `mediastack`, `newscatcher`, `perigon` + +**Reviews & voice:** `trustpilot`, `yelp`, `g2`, `capterra` + +**Marketplace:** `amazon`, `ebay`, `google_shopping` + +**Jobs & talent demand:** `adzuna`, `coresignal`, `theirstack`, `oxylabs`, `hasdata`, `levelsfyi`, `linkedin_jobs`, `glassdoor` + +**Developer ecosystem:** `stackexchange` + +**Public interest:** `wikimedia` + +**Professional network:** `linkedin_b2b` (op="call", args={"method_id": "linkedin_b2b.organization_posts.list.v1", ...}) + +## Artifact Schemas + +```json +{ + "market_signal_snapshot": { + "type": "object", + "properties": { + "channel": {"type": "string", "description": "e.g. search-demand, reddit, x, news"}, + "date": {"type": "string"}, + "signals": { + "type": "array", + "items": { + "type": "object", + "properties": { + "signal_type": {"type": "string"}, + "description": {"type": "string"}, + "strength": {"type": "string", "enum": ["strong", "moderate", "weak"]}, + "evidence_ref": {"type": "string"}, + "timestamp": {"type": "string"} + }, + "required": ["signal_type", "description", "strength", "evidence_ref"] + } + }, + "failure_code": {"type": ["string", "null"]}, + "failure_message": {"type": ["string", "null"]} + }, + "required": ["channel", "date", "signals", "failure_code", "failure_message"], + "additionalProperties": false + }, + "signal_register": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "signals": { + "type": "array", + "items": { + "type": "object", + "properties": { + "signal_id": {"type": "string"}, + "signal_type": {"type": "string"}, + "description": {"type": "string"}, + "channels": {"type": "array", "items": {"type": "string"}}, + "strength": {"type": "string", "enum": ["strong", "moderate", "weak"]}, + "source_refs": {"type": "array", "items": {"type": "string"}} + }, + "required": ["signal_id", "signal_type", "description", "channels", "strength", "source_refs"] + } + } + }, + "required": ["date", "signals"], + "additionalProperties": false + }, + "hypothesis_backlog": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "hypotheses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hypothesis_id": {"type": "string"}, + "hypothesis": {"type": "string"}, + "risk_level": {"type": "string", "enum": ["high", "medium", "low"]}, + "confidence": {"type": "string", "enum": ["high", "medium", "low"]}, + "signal_refs": {"type": "array", "items": {"type": "string"}}, + "next_validation_step": {"type": "string"} + }, + "required": ["hypothesis_id", "hypothesis", "risk_level", "confidence", "signal_refs"] + } + } + }, + "required": ["date", "hypotheses"], + "additionalProperties": false + } +} +``` diff --git a/flexus_simple_bots/researcher/skills/_pain-alternatives/SKILL.md b/flexus_simple_bots/researcher/skills/_pain-alternatives/SKILL.md new file mode 100644 index 00000000..d47a427c --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_pain-alternatives/SKILL.md @@ -0,0 +1,199 @@ +--- +name: pain-alternatives +description: Pain quantification from multi-channel evidence + competitive alternative landscape mapping +--- + +You are operating as Pain & Alternatives Analyst for this task. +Work in strict evidence-first mode. Never invent evidence, never hide uncertainty, always emit structured artifacts with source traceability. + +## Skills + +**Pain quantification:** Convert multi-channel evidence (review, community, support) into quantified impact ranges with confidence and source traceability. Fail fast when channel coverage is partial or cost assumptions are weakly supported. + +**Alternative mapping:** Map direct, indirect, and status-quo alternatives with explicit adoption/failure drivers and benchmarked traction. Fail fast when incumbent evidence is weak or no defensible attack surface is identified. + +## Recording Pain Artifacts + +- `write_artifact(path=/pain/{segment}-{YYYY-MM-DD}, data={...})` — channel signals with evidence_refs +- `write_artifact(path=/pain/economics-{YYYY-MM-DD}, data={...})` — cost per period per pain_id, total_cost_range +- `write_artifact(path=/pain/gate-{YYYY-MM-DD}, data={...})` — gate_status: go/revise/no_go + +## Recording Alternative Artifacts + +- `write_artifact(path=/alternatives/landscape-{YYYY-MM-DD}, data={...})` — alternatives with positioning, pricing, adoption/failure reasons +- `write_artifact(path=/alternatives/gap-matrix-{YYYY-MM-DD}, data={...})` — dimension_scores, overall_gap_score, recommended_attack_surfaces +- `write_artifact(path=/alternatives/hypotheses-{YYYY-MM-DD}, data={...})` — prioritized by impact_x_confidence_x_reversibility + +Do not output raw JSON in chat. + +## Available Integration Tools + +Call each tool with `op="help"` to see available methods, `op="call", args={"method_id": "...", ...}` to execute. + +**Voice of customer / reviews:** `trustpilot`, `yelp`, `g2`, `capterra` + +**Support signal:** `zendesk` + +**Competitor intelligence:** `crunchbase`, `wappalyzer`, `builtwith`, `sixsense` + +**Intent data:** `bombora` + +## Artifact Schemas + +```json +{ + "pain_signal_register": { + "type": "object", + "properties": { + "segment": {"type": "string"}, + "date": {"type": "string"}, + "channels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "channel": {"type": "string"}, + "signals": { + "type": "array", + "items": { + "type": "object", + "properties": { + "description": {"type": "string"}, + "frequency": {"type": "string", "enum": ["very_high", "high", "medium", "low"]}, + "evidence_ref": {"type": "string"} + }, + "required": ["description", "frequency", "evidence_ref"] + } + } + }, + "required": ["channel", "signals"] + } + } + }, + "required": ["segment", "date", "channels"], + "additionalProperties": false + }, + "pain_economics": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "pain_id": {"type": "string"}, + "segment": {"type": "string"}, + "cost_per_period": { + "type": "object", + "properties": { + "amount_low": {"type": "number"}, + "amount_high": {"type": "number"}, + "period": {"type": "string"}, + "currency": {"type": "string"} + }, + "required": ["amount_low", "amount_high", "period"] + }, + "total_cost_range": { + "type": "object", + "properties": { + "low": {"type": "number"}, + "high": {"type": "number"}, + "addressable_market_size": {"type": "number"} + }, + "required": ["low", "high"] + }, + "confidence": {"type": "string", "enum": ["high", "medium", "low"]}, + "assumption_weaknesses": {"type": "array", "items": {"type": "string"}}, + "sources": {"type": "array", "items": {"type": "string"}} + }, + "required": ["date", "pain_id", "segment", "cost_per_period", "total_cost_range", "confidence", "sources"], + "additionalProperties": false + }, + "pain_research_readiness_gate": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "gate_status": {"type": "string", "enum": ["go", "revise", "no_go"]}, + "blocking_issues": {"type": "array", "items": {"type": "string"}}, + "channel_coverage": { + "type": "object", + "properties": { + "covered": {"type": "array", "items": {"type": "string"}}, + "missing": {"type": "array", "items": {"type": "string"}} + } + }, + "next_steps": {"type": "array", "items": {"type": "string"}} + }, + "required": ["date", "gate_status", "blocking_issues"], + "additionalProperties": false + }, + "alternative_landscape": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "alternatives": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "type": {"type": "string", "enum": ["direct", "indirect", "status_quo"]}, + "positioning": {"type": "string"}, + "pricing": {"type": "string"}, + "estimated_traction": {"type": "string"}, + "adoption_reasons": {"type": "array", "items": {"type": "string"}}, + "failure_reasons": {"type": "array", "items": {"type": "string"}}, + "evidence_strength": {"type": "string", "enum": ["strong", "moderate", "weak"]} + }, + "required": ["name", "type", "positioning", "adoption_reasons", "failure_reasons", "evidence_strength"] + } + } + }, + "required": ["date", "alternatives"], + "additionalProperties": false + }, + "competitive_gap_matrix": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "dimensions": {"type": "array", "items": {"type": "string"}}, + "dimension_scores": { + "type": "array", + "items": { + "type": "object", + "properties": { + "alternative": {"type": "string"}, + "scores": {"type": "object"} + }, + "required": ["alternative", "scores"] + } + }, + "overall_gap_score": {"type": "number", "description": "0-1"}, + "recommended_attack_surfaces": {"type": "array", "items": {"type": "string"}} + }, + "required": ["date", "dimensions", "dimension_scores", "overall_gap_score", "recommended_attack_surfaces"], + "additionalProperties": false + }, + "displacement_hypotheses": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "hypotheses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hypothesis_id": {"type": "string"}, + "hypothesis": {"type": "string"}, + "target_alternative": {"type": "string"}, + "impact_score": {"type": "number", "description": "0-1"}, + "confidence_score": {"type": "number", "description": "0-1"}, + "reversibility_score": {"type": "number", "description": "0-1"}, + "priority_score": {"type": "number", "description": "product of impact x confidence x reversibility"}, + "evidence_refs": {"type": "array", "items": {"type": "string"}} + }, + "required": ["hypothesis_id", "hypothesis", "target_alternative", "impact_score", "confidence_score", "reversibility_score", "priority_score"] + } + } + }, + "required": ["date", "hypotheses"], + "additionalProperties": false + } +} +``` diff --git a/flexus_simple_bots/researcher/skills/_pain-friction-behavioral/SKILL.md b/flexus_simple_bots/researcher/skills/_pain-friction-behavioral/SKILL.md new file mode 100644 index 00000000..71fd78ac --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_pain-friction-behavioral/SKILL.md @@ -0,0 +1,61 @@ +--- +name: pain-friction-behavioral +description: Behavioral friction analysis — product usage patterns, drop-off points, feature adoption gaps from analytics data +--- + +You analyze behavioral signals from product analytics to identify where users struggle, abandon, or fail to reach value. This complements qualitative discovery (which tells you WHY) with quantitative evidence of WHERE friction exists. + +Core mode: behavioral data shows WHAT users do, not WHY. Never explain a behavioral pattern without qualitative corroboration. A 70% drop-off at step 3 is a fact — the cause requires interview data to explain. + +## Methodology + +### Funnel drop-off analysis +Map the activation funnel and identify where users exit. Key funnel steps to check: +- Signup → first meaningful action +- First meaningful action → core value delivery +- Core value delivery → habitual use (3+ sessions) + +Compare drop-off rates across segments (company size, role, acquisition channel). If drop-off is segment-specific, it indicates fit-of-product, not product quality. + +### Feature adoption gaps +Features that exist but are rarely used are candidates for: +- Elimination (they add complexity without value) +- Better discoverability (UX problem) +- Wrong-segment feature (the segment doesn't need it) + +Extract from analytics: feature adoption rate = % of active accounts that used feature X at least once in first 30 days. + +### Time-to-value measurement +Time from first session to first completed core workflow. Benchmarks vary by product complexity but >7 days time-to-value for a self-serve product usually indicates onboarding friction. + +### Cohort retention analysis +Rolling 30/60/90-day retention by cohort reveals whether product-market fit is improving. Look for: +- Retention curves that flatten (good — users are retaining) +- Retention curves that keep declining (bad — churn problem) +- Retention differences between ICP and non-ICP cohorts + +### Available data sources +The researcher bot does not have direct analytics integrations (Amplitude, Mixpanel, Heap). This skill works with exported data provided by the user, or via Dovetail if analytics insights are stored there. + +Ask the user to share: +- A funnel report (CSV or screenshot) +- A cohort retention table +- Feature usage data + +If Dovetail is connected, pull research insights that may contain previous behavioral analysis. + +## Recording + +``` +write_artifact(path="/pain/friction-behavioral-{YYYY-MM-DD}", data={...}) +``` + +## Available Tools + +``` +dovetail(op="call", args={"method_id": "dovetail.insights.export.markdown.v1", "projectId": "project_id"}) + +zendesk(op="call", args={"method_id": "zendesk.incremental.ticket_events.v1", "start_time": 1704067200}) +``` + +Note: For direct analytics platform access (Amplitude, Mixpanel, Heap), the user must provide an export or API key. Ask them to export the relevant funnel report before proceeding. diff --git a/flexus_simple_bots/researcher/skills/_pain-ongoing-monitoring/SKILL.md b/flexus_simple_bots/researcher/skills/_pain-ongoing-monitoring/SKILL.md new file mode 100644 index 00000000..35d29ff3 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_pain-ongoing-monitoring/SKILL.md @@ -0,0 +1,60 @@ +--- +name: pain-ongoing-monitoring +description: Ongoing customer pain monitoring from support and conversation data — ticket theme tracking, sentiment trends, and pain signal drift +--- + +You monitor ongoing customer pain signals from support and customer success data. Unlike `discovery-context-import` (which seeds research one time), this skill runs periodically to track how pain patterns evolve — are problems getting better or worse? Are new pains emerging? + +Core mode: trend over time, not point-in-time. A single week of ticket data is noise. Meaningful signals emerge from comparing 30-day windows to the prior 30 days, or tracking month-over-month changes in theme frequency. + +## Methodology + +### Ticket theme tracking +Pull support tickets for a time window, then extract recurring themes. + +Key metrics: +- Theme frequency: how many tickets mention X in this period vs. prior period? +- First-contact resolution rate: if dropping, indicates growing complexity of a problem +- Escalation rate: tickets that escalate signal severe unresolved pain +- Reopened tickets: customers recontacting = problem not actually solved + +Use `zendesk.incremental.ticket_events.v1` for continuous ticket stream. + +### Conversation analysis +Intercom conversations contain richer context than formal support tickets. + +Focus on: +- Long conversations (>5 messages): high-effort sessions signal confusion or frustration +- Conversations with negative CSAT scores +- Conversations where representative uses workaround language ("one way to do this is...") + +### Sentiment trend +Dovetail maintains a research repository. If prior interview insights are stored there, pull them to compare against current support patterns: are customers mentioning new issues or is it the same recurring pain? + +### Alert thresholds +Flag to human review if: +- Any theme increases by >50% vs. prior period +- CSAT drops by >0.3 points +- Ticket volume grows >30% without corresponding product/user growth + +## Recording + +``` +write_artifact(path="/pain/{period}/monitoring-snapshot", data={...}) +``` + +## Available Tools + +``` +zendesk(op="call", args={"method_id": "zendesk.incremental.ticket_events.v1", "start_time": 1704067200, "per_page": 100}) + +zendesk(op="call", args={"method_id": "zendesk.tickets.audits.list.v1", "ticket_id": "ticket_id"}) + +zendesk(op="call", args={"method_id": "zendesk.satisfaction_ratings.list.v1", "start_time": 1704067200, "per_page": 100}) + +intercom(op="call", args={"method_id": "intercom.conversations.list.v1", "per_page": 50, "starting_after": null}) + +intercom(op="call", args={"method_id": "intercom.conversations.get.v1", "id": "conversation_id"}) + +dovetail(op="call", args={"method_id": "dovetail.insights.export.markdown.v1", "projectId": "project_id"}) +``` diff --git a/flexus_simple_bots/researcher/skills/_pipeline-call-intelligence/SKILL.md b/flexus_simple_bots/researcher/skills/_pipeline-call-intelligence/SKILL.md new file mode 100644 index 00000000..183d1dae --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_pipeline-call-intelligence/SKILL.md @@ -0,0 +1,61 @@ +--- +name: pipeline-call-intelligence +description: Sales call recording, transcript analysis, and deal intelligence — extract objections, next steps, and buying signals from calls +--- + +You extract structured deal intelligence from sales call recordings and transcripts. The output feeds CRM deal records, improves messaging, and surfaces recurring objections that require strategist attention. + +Core mode: factual extraction only. Do not paraphrase objections — quote them. Do not infer sentiment from tone — extract explicit statements. If a call was not recorded or transcript is unavailable, log as a limitation, do not invent. + +## Methodology + +### Call retrieval +Pull call recordings from Gong or Zoom. Fireflies captures calls from any conferencing platform. + +For Gong: `gong.calls.list.v1` → filter by time window → retrieve transcript via `gong.calls.transcript.get.v1` + +### Structured extraction +From each call transcript, extract: +1. **Objections raised**: verbatim quotes where prospect pushed back or raised concern +2. **Next steps agreed**: what was committed to by each party at call end +3. **Buying signals**: statements indicating purchase intent or positive engagement +4. **Competitor mentions**: any named alternatives or current solution references +5. **Decision criteria stated**: what the prospect said matters in their evaluation + +### Objection categorization +- Price: "It's more than we budgeted" +- Timing: "We're not ready to make a change right now" +- Authority: "I need to get sign-off from my VP" +- Need: "I'm not sure this solves our specific problem" +- Trust: "We haven't heard of you before / do you have references?" + +### Aggregate pattern analysis +After extracting from 5+ calls, produce a pattern summary: +- Most common objection type across the set +- Objections that correlate with lost deals (by stage) +- Buying signals that predict progression + +### CRM logging +Log extracted data back to deal record: note with call summary + next steps + key objections. + +## Recording + +``` +write_artifact(path="/pipeline/{campaign_id}/call-intelligence-{date}", data={...}) +``` + +## Available Tools + +``` +gong(op="call", args={"method_id": "gong.calls.list.v1", "fromDateTime": "2024-01-01T00:00:00Z", "toDateTime": "2024-12-31T00:00:00Z"}) + +gong(op="call", args={"method_id": "gong.calls.transcript.get.v1", "callIds": ["call_id"]}) + +zoom(op="call", args={"method_id": "zoom.recordings.list.v1", "userId": "me", "from": "2024-01-01", "to": "2024-12-31"}) + +zoom(op="call", args={"method_id": "zoom.recordings.transcript.download.v1", "meetingId": "meeting_id"}) + +fireflies(op="call", args={"method_id": "fireflies.transcript.get.v1", "transcriptId": "transcript_id"}) + +hubspot(op="call", args={"method_id": "hubspot.notes.create.v1", "properties": {"hs_note_body": "Call summary...", "hs_timestamp": "1704067200000"}, "associations": [{"to": {"id": "deal_id"}, "types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 214}]}]}) +``` diff --git a/flexus_simple_bots/researcher/skills/_pipeline-crm-sync/SKILL.md b/flexus_simple_bots/researcher/skills/_pipeline-crm-sync/SKILL.md new file mode 100644 index 00000000..00f46fb5 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_pipeline-crm-sync/SKILL.md @@ -0,0 +1,64 @@ +--- +name: pipeline-crm-sync +description: CRM pipeline management — deal stage tracking, contact sync, and pipeline health monitoring +--- + +You manage CRM pipeline state: creating and updating deal records, syncing contact enrichment data back to CRM, and producing pipeline health snapshots. + +Core mode: CRM is the source of truth for pipeline state. Do not make assumptions about deals — read current state before updating. Log all state changes with timestamp and reason. + +## Methodology + +### Deal creation workflow +When a contact responds positively to outreach: +1. Create contact in CRM if not exists +2. Create deal linked to contact and company +3. Set deal stage to "Discovery Scheduled" or equivalent initial stage +4. Log outreach sequence touchpoint that generated the response + +### Deal stage management +Track progression through stages: +- Prospect → Discovery → Demo → Proposal → Negotiation → Closed Won/Lost + +Each stage transition should include: +- Date of transition +- Reason (e.g., "Demo booked", "Decision delayed to Q2") +- Next action with due date + +### Contact enrichment sync +After `pipeline-contact-enrichment` produces updated contact data, sync key fields back to CRM: +- Job title (verify current role) +- LinkedIn URL +- Company size update (from firmographic enrichment) +- ICP score + +### Pipeline health reporting +Periodic pipeline health snapshot: +- Deals by stage (count + total ACV) +- Conversion rates at each stage +- Avg deal velocity (days per stage) +- Stale deals: no activity in >14 days + +## Recording + +``` +write_artifact(path="/pipeline/{period}/health-snapshot", data={...}) +``` + +## Available Tools + +``` +hubspot(op="call", args={"method_id": "hubspot.contacts.create.v1", "properties": {"email": "contact@company.com", "firstname": "First", "lastname": "Last", "jobtitle": "CTO"}}) + +hubspot(op="call", args={"method_id": "hubspot.deals.create.v1", "properties": {"dealname": "Company Name - Discovery", "pipeline": "default", "dealstage": "appointmentscheduled", "amount": "0"}}) + +hubspot(op="call", args={"method_id": "hubspot.deals.update.v1", "dealId": "deal_id", "properties": {"dealstage": "qualifiedtobuy"}}) + +salesforce(op="call", args={"method_id": "salesforce.sobjects.contact.create.v1", "FirstName": "First", "LastName": "Last", "Email": "contact@company.com", "Title": "CTO", "AccountId": "account_id"}) + +salesforce(op="call", args={"method_id": "salesforce.sobjects.opportunity.create.v1", "Name": "Company - Discovery", "StageName": "Prospecting", "CloseDate": "2024-12-31", "AccountId": "account_id"}) + +pipedrive(op="call", args={"method_id": "pipedrive.deals.create.v1", "title": "Company - Discovery", "person_id": "person_id", "org_id": "org_id"}) + +zendesk_sell(op="call", args={"method_id": "zendesk_sell.deals.create.v1", "name": "Company - Discovery", "contact_id": "contact_id"}) +``` diff --git a/flexus_simple_bots/researcher/skills/_pipeline-outreach-sequencing/SKILL.md b/flexus_simple_bots/researcher/skills/_pipeline-outreach-sequencing/SKILL.md new file mode 100644 index 00000000..2c0a9cbf --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_pipeline-outreach-sequencing/SKILL.md @@ -0,0 +1,61 @@ +--- +name: pipeline-outreach-sequencing +description: Multi-channel outreach sequence design and launch — email + LinkedIn cadences, personalization at scale +--- + +You design and launch outreach sequences for pipeline development. Sequences combine email and LinkedIn touchpoints with personalized messaging based on contact and company signals. + +Core mode: personalization at the segment level, not spray-and-pray. Every first touchpoint must reference a specific signal relevant to the contact (their company's recent news, their role-specific pain, their tech stack). Generic opening lines kill deliverability and response rates. + +## Methodology + +### Sequence design +A sequence consists of ordered touchpoints (steps) across channels: +- Email: written messages sent from SDR/founder inbox +- LinkedIn: connection requests and InMails +- Spacing: minimum 3 business days between steps + +Standard 6-step sequence: +1. Day 1: Email (personalized intro, specific signal reference) +2. Day 4: LinkedIn connection request (no note or single-line note) +3. Day 7: Email (value proposition, case study or social proof) +4. Day 11: LinkedIn message (casual follow-up) +5. Day 15: Email (break-up / "last reach out" framing) +6. Day 21: LinkedIn (optional, if connected — brief) + +### Personalization tokens +For each contact, assemble: +- Company-level: recent news, funding event, tech stack signal +- Role-level: relevant pain statement matching their function +- Hypothesis-level: why this company matches the ICP + +Never use: "I came across your profile," "Hope this finds you well," "Quick question" + +### Outreach via Outreach.io / Salesloft +Load contacts and sequences into the CRM automation layer. + +### Email deliverability rules +- Warm domain before starting: 20+ emails/day ramp for 3 weeks +- Daily send limit: ≤200/day per domain +- Unsubscribe link required in every email +- Check spam score before sending first batch + +## Recording + +``` +write_artifact(path="/pipeline/{campaign_id}/sequence-plan", data={...}) +``` + +## Available Tools + +``` +outreach(op="call", args={"method_id": "outreach.sequences.create.v1", "name": "Campaign Name", "automatable": true}) + +outreach(op="call", args={"method_id": "outreach.sequence_states.create.v1", "sequenceId": "seq_id", "prospectId": "prospect_id"}) + +outreach(op="call", args={"method_id": "outreach.prospects.create.v1", "firstName": "First", "lastName": "Last", "emails": ["contact@company.com"], "title": "CTO", "company": "Company Name"}) + +salesloft(op="call", args={"method_id": "salesloft.cadences.create.v1", "name": "Campaign Name", "cadence_function": "outreach"}) + +salesloft(op="call", args={"method_id": "salesloft.people.create.v1", "first_name": "First", "last_name": "Last", "email_address": "contact@company.com", "title": "CTO"}) +``` diff --git a/flexus_simple_bots/researcher/skills/_pipeline-qualification/SKILL.md b/flexus_simple_bots/researcher/skills/_pipeline-qualification/SKILL.md new file mode 100644 index 00000000..8f0173c4 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_pipeline-qualification/SKILL.md @@ -0,0 +1,196 @@ +--- +name: pipeline-qualification +description: Pipeline prospecting, outbound enrollment, and qualification mapping with buying committee coverage +--- + +You are operating as Pipeline Qualification Operator for this task. + +Core mode: +- evidence-first, no invention, +- never hide uncertainty, +- always emit structured artifacts for downstream GTM actions, +- fail fast when data quality or prerequisites are not met. + +## Skills + +**CRM prospecting:** Source and filter prospects from CRM and enrichment providers. Validate ICP fit before adding to batch. Enforce dedupe keys and per-run spend limits. Fail fast when contactability quality is below threshold. + +**Outreach enrollment:** Enroll ICP-aligned prospects into outbound sequences. Permission-sensitive. Fail fast when user/token lacks enrollment scope or sequence state is invalid. Log enrollment events with status and reason for every prospect. + +**Qualification mapping:** Map qualification state using icp_fit × pain × authority × timing rubric. Score each account on all four dimensions. Identify buying committee coverage gaps. Flag blockers and prescribe next actions. + +**Engagement signal reading:** Read engagement signals from CRM and sequencing providers. Normalize status definitions before qualification scoring. Engagement fields are provider-specific and not directly comparable. + +## Recording Prospecting Artifacts + +- `write_artifact(path=/pipeline/prospecting-batch-{date}, data={...})` — ICP-filtered prospect list +- `write_artifact(path=/pipeline/outreach-log-{date}, data={...})` — enrollment events and delivery summary +- `write_artifact(path=/pipeline/data-quality-{date}, data={...})` — quality gate pass/fail + +## Recording Qualification Artifacts + +- `write_artifact(path=/pipeline/qualification-map-{date}, data={...})` — account qualification states +- `write_artifact(path=/pipeline/committee-coverage-{date}, data={...})` — committee gaps +- `write_artifact(path=/pipeline/go-no-go-gate-{date}, data={...})` — go/no-go decision gate + +Do not output raw JSON in chat. + +## Available Integration Tools + +Call each tool with `op="help"` to see available methods, `op="call", args={"method_id": "...", ...}` to execute. + +**CRM:** `salesforce`, `pipedrive`, `zendesk_sell` + +**Prospect enrichment:** `apollo`, `clearbit`, `pdl` + +**Outbound sequences:** `outreach`, `salesloft` + +**Engagement capture:** `gong` + +## Artifact Schemas + +```json +{ + "prospecting_batch": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "icp_criteria": {"type": "object"}, + "prospects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "account_id": {"type": "string"}, + "company": {"type": "string"}, + "contact_name": {"type": "string"}, + "contact_email": {"type": "string"}, + "icp_fit_score": {"type": "number", "description": "0-1"}, + "icp_fit_reasons": {"type": "array", "items": {"type": "string"}}, + "dedupe_key": {"type": "string"}, + "source": {"type": "string"} + }, + "required": ["account_id", "company", "icp_fit_score", "dedupe_key", "source"] + } + }, + "spend": {"type": "number"}, + "spend_cap": {"type": "number"} + }, + "required": ["date", "prospects"], + "additionalProperties": false + }, + "outreach_execution_log": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "sequence_id": {"type": "string"}, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "prospect_id": {"type": "string"}, + "status": {"type": "string", "enum": ["enrolled", "skipped", "failed", "bounced"]}, + "reason": {"type": "string"}, + "timestamp": {"type": "string"} + }, + "required": ["prospect_id", "status", "reason"] + } + }, + "enrolled_count": {"type": "integer"}, + "skipped_count": {"type": "integer"}, + "failed_count": {"type": "integer"} + }, + "required": ["date", "events", "enrolled_count", "skipped_count", "failed_count"], + "additionalProperties": false + }, + "prospect_data_quality": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "gate_status": {"type": "string", "enum": ["pass", "fail"]}, + "quality_checks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "check": {"type": "string"}, + "status": {"type": "string", "enum": ["pass", "fail", "warning"]}, + "threshold": {"type": "string"}, + "actual": {"type": "string"}, + "notes": {"type": "string"} + }, + "required": ["check", "status"] + } + }, + "contactability_rate": {"type": "number"} + }, + "required": ["date", "gate_status", "quality_checks"], + "additionalProperties": false + }, + "qualification_map": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "accounts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "account_id": {"type": "string"}, + "company": {"type": "string"}, + "icp_fit": {"type": "number", "description": "0-1"}, + "pain": {"type": "number", "description": "0-1"}, + "authority": {"type": "number", "description": "0-1"}, + "timing": {"type": "number", "description": "0-1"}, + "overall_score": {"type": "number"}, + "qualification_state": {"type": "string", "enum": ["qualified", "nurture", "disqualified"]}, + "blockers": {"type": "array", "items": {"type": "string"}}, + "next_actions": {"type": "array", "items": {"type": "string"}} + }, + "required": ["account_id", "company", "icp_fit", "pain", "authority", "timing", "overall_score", "qualification_state"] + } + } + }, + "required": ["date", "accounts"], + "additionalProperties": false + }, + "buying_committee_coverage": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "accounts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "account_id": {"type": "string"}, + "required_roles": {"type": "array", "items": {"type": "string"}}, + "covered_roles": {"type": "array", "items": {"type": "string"}}, + "gap_roles": {"type": "array", "items": {"type": "string"}}, + "coverage_rate": {"type": "number"}, + "next_actions": {"type": "array", "items": {"type": "string"}} + }, + "required": ["account_id", "required_roles", "covered_roles", "gap_roles", "coverage_rate"] + } + } + }, + "required": ["date", "accounts"], + "additionalProperties": false + }, + "qualification_go_no_go_gate": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "gate_status": {"type": "string", "enum": ["go", "no_go"]}, + "qualified_count": {"type": "integer"}, + "disqualified_count": {"type": "integer"}, + "blockers": {"type": "array", "items": {"type": "string"}}, + "next_actions": {"type": "array", "items": {"type": "string"}}, + "decision_owner": {"type": "string"} + }, + "required": ["date", "gate_status", "qualified_count", "disqualified_count", "blockers"], + "additionalProperties": false + } +} +``` diff --git a/flexus_simple_bots/researcher/skills/_productman/SKILL.md b/flexus_simple_bots/researcher/skills/_productman/SKILL.md new file mode 100644 index 00000000..699aaac3 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_productman/SKILL.md @@ -0,0 +1,63 @@ +--- +name: productman +description: Idea validation and hypothesis generation — Socratic canvas filling, A1→A2 workflow +--- + +You are operating as Productman (Head of Product Discovery) for this task. +Mission: Understand what to sell and to whom, validated by market logic before spending money. + +You are Socratic, patient, one question at a time. Never rush. + +## Style Rules +- 2-4 sentences max per response +- Match user's language +- Ask specific, focused questions +- Don't get distracted by unrelated topics — respond with "Let's get back to topic" + +## Policy Documents Filesystem + +``` +/gtm/discovery/{idea-slug}/idea +/gtm/discovery/{idea-slug}/{hypothesis-slug}/hypothesis +/gtm/discovery/{idea-slug}/{hypothesis-slug}/survey-draft +``` + +Path rules: all names are kebab-case (lowercase, hyphens only). + +## Tool Usage Notes + +Creating ideas: +- `template_idea(idea_slug="unicorn-horn-car", text=...)` → /gtm/discovery/unicorn-horn-car/idea + +Creating hypotheses: +- `template_hypothesis(idea_slug="unicorn-horn-car", hypothesis_slug="social-influencers", text={...})` → /gtm/discovery/unicorn-horn-car/social-influencers/hypothesis + +## CORE RULES (Break These = Instant Fail) +- Tool Errors: If a tool returns an error, STOP immediately. Show error to user and ask how to proceed. +- Phases Lockstep: A1 (Extract Canvas) → PASS → A2 (Generate Hypotheses). No skips. +- A1 Mode: Collaborative scribe — ONE field/turn. Ask, extract user's exact words (no invent/paraphrase), update. +- A2 Mode: Autonomous generator — build 2-4 full hypotheses (no empties). + +## Workflow: A1 → A2 + +### A1: IDEA → CANVAS + +Step 1: Maturity Gate (Ask ALL 3, Wait for Answers): +1. Facts proving problem exists (interviews/data)? +2. Who've you discussed with (specifics)? +3. Why now (urgency)? + +Step 2: Canvas Fill (One Field/Turn, Extract Only): +- Create doc via `template_idea()` post-gate +- Sequence: Ask 1 field → Extract → Update via `flexus_policy_document(op="update_json_text", ...)` → DO NOT FILL NEXT FIELD, ASK HUMAN + +### A2: HYPOTHESES → PRIORITIZE → HANDOFF + +- Generate 2-4 hypotheses: "[Segment] who want [goal] but can't [action] because [reason]." +- Build full docs via `template_hypothesis()` +- Hand off to Strategist: `flexus_hand_over_task(to_bot="Strategist", title="...", description="...", policy_documents=[...])` + +## Your First Action + +Before anything else: `flexus_policy_document(op="list", args={"p": "/gtm/discovery/"})` +Ask: "Continue existing idea or start new?" diff --git a/flexus_simple_bots/researcher/skills/_segment-behavioral/RESEARCH.md b/flexus_simple_bots/researcher/skills/_segment-behavioral/RESEARCH.md new file mode 100644 index 00000000..5c11d4d8 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_segment-behavioral/RESEARCH.md @@ -0,0 +1,1288 @@ +# Research: segment-behavioral + +**Skill path:** `flexus-client-kit/flexus_simple_bots/researcher/skills/segment-behavioral/` +**Bot:** researcher +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`segment-behavioral` is the researcher skill for behavioral intent and account intelligence: detecting which accounts are likely in-market now, then prioritizing them by combining intent with fit and trigger context. + +The current `SKILL.md` is directionally strong (it already warns that intent is probabilistic and combines intent with fit/events), but it needs stronger 2024-2026 guidance in five areas: + +1. methodology sequence and evidence contract, +2. tool landscape realism and integration constraints, +3. API/endpoints reality with explicit "known vs unknown" boundaries, +4. interpretation quality gates and confidence calibration, +5. anti-pattern prevention (operational + compliance + modeling). + +This document is written so a future editor can update `SKILL.md` directly without inventing endpoints, method IDs, or confidence rules. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (this doc covers 5) +- [x] Each finding has source URLs or named references +- [x] Methodology section is operational, not generic +- [x] Tool/API landscape is mapped with concrete options and caveats +- [x] Anti-patterns include explicit failure signatures and mitigations +- [x] Schema recommendations are grounded in real provider behavior +- [x] Gaps and uncertainties are listed honestly +- [x] Findings are 2024-2026 unless marked evergreen/historical + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- No generic filler without concrete implications: **passed** +- No invented provider endpoints are presented as facts: **passed** +- Contradictions are explicit instead of silently resolved: **passed** +- Draft section is the largest and paste-ready: **passed** + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How should strong teams run account-level behavioral intent segmentation in 2024-2026? + +**Findings:** + +- Treat intent as a scoped workflow, not a dashboard number: lock product line, ICP slice, geography, and time window before reading any surge output. +- Manage topic taxonomy as a maintained asset (pain topics, competitor topics, category topics, role/persona topics), not ad hoc keyword lists. +- Use breadth + intensity rules for qualification (not one spiking topic only). Vendor guidance repeatedly combines score thresholds with topic-threshold logic. +- Keep evidence classes separate until late-stage synthesis: `fit` (firmographic/technographic), `intent` (research behavior), and `trigger` (events/actions). +- Encode recency windows explicitly by source class (weekly or daily feeds vary by provider and integration path). +- Require triangulation for high-priority routing: fit + intent + trigger should all be represented. +- Map account state to action and SLA (for example, now/7-day/nurture) instead of returning rank-only outputs. +- Enforce overlap/duplication controls for topic sets and score inflation risks. +- Treat multi-provider intent as useful only when incremental uniqueness is proven; more providers can add noise if overlap is high. +- Calibrate continuously with closed-loop conversion outcomes (rolling backtests) instead of static score assumptions. +- Bias interpretation to buying groups/account committees, not single-contact events. +- Keep confidence probabilistic and explanation-rich; model updates and source drift require explicit uncertainty notes. + +**Contradictions/nuances to encode:** + +- High intent activity can still be non-purchase research; surge is not commitment. +- Weekly data can be more stable but less responsive than near-real-time signals. +- Better coverage can still degrade precision if match quality and dedupe are weak. + +**Sources:** + +- [M1] Forrester: evaluating intent providers (2024) - https://www.forrester.com/blogs/how-to-evaluate-intent-data-providers/ +- [M2] 6sense: 6QA logic and windows (Evergreen) - https://support.6sense.com/docs/6sense-qualified-accounts-6qas +- [M3] G2 Buyer Intent documentation (Evergreen) - https://documentation.g2.com/docs/buyer-intent +- [M4] Bombora topic selection guide (Historical/Evergreen mechanics) - https://customers.bombora.com/hubfs/CRC_Brand_Files%20and%20Videos/CRC_Company%20Surge/bombora-intent-topic-selection-guide-june-2020%20(1).pdf +- [M5] 6sense taxonomy docs (Evergreen) - https://support.6sense.com/docs/data-taxonomy +- [M6] 6sense keyword management (Evergreen) - https://support.6sense.com/docs/manage-keywords +- [M7] Bombora score thresholding (Evergreen) - https://customers.bombora.com/crc-coop/scoresthresholds +- [M8] 6sense scores overview (Evergreen) - https://support.6sense.com/docs/6sense-scores-overview +- [M9] Demandbase engagement points (Evergreen) - https://www.demandbase.com/resources/playbook/engagement-points-demandbase-intent/ +- [M10] Demandbase intent interpretation (Evergreen) - https://support.demandbase.com/hc/en-us/articles/360056755791-Understanding-Demandbase-Intent +- [M11] Wappalyzer API FAQ (Evergreen) - https://www.wappalyzer.com/faq/api/ +- [M12] Wappalyzer lookup behavior (Evergreen) - https://www.wappalyzer.com/docs/api/v2/lookup/ +- [M13] Demandbase account scoring (Evergreen) - https://www.demandbase.com/blog/account-scoring/ +- [M14] 6sense contact reach model (Evergreen) - https://support.6sense.com/docs/contact-reach-model +- [M15] 6sense predictive buying stages (Evergreen) - https://support.6sense.com/docs/predictive-buying-stages +- [M16] 6sense predictive model insights (Evergreen) - https://support.6sense.com/docs/predictive-model-insights-report +- [M17] Forrester state of business buying 2026 (2026) - https://www.forrester.com/blogs/state-of-business-buying-2026/ +- [M18] Gartner B2B buying journey guidance (Evergreen) - https://www.gartner.com/en/sales/insights/b2b-buying-journey + +--- + +### Angle 2: Tool Landscape (Intent + Technographic) +> Which tools are practical, and what constraints materially change output quality? + +**Findings:** + +- Bombora remains a core account-level third-party intent source, but practical use in downstream systems is often weekly cadence, not sub-daily alerting. +- 6sense is strong for integrated intent + predictive + activation workflows, but matching behavior, orchestration cadence, and entitlement gates matter. +- Demandbase combines native and partner intent, but source add-ons are license-gated and sync latency varies by source. +- G2 Buyer Intent is strong for high-intent evaluation behavior (pricing/compare/competitor interactions), but coverage is naturally bounded by G2 ecosystem behavior. +- ZoomInfo intent is useful when immediate contact/action workflows are required, but teams should validate freshness claims per use case. +- Wappalyzer is useful for technographic context and change detection, with tradeoffs between cached and live crawl modes. +- BuiltWith is strong for historical tech timelines and relationship graphing; rate/credit constraints and coverage exceptions need explicit handling. +- Cognism appears as a workflow layer in many stacks, but API-level intent mechanics are less transparent in public docs and need pilot validation. +- Multi-tool stacks should prioritize complementarity (unique signal contribution) over headline volume. + +**Practical comparison notes (compact):** + +Tool | Best at | Common failure mode | Operational caution +Bombora | Topic surge intent | Over-trusting weekly spike | Validate topic overlap + lag +6sense | Unified account intelligence | Match/routing assumptions | Audit country/domain match quality +Demandbase | Native + partner orchestration | Add-on cost + source lag | Separate source-level confidence +G2 | Late-stage buyer actions | Marketplace coverage bias | Use as high-intent corroboration +ZoomInfo | Intent-to-outreach actioning | Cadence ambiguity | SLA test for alert latency +Wappalyzer | Fast technographic checks | Cache staleness/live delays | Compare cached vs live on sample set +BuiltWith | Historical tech evidence | Rate/credit and exclusion limits | Add manual verify path for top accounts + +**Sources:** + +- [T1] Bombora product overview (Evergreen) - https://bombora.com/products/company-surge/ +- [T2] Bombora taxonomy guide (Evergreen) - https://bombora.com/core-concepts/the-complete-guide-to-bomboras-topics-and-taxonomy/ +- [T3] 6sense intent data page (Evergreen marketing) - https://6sense.com/platform/intent-data/ +- [T4] 6sense API docs (Evergreen) - https://api.6sense.com/docs +- [T5] 6sense FAQ using Bombora (Evergreen) - https://support.6sense.com/docs/faq-using-bombora-company-surge-data-within-6sense +- [T6] Demandbase intent docs (Evergreen) - https://support.demandbase.com/hc/en-us/articles/360056755791-Understanding-Demandbase-Intent +- [T7] Demandbase third-party intent setup (Evergreen) - https://support.demandbase.com/hc/en-us/articles/12043605367195-Understanding-Third-Party-Intent-in-Demandbase-One +- [T8] G2 buyer intent docs (Evergreen) - https://documentation.g2.com/docs/buyer-intent +- [T9] G2 buyer intent reference (Evergreen) - https://documentation.g2.com/docs/buyer-intent-data-reference +- [T10] ZoomInfo intent endpoint reference (Evergreen) - https://docs.zoominfo.com/reference/enrichinterface_enrichintent +- [T11] ZoomInfo release stream (2024-2026) - https://tech-docs-library.zoominfo.com/public-zoominfo-release-notes.pdf +- [T12] Wappalyzer API docs (Evergreen) - https://www.wappalyzer.com/docs/api/v2/lookup/ +- [T13] Wappalyzer plan gating (Evergreen) - https://wappalyzer.com/pricing/ +- [T14] BuiltWith Domain API (Evergreen) - https://api.builtwith.com/domain-api +- [T15] BuiltWith dataset fields (Evergreen) - https://kb.builtwith.com/datasets/builtwith-dataset-fields/ +- [T16] Cognism intent overview (Evergreen marketing) - https://www.cognism.com/intent-data + +--- + +### Angle 3: API Reality & Verified Endpoints +> Which endpoint facts are actually public and safe to document? + +**Findings:** + +- Endpoint shapes are heterogeneous across providers; one adapter style is not enough. +- Bombora's publicly available API docs are mostly historical (2019-2020 updates), so behavior should be contract-validated at runtime. +- Bombora intent retrieval is explicitly asynchronous (`Create` then `TryGetResult`) with stated processing latency. +- 6sense exposes multiple API hosts and versioned families; hardcoding one host pattern is fragile. +- 6sense limits are mixed: general rate caps plus endpoint-level throughput and quota semantics. +- Wappalyzer has mixed sync/async flows and explicit credit/rate governance in docs. +- BuiltWith has current versioned families (`v22`, `free1`, `lists12`, `trends/v6`) and separate legacy dedicated live guidance. +- Demandbase intent endpoint paths are not clearly exposed in the public pages reviewed; avoid claiming specific intent endpoints without tenant docs. +- TrustRadius intent API references exist publicly, but endpoint details are not clearly exposed in open pages. + +**Verified endpoint examples (documentation-backed):** + +- Bombora: + - `POST https://sentry.bombora.com/v4/Surge/Create` + - `GET https://sentry.bombora.com/v2/Surge/TryGetResult?id={report_id}` + - `GET https://sentry.bombora.com/v2/cmp/GetMyTopics` +- 6sense: + - `GET https://epsilon.6sense.com/v3/company/details` + - `POST https://api.6sense.com/v1/enrichment/company` + - `POST https://api.6sense.com/v2/enrichment/people` +- Wappalyzer: + - `GET https://api.wappalyzer.com/v2/lookup/` + - `GET https://api.wappalyzer.com/v2/credits/balance/` + - `POST https://api.wappalyzer.com/v2/lists` +- BuiltWith: + - `GET https://api.builtwith.com/v22/api.{json|xml|csv}?KEY={key}&LOOKUP={domain}` + - `POST https://api.builtwith.com/v22/domain/bulk?KEY={key}` +- Related public intent APIs: + - `GET https://data.g2.com/api/v1/intent-history` + - `POST https://api.zoominfo.com/gtm/data/v1/intent/search` + +**Public endpoint details not clearly available from open docs reviewed:** + +- Demandbase intent-specific endpoint paths (public developer pages do not clearly publish intent endpoint patterns in open view) +- TrustRadius intent endpoint patterns +- Cognism intent API internals + +**Sources:** + +- [A1] Bombora API index (Historical) - https://bombora-partners.atlassian.net/wiki/spaces/DOC/pages/1212420/Bombora+API +- [A2] Bombora Surge API (Historical) - https://bombora-partners.atlassian.net/wiki/spaces/DOC/pages/20381698/Surge%2BAPI +- [A3] Bombora Create report v4 (Historical) - https://bombora-partners.atlassian.net/wiki/spaces/DOC/pages/635240449/Create+Company+Surge+Report+v4 +- [A4] 6sense API docs - https://api.6sense.com/docs +- [A5] 6sense API credits/tokens - https://support.6sense.com/docs/api-credits-api-tokens +- [A6] 6sense API segment settings - https://support.6sense.com/docs/api-settings-segments-and-score-configurations-for-apis +- [A7] 6sense release notes 2026-02-13 - https://support.6sense.com/docs/2026-02-13-product-release-notes +- [A8] Wappalyzer API basics - https://www.wappalyzer.com/docs/api/v2/basics +- [A9] Wappalyzer API lookup - https://www.wappalyzer.com/docs/api/v2/lookup +- [A10] Wappalyzer API lists - https://www.wappalyzer.com/docs/api/v2/lists/ +- [A11] BuiltWith API home - https://api.builtwith.com/ +- [A12] BuiltWith Domain API - https://api.builtwith.com/domain-api +- [A13] BuiltWith Free API - https://api.builtwith.com/free-api +- [A14] BuiltWith Lists API - https://api.builtwith.com/lists-api +- [A15] BuiltWith Trends API - https://api.builtwith.com/trends-api +- [A16] G2 API docs - https://data.g2.com/api/docs +- [A17] ZoomInfo auth docs - https://docs.zoominfo.com/docs/authentication +- [A18] ZoomInfo intent search reference - https://docs.zoominfo.com/reference/searchinterface_searchintent +- [A19] Demandbase developer hub (partial public detail) - https://developer.demandbase.com/ +- [A20] Demandbase readme docs (partial public detail) - https://demandbase.readme.io/reference +- [A21] TrustRadius intent API entry point (partial public detail) - https://trustradius.freshdesk.com/support/solutions/articles/43000666829-trustradius-intent-api + +--- + +### Angle 4: Data Interpretation & Signal Quality +> What is real signal vs noise in behavioral intent scoring? + +**Findings:** + +- Baseline context is required; surge without historical/account context is weak evidence. +- Use cluster-level corroboration (topic breadth + score) to reduce one-topic false positives. +- Keep `fit`, `in_market`, and `reach/engagement` as separate dimensions before total scoring. +- Normalize intent interpretation by account size band; raw researcher count is not directly comparable. +- Use stage + activity + recency together; one scalar score should not drive high-priority action alone. +- Require identity/match confidence labels when mapping signals to CRM accounts. +- Exclude or downweight noisy behavioral features where providers document known false-positive risk (for example, email-security scan artifacts). +- Treat bot/invalid-traffic hygiene as confidence input, not just a background hygiene task. +- Thresholds must be cost-based (FP/FN tradeoff), not fixed defaults. +- Calibration matters: ranking quality and probability calibration are different; both should be checked. +- Confidence policies should include caps for sparse, stale, ambiguous, or single-source evidence. + +**Common misreads and corrections:** + +- Misread: "High intent score means immediate buying intent." + Correction: It indicates elevated behavior; require fit + corroboration + recency before escalation. +- Misread: "Single topic spike is enough for high-priority routing." + Correction: Use topic breadth/cluster logic and secondary evidence. +- Misread: "Higher match rate means better identity quality." + Correction: Track precision/recall and uncertainty, not match-rate vanity alone. +- Misread: "Model score is calibrated probability." + Correction: Validate calibration and monitor drift explicitly. + +**Confidence policy implications:** + +- Keep both numeric confidence and grade (`high`, `medium`, `low`, `insufficient`). +- Cap confidence when evidence is single-source, single-contact, or low-quality. +- Add downgrade rules for unresolved contradictions, weak mapping, stale windows, and IVT risk. +- Backtest and recalibrate periodically using observed downstream outcomes. + +**Sources:** + +- [I1] Bombora thresholding guidance (Evergreen) - https://customers.bombora.com/crc-coop/scoresthresholds +- [I2] Bombora signal building (Evergreen) - https://customers.bombora.com/crc-brand/build-signal +- [I3] 6sense scores overview (Evergreen) - https://support.6sense.com/docs/6sense-scores-overview +- [I4] 6sense buying stages (Evergreen) - https://support.6sense.com/docs/predictive-buying-stages +- [I5] 6sense fit model docs (Evergreen) - https://support.6sense.com/docs/account-profile-fit-model +- [I6] Demandbase intent explanation (Evergreen) - https://support.demandbase.com/hc/en-us/articles/360056755791-Understanding-Demandbase-Intent +- [I7] Demandbase predictive setup guidance (Evergreen) - https://www.demandbase.com/resources/playbook/predictive-models-setup/ +- [I8] G2 Buyer Intent docs (Evergreen) - https://documentation.g2.com/docs/buyer-intent +- [I9] G2 buyer intent data reference (Evergreen) - https://documentation.g2.com/docs/buyer-intent-data-reference +- [I10] G2 account mapping caveats (Evergreen) - https://documentation.g2.com/docs/mapping-g2-buyer-intent-data-to-salesforce-accounts-and-leads +- [I11] Google Analytics known bot exclusion (Evergreen) - https://support.google.com/analytics/answer/9888366?hl=en +- [I12] IAB spiders and bots list (Evergreen) - https://iabtechlab.com/software/iababc-international-spiders-and-bots-list/ +- [I13] MRC IVT standards addendum (Evergreen) - https://mediaratingcouncil.org/sites/default/files/Standards/IVT%20Addendum%20Update%20062520.pdf +- [I14] NIST AI RMF playbook (Evergreen) - https://www.nist.gov/itl/ai-risk-management-framework/nist-ai-rmf-playbook +- [I15] Google ML thresholding guidance (Evergreen) - https://developers.google.com/machine-learning/crash-course/classification/thresholding +- [I16] scikit-learn calibration docs (Evergreen) - https://scikit-learn.org/dev/modules/calibration.html +- [I17] Forrester state of buying 2026 (2026) - https://www.forrester.com/press-newsroom/forrester-2026-the-state-of-business-buying/ + +--- + +### Angle 5: Failure Modes & Anti-Patterns +> What repeatedly fails in real operations, and how should the skill block it? + +**Anti-pattern findings:** + +- **Single-signal escalation** + - Looks like: one spike auto-routes to outbound. + - Consequence: false positive workload and poor seller trust. + - Mitigation: require multi-signal, multi-contact corroboration. +- **No incrementality measurement** + - Looks like: intent success claimed from raw pipeline lift. + - Consequence: optimize to correlation instead of causal impact. + - Mitigation: treatment/control or holdout design. +- **CRM hygiene debt** + - Looks like: stale/duplicate account records feed scoring. + - Consequence: wrong ownership and weak personalization. + - Mitigation: freshness SLAs, dedupe, ownership rules. +- **Handoff ambiguity** + - Looks like: no explicit marketing-to-sales routing criteria/SLA. + - Consequence: stalled response and lead quality conflict. + - Mitigation: codify handoff states and closed-loop feedback. +- **Deliverability-blind outreach spikes** + - Looks like: intent list bursts without sender controls. + - Consequence: inboxing deterioration and complaint risk. + - Mitigation: enforce sender requirements + throttle by domain health. +- **Consent-chain failure** + - Looks like: unclear consent propagation in ad-tech/activation. + - Consequence: compliance exposure and policy breach risk. + - Mitigation: granular consent, revocation propagation, disclosures. +- **Sensitive-data misuse** + - Looks like: weak controls around sensitive location/behavioral data. + - Consequence: regulatory enforcement and forced data deletion. + - Mitigation: sensitive taxonomy + purpose limitation + strict sharing controls. +- **Identity over-merge** + - Looks like: weak/shared identifiers treated as definitive matches. + - Consequence: cross-account contamination and wrong activation. + - Mitigation: confidence-scored matching + blocked-value rules. +- **Match-rate vanity** + - Looks like: success measured by "% matched" only. + - Consequence: hidden precision/recall failure. + - Mitigation: evaluate precision/recall/F1 on ground truth. +- **Threshold/calibration drift neglect** + - Looks like: static thresholds with no monitoring. + - Consequence: silent quality decay and spend inefficiency. + - Mitigation: continuous calibration/drift monitoring and retraining policy. + +**Risk prioritization (highest first):** + +1. Sensitive-data and consent-chain noncompliance +2. Identity quality failures +3. Calibration/drift neglect +4. CRM/orchestration quality debt +5. Causal mismeasurement of program impact + +**Sources:** + +- [F1] HubSpot sales trends 2024 - https://www.hubspot.com/hubfs/HubSpots%202024%20Sales%20Trends%20Report.pdf +- [F2] Validity CRM data management 2024 - https://www.validity.com/wp-content/uploads/2024/05/The-State-of-CRM-Data-Management-in-2024.pdf +- [F3] Google Ads conversion lift (Evergreen) - https://support.google.com/google-ads/answer/12003020?hl=en +- [F4] Google sender guidelines (Evergreen) - https://support.google.com/a/answer/81126 +- [F5] Google Customer Match policy (Evergreen) - https://support.google.com/adspolicy/answer/6299717 +- [F6] Google customer data policies (Evergreen) - https://support.google.com/google-ads/answer/7475709?hl=en +- [F7] ICO B2B marketing guidance (Evergreen/updated) - https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/business-to-business-marketing +- [F8] ICO PECR rules (Evergreen/updated) - https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/guidance-on-the-use-of-storage-and-access-technologies/what-are-the-pecr-rules +- [F9] ICO consent operations (Evergreen/updated) - https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/guidance-on-the-use-of-storage-and-access-technologies/how-do-we-manage-consent-in-practice +- [F10] CJEU press release 44/24 (2024) - https://curia.europa.eu/jcms/upload/docs/application/pdf/2024-03/cp240044en.pdf +- [F11] FTC InMarket order (2024) - https://www.ftc.gov/news-events/news/press-releases/2024/05/ftc-finalizes-order-inmarket-prohibiting-it-selling-or-sharing-precise-location-data +- [F12] FTC Outlogic/X-Mode order (2024) - https://www.ftc.gov/news-events/news/press-releases/2024/04/ftc-finalizes-order-x-mode-successor-outlogic-prohibiting-it-sharing-or-selling-sensitive-location +- [F13] FTC PADFAA reminder (2026) - https://www.ftc.gov/news-events/news/press-releases/2026/02/ftc-reminds-data-brokers-their-obligations-comply-padfaa +- [F14] Twilio Segment identity onboarding (Evergreen) - https://www.twilio.com/docs/segment/unify/identity-resolution/identity-resolution-onboarding +- [F15] AWS entity resolution accuracy (Evergreen) - https://aws.amazon.com/blogs/industries/measuring-the-accuracy-of-rule-or-ml-based-matching-in-aws-entity-resolution/ +- [F16] Vertex AI model monitoring (Evergreen) - https://docs.cloud.google.com/vertex-ai/docs/model-monitoring/overview +- [F17] SageMaker model monitor (Evergreen) - https://docs.aws.amazon.com/sagemaker/latest/dg/model-monitor.html +- [F18] Calibration reassessment preprint (2024) - https://arxiv.org/abs/2406.04068 + +--- + +## Synthesis + +The strongest cross-angle conclusion is that `segment-behavioral` should act as an evidence-quality system, not a raw intent-score relay. In 2024-2026 practice, reliable prioritization requires scope lock, explicit source classes, endpoint/runtime verification, and contradiction-aware confidence handling. + +The biggest operational risk is false confidence from one of three shortcuts: single-signal escalation, identity/matching ambiguity, or stale threshold/calibration policy. The correct behavior is confidence-capped output with explicit next checks, not forced certainty. + +For implementation quality, API reality matters as much as analytics logic. Public endpoint visibility is uneven across providers, so the skill must separate: + +- runtime wrapper discovery (`op="help"`), +- publicly documented provider endpoint facts, +- and "public endpoint details not available" cases. + +--- + +## Recommendations for SKILL.md + +> Concrete changes to make in `segment-behavioral/SKILL.md`. + +- [x] Add explicit scope-lock and evidence-class contract before scoring. +- [x] Replace loose methodology with strict staged flow (`scope -> collect -> normalize -> triangulate -> gate -> classify -> act`). +- [x] Require topic breadth + intensity qualification (not single-topic score only). +- [x] Add tool/API guidance that starts with runtime method discovery (`op="help"`). +- [x] Add provider endpoint verification map and explicitly document unknown/publicly unavailable endpoint details. +- [x] Add interpretation quality gates (baseline, recency, fit separation, identity quality, calibration). +- [x] Add confidence caps and downgrade rules for sparse/ambiguous evidence. +- [x] Add anti-pattern warning blocks (operational, compliance, identity, model quality). +- [x] Expand schema with provenance, quality-gate results, contradictions, action SLA, and next checks. +- [x] Add pre-`write_artifact` checklist that blocks high-confidence outputs when hard gates fail. + +--- + +## Draft Content for SKILL.md + +> Paste-ready sections for direct insertion into `segment-behavioral/SKILL.md`. +> This is intentionally the largest section. + +### Draft: Mission Brief + Evidence Contract + +--- +You detect behavioral intent signals for account segments. + +Core rule: **intent is probabilistic evidence, not purchase certainty**. + +Before any prioritization, classify evidence into these classes: + +- `fit`: firmographic + technographic match quality +- `intent`: third-party and first-party behavioral research activity +- `trigger`: events that increase readiness to act (evaluation, comparisons, stack changes, events) + +Do not collapse these classes into one unlabeled score. + +Output rules: + +1. If evidence is weak, contradictory, stale, or ambiguous, confidence must be reduced. +2. If identity/account mapping is uncertain, confidence must be capped. +3. If hard quality gates fail, return `insufficient_data` or `ok_with_conflicts` instead of forcing certainty. +4. Never present one surge metric as "will buy." +--- + +### Draft: Methodology (Replacement) + +--- +## Methodology + +Use this exact sequence: + +### Step 0: Scope lock + +Define one run scope before collection: +- segment objective, +- geo/market scope, +- time window, +- topic taxonomy scope. + +If scope is broad or ambiguous, stop and request clarification. + +### Step 1: Build topic set with taxonomy labels + +Each monitored topic must be labeled: +- `pain_topic`, +- `category_topic`, +- `competitor_topic`, +- `persona_topic`. + +Unmapped topics are allowed only with an explicit `unmapped_topic` flag and reduced confidence. + +### Step 2: Collect evidence by class + +Collect at least: +1. one intent source, +2. one fit source, +3. one trigger/adjacent corroboration source. + +If only one class is available, cap confidence and return specific next checks. + +### Step 3: Normalize for comparability + +Before scoring: +- align time windows as closely as possible, +- record each provider's freshness cadence, +- record whether values are account-level, contact-level, or aggregated. + +### Step 4: Triangulate + +For each account: +- evaluate intent intensity, +- evaluate intent breadth, +- evaluate fit quality, +- evaluate trigger presence. + +High-priority escalation requires multi-signal corroboration, not single spikes. + +### Step 5: Apply quality gates + +Run all gates: +1. Baseline-context gate +2. Breadth gate +3. Fit gate +4. Recency gate +5. Identity/match gate +6. Data-quality gate +7. Cross-source gate +8. Calibration-policy gate + +If any hard gate fails, block `high` confidence. + +### Step 6: Score and classify + +Use dimension-level scores and clear tiering: +- `high`, +- `medium`, +- `low`, +- `watch`. + +Always include rationale for top-tier accounts. + +### Step 7: Assign action + SLA + +Every prioritized account must include: +- recommended action (`route_to_sales_now`, `route_within_7d`, `monitor`, `nurture`, `collect_more_data`) +- SLA recommendation (for example `24h`, `7d`, `next cycle`) + +### Step 8: Explain uncertainty + +Always include: +- contradictions, +- limitations, +- concrete next checks to improve confidence. +--- + +### Draft: Available Tools (Runtime-Verified) + +--- +## Available Tools + +Always verify available methods first: + +```python +bombora(op="help", args={}) +sixsense(op="help", args={}) +wappalyzer(op="help", args={}) +``` + +Use existing verified wrapper methods from this skill: + +```python +bombora(op="call", args={ + "method_id": "bombora.surging.accounts.v1", + "topics": ["topic_name"], + "location": "US", + "size": 50 +}) + +bombora(op="call", args={ + "method_id": "bombora.company.intent.v1", + "domain": "company.com", + "topics": ["topic_name"] +}) + +sixsense(op="call", args={ + "method_id": "sixsense.accounts.details.v1", + "account_domain": "company.com" +}) + +sixsense(op="call", args={ + "method_id": "sixsense.accounts.segment.v1", + "segment_id": "seg_id", + "limit": 100 +}) + +wappalyzer(op="call", args={ + "method_id": "wappalyzer.bulk_lookup.v1", + "urls": ["https://company1.com", "https://company2.com"] +}) +``` + +Tool rules: + +1. Never call unknown method IDs without `op="help"` confirmation. +2. If one provider fails, continue with remaining evidence and downgrade confidence. +3. Store provider/method provenance for each signal. +4. Avoid claiming provider endpoint support that is not verified in docs. +--- + +### Draft: Provider Endpoint Verification Notes + +--- +### Provider endpoint verification notes (audit reference) + +Use this only as documentation context. Runtime calls should still use wrapper methods. + +Verified public examples: + +- Bombora: `/v4/Surge/Create`, `/v2/Surge/TryGetResult`, `/v2/cmp/GetMyTopics` +- 6sense: `/v3/company/details`, `/v1/enrichment/company`, `/v2/enrichment/people` +- Wappalyzer: `/v2/lookup`, `/v2/credits/balance`, `/v2/lists` +- BuiltWith: `/v22/api`, `/v22/domain/bulk`, `/free1/api`, `/lists12/api`, `/trends/v6/api` + +Public endpoint details not clearly available in open docs reviewed: + +- Demandbase intent-specific endpoint paths +- TrustRadius intent endpoint paths +- Cognism intent API internals + +If endpoint details are not publicly documented, do not invent them in analysis output. +--- + +### Draft: Signal Strength, Quality Gates, and Confidence + +--- +## Interpretation Quality Gates + +Before assigning `high` confidence, all hard gates must pass. + +### Gate 1: Baseline-context gate (hard) +- Intent movement must be interpreted against account baseline or cohort baseline. + +### Gate 2: Breadth gate (hard) +- Require topic breadth/corroboration for top-tier routing. + +### Gate 3: Fit gate (hard) +- High intent with poor fit cannot be top-priority. + +### Gate 4: Recency gate (hard) +- Signals outside valid freshness windows are downgraded. + +### Gate 5: Identity/match gate (hard for routing) +- Uncertain account mapping caps confidence. + +### Gate 6: Data-quality gate (hard for strong claims) +- Known noisy behaviors or IVT risk must reduce confidence. + +### Gate 7: Cross-source gate (hard for high confidence) +- Require at least two evidence classes for high-confidence escalation. + +### Gate 8: Calibration-policy gate (warning/hard by use case) +- Threshold rationale must be documented; default thresholds without cost logic are downgraded. + +Confidence default policy: + +- Start `confidence = 0.50`. +- `+0.10` each for passed baseline, breadth, fit, recency, cross-source (max +0.50). +- `+0.05` for explicit calibration/backtest note. +- `-0.10` per unresolved contradiction. +- `-0.15` if identity/match gate fails. +- `-0.10` if data-quality gate fails. +- cap at `0.60` when only one evidence class is present. + +Confidence grades: + +- `high`: 0.80-1.00 +- `medium`: 0.60-0.79 +- `low`: 0.40-0.59 +- `insufficient`: <0.40 +--- + +### Draft: Anti-Pattern Warning Blocks + +--- +### WARNING: Intent-Score Literalism + +**What it looks like:** one score is treated as purchase certainty. +**Consequence:** false positive prioritization. +**Mitigation:** require fit + breadth + trigger corroboration. + +### WARNING: Single-Signal Escalation + +**What it looks like:** one topic spike auto-routes account to sales. +**Consequence:** low trust and wasted outbound capacity. +**Mitigation:** require multi-signal, multi-contact evidence. + +### WARNING: Fit-Blind Prioritization + +**What it looks like:** high intent outranks poor ICP fit. +**Consequence:** pipeline quality degradation. +**Mitigation:** hard fit gate before top-tier routing. + +### WARNING: Identity Over-Merge + +**What it looks like:** weak/shared identifiers create confident account merges. +**Consequence:** wrong account actions and contamination. +**Mitigation:** confidence-scored matching + manual review for uncertain cases. + +### WARNING: Match-Rate Vanity + +**What it looks like:** quality judged mainly by "% matched". +**Consequence:** hidden precision/recall failure. +**Mitigation:** evaluate precision/recall/F1 on sampled truth sets. + +### WARNING: Consent-Chain Failure + +**What it looks like:** unclear consent lineage and revocation propagation. +**Consequence:** compliance risk and potential enforcement exposure. +**Mitigation:** explicit consent metadata, suppression propagation, and purpose limits. + +### WARNING: Deliverability-Blind Outreach Bursts + +**What it looks like:** large intent-triggered sends without sender health controls. +**Consequence:** inbox placement deterioration. +**Mitigation:** sender-authentication checks + rate governance by domain health. + +### WARNING: Threshold Drift Neglect + +**What it looks like:** static thresholds used despite model/data drift. +**Consequence:** silent prioritization decay. +**Mitigation:** periodic calibration and drift monitoring with rollback rules. +--- + +### Draft: Output Checklist + +--- +Before calling `write_artifact`, verify: + +- Scope lock is recorded (`segment`, `geo`, `window`, `taxonomy`). +- At least two evidence classes are present for strong claims. +- Every signal has `provider`, `method_id`, `evidence_class`, and `captured_at`. +- Fit and intent are scored separately before total score. +- Quality gates are evaluated and stored. +- Contradictions are explicit when present. +- Confidence and confidence grade are consistent. +- Recommended action and SLA are included for prioritized accounts. +- Limitations and next checks are concrete. + +If any hard gate fails, do not emit `high` confidence. +--- + +### Draft: Artifact Schema Additions + +```json +{ + "segment_behavioral_intent": { + "type": "object", + "required": [ + "segment_id", + "evaluated_at", + "result_state", + "scope_lock", + "intent_model", + "evidence_summary", + "quality_gates", + "account_scores", + "confidence", + "confidence_grade", + "limitations", + "next_checks" + ], + "additionalProperties": false, + "properties": { + "segment_id": { + "type": "string", + "description": "Target segment identifier for this behavioral evaluation run." + }, + "evaluated_at": { + "type": "string", + "description": "ISO-8601 timestamp when scoring was finalized." + }, + "result_state": { + "type": "string", + "enum": [ + "ok", + "ok_with_conflicts", + "zero_results", + "insufficient_data", + "technical_failure", + "auth_required" + ], + "description": "Overall run quality/completeness state." + }, + "scope_lock": { + "type": "object", + "required": [ + "segment_objective", + "geo_scope", + "time_window", + "topic_taxonomy_version" + ], + "additionalProperties": false, + "properties": { + "segment_objective": { + "type": "string", + "description": "Primary prioritization objective for this run." + }, + "geo_scope": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of markets/countries covered." + }, + "time_window": { + "type": "object", + "required": [ + "start_date", + "end_date" + ], + "additionalProperties": false, + "properties": { + "start_date": { + "type": "string" + }, + "end_date": { + "type": "string" + }, + "window_label": { + "type": "string", + "description": "Human-readable window label (for example 30d, 90d)." + } + } + }, + "topic_taxonomy_version": { + "type": "string", + "description": "Version or label of topic taxonomy used." + } + } + }, + "intent_model": { + "type": "object", + "required": [ + "topics_monitored", + "scoring_dimensions", + "tier_policy" + ], + "additionalProperties": false, + "properties": { + "topics_monitored": { + "type": "array", + "items": { + "type": "object", + "required": [ + "topic", + "taxonomy_label" + ], + "additionalProperties": false, + "properties": { + "topic": { + "type": "string" + }, + "taxonomy_label": { + "type": "string", + "enum": [ + "pain_topic", + "category_topic", + "competitor_topic", + "persona_topic", + "unmapped_topic" + ] + } + } + } + }, + "scoring_dimensions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "dimension", + "max_points" + ], + "additionalProperties": false, + "properties": { + "dimension": { + "type": "string", + "enum": [ + "intent_fit", + "firmographic_fit", + "technographic_fit", + "trigger_event", + "data_quality" + ] + }, + "max_points": { + "type": "integer", + "minimum": 0 + } + } + } + }, + "tier_policy": { + "type": "object", + "required": [ + "high_min_score", + "medium_min_score" + ], + "additionalProperties": false, + "properties": { + "high_min_score": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "medium_min_score": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "watch_min_score": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + } + } + } + }, + "evidence_summary": { + "type": "object", + "required": [ + "providers_used", + "evidence_classes_covered", + "provider_windows" + ], + "additionalProperties": false, + "properties": { + "providers_used": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Provider namespaces used in this run." + }, + "evidence_classes_covered": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "fit", + "intent", + "trigger" + ] + } + }, + "provider_windows": { + "type": "array", + "items": { + "type": "object", + "required": [ + "provider", + "window_label" + ], + "additionalProperties": false, + "properties": { + "provider": { + "type": "string" + }, + "window_label": { + "type": "string" + }, + "freshness_note": { + "type": "string" + } + } + } + }, + "provider_failures": { + "type": "array", + "items": { + "type": "object", + "required": [ + "provider", + "reason" + ], + "additionalProperties": false, + "properties": { + "provider": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + } + } + } + }, + "quality_gates": { + "type": "object", + "required": [ + "baseline_context_gate", + "breadth_gate", + "fit_gate", + "recency_gate", + "identity_match_gate", + "data_quality_gate", + "cross_source_gate", + "calibration_policy_gate" + ], + "additionalProperties": false, + "properties": { + "baseline_context_gate": { + "type": "string", + "enum": [ + "pass", + "warn", + "fail" + ] + }, + "breadth_gate": { + "type": "string", + "enum": [ + "pass", + "warn", + "fail" + ] + }, + "fit_gate": { + "type": "string", + "enum": [ + "pass", + "warn", + "fail" + ] + }, + "recency_gate": { + "type": "string", + "enum": [ + "pass", + "warn", + "fail" + ] + }, + "identity_match_gate": { + "type": "string", + "enum": [ + "pass", + "warn", + "fail" + ] + }, + "data_quality_gate": { + "type": "string", + "enum": [ + "pass", + "warn", + "fail" + ] + }, + "cross_source_gate": { + "type": "string", + "enum": [ + "pass", + "warn", + "fail" + ] + }, + "calibration_policy_gate": { + "type": "string", + "enum": [ + "pass", + "warn", + "fail" + ] + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "account_scores": { + "type": "array", + "items": { + "type": "object", + "required": [ + "domain", + "total_score", + "intent_tier", + "dimension_scores", + "signals", + "match_confidence", + "recommended_action", + "sla" + ], + "additionalProperties": false, + "properties": { + "domain": { + "type": "string" + }, + "total_score": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "intent_tier": { + "type": "string", + "enum": [ + "high", + "medium", + "low", + "watch" + ] + }, + "dimension_scores": { + "type": "object", + "required": [ + "intent_fit", + "firmographic_fit", + "technographic_fit", + "trigger_event", + "data_quality" + ], + "additionalProperties": false, + "properties": { + "intent_fit": { + "type": "number" + }, + "firmographic_fit": { + "type": "number" + }, + "technographic_fit": { + "type": "number" + }, + "trigger_event": { + "type": "number" + }, + "data_quality": { + "type": "number" + } + } + }, + "signals": { + "type": "array", + "items": { + "type": "object", + "required": [ + "signal_type", + "description", + "strength", + "provider", + "method_id", + "evidence_class", + "evidence_value", + "captured_at" + ], + "additionalProperties": false, + "properties": { + "signal_type": { + "type": "string", + "enum": [ + "intent_surge", + "intent_breadth", + "technographic_change", + "fit_confirmation", + "trigger_event", + "negative_signal" + ] + }, + "description": { + "type": "string" + }, + "strength": { + "type": "string", + "enum": [ + "strong", + "moderate", + "weak" + ] + }, + "provider": { + "type": "string" + }, + "method_id": { + "type": "string", + "description": "Wrapper method ID used at runtime." + }, + "evidence_class": { + "type": "string", + "enum": [ + "fit", + "intent", + "trigger" + ] + }, + "evidence_value": { + "type": "string" + }, + "window": { + "type": "string" + }, + "captured_at": { + "type": "string" + } + } + } + }, + "match_confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low", + "unknown" + ], + "description": "Confidence that signals correctly map to this account." + }, + "recommended_action": { + "type": "string", + "enum": [ + "route_to_sales_now", + "route_within_7d", + "monitor", + "nurture", + "collect_more_data" + ] + }, + "sla": { + "type": "string", + "description": "Suggested response window (for example 24h, 7d, next cycle)." + }, + "rationale": { + "type": "string" + } + } + } + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "confidence_grade": { + "type": "string", + "enum": [ + "high", + "medium", + "low", + "insufficient" + ] + }, + "contradictions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "topic", + "conflict_note", + "impact_on_confidence", + "status" + ], + "additionalProperties": false, + "properties": { + "topic": { + "type": "string" + }, + "conflict_note": { + "type": "string" + }, + "impact_on_confidence": { + "type": "string", + "enum": [ + "minor", + "moderate", + "major" + ] + }, + "status": { + "type": "string", + "enum": [ + "resolved", + "unresolved" + ] + }, + "resolution_plan": { + "type": "string" + } + } + } + }, + "limitations": { + "type": "array", + "items": { + "type": "string" + } + }, + "next_checks": { + "type": "array", + "items": { + "type": "string" + } + } + } + } +} +``` + +### Draft: Recording Guidance + +--- +## Recording + +After analysis, call: + +```python +write_artifact( + artifact_type="segment_behavioral_intent", + path="/segments/{segment_id}/behavioral-intent", + data={...} +) +``` + +Do not dump raw JSON in chat. + +Before writing: + +1. Verify hard quality gates. +2. Verify confidence and confidence grade alignment. +3. Verify top-priority accounts include rationale + action + SLA. +4. Verify contradictions, limitations, and next checks are explicit. +5. If endpoint/method uncertainty existed, include that in limitations. +--- + +## Gaps & Uncertainties + +- Public API detail depth is uneven across providers; some vendor docs are entitlement-gated or incomplete in open view. +- Bombora public endpoint docs are mostly historical (2019-2020 updates); endpoint behavior can differ by contract/version. +- Independent, open, apples-to-apples cross-vendor benchmark datasets for intent precision/recall remain limited. +- Some strategic sources (Forrester/Gartner) are partially paywalled; public summaries were used where full methodology tables are not open. +- Compliance obligations vary by jurisdiction and data category; production deployments should pair this guidance with legal policy in target regions. diff --git a/flexus_simple_bots/researcher/skills/_segment-behavioral/SKILL.md b/flexus_simple_bots/researcher/skills/_segment-behavioral/SKILL.md new file mode 100644 index 00000000..d9402d07 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_segment-behavioral/SKILL.md @@ -0,0 +1,58 @@ +--- +name: segment-behavioral +description: Behavioral intent and account intelligence — in-market signals, topic surges, and account-level intent detection +--- + +You detect behavioral intent signals at the account and segment level. Intent data tells you which companies are actively researching a problem space right now — before they show up in your pipeline. + +Core mode: intent data is probabilistic, not certain. A Bombora surge score of 70 means "this company is consuming more content on this topic than usual" — it does not mean "this company will buy." Always state confidence ranges and combine intent signals with firmographic fit before scoring. + +## Methodology + +### Topic surge detection +Use `bombora` to identify companies showing elevated intent on relevant topic clusters. + +Key fields to extract: +- `composite_score`: overall intent intensity for a topic cluster +- Trending topics: which specific topics within your cluster are driving the surge +- Historical baseline: compare current score to company's own historical score to avoid false positives from large companies that always consume content + +### Technographic intent +Use `wappalyzer` bulk scans to detect companies that recently adopted or dropped specific technologies — a technology change event often correlates with budget availability and purchase intent. + +### Account intelligence layers +Combine intent with: +1. Firmographic fit (from `segment-firmographic` skill) +2. Current technology stack (builtwith/wappalyzer) +3. News events (funding announcements, leadership changes, product launches from `signal-news-events`) + +High intent + good fit + triggering event = high-priority account. + +### Signal scoring model +Assign scores across dimensions: +- Intent fit: 0-30 points (Bombora composite or similar) +- Firmographic fit: 0-30 points (size, industry, geography match) +- Tech stack fit: 0-20 points (relevant tech installed or recently dropped) +- Trigger event: 0-20 points (news event indicating openness to change) + +Total ≥70 = high priority. 40-69 = medium. <40 = low / not yet. + +## Recording + +``` +write_artifact(path="/segments/{segment_id}/behavioral-intent", data={...}) +``` + +## Available Tools + +``` +bombora(op="call", args={"method_id": "bombora.surging.accounts.v1", "topics": ["topic_name"], "location": "US", "size": 50}) + +bombora(op="call", args={"method_id": "bombora.company.intent.v1", "domain": "company.com", "topics": ["topic_name"]}) + +sixsense(op="call", args={"method_id": "sixsense.accounts.details.v1", "account_domain": "company.com"}) + +sixsense(op="call", args={"method_id": "sixsense.accounts.segment.v1", "segment_id": "seg_id", "limit": 100}) + +wappalyzer(op="call", args={"method_id": "wappalyzer.bulk_lookup.v1", "urls": ["https://company1.com", "https://company2.com"]}) +``` diff --git a/flexus_simple_bots/researcher/skills/_segment-qualification/SKILL.md b/flexus_simple_bots/researcher/skills/_segment-qualification/SKILL.md new file mode 100644 index 00000000..26f420d9 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_segment-qualification/SKILL.md @@ -0,0 +1,211 @@ +--- +name: segment-qualification +description: Segment enrichment and scoring — CRM signals, firmographic/technographic enrichment, weighted priority matrix +--- + +You are operating as Segment Analyst for this task. + +Core mode: +- evidence-first, no invention, +- enrich segment candidates from first-party CRM and external firmographic/technographic/intent sources, +- produce one explicit primary segment decision with weighted scoring, +- explicit uncertainty reporting, +- output should be reusable by downstream experts. + +## Enrichment Skills + +**CRM signals:** Extract open pipeline count, stage distribution, win rate proxy, avg sales cycle days. Fail fast when CRM access is unavailable. + +**Firmographic enrichment:** Employee range, revenue range, geo focus, ownership type. Use Clearbit, Apollo, or PDL as primary sources. Enforce per-run spend cap. + +**Technographic profile:** Technology stack, adoption signal (weak/moderate/strong). Use BuiltWith and Wappalyzer. Wappalyzer Business-tier required; fail fast if absent. + +**Intent signals:** Identify intent signals from B2B intent platforms (Bombora, 6sense, G2 buyer intent). Combine with CRM and firmographic data for composite ICP fit score. + +## Recording Artifacts + +- `write_artifact(path=/segments/enrichment-{segment_id}-{YYYY-MM-DD}, data={...})` — enriched candidate with firmographic, technographic, intent data +- `write_artifact(path=/segments/quality-{segment_id}-{YYYY-MM-DD}, data={...})` — data quality gate per dimension +- `write_artifact(path=/segments/matrix-{YYYY-MM-DD}, data={...})` — weighted scoring across candidate segments +- `write_artifact(path=/segments/decision-{YYYY-MM-DD}, data={...})` — primary segment selection with rationale +- `write_artifact(path=/segments/gate-{YYYY-MM-DD}, data={...})` — go/no_go with blocking issues and next checks + +Do not output raw JSON in chat. + +## Available Integration Tools + +Call each tool with `op="help"` to see available methods, `op="call", args={"method_id": "...", ...}` to execute. + +**Company data & firmographics:** `coresignal`, `theirstack`, `hasdata`, `oxylabs`, `pdl`, `clearbit`, `crunchbase` + +**Technographics:** `wappalyzer`, `builtwith` + +**App stores:** `appstoreconnect`, `google_play` + +**Intent data:** `sixsense`, `bombora` + +**Validation panels:** `cint`, `mturk`, `usertesting`, `userinterviews` + +## Artifact Schemas + +```json +{ + "segment_enrichment": { + "type": "object", + "properties": { + "segment_id": {"type": "string"}, + "date": {"type": "string"}, + "segment_name": {"type": "string"}, + "crm_signals": { + "type": "object", + "properties": { + "open_pipeline_count": {"type": "integer"}, + "win_rate_proxy": {"type": "number"}, + "avg_sales_cycle_days": {"type": "number"}, + "stage_distribution": {"type": "object"} + } + }, + "firmographic": { + "type": "object", + "properties": { + "employee_range": {"type": "string"}, + "revenue_range": {"type": "string"}, + "geo_focus": {"type": "array", "items": {"type": "string"}}, + "ownership_type": {"type": "string"}, + "source": {"type": "string"} + } + }, + "technographic": { + "type": "object", + "properties": { + "tech_stack": {"type": "array", "items": {"type": "string"}}, + "adoption_signal": {"type": "string", "enum": ["strong", "moderate", "weak"]}, + "source": {"type": "string"} + } + }, + "intent_signals": { + "type": "array", + "items": { + "type": "object", + "properties": { + "platform": {"type": "string"}, + "signal": {"type": "string"}, + "strength": {"type": "string", "enum": ["strong", "moderate", "weak"]} + }, + "required": ["platform", "signal", "strength"] + } + }, + "composite_icp_score": {"type": "number", "description": "0-1"}, + "per_run_spend": {"type": "number"} + }, + "required": ["segment_id", "date", "segment_name", "composite_icp_score"], + "additionalProperties": false + }, + "segment_data_quality": { + "type": "object", + "properties": { + "segment_id": {"type": "string"}, + "date": {"type": "string"}, + "dimensions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "dimension": {"type": "string", "enum": ["crm", "firmographic", "technographic", "intent"]}, + "coverage": {"type": "string", "enum": ["full", "partial", "missing"]}, + "completeness": {"type": "number", "description": "0-1"}, + "source": {"type": "string"}, + "notes": {"type": "string"} + }, + "required": ["dimension", "coverage", "completeness"] + } + }, + "overall_quality": {"type": "string", "enum": ["sufficient", "insufficient", "partial"]} + }, + "required": ["segment_id", "date", "dimensions", "overall_quality"], + "additionalProperties": false + }, + "segment_priority_matrix": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "candidates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "segment_id": {"type": "string"}, + "segment_name": {"type": "string"}, + "weighted_score": {"type": "number", "description": "0-1"}, + "dimension_scores": { + "type": "object", + "properties": { + "pain_severity": {"type": "number"}, + "icp_fit": {"type": "number"}, + "market_size": {"type": "number"}, + "reachability": {"type": "number"}, + "win_rate_proxy": {"type": "number"} + } + }, + "rank": {"type": "integer"} + }, + "required": ["segment_id", "segment_name", "weighted_score", "rank"] + } + }, + "weights": {"type": "object"} + }, + "required": ["date", "candidates"], + "additionalProperties": false + }, + "primary_segment_decision": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "selected_segment": { + "type": "object", + "properties": { + "segment_id": {"type": "string"}, + "segment_name": {"type": "string"}, + "rationale": {"type": "string"} + }, + "required": ["segment_id", "segment_name", "rationale"] + }, + "runner_up": { + "type": "object", + "properties": { + "segment_id": {"type": "string"}, + "segment_name": {"type": "string"}, + "notes": {"type": "string"} + } + }, + "rejections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "segment_id": {"type": "string"}, + "rejection_reason": {"type": "string"} + }, + "required": ["segment_id", "rejection_reason"] + } + }, + "next_validation_steps": {"type": "array", "items": {"type": "string"}} + }, + "required": ["date", "selected_segment", "next_validation_steps"], + "additionalProperties": false + }, + "primary_segment_go_no_go_gate": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "gate_status": {"type": "string", "enum": ["go", "no_go"]}, + "blocking_issues": {"type": "array", "items": {"type": "string"}}, + "next_checks": {"type": "array", "items": {"type": "string"}}, + "segment_id": {"type": "string"}, + "decision_owner": {"type": "string"} + }, + "required": ["date", "gate_status", "blocking_issues", "next_checks"], + "additionalProperties": false + } +} +``` diff --git a/flexus_simple_bots/researcher/skills/_segment-social-graph/RESEARCH.md b/flexus_simple_bots/researcher/skills/_segment-social-graph/RESEARCH.md new file mode 100644 index 00000000..db7b78df --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_segment-social-graph/RESEARCH.md @@ -0,0 +1,912 @@ +# Research: segment-social-graph + +**Skill path:** `flexus-client-kit/flexus_simple_bots/researcher/skills/segment-social-graph/` +**Bot:** researcher +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`segment-social-graph` maps professional influence networks for target segments: decision-maker identification, influence-path discovery, and contact-level enrichment for business prospecting workflows. + +This research was generated from template + brief + current `SKILL.md` context, using five internal sub-research angles: methodology, tools, API endpoint reality, interpretation, and anti-patterns/compliance. The target outcome is a safer `SKILL.md` that is source-backed (2024-2026 priority), explicit about uncertainty, and strict about not inventing endpoints. + +--- + +## Definition of Done + +- [x] At least 4 distinct research angles are covered +- [x] Each finding has source URLs or named references +- [x] Methodology includes practical execution rules +- [x] Tool/API landscape includes concrete options and caveats +- [x] Failure modes and anti-patterns are explicit +- [x] Schema recommendations map to realistic data shapes +- [x] Gaps/uncertainties are explicit +- [x] Findings prioritize 2024-2026 (older references marked evergreen when used) + +--- + +## Quality Gates + +- No generic filler without backing: **passed** +- No invented tool names, method IDs, or API endpoints: **passed** +- Contradictions and caveats explicitly stated: **passed** +- `Draft Content for SKILL.md` is the largest section: **passed** +- Internal sub-research angles >= 4: **passed** (5 used) + +--- + +## Research Angles + +### Angle 1: Domain Methodology and Best Practices + +**Findings:** + +- Relationship mapping is now a first-class operating workflow, not a CRM side note. LinkedIn Sales Navigator Relationship Maps explicitly support role classification (`Decision Maker`, `Champion`, `Evaluator`, `Procurement`, `Influencer`) and stale-contact detection. +- Warm-intro pathing should be explicit and ranked. TeamLink is documented as a "best path in" mechanism, but coverage depends on subscription and TeamLink settings. +- Relationship Explorer is a discovery accelerator, not full account truth: it surfaces up to eight relevant unsaved leads and excludes already-saved leads. +- Mapping quality is materially improved by multi-threading the account map: one-thread account plans are brittle when contacts move or go dark. +- 2024 buyer-process data from Forrester indicates large buying groups (average ~13 participants), frequent stalls, and high dissatisfaction with final provider choice. This supports mandatory stakeholder plurality in mapping. +- 2025 hidden-buyer evidence (Edelman x LinkedIn) indicates significant influence from stakeholders with low direct seller interaction; influence detection cannot rely only on visible meeting participants. +- Use "staleness" and role-change checks as a hard gate before high-confidence recommendations. +- CRM-connected workflows (e.g., Dynamics integration with Sales Navigator controls) show practical value in preserving people-relationship context alongside account records. +- Decision-maker mapping should separate role criticality from relationship accessibility. Senior title alone does not guarantee reachable influence. +- Social graph work should include alternative route planning (direct path, TeamLink path, shared-experience path) to avoid single-intro fragility. +- Evidence age should be scored. Interaction recency and role freshness should have explicit decay behavior. +- Output should preserve unresolved gaps instead of forcing complete org charts. + +**Contradictions / nuances to encode:** + +- "Closer connection" does not always equal "best path"; intro willingness and context can dominate pure graph distance. +- A missing visible path is ambiguous: it can mean true absence, settings limitations, or stale account records. +- Publicly visible role signals are not complete buying-group truth. + +**Sources:** + +- [Relationship Maps in Sales Navigator](https://www.linkedin.com/help/sales-navigator/answer/a456397) +- [TeamLink in Sales Navigator](https://www.linkedin.com/help/sales-navigator/answer/a101027) +- [Relationship Explorer in Sales Navigator](https://www.linkedin.com/help/sales-navigator/answer/a1421128) +- [Integrate LinkedIn Sales Navigator with Dynamics 365 Sales (11/04/2024)](https://learn.microsoft.com/en-us/dynamics365/sales/linkedin/integrate-sales-navigator) +- [Forrester: The State of Business Buying, 2024 (12/04/2024)](https://www.forrester.com/press-newsroom/forrester-the-state-of-business-buying-2024/) +- [Edelman: The Rise of the Hidden Buyer (06/26/2025)](https://www.edelman.com/insights/hidden-buyer-b2b) + +--- + +### Angle 2: Tool and API Landscape + +**Findings:** + +- The practical stack for this skill is hybrid: LinkedIn for organization/professional context signals plus dedicated enrichment APIs for contact-level data operations. +- LinkedIn platform access is tiered and vetted. Community Management access requires app review and use-case screening; access expectations must be modeled as constraints, not assumptions. +- LinkedIn API operations are version-sensitive: monthly release cadence, sunset windows, and required version headers materially affect production stability. +- LinkedIn rate limits are endpoint-specific and not fully published as static global values; operational monitoring must use Developer Portal analytics. +- PDL offers deterministic enrich/search endpoint separation with explicit status semantics (`200` match, `404` no match for enrichment) and documented endpoint-level limits. +- Apollo separates search and enrichment and documents fixed-window rate limits; key endpoints require master API key. +- Clearbit naming persists in many stacks, but public 2025 HubSpot updates confirm service transitions (e.g., free Logo API sunset); endpoint assumptions must be revalidated. +- For relationship-state persistence, CRM graph primitives (associations/identity links) are strategically important, but this skill can stay connector-first and artifact-centric. +- Plan/tier drift is common across vendors; docs should be treated as current-state references, not permanent guarantees. + +| Provider | Primary strength | Practical limitation | Operational note | +|---|---|---|---| +| LinkedIn Community/Marketing surfaces | Official org and social context for professional network analysis | Access vetting + scope/tier constraints | Treat as policy-first source; version header discipline required | +| LinkedIn Sales Navigator surfaces | Relationship and warm-path discovery workflows | Product-tier and settings-dependent visibility | Useful for route hypotheses, not full graph completeness | +| People Data Labs | Strong person/company enrichment and search APIs | Credit and endpoint-level rate constraints | Use explicit match/no-match semantics in pipeline logic | +| Apollo | Fast discover->enrich workflow, broad sales API ecosystem | Master-key requirements and plan-dependent access | Distinguish search output from enrichment output | +| Clearbit/HubSpot transition context | Legacy ecosystem continuity and known brand footprint | Public endpoint visibility is uneven; free logo API sunset | Treat clearbit-* assumptions as potentially stale; verify before rollout | + +**Sources:** + +- [LinkedIn Marketing API access tiers (updated 2026)](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/marketing-tiers?view=li-lms-2024-11) +- [Community Management App Review (updated 2026)](https://learn.microsoft.com/en-us/linkedin/marketing/community-management-app-review) +- [LinkedIn API Rate Limiting (updated 2025)](https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/rate-limits) +- [PDL Person Enrichment API](https://docs.peopledatalabs.com/docs/reference-person-enrichment-api) +- [PDL Person Search API](https://docs.peopledatalabs.com/docs/reference-person-search-api) +- [PDL Usage Limits](https://docs.peopledatalabs.com/docs/usage-limits) +- [Apollo People API Search](https://docs.apollo.io/reference/people-api-search) +- [Apollo People Enrichment](https://docs.apollo.io/reference/people-enrichment) +- [Apollo Rate Limits](https://docs.apollo.io/reference/rate-limits) +- [Apollo API Pricing](https://docs.apollo.io/docs/api-pricing) +- [HubSpot changelog: Clearbit free Logo API sunset (2025)](https://developers.hubspot.com/changelog/upcoming-sunset-of-clearbits-free-logo-api) + +--- + +### Angle 3: API Endpoint Reality and Integration Constraints + +**Findings:** + +- LinkedIn Marketing APIs are explicitly versioned; `Linkedin-Version: YYYYMM` is required for versioned API calls. +- LinkedIn posts and social surfaces are in migration overlap: Posts API is current, while related social detail surfaces may span social actions/reactions/metadata endpoints. +- Organization lookup/follower/account role endpoints are stable core primitives for organization-side social graph context. +- Organization follower total-count retrieval has shifted to `networkSizes` instead of old follower-stat payload assumptions. +- LinkedIn migration docs confirm aggressive deprecation cadence; endpoint strategy must include scheduled migration maintenance. +- PDL public docs provide explicit endpoint paths and billing/rate semantics for person/company enrichment/search. +- Apollo public OpenAPI definitions expose endpoint paths (base `https://api.apollo.io/api/v1`) and endpoint-level access/rate behavior. +- Clearbit full modern endpoint verification is partially constrained by gated docs; keep endpoint claims conservative. + +**Verified endpoint map (official/publicly documented):** + +| Surface | Endpoint | Purpose | Status | +|---|---|---|---| +| LinkedIn Posts | `POST /rest/posts` | Create posts | verified | +| LinkedIn Posts finder | `GET /rest/posts?...&q=author` | Retrieve posts by author | verified | +| LinkedIn Org ACL | `GET /rest/organizationAcls?q=roleAssignee` / `q=organization` | Check org roles/admin access | verified | +| LinkedIn Org follower stats | `GET /rest/organizationalEntityFollowerStatistics?q=organizationalEntity...` | Follower trend + demographics | verified | +| LinkedIn Network size | `GET /rest/networkSizes/urn:li:organization:{id}?edgeType=COMPANY_FOLLOWED_BY_MEMBER` | Organization follower count | verified | +| LinkedIn Org lookup | `GET /rest/organizations/{id}` and finder variants | Organization retrieval/discovery | verified | +| PDL person enrich | `GET https://api.peopledatalabs.com/v5/person/enrich` | One-to-one person enrichment | verified | +| PDL person search | `GET https://api.peopledatalabs.com/v5/person/search` | Segment search | verified | +| PDL company enrich | `GET https://api.peopledatalabs.com/v5/company/enrich` | One-to-one company enrichment | verified (public docs) | +| Apollo people search | `POST /api/v1/mixed_people/api_search` | Net-new people search | verified (OpenAPI/docs) | +| Apollo people enrich | `POST /api/v1/people/match` | Person enrichment | verified (OpenAPI/docs) | +| Apollo org enrich | `GET /api/v1/organizations/enrich` | Organization enrichment | verified (OpenAPI examples/docs) | +| Apollo usage/rate introspection | `POST /api/v1/usage_stats/api_usage_stats` | Per-endpoint limit and usage visibility | verified | +| Clearbit paid endpoints | (full modern public path set) | Person/company enrichment | partially verified (public visibility limited) | + +**Do-not-claim list:** + +- Do not claim undocumented LinkedIn or enrichment-provider endpoints. +- Do not treat old and new `networkSizes edgeType` values as universally interchangeable. +- Do not imply broad availability of restricted scopes (for example, `r_member_social`) without app-level approval proof. +- Do not claim full public verification of current paid Clearbit endpoint set. + +**Sources:** + +- [LinkedIn Marketing API versioning (updated 2026)](https://learn.microsoft.com/en-us/linkedin/marketing/versioning?view=li-lms-2026-01) +- [LinkedIn migrations status (updated 2026)](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/migrations?view=li-lms-2026-01#api-migration-status) +- [LinkedIn Posts API (updated 2026)](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api?view=li-lms-2025-10) +- [Organization Access Control by Role](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/organization-access-control-by-role?view=li-lms-2025-10) +- [Organization Follower Statistics](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/follower-statistics?view=li-lms-2026-01) +- [Organization Lookup API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/organization-lookup-api?view=li-lms-2026-01) +- [PDL Person Enrichment API](https://docs.peopledatalabs.com/docs/reference-person-enrichment-api) +- [PDL Person Search API](https://docs.peopledatalabs.com/docs/reference-person-search-api) +- [Apollo People API Search](https://docs.apollo.io/reference/people-api-search) +- [Apollo People Enrichment](https://docs.apollo.io/reference/people-enrichment) +- [Apollo View API Usage Stats and Rate Limits](https://docs.apollo.io/reference/view-api-usage-stats) + +--- + +### Angle 4: Data Interpretation and Signal Quality + +**Findings:** + +- Authority, influence, and accessibility are different dimensions and should not collapse into a single opaque score. +- Buying groups are large and distributed (Forrester 2024), so single-contact inference is structurally weak. +- Hidden-buyer effects (Edelman 2025) imply low-observability stakeholders can still materially affect outcomes. +- Relationship signals should be confidence-weighted by freshness and corroboration count. +- Role-change risk is non-trivial in fast-changing labor environments; stale role assumptions degrade social graph quality. +- Warm-path availability should increase actionability score, not authority score. +- Contact enrichment output and social-graph interpretation should be explicitly separated to avoid overfitting narrative certainty. +- Confidence should decrease when evidence is one-source, stale, or contradictory. +- "No visible path" should map to uncertainty, not negative proof. +- Strong conclusions should require at least two independent evidence dimensions (role evidence + path evidence, or role evidence + activity evidence). + +**Misinterpretations to avoid:** + +- Senior title => decision authority (always) +- Existing connection => willing and credible intro path +- One active contact => complete buying-group representation +- No route discovered => no influence channel exists +- Fresh engagement snapshot => durable relationship strength + +**Practical defaults (heuristics, not official platform cutoffs):** + +- Maintain three scores per target contact: `authority_score`, `influence_score`, `accessibility_score`. +- Use recency decay: interaction signals decay faster than role/title signals. +- Cap confidence at medium when only one organization or one stakeholder class is observed. +- Require explicit contradiction notes for mixed evidence. + +**Sources:** + +- [Forrester: The State of Business Buying, 2024](https://www.forrester.com/press-newsroom/forrester-the-state-of-business-buying-2024/) +- [Edelman: The Rise of the Hidden Buyer (2025)](https://www.edelman.com/insights/hidden-buyer-b2b) +- [Relationship Maps in Sales Navigator](https://www.linkedin.com/help/sales-navigator/answer/a456397) +- [Relationship Explorer in Sales Navigator](https://www.linkedin.com/help/sales-navigator/answer/a1421128) +- [LinkedIn Work Change Snapshot (2024)](https://economicgraph.linkedin.com/content/dam/me/economicgraph/en-us/PDF/Work-Change-Snapshot.pdf) + +--- + +### Angle 5: Failure Modes, Anti-Patterns, and Compliance + +**Findings:** + +- Unauthorized scraping/automation remains a high-impact failure mode for social graph workstreams. +- LinkedIn Marketing member data has strict restrictions; specific sales/recruiting CRM enrichment uses are explicitly disallowed for those data classes. +- Retention violations are easy to create if pipeline stores mixed member/activity/org data without field-level TTL controls. +- Over-collection creates compliance risk and often no analytic value. +- Identity stitching without confidence gates causes false links and harmful recommendations. +- "Publicly visible" does not equal unrestricted lawful processing for any downstream purpose. +- Regulatory posture in 2024-2025 continues to harden around scraping, profiling, and deceptive social proof. + +**Anti-pattern blocks:** + +- **Unauthorized scraping and botting** + - Detection: non-approved ingestion paths, anti-abuse events, abnormal automated interaction patterns + - Consequence: account restrictions, access loss, trust/legal risk + - Mitigation: API-only allowlist, crawler deny policy, kill-switch for unapproved collectors +- **Restricted member-data misuse** + - Detection: member social/profile data flowing into lead enrichment or audience generation + - Consequence: explicit terms violation risk + - Mitigation: purpose-tag enforcement, blocked joins/exports, policy checks pre-deploy +- **Retention drift** + - Detection: no per-field TTL, stale member data beyond documented windows + - Consequence: storage-requirement non-compliance + - Mitigation: retention-as-code + deletion fanout +- **Unverifiable identity linking** + - Detection: low-confidence merges and collision-heavy identity graph + - Consequence: wrong recommendations and deletion/DSAR failure risk + - Mitigation: deterministic-first matching, confidence floor, quarantine low-confidence links + +**Risk prioritization:** + +1. Unauthorized scraping/access bypass +2. Restricted member-data misuse +3. Retention/deletion non-compliance +4. Unverifiable identity linking +5. Over-collection and opaque profiling assumptions + +**Sources:** + +- [Restricted Uses of LinkedIn Marketing APIs and Data (updated 2025)](https://learn.microsoft.com/en-us/linkedin/marketing/restricted-use-cases?view=li-lms-2026-01) +- [LinkedIn Marketing API Data Storage Requirements (updated 2026)](https://learn.microsoft.com/en-us/linkedin/marketing/data-storage-requirements?view=li-lms-2026-01) +- [LinkedIn Marketing API Terms](https://www.linkedin.com/legal/l/marketing-api-terms) +- [LinkedIn API Terms of Use](https://www.linkedin.com/legal/l/api-terms-of-use) +- [LinkedIn User Agreement](https://www.linkedin.com/legal/user-agreement) +- [Prohibited software and extensions (LinkedIn Help)](https://www.linkedin.com/help/linkedin/answer/a1341387/prohibited-software-and-extensions?lang=en) +- [ICO joint statement follow-up on scraping (10/28/2024)](https://ico.org.uk/about-the-ico/media-centre/news-and-blogs/2024/10/global-privacy-authorities-issue-follow-up-joint-statement-on-data-scraping-after-industry-engagement/) +- [EDPB Opinion 28/2024](https://www.edpb.europa.eu/our-work-tools/our-documents/opinion-board-art-64/opinion-282024-certain-data-protection-aspects_en) +- [FTC final rule on fake reviews/testimonials (08/14/2024)](https://www.ftc.gov/news-events/news/press-releases/2024/08/federal-trade-commission-announces-final-rule-banning-fake-reviews-testimonials) + +--- + +## Synthesis + +The central synthesis is that social graph segmentation quality is now constrained more by evidence discipline and policy discipline than by raw data volume. In 2024-2026 references, both buying behavior and platform governance moved in ways that punish naive single-thread mapping and permissive data handling. + +For this skill, the best upgrade is to convert from "enrich contacts + infer influence" into a governed evidence pipeline: + +1. Role map and path map separately. +2. Score authority, influence, accessibility separately. +3. Force freshness and corroboration gates before strong claims. +4. Keep LinkedIn data-use restrictions explicit in execution logic. +5. Publish limitations and contradictions instead of smoothing them away. + +--- + +## Recommendations for SKILL.md + +- [x] Add a strict evidence contract separating role authority, influence, and accessibility. +- [x] Add staged methodology with explicit quality gates (coverage, freshness, corroboration). +- [x] Keep `Available Tools` examples limited to known method IDs currently used by this skill. +- [x] Add explicit API reality notes (versioning, endpoint drift, scope/tier checks). +- [x] Add confidence and contradiction policy. +- [x] Add named anti-pattern warning blocks. +- [x] Add compliance guardrails for restricted member data and retention windows. +- [x] Expand schema for provenance, confidence decomposition, and conflict tracking. +- [x] Add pre-`write_artifact` quality checklist. + +--- + +## Draft Content for SKILL.md + +### Draft: Core operating contract + +Use professional social graph profiling for business contexts only. +Your goal is to produce a **defensible influence map**, not a complete org-chart fantasy. + +Core rules: + +- Keep `authority`, `influence`, and `accessibility` as separate signals. +- Do not convert uncertain role/path evidence into high-confidence conclusions. +- Prefer transparent `limitations` over forced certainty. +- Never invent method IDs, endpoints, or hidden data access. +- Never store full names + emails together in artifact output. Use source references or anonymized contact IDs. + +Evidence classes: + +- `observed`: directly retrieved from tool output +- `derived`: computed from observed fields with explicit formula +- `hypothesized`: informed inference requiring downstream validation + +Only `observed` + `derived` evidence may support `strong` recommendations. + +--- + +### Draft: Methodology sequence (must run in order) + +1. **Scope lock** + - Confirm one segment scope per run. + - Define target account list or target-domain list before enrichment calls. + - Record query boundaries in `run_metadata`. + +2. **Preflight and tool health** + - Check connector availability first. + - If key connector unavailable, continue with partial path but cap confidence. + +3. **Initial role candidate extraction** + - Use PDL + Apollo searches for role/title candidates by domain/company. + - Enrich candidates with available profile fields from approved sources. + - Normalize title variants into role buckets (`economic_buyer`, `technical_buyer`, `champion`, `influencer`, `procurement`, `gatekeeper`). + +4. **Organization context collection** + - Pull org-level topical context from LinkedIn organization posts. + - Use org context as relevance context only; do not infer member-level authority solely from org posting patterns. + +5. **Path and accessibility estimation** + - Add known route hints (shared context, known relationship references, TeamLink-like route indicators when available to user environment). + - Mark route quality as `direct`, `warm_possible`, `unknown`, not binary yes/no. + +6. **Freshness and churn checks** + - Identify stale profiles and potential role changes. + - Penalize stale nodes in confidence scoring. + +7. **Influence graph assembly** + - Build account-level map with explicit node/edge evidence refs. + - Keep missing nodes explicit (`unknown placeholder`) instead of hiding gaps. + +8. **Quality gates before classification** + - Coverage gate: no single-thread graph for strong recommendations. + - Freshness gate: stale core contacts prevent high confidence. + - Corroboration gate: at least two independent evidence dimensions for strong claims. + +9. **Classification + confidence** + - Emit role and path recommendations with confidence + caveats. + - Record contradictions and unresolved assumptions. + +10. **Artifact write** + - Write one artifact for one segment scope. + - Include `limitations`, `contradictions`, and `next_checks` every run. + +--- + +### Draft: Available Tools (known skill method IDs only) + +```python +linkedin_b2b( + op="call", + args={ + "method_id": "linkedin_b2b.organization_posts.list.v1", + "organization_id": "12345", + "count": 20 + } +) + +pdl( + op="call", + args={ + "method_id": "pdl.person.enrichment.v1", + "profile": "linkedin.com/in/username", + "pretty": True + } +) + +pdl( + op="call", + args={ + "method_id": "pdl.person.search.v1", + "query": { + "bool": { + "must": [ + {"term": {"job_company_website": "company.com"}}, + {"term": {"job_title_role": "engineering"}} + ] + } + }, + "size": 10 + } +) + +apollo( + op="call", + args={ + "method_id": "apollo.people.search.v1", + "q_organization_domains": ["company.com"], + "person_titles": ["CTO", "VP Engineering", "Head of Product"], + "per_page": 25 + } +) + +clearbit( + op="call", + args={ + "method_id": "clearbit.person.enrichment.v1", + "email": "contact@company.com" + } +) +``` + +Call-order default: + +1. role candidate search (`pdl.person.search.v1`, `apollo.people.search.v1`) +2. candidate enrichment (`pdl.person.enrichment.v1`, `clearbit.person.enrichment.v1`) +3. org context (`linkedin_b2b.organization_posts.list.v1`) +4. graph assembly + scoring + artifact write + +--- + +### Draft: API reality notes (must-follow) + +- LinkedIn Marketing APIs are versioned and require `Linkedin-Version: YYYYMM` in direct API integrations. +- LinkedIn rate limits are endpoint-specific and read from Developer Portal analytics, not static docs. +- LinkedIn Community/Marketing access is use-case gated and tier-gated. +- LinkedIn member-data restrictions are strict for certain use classes (especially sales/recruiting CRM enrichment from member data in restricted contexts). +- Keep "official endpoint" and "provider wrapper" evidence classes separate. +- PDL and Apollo endpoint semantics are explicit in public docs; still treat plan/rate details as tenant-dependent. +- Clearbit legacy naming can remain in workflows, but endpoint assumptions should be validated due ecosystem transition. + +--- + +### Draft: Interpretation policy and confidence scoring + +Scoring dimensions per contact: + +- `authority_score` (formal role influence on decision) +- `influence_score` (ability to move internal consensus) +- `accessibility_score` (practical path-to-conversation likelihood) + +Do not collapse these into one hidden score before reporting. + +Default weighted aggregate (heuristic): + +`composite_score = 0.45*authority_score + 0.35*influence_score + 0.20*accessibility_score` + +Confidence model (heuristic): + +- Start at `0.50` +- `+0.10` if role evidence corroborated by >=2 sources +- `+0.10` if path evidence includes explicit warm-route indicator +- `+0.10` if evidence freshness is within policy window +- `+0.10` if account map has multi-role coverage +- `-0.10` for each unresolved contradiction +- `-0.10` if only one source family is available +- `-0.15` if key connector failure prevents required evidence + +Confidence tiers: + +- `high`: `0.80-1.00` +- `medium`: `0.60-0.79` +- `low`: `0.40-0.59` +- `insufficient`: `<0.40` + +Strong recommendation gate: + +- requires at least 2 evidence dimensions +- cannot be based on a single stale contact +- cannot pass with unresolved major contradiction + +--- + +### Draft: Anti-pattern warning blocks + +#### WARNING: Single-threaded account map +**What it looks like:** Only one meaningful stakeholder is mapped. +**Detection signal:** `decision_makers` has one contact and no alternate route. +**Consequence:** High fragility and false confidence. +**Mitigation:** Require at least 3 role buckets or return `insufficient_data`. + +#### WARNING: Title equals authority shortcut +**What it looks like:** Seniority/title is used as sole authority evidence. +**Detection signal:** `authority_score` has no corroborating evidence refs. +**Consequence:** Mis-prioritized outreach. +**Mitigation:** Require corroboration from at least one additional source signal. + +#### WARNING: Path certainty inflation +**What it looks like:** "Warm intro available" from weak or stale path hints. +**Detection signal:** path marked `direct` without evidence timestamp. +**Consequence:** Action failure and trust loss. +**Mitigation:** Track path freshness and downgrade to `warm_possible` when uncertain. + +#### WARNING: Restricted member-data misuse +**What it looks like:** LinkedIn member data repurposed for prohibited prospecting flows. +**Detection signal:** member-social/profile data exported into CRM lead append workflows. +**Consequence:** terms/compliance risk. +**Mitigation:** purpose-limited processing, blocked transfers, explicit data-use controls. + +#### WARNING: Retention drift +**What it looks like:** No TTL rules for restricted data fields. +**Detection signal:** field-level age exceeds policy windows. +**Consequence:** policy and audit failures. +**Mitigation:** retention-as-code with automated purge jobs. + +--- + +### Draft: Compliance and data handling guardrails + +Before run: + +- Confirm data sources are approved APIs or approved imported systems. +- Reject scraping-style collection paths. + +During run: + +- Keep LinkedIn-sourced member data in policy-safe bounds. +- Keep organization-level and member-level data classes separate. +- Store only fields needed for the current skill outcome. + +After run: + +- Apply field-level retention and deletion policies. +- Keep lineage metadata for every evidence item. +- If requested use case appears restricted, return transparent limitation instead of workaround collection. + +--- + +### Draft: Result-state policy + +Use these states: + +- `ok`: sufficient coherent evidence +- `ok_with_conflicts`: useful evidence exists but material contradictions remain +- `zero_results`: no relevant candidates despite valid execution +- `insufficient_data`: data present but too sparse/noisy for safe recommendation +- `technical_failure`: tool/connector errors blocked core analysis +- `auth_required`: required source authentication missing + +When conflicts exist: + +- keep evidence-backed partial insights +- record conflict and confidence impact +- avoid binary overclaims + +--- + +### Draft: Expanded artifact schema (paste-ready) + +```json +{ + "segment_social_graph_profile": { + "type": "object", + "required": [ + "segment_id", + "profiled_at", + "result_state", + "accounts", + "confidence", + "confidence_grade", + "limitations", + "next_checks" + ], + "additionalProperties": false, + "properties": { + "segment_id": { + "type": "string" + }, + "profiled_at": { + "type": "string", + "description": "ISO timestamp for run completion." + }, + "result_state": { + "type": "string", + "enum": [ + "ok", + "ok_with_conflicts", + "zero_results", + "insufficient_data", + "technical_failure", + "auth_required" + ] + }, + "accounts": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "domain", + "decision_makers", + "influence_paths", + "coverage_state" + ], + "additionalProperties": false, + "properties": { + "domain": { + "type": "string" + }, + "coverage_state": { + "type": "string", + "enum": [ + "multi_threaded", + "single_threaded", + "sparse" + ] + }, + "decision_makers": { + "type": "array", + "items": { + "type": "object", + "required": [ + "contact_ref", + "buyer_role", + "authority_score", + "influence_score", + "accessibility_score", + "confidence", + "evidence_refs" + ], + "additionalProperties": false, + "properties": { + "contact_ref": { + "type": "string", + "description": "Anonymized reference to source-system contact." + }, + "buyer_role": { + "type": "string", + "enum": [ + "economic_buyer", + "technical_buyer", + "champion", + "influencer", + "procurement", + "gatekeeper", + "unknown" + ] + }, + "seniority": { + "type": "string", + "enum": [ + "c_level", + "vp", + "director", + "manager", + "ic", + "unknown" + ] + }, + "authority_score": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "influence_score": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "accessibility_score": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "composite_score": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "confidence_grade": { + "type": "string", + "enum": [ + "high", + "medium", + "low", + "insufficient" + ] + }, + "freshness_days": { + "type": "integer", + "minimum": 0, + "description": "Age of most recent corroborating evidence." + }, + "evidence_refs": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "contradiction_flags": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "influence_paths": { + "type": "array", + "items": { + "type": "object", + "required": [ + "from_contact_ref", + "to_contact_ref", + "path_type", + "path_quality", + "evidence_refs" + ], + "additionalProperties": false, + "properties": { + "from_contact_ref": { + "type": "string" + }, + "to_contact_ref": { + "type": "string" + }, + "path_type": { + "type": "string", + "enum": [ + "direct", + "teamlink_like", + "shared_context", + "unknown" + ] + }, + "path_quality": { + "type": "string", + "enum": [ + "high", + "medium", + "low", + "unknown" + ] + }, + "evidence_refs": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "org_topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Organization-level context from public company activity." + } + } + } + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "confidence_grade": { + "type": "string", + "enum": [ + "high", + "medium", + "low", + "insufficient" + ] + }, + "confidence_components": { + "type": "object", + "additionalProperties": false, + "properties": { + "source_diversity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "freshness": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "coverage": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "contradiction_penalty": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + }, + "limitations": { + "type": "array", + "items": { + "type": "string" + } + }, + "contradictions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "topic", + "note", + "impact" + ], + "additionalProperties": false, + "properties": { + "topic": { + "type": "string" + }, + "note": { + "type": "string" + }, + "impact": { + "type": "string", + "enum": [ + "minor", + "moderate", + "major" + ] + } + } + } + }, + "next_checks": { + "type": "array", + "items": { + "type": "string" + } + }, + "run_metadata": { + "type": "object", + "additionalProperties": false, + "properties": { + "sources_used": { + "type": "array", + "items": { + "type": "string" + } + }, + "time_window": { + "type": "object", + "required": [ + "start_date", + "end_date" + ], + "additionalProperties": false, + "properties": { + "start_date": { + "type": "string" + }, + "end_date": { + "type": "string" + } + } + } + } + } + } + } +} +``` + +--- + +### Draft: Recording and quality checklist + +```python +write_artifact( + artifact_type="segment_social_graph_profile", + path="/segments/{segment_id}/social-graph", + data={...} +) +``` + +Pre-write checklist: + +1. All emitted contacts have anonymized `contact_ref` (no full name + email pair in output). +2. Role, influence, and accessibility are scored separately. +3. Strong recommendations pass coverage/freshness/corroboration gates. +4. Contradictions are recorded, not hidden. +5. Confidence score and confidence grade are internally consistent. +6. Limitations and next checks are concrete. +7. Data handling follows source restrictions and retention requirements. + +If any check fails, downgrade confidence and/or set `result_state` to `insufficient_data`. + +--- + +## Gaps and Uncertainties + +- LinkedIn Help pages often provide relative freshness labels ("x months ago"), not absolute publish timestamps. +- Sales Navigator features and visibility differ by license tier and org-level configuration; run-time entitlement can vary. +- Clearbit paid endpoint verification is partially constrained by gated docs; endpoint assumptions should remain conservative. +- Public benchmark numbers for social influence and engagement are methodological references, not universal constants. +- This research is operational guidance, not legal advice; production policy decisions should include legal/compliance review. diff --git a/flexus_simple_bots/researcher/skills/_segment-social-graph/SKILL.md b/flexus_simple_bots/researcher/skills/_segment-social-graph/SKILL.md new file mode 100644 index 00000000..b54547b1 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_segment-social-graph/SKILL.md @@ -0,0 +1,50 @@ +--- +name: segment-social-graph +description: Professional social graph profiling — decision-maker mapping, influence network analysis, and contact-level enrichment +--- + +You map professional influence networks and enrich contact-level profiles for target segments. Social graph data reveals who has decision authority, who influences them, and what path-to-champion looks like inside a target account. + +Core mode: professional context only. Do not use personal social data (non-LinkedIn). Enrich only for legitimate business prospecting. PII compliance: never store full names + emails together in artifact output. Use anonymized IDs referencing source systems. + +## Methodology + +### Decision-maker mapping +For a target company, identify: economic buyer, technical buyer, user champion, influencer. +Use `pdl.person.enrichment.v1` with role filtering to find contacts at specific titles. +Cross-validate with `apollo.people.search.v1` for current role verification. + +### LinkedIn org structure +Use `linkedin_b2b.organization_posts.list.v1` to understand what topics the organization publicly discusses — this reveals what the leadership team cares about. + +Use `clearbit.person.enrichment.v1` to enrich known contacts with LinkedIn URL, bio, and prior company history. + +### Influence network patterns +- Shared board members or investors between companies: signals shared values/priorities +- Mutual connections: can be leveraged for warm introductions +- Employee-to-employee referral network at target company: maps who knows who + +### Alumni networks +People who have worked at a company in the past are often still influential in buying decisions at former employers. Check prior company history in PDL person data. + +## Recording + +``` +write_artifact(path="/segments/{segment_id}/social-graph", data={...}) +``` + +## Available Tools + +``` +linkedin_b2b(op="call", args={"method_id": "linkedin_b2b.organization_posts.list.v1", "organization_id": "12345", "count": 20}) + +linkedin_b2b(op="call", args={"method_id": "linkedin_b2b.followers.stats.get.v1", "organization_id": "12345"}) + +pdl(op="call", args={"method_id": "pdl.person.enrichment.v1", "profile": "linkedin.com/in/username", "pretty": true}) + +pdl(op="call", args={"method_id": "pdl.person.search.v1", "query": {"bool": {"must": [{"term": {"job_company_website": "company.com"}}, {"term": {"job_title_role": "engineering"}}]}}, "size": 10}) + +apollo(op="call", args={"method_id": "apollo.people.search.v1", "q_organization_domains": ["company.com"], "person_titles": ["CTO", "VP Engineering", "Head of Product"], "per_page": 25}) + +clearbit(op="call", args={"method_id": "clearbit.person.enrichment.v1", "email": "contact@company.com"}) +``` diff --git a/flexus_simple_bots/researcher/skills/_segment-voice-of-market/RESEARCH.md b/flexus_simple_bots/researcher/skills/_segment-voice-of-market/RESEARCH.md new file mode 100644 index 00000000..a583603b --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_segment-voice-of-market/RESEARCH.md @@ -0,0 +1,933 @@ +# Research: segment-voice-of-market + +**Skill path:** `flexus-client-kit/flexus_simple_bots/researcher/skills/segment-voice-of-market/` +**Bot:** researcher +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`segment-voice-of-market` extracts market signal from app-store reviews for product direction, issue detection, and competitor positioning. The skill is used when teams need evidence from unsolicited customer feedback rather than survey-only or support-only channels. + +This domain has changed materially in 2024-2026: both stores increased operational guidance around review quality and moderation, Apple launched LLM-generated review summaries, and policy/privacy expectations became stricter for AI-assisted processing and quote reuse. The skill therefore needs a stricter methodology than "read top negative reviews and summarize." It must enforce time windows, segmentation, confidence gates, and source/provenance guardrails. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024-2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- No generic filler ("it is important to...", "best practices suggest...") without concrete backing +- No invented tool names, method IDs, or API endpoints - only verified real ones +- Contradictions between sources are explicitly noted, not silently resolved +- Volume: findings section should be 800-4000 words (too short = shallow, too long = unsynthesized) + +Gate check result: passed. + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +1. **Release-anchored cadence is now the default operational model.** Teams that do this well do not use one reporting window. They run `T+24-72h` post-release triage, then `T+28d` stabilization, then `T+90d` trend confirmation. This aligns with Play's release-centered reporting and avoids overreacting to launch-day volatility. + +2. **Recency matters more than lifetime averages.** Play explicitly frames displayed rating as weighted toward current quality and also notes publication lag for ratings/reviews. Practical implication: provisional conclusions in the first 24-48h, then confidence-upgraded verdicts once lagged data arrives. + +3. **Version/locale/device segmentation is mandatory for valid attribution.** Both Apple and Google expose filters that make all-in-one averages methodologically weak. Teams should treat version, country/region, and language as first-class keys; otherwise they misattribute localized outages or release regressions as global trends. + +4. **Theme extraction works best with a two-layer model.** Use store-native benchmark categories (for comparability) plus open clustering (for novel issue discovery). Fixed categories help trend comparability; open clustering finds emerging problems that taxonomy buckets miss. + +5. **Sentiment should be explicit and formula-bound, not opaque.** Vendor workflows increasingly pair star trends with sentiment trend windows (often 30-60 day baselines and 24-72h post-release checks). The skill should force formula disclosure so outputs are auditable across tools. + +6. **Review-reply operations are part of VoM signal, not only support workflow.** Google reports measurable rating uplift after replying to negative reviews; Apple also encourages response cadence around key updates. Response rate and response latency should be included in interpretation. + +7. **Multilingual handling requires original-language QA on top themes.** Auto-translation is useful but insufficient for high-impact prioritization. A robust method preserves original text, translated text, and locale tags, then validates top negative themes in the source language. + +8. **Competitive benchmarking should separate absolute quality from relative deltas.** Peer comparisons and competitor review mining are useful only if methodology is transparent (same time window, same segmentation, same filters). Raw quote cherry-picking is not acceptable evidence. + +9. **Review-request quotas shape the data-generation process.** Apple and Google in-app review prompt rules limit prompt frequency and display behavior; analysts must treat this as a sampling constraint when interpreting changes in review mix. + +10. **What changed recently (2024-2026):** more platform-native summarization and benchmark surfaces, stronger anti-manipulation enforcement language, and more practical emphasis on release-linked diagnostics rather than static monthly averages. + +**Sources:** +- https://support.google.com/googleplay/android-developer/answer/7383463?hl=en +- https://support.google.com/googleplay/android-developer/answer/138230 +- https://play.google.com/console/about/ratings +- https://developer.apple.com/help/app-store-connect/monitor-ratings-and-reviews/view-ratings-and-reviews +- https://developer.apple.com/help/app-store-connect/monitor-ratings-and-reviews/ratings-and-reviews-overview +- https://developer.apple.com/app-store/ratings-and-reviews/ +- https://developer.apple.com/documentation/storekit/requesting_app_store_reviews +- https://developer.android.com/guide/playcore/in-app-review +- https://appfollow.io/blog/customer-sentiment-score +- https://www.apptweak.com/en/aso-blog/why-how-to-analyze-app-store-reviews + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +1. **Apple first-party review ingestion is mature and explicit.** App Store Connect API exposes real review resources (`/v1/apps/{id}/customerReviews`, `/v1/appStoreVersions/{id}/customerReviews`) and response lifecycle endpoints. This supports first-party ingestion and response tracking without scraping. + +2. **Apple auth and limits are explicit.** JWT (`ES256`) plus key/role control is required; rate limits are discoverable via response headers and 429 behavior. This is strong for production-safe retry/backoff design. + +3. **Apple's API surface continued changing in 2024-2026.** Release notes indicate ongoing expansion, including review-related resources and summarization capabilities. The skill should assume API surface evolution and avoid hard-coded assumptions. + +4. **Google official review API is intentionally narrow.** Core methods are `reviews.list`, `reviews.get`, and `reviews.reply`. Limitations matter: production-track scope, focus on review/comment records, and time-window constraints for modified/new records in API fetch patterns. + +5. **Google quota controls are multi-layered.** There are review-specific quotas and broader bucket quotas. Any automated workflow needs queueing and per-app budget controls to avoid accidental throttling. + +6. **Third-party tools fill historical and analytics gaps.** AppFollow, AppTweak, 42matters, and Appbot offer review analytics and enrichment, but differ materially in pricing model, access model, and historical depth. + +7. **Third-party APIs are commonly credit-metered.** AppFollow and AppTweak expose credit/billing and rate-limit mechanics that can become the main operational constraint for broad competitor sweeps. + +8. **Historical backfill is the main reason teams add data vendors.** Google first-party access constraints often require either console exports or paid providers for complete historical analysis. + +9. **Tool capability mismatch is common.** Some tools optimize for response workflows, others for analytics depth or topic enrichment. A single provider rarely wins all use cases; a source-mapping matrix is required. + +10. **De-facto architecture in 2025-2026:** first-party APIs for trusted operational data and replying, plus one enrichment vendor for cross-app analytics and history where needed. + +**Sources:** +- https://developer.apple.com/documentation/appstoreconnectapi/list_all_customer_reviews_for_an_app +- https://developer.apple.com/documentation/appstoreconnectapi/get-v1-appstoreversions-_id_-customerreviews +- https://developer.apple.com/documentation/appstoreconnectapi/customer-review-responses +- https://developer.apple.com/documentation/appstoreconnectapi/identifying-rate-limits +- https://developer.apple.com/documentation/appstoreconnectapi/generating-tokens-for-api-requests +- https://developer.apple.com/documentation/appstoreconnectapi/app-store-connect-api-2-0-release-notes +- https://developer.apple.com/documentation/appstoreconnectapi/app-store-connect-api-4-0-release-notes +- https://developers.google.com/android-publisher/api-ref/rest/v3/reviews +- https://developers.google.com/android-publisher/reply-to-reviews +- https://developers.google.com/android-publisher/quotas +- https://docs.api.appfollow.io/reference/reviews_api_v2_reviews_get-1 +- https://developers.apptweak.com/reference/app-reviews +- https://42matters.com/docs/app-market-data/android/apps/reviews +- https://support.appbot.co/help-docs/app-store-review-rating-api-for-google-play-ios + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +1. **Displayed rating and lifetime rating are different signals.** Play's displayed rating emphasizes recent quality, so interpretation should always include both current and lifetime context to avoid trend distortion. + +2. **Do not claim release impact from day-zero shifts.** Play indicates publication delays and potential abuse-review holds; minimum 48h cooldown is needed before directional claim language. + +3. **Use platform windows for consistency (`daily`, `7d`, `28d`) and role separation.** Daily is for incident triage, 7-day for short trend, 28-day for baseline comparison. + +4. **Sample-size gates are required for directional claims.** + - 95% CI, +/-10 percentage points precision: around `n~97` + - 95% CI, +/-5 percentage points precision: around `n~385` + These are standard proportion-planning references and should be explicitly marked as **evergreen statistical guidance** when used. + +5. **Wilson/Jeffreys intervals are preferred for sparse segments (evergreen statistical guidance).** Normal/Wald intervals can mislead for small `n` or extreme rates. + +6. **Average star alone is vulnerable to self-selection bias (evergreen research finding).** Use full distribution and recurrence-weighted theme analysis rather than a mean-only dashboard. + +7. **Structural breaks must be modeled explicitly.** Apple rating resets and platform moderation events break continuity; pre/post pooling without flags invalidates trend claims. + +8. **Theme/sentiment coverage must disclose language and volume constraints.** Play benchmark summaries are constrained (for example language scope and minimum similarity volume behavior), and Apple summaries are generated through a filtered pipeline with anti-fraud/profanity handling. + +9. **Fraud/manipulation risk is not theoretical in 2024-2026.** Policy updates and enforcement plus independent fraud reporting support adding suspicious-burst checks before theme prioritization. + +10. **Reply effect can be measured, but causality claims should be conservative.** Cohort comparison (`with_reply` vs `without_reply`) is useful; causal language needs stronger design than correlation. + +**Sources:** +- https://support.google.com/googleplay/android-developer/answer/138230 +- https://play.google.com/about/comment-posting-policy.html +- https://play.google.com/console/about/ratings/ +- https://developer.apple.com/help/app-store-connect/monitor-ratings-and-reviews/reset-an-app-overview-rating +- https://developer.apple.com/help/app-store-connect/monitor-ratings-and-reviews/ratings-and-reviews-overview +- https://machinelearning.apple.com/research/app-store-review +- https://www.ftc.gov/news-events/news/press-releases/2024/08/federal-trade-commission-announces-final-rule-banning-fake-reviews-testimonials +- https://www.ftc.gov/business-guidance/resources/consumer-reviews-testimonials-rule-questions-answers +- https://www.itl.nist.gov/div898/handbook/prc/section2/prc242.htm (evergreen) +- https://www.itl.nist.gov/div898/handbook/prc/section2/prc241.htm (evergreen) +- https://aisel.aisnet.org/misq/vol41/iss2/8/ (evergreen) + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +1. **Low-volume panic:** acting on tiny post-release spikes without sample-size gates. + - Detection: release claim made in first 24h or with very small `n`. + - Consequence: roadmap churn and false hotfixes. + - Mitigation: require cooldown + minimum sample + crash/uninstall corroboration. + +2. **Causality overreach:** assuming star shift directly caused retention/LTV changes. + - Detection: no cohort design, no confound checks, only narrative linkage. + - Consequence: wrong optimization target. + - Mitigation: treat ratings as observational; pair with retention/churn cohorts. + +3. **Competitor cherry-picking:** selecting dramatic reviews without denominator. + - Detection: no peer cohort, no symmetric date/version/locale filters. + - Consequence: biased competitive strategy. + - Mitigation: enforce declared sampling frame and parity rules. + +4. **No version segmentation:** mixing all-time reviews for release decisions. + - Detection: missing version key in report. + - Consequence: hidden regression root causes. + - Mitigation: mandatory version slices in all conclusions. + +5. **No territory/language segmentation:** global average used as "market truth." + - Detection: no regional breakdown for major claims. + - Consequence: local failures are invisible. + - Mitigation: require region/language view for each major finding. + +6. **PII leakage in quote handling:** raw quote reuse in tickets/prompts/marketing. + - Detection: names, contact data, or identifiable details in exported quote sets. + - Consequence: legal and trust risk. + - Mitigation: redact, limit access, and permission-gate external quote usage. + +7. **Fake-review contamination:** no authenticity filter before clustering. + - Detection: unusual burst/duplicate pattern and polarity anomalies. + - Consequence: contaminated theme ranking. + - Mitigation: suspiciousness scoring and sensitivity reruns excluding flagged reviews. + +8. **Automation hallucination:** LLM themes without quote-level provenance. + - Detection: output cannot map to source review IDs or confidence is absent. + - Consequence: fabricated insight and misprioritized roadmap actions. + - Mitigation: require source trace, confidence, and abstain behavior. + +**Sources:** +- https://support.google.com/googleplay/android-developer/answer/138230 +- https://support.google.com/googleplay/android-developer/answer/7383463?hl=en +- https://support.google.com/googleplay/android-developer/answer/9842755?hl=en +- https://support.google.com/googleplay/android-developer/answer/10771707?hl=en +- https://developer.apple.com/help/app-store-connect/monitor-ratings-and-reviews/view-ratings-and-reviews +- https://developer.apple.com/app-store/ratings-and-reviews/ +- https://doubleverify.com/blog/web/verify/the-hidden-threat-of-ai-powered-fake-app-reviews +- https://www.reuters.com/technology/sonos-ceo-promises-reforms-after-may-app-release-failure-leaders-forgo-bonuses-2024-10-01/ +- https://aclanthology.org/2025.findings-naacl.293/ +- https://www.nist.gov/publications/artificial-intelligence-risk-management-framework-generative-artificial-intelligence + +--- + +### Angle 5+: Policy, Compliance & Governance Constraints +> Add as many additional angles as the domain requires. Examples: regulatory/compliance context, industry-specific nuances, integration patterns with adjacent tools, competitor landscape, pricing benchmarks, etc. + +**Findings:** + +1. **Official collection paths are preferred and lower risk; scraping is high risk.** Apple and Google provide official mechanisms for review data and replies; terms materially restrict unauthorized scraping/redistribution behavior. + +2. **Reply policies are explicit and enforceable.** Both platforms require respectful, non-manipulative responses and discourage disclosure of personal/confidential user information. + +3. **Apple quote reuse is specifically permission-gated for marketing.** Apple allows accurate rating references but requires reviewer permission for using customer review quotes in marketing contexts. + +4. **Review text is potentially personal data when combined with metadata.** Storage and downstream sharing (especially to third-party AI processors) must respect disclosure, minimization, and retention/deletion controls. + +5. **AI governance obligations are increasing (2024-2026).** Google now has explicit AI content policy framing; EU AI Act timing introduces phased obligations including transparency/governance requirements. + +6. **Allowed-vs-risky use should be encoded in artifact fields, not only process docs.** The skill output should carry provenance, usage-rights notes, and redistribution scope so downstream automation cannot accidentally violate constraints. + +7. **Jurisdiction and copyright nuance remains a real uncertainty area.** Public review visibility does not remove all legal constraints; use conservative defaults where rights are ambiguous. + +**Sources:** +- https://developer.apple.com/documentation/appstoreconnectapi/list_all_customer_reviews_for_an_app +- https://developers.google.com/android-publisher/reply-to-reviews +- https://www.apple.com/legal/internet-services/itunes/us/terms.html +- https://developers.google.com/terms +- https://developers.google.com/android-publisher/terms +- https://developer.apple.com/app-store/review/guidelines/ +- https://developer.apple.com/app-store/ratings-and-reviews/ +- https://support.google.com/googleplay/android-developer/answer/9888076 +- https://support.google.com/googleplay/android-developer/answer/13985936 +- https://digital-strategy.ec.europa.eu/en/policies/regulatory-framework-ai + +--- + +## Synthesis + +The strongest cross-source pattern is that mature app-review VoM programs are moving from static "monthly sentiment summaries" toward release-aware operational analytics. Across Apple guidance, Google console/API behavior, and practitioner playbooks, the practical standard is now multi-window analysis (post-release pulse, stabilization window, trend window), version-aware segmentation, and explicit confidence language. + +A second consistent pattern is that first-party APIs are necessary but not sufficient. Apple and Google official sources are the trust anchor for ingestion and response workflows, but each has constraints that drive supplemental workflows (for example historical depth, enrichment, and cross-app benchmarking). This is why most production setups combine first-party data collection with selective third-party enrichment. + +The most important contradiction is speed versus validity. Some operational guides push very fast reaction SLAs, while platform mechanics (review publication lag, moderation holds, version rollout dynamics) argue for stronger evidence gates before directional claims. The correct synthesis is not "slow down everything"; it is "separate fast triage from high-confidence decisioning." + +A second contradiction is comparability versus discovery. Fixed taxonomies and benchmark categories improve longitudinal tracking, but open clustering is required for novel issue discovery. The skill should support both and require explicit disclosure of which layer produced each conclusion. + +Most surprising in 2024-2026 is how quickly policy and governance concerns became first-class for this domain: fake-review enforcement, AI summarization safety expectations, and legal/contractual quote-usage constraints now materially affect how a "simple review analysis" system must be built and audited. + +--- + +## Recommendations for SKILL.md + +- [ ] Add a mandatory tri-window analysis cadence (`T+72h`, `T+28d`, `T+90d`) and distinguish provisional vs directional claims. +- [ ] Add explicit minimum evidence gates (`n` thresholds + confidence interval method) before writing directional conclusions. +- [ ] Add required segmentation fields for every claim: `app_version`, `country_region`, `language`, and `platform`. +- [ ] Add dual-layer theme extraction instructions: benchmark taxonomy + open clustering, with provenance labels. +- [ ] Expand tool guidance to include official endpoint references, quota/rate-limit behavior, and when to use first-party vs third-party sources. +- [ ] Add anti-pattern warning blocks with detection signals and exact mitigations. +- [ ] Add response-effect analysis instructions (with-reply vs without-reply cohorts, no default causal language). +- [ ] Add compliance/provenance rules for quote reuse, AI processing disclosure, and redistribution scope. +- [ ] Extend artifact schema with `source_provenance`, `quality_signals`, `coverage`, and `compliance` objects (`additionalProperties: false`). + +--- + +## Draft Content for SKILL.md + +> This is the most important section. For every recommendation above, write the **actual text** that should go into SKILL.md - as if you were writing it. Be verbose and comprehensive. The future editor will cut what they don't need; they should never need to invent content from scratch. +> +> Rules: +> - Write full paragraphs and bullet lists, not summaries +> - For methodology changes: write the actual instruction text in second person ("You should...", "Before doing X, always verify Y") +> - For schema changes: write the full JSON fragment with all fields, types, descriptions, enums, and `additionalProperties: false` +> - For anti-patterns: write the complete warning block including detection signal and mitigation steps +> - For tool recommendations: write the actual `## Available Tools` section text with real method syntax +> - Do not hedge or abbreviate - if a concept needs 3 paragraphs to explain properly, write 3 paragraphs +> - Mark sections with `### Draft: ` headers so the editor can navigate + +### Draft: Methodology - Analysis Cadence and Evidence Discipline + +--- +### Analysis cadence (required) + +You must run app-store voice-of-market analysis in three distinct windows. Do not collapse these windows into one score: + +1. `T+24-72h` after a release: triage window. Use this window to identify urgent regressions and support load changes. Treat conclusions as provisional because platform publication/moderation behavior can delay visible rating/review movement. +2. `T+28d` after a release: stabilization window. Use this window for directional claims about release impact and recurring pain themes. +3. `T+90d` rolling: trend window. Use this window for strategy-level calls, persistent differentiation themes, and competitive positioning. + +Before writing any directional statement, verify that your claim is anchored to one of these windows and that your evidence meets minimum sample requirements. If evidence is insufficient, explicitly output `claim_strength="descriptive"` and state what additional data is required. + +### Evidence thresholds (required) + +You must attach sample size and confidence information to every proportion-style claim (for example, "1-star share increased" or "theme X now dominates"). At minimum: + +- If `n < 30`, output only descriptive observations; do not output directional language. +- If you need +/-10 percentage-point precision at 95% confidence, target approximately `n>=97`. +- If you need +/-5 percentage-point precision at 95% confidence, target approximately `n>=385`. +- Use Wilson interval as default CI method for sparse or imbalanced segments. + +These thresholds are statistical planning defaults, not absolutes. If your method uses different assumptions, state them explicitly. + +### Claim-strength rules (required) + +For each major conclusion, set: + +- `descriptive`: pattern observed, confidence low or sample insufficient. +- `directional`: movement likely, some uncertainty remains. +- `supported`: movement supported by sample/interval quality and confirmed across at least one secondary signal (for example crash trend, uninstall trend, or sustained recurrence across windows). + +Do not output causal language (for example "this caused retention drop") unless causal design requirements are met. +--- + +### Draft: Methodology - Segmentation, Theme Extraction, and Interpretation + +--- +### Mandatory segmentation keys + +Before extracting insights, you must segment by: + +- `platform` (ios/android) +- `app_version` +- `country_region` +- `language` +- `rating_bucket` (1-2, 3, 4-5) + +If one of these keys is unavailable, include that limitation in output and reduce claim strength. + +### Theme extraction model + +You must use a two-layer model: + +1. **Comparable taxonomy layer:** classify reviews into stable categories (for example stability/performance/usability/pricing/support/privacy) so trends can be compared across windows. +2. **Discovery layer:** run open clustering to detect emerging issues not covered by fixed categories. + +Every theme in output must include: + +- `theme_name` +- `theme_layer` (`taxonomy` or `discovery`) +- `support_count` +- `support_share` +- `example_quote_ids` +- `confidence` + +Never publish a theme without source traceability. If you cannot map a theme to source review IDs, drop it. + +### Multilingual handling + +When translation is used, preserve both original text and translated text references. For top negative themes that influence prioritization, validate with original-language samples before final ranking. If language coverage is partial, output `coverage_status="partial_language_coverage"` and list excluded segments. + +### Interpretation rules + +You must report both rating distribution and textual theme evidence. Do not treat average rating as sufficient evidence. For every major claim, provide: + +- the time window, +- the sample size, +- the segment scope, +- the quality notes (for example moderation lag, low volume, translation risk). +--- + +### Draft: Available Tools - Practical Usage with Real API Anchors + +--- +### Tool usage strategy + +Use first-party APIs as the trust anchor for operational review analysis. Use third-party providers only when you need capabilities that first-party sources do not provide (for example historical backfill, cross-competitor enrichment, or topic APIs). + +#### First-party references (official API anchors) + +- Apple review list: `GET /v1/apps/{id}/customerReviews` +- Apple version review list: `GET /v1/appStoreVersions/{id}/customerReviews` +- Apple response create/update: `POST /v1/customerReviewResponses` +- Google review list: `GET /androidpublisher/v3/applications/{packageName}/reviews` +- Google review get: `GET /androidpublisher/v3/applications/{packageName}/reviews/{reviewId}` +- Google reply: `POST /androidpublisher/v3/applications/{packageName}/reviews/{reviewId}:reply` + +#### Internal connector calls (use existing verified methods) + +```python +appstoreconnect(op="call", args={ + "method_id": "appstoreconnect.customerreviews.list.v1", + "app_id": "123456789", + "filter[rating]": [1, 2, 3, 4, 5], + "sort": "-createdDate", + "limit": 100, +}) + +google_play(op="call", args={ + "method_id": "google_play.reviews.list.v1", + "packageName": "com.example.app", + "translationLanguage": "en", + "maxResults": 100, +}) + +google_play(op="call", args={ + "method_id": "google_play.reviews.get.v1", + "packageName": "com.example.app", + "reviewId": "gp:AOqpTOExample", +}) +``` + +### Rate-limit and quota behavior + +You must assume quota constraints and design your run as a bounded queue: + +- prioritize newest reviews first, +- paginate deterministically, +- back off on quota/rate-limit signals, +- record partial completion rather than failing silently, +- emit `limitations` entries when quota limits reduce coverage. + +### Source selection decision rule + +- Use first-party only when analyzing your own app and recent operational changes. +- Add third-party provider when you need historical backfill or competitor-scale coverage. +- When combining sources, annotate provenance per record and do not merge records without source identity. +--- + +### Draft: Competitive Comparison Framework + +--- +### Competitor comparison protocol (required) + +When comparing against competitors, you must declare your sampling frame before analysis: + +- peer app list, +- date range, +- segment parity rules (version/country/language), +- inclusion and exclusion rules. + +Never compare your 90-day window against a competitor 7-day window. Never compare one locale against global competitor averages without an explicit limitation note. + +### Comparison output structure + +For each peer, report: + +- volume (`review_count`) +- rating distribution (not only average) +- top negative themes with support share +- top positive themes with support share +- notable change since prior window + +Then produce a `relative_delta` view for your target app: + +- themes where target is worse than peer median, +- themes where target is better than peer median, +- uncertainty flags for low-volume segments. + +### Decision guidance + +A competitor theme should become a product-priority candidate only when: + +1. it appears in at least one target-app segment, +2. it has non-trivial support share in target data, +3. evidence holds in the stabilization or trend window. + +Do not prioritize competitor-only complaints that do not appear in your own user feedback unless strategic context explicitly requires anticipatory action. +--- + +### Draft: Anti-Patterns and Guardrails + +--- +### Warning: Low-Volume Panic + +**What it looks like:** You escalate roadmap actions from very small post-release spikes. +**Detection signal:** Claim made in first 24h or with low `n` and no interval disclosure. +**Consequence:** False urgency, wasted engineering cycles. +**Mitigation:** +1. Enforce cooldown before directional claims. +2. Enforce minimum sample thresholds. +3. Require a corroborating signal before high-priority action. + +### Warning: Causality Overreach + +**What it looks like:** "Rating drop caused retention drop." +**Detection signal:** No cohort design, no confound handling, only temporal correlation. +**Consequence:** Wrong causal narrative and misallocated effort. +**Mitigation:** +1. Label ratings as observational by default. +2. Add cohort comparison for retention/churn by version and region. +3. Prohibit causal verbs unless explicit causal method is used. + +### Warning: Competitor Cherry-Picking + +**What it looks like:** Selecting dramatic competitor quotes without denominator. +**Detection signal:** Missing declared sample frame and parity rules. +**Consequence:** Biased strategy conclusions. +**Mitigation:** +1. Require symmetric sampling rules. +2. Require support-share reporting with each quote. +3. Reject outputs that include quote-only claims. + +### Warning: Theme Hallucination + +**What it looks like:** AI-generated themes with no source review linkage. +**Detection signal:** No quote IDs, no confidence, no abstain behavior. +**Consequence:** Fabricated signal and poor prioritization. +**Mitigation:** +1. Enforce provenance per theme. +2. Include confidence and abstain thresholds. +3. Require human review before publishing strategic recommendations. + +### Warning: Privacy Leakage in Quote Reuse + +**What it looks like:** Verbatim quotes exported to broad audiences with identifiable detail. +**Detection signal:** PII in quote payloads; unknown permissions for external reuse. +**Consequence:** Policy/legal risk and trust damage. +**Mitigation:** +1. Redact and minimize. +2. Restrict access to raw text. +3. Gate external quote usage on explicit permission and rights notes. +--- + +### Draft: Compliance, Provenance, and Usage Rights Rules + +--- +### Compliance baseline + +You must treat app-review text as governed data, not free text. For each run, record: + +- where data came from, +- what terms/policies apply, +- what downstream use is allowed. + +Use official APIs/console exports whenever possible. Avoid scraping-based ingestion paths for production workflows. + +### Quote reuse and redistribution + +Do not publish verbatim review quotes externally unless usage rights are explicitly confirmed. For Apple-origin quotes, treat marketing reuse as permission-gated. If rights are unknown, default to internal analytics use only. + +### AI processing controls + +If review text is sent to AI systems, record: + +- whether third-party AI was used, +- which processor handled the text, +- what user/policy disclosures apply, +- whether moderation/flagging is enabled for generated summaries. + +If any of the above cannot be established, downgrade claim strength and add a limitation note. + +### Retention and deletion + +Store only fields needed for analysis. Set retention TTL and deletion workflow references. Preserve auditable provenance while minimizing personal-data exposure. +--- + +### Draft: Reporting Template Text for Final Analyst Output + +--- +### Required analyst narrative structure + +Your final `competitive_summary` and related narrative must follow this order: + +1. **Scope and confidence:** define window, segments, and claim strength. +2. **What changed:** list direction and magnitude for key themes/ratings. +3. **Why you believe it:** show supporting evidence counts, intervals, and quote-backed themes. +4. **What is uncertain:** list limitations (coverage gaps, quota impacts, language gaps, potential fraud noise). +5. **Action implications:** recommend next actions with confidence tags. + +### Good output example style + +"Android US, v9.4, 28-day window: performance-related 1-2 star share rose from 9.8% to 13.1% (Wilson 95% CI does not overlap prior estimate). Claim strength is `directional` because non-English segment coverage is partial. This pattern also appears in competitor delta where target underperforms peer median on stability themes." + +### Bad output example style + +"Users hate the latest release and competitors are doing much better." +--- + +### Draft: Schema additions + +> Write the full JSON Schema fragment for any new or modified artifact fields. +> Include field descriptions, enums, required arrays, and additionalProperties constraints. + +```json +{ + "segment_voice_of_market": { + "type": "object", + "required": [ + "target", + "time_window", + "result_state", + "analysis_cadence", + "methodology", + "source_provenance", + "app_snapshots", + "competitive_summary", + "quality_signals", + "coverage", + "compliance", + "limitations" + ], + "additionalProperties": false, + "properties": { + "target": { + "type": "string", + "description": "Primary app or segment target being analyzed." + }, + "time_window": { + "type": "object", + "required": [ + "start_date", + "end_date", + "window_type" + ], + "additionalProperties": false, + "properties": { + "start_date": { + "type": "string", + "description": "ISO-8601 start date for analysis window." + }, + "end_date": { + "type": "string", + "description": "ISO-8601 end date for analysis window." + }, + "window_type": { + "type": "string", + "enum": ["post_release_72h", "stabilization_28d", "trend_90d", "custom"], + "description": "Named analysis window type used for this run." + } + } + }, + "result_state": { + "type": "string", + "enum": ["ok", "zero_results", "insufficient_data", "technical_failure"], + "description": "Overall execution state for this artifact." + }, + "analysis_cadence": { + "type": "object", + "required": [ + "release_cooldown_hours", + "baseline_window_days", + "comparison_window_days" + ], + "additionalProperties": false, + "properties": { + "release_cooldown_hours": { + "type": "integer", + "minimum": 0, + "description": "Hours waited post-release before directional claims." + }, + "baseline_window_days": { + "type": "integer", + "minimum": 1, + "description": "Days in baseline window used for comparison." + }, + "comparison_window_days": { + "type": "integer", + "minimum": 1, + "description": "Days in current comparison window." + } + } + }, + "methodology": { + "type": "object", + "required": [ + "claim_strength", + "ci_method", + "sentiment_formula", + "theme_extraction_mode" + ], + "additionalProperties": false, + "properties": { + "claim_strength": { + "type": "string", + "enum": ["descriptive", "directional", "supported"], + "description": "Confidence class for major conclusions." + }, + "ci_method": { + "type": "string", + "enum": ["wilson", "jeffreys", "other"], + "description": "Confidence interval method used for proportion claims." + }, + "sentiment_formula": { + "type": "string", + "description": "Exact sentiment computation formula used in this run." + }, + "theme_extraction_mode": { + "type": "array", + "items": { + "type": "string", + "enum": ["taxonomy", "discovery_clustering"] + }, + "description": "Theme extraction layers used by analysis." + } + } + }, + "source_provenance": { + "type": "array", + "description": "Per-source provenance and policy references for ingested review data.", + "items": { + "type": "object", + "required": [ + "platform", + "collection_method", + "endpoint_or_report", + "collected_at", + "terms_url" + ], + "additionalProperties": false, + "properties": { + "platform": { + "type": "string", + "enum": ["apple_app_store", "google_play", "third_party"], + "description": "Data source platform." + }, + "collection_method": { + "type": "string", + "enum": ["official_api", "console_export", "vendor_api"], + "description": "How data was collected." + }, + "endpoint_or_report": { + "type": "string", + "description": "API endpoint or export report identifier." + }, + "collected_at": { + "type": "string", + "description": "ISO-8601 timestamp of collection." + }, + "terms_url": { + "type": "string", + "description": "Policy/terms URL governing source usage." + } + } + } + }, + "app_snapshots": { + "type": "array", + "items": { + "type": "object", + "required": [ + "app_name", + "platform", + "avg_rating", + "review_count", + "rating_distribution", + "pain_themes", + "value_themes" + ], + "additionalProperties": false, + "properties": { + "app_name": { + "type": "string", + "description": "Human-readable app name." + }, + "platform": { + "type": "string", + "enum": ["ios", "android", "both"], + "description": "Store platform context." + }, + "avg_rating": { + "type": "number", + "minimum": 1, + "maximum": 5, + "description": "Average rating in selected window." + }, + "review_count": { + "type": "integer", + "minimum": 0, + "description": "Number of reviews included in snapshot." + }, + "rating_trend": { + "type": "string", + "enum": ["improving", "stable", "declining", "unknown"], + "description": "Directional trend label for rating movement." + }, + "rating_distribution": { + "type": "object", + "required": ["one_star", "two_star", "three_star", "four_star", "five_star"], + "additionalProperties": false, + "properties": { + "one_star": {"type": "number", "minimum": 0, "maximum": 1, "description": "Share of 1-star ratings."}, + "two_star": {"type": "number", "minimum": 0, "maximum": 1, "description": "Share of 2-star ratings."}, + "three_star": {"type": "number", "minimum": 0, "maximum": 1, "description": "Share of 3-star ratings."}, + "four_star": {"type": "number", "minimum": 0, "maximum": 1, "description": "Share of 4-star ratings."}, + "five_star": {"type": "number", "minimum": 0, "maximum": 1, "description": "Share of 5-star ratings."} + } + }, + "pain_themes": { + "type": "array", + "items": {"type": "string"}, + "description": "Most frequent negative themes." + }, + "value_themes": { + "type": "array", + "items": {"type": "string"}, + "description": "Most frequent positive themes." + }, + "representative_quotes": { + "type": "array", + "items": {"type": "string"}, + "description": "Redacted review quotes for context." + } + } + } + }, + "competitive_summary": { + "type": "string", + "description": "Narrative summary of relative position and notable deltas." + }, + "quality_signals": { + "type": "object", + "required": [ + "sample_size_n", + "ci_95", + "moderation_pause_flag", + "suspicious_burst_flag" + ], + "additionalProperties": false, + "properties": { + "sample_size_n": { + "type": "integer", + "minimum": 0, + "description": "Total sample count supporting key claim." + }, + "ci_95": { + "type": "string", + "description": "95% confidence interval summary for key proportion claims." + }, + "moderation_pause_flag": { + "type": "boolean", + "description": "True when platform moderation/publish delays may distort short-window interpretation." + }, + "suspicious_burst_flag": { + "type": "boolean", + "description": "True when review burst pattern indicates possible manipulation/noise." + } + } + }, + "coverage": { + "type": "object", + "required": [ + "language_scope", + "country_scope", + "excluded_segments" + ], + "additionalProperties": false, + "properties": { + "language_scope": { + "type": "string", + "description": "Language coverage statement, such as english_only or multilingual_partial." + }, + "country_scope": { + "type": "string", + "description": "Country/region coverage description." + }, + "excluded_segments": { + "type": "array", + "items": {"type": "string"}, + "description": "Segments excluded due to low volume, quota, or data quality limits." + } + } + }, + "compliance": { + "type": "object", + "required": [ + "allowed_use_scope", + "redistribution_allowed", + "usage_rights_note", + "third_party_ai_shared", + "quote_permission_status" + ], + "additionalProperties": false, + "properties": { + "allowed_use_scope": { + "type": "string", + "enum": ["internal_analytics", "product_feature", "public_marketing"], + "description": "Permitted downstream usage scope for this output." + }, + "redistribution_allowed": { + "type": "boolean", + "description": "Whether verbatim quote redistribution is allowed under current rights context." + }, + "usage_rights_note": { + "type": "string", + "description": "Human-readable rights and restrictions note." + }, + "third_party_ai_shared": { + "type": "boolean", + "description": "Whether review text was shared with third-party AI processors." + }, + "quote_permission_status": { + "type": "string", + "enum": ["not_needed", "unknown", "requested", "granted", "denied"], + "description": "Permission status for external quote use." + } + } + }, + "limitations": { + "type": "array", + "items": {"type": "string"}, + "description": "Known limitations, uncertainty notes, and data gaps." + } + } + } +} +``` + +--- + +## Gaps & Uncertainties + +- There is limited public, app-store-specific consensus on one universal minimum sample threshold for each type of thematic claim; statistical thresholds are adapted from broader proportion-estimation practice (marked evergreen where used). +- Third-party pricing/credit/rate-limit details change frequently and may require verification at implementation time. +- Some legal constraints (especially quote rights outside Apple's explicit guidance) remain jurisdiction-specific and context-dependent; legal review may be needed for public-marketing use cases. +- Public evidence on fake/AI-generated review prevalence is still mixed by category and market; suspicious-burst controls should be treated as risk mitigation rather than definitive fraud classification. +- Apple and Google product/API surfaces continue to evolve quickly; endpoint capabilities and policy language should be revalidated before major skill revisions. diff --git a/flexus_simple_bots/researcher/skills/_segment-voice-of-market/SKILL.md b/flexus_simple_bots/researcher/skills/_segment-voice-of-market/SKILL.md new file mode 100644 index 00000000..bb5a1d73 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_segment-voice-of-market/SKILL.md @@ -0,0 +1,50 @@ +--- +name: segment-voice-of-market +description: App store review and mobile marketplace signal detection — product feedback, rating trends, and competitor positioning in app stores +--- + +You detect market signals and customer pain from mobile app store data. App store reviews are a rich source of authentic, unsolicited voice-of-market feedback that is rarely filtered by corporate messaging. + +Core mode: reviews are unsolicited customer statements — high signal quality. Focus on pattern extraction, not individual reviews. Require ≥20 reviews before making directional claims. Weight recent reviews (past 90 days) more than historical ones. + +## Methodology + +### Rating distribution and trend +App store ratings are a blunt quality signal. More useful is the trend: +- Is avg rating improving or declining over the past 3 release cycles? +- Do 1-star reviews cluster around specific product events (update releases, pricing changes)? +- Does the competitor have a pattern of addressing review feedback? + +### Theme extraction from reviews +Low-star reviews: extract recurring pain themes (performance, bugs, missing features, pricing changes, support failures) +High-star reviews: extract usage context and core value propositions ("I use this for...", "Best for...") + +### Competitive comparison +Compare your target against 2-3 closest competitors on: +- Avg rating +- Review volume (proxy for user base size) +- Response rate to reviews (indicates customer success investment) +- Most complained-about feature gaps (potential displacement opportunity) + +### New feature signal +Review spikes after update releases reveal what changes users care about most (positive and negative). + +## Recording + +``` +write_artifact(path="/segments/{segment_id}/voice-of-market", data={...}) +``` + +## Available Tools + +``` +appstoreconnect(op="call", args={"method_id": "appstoreconnect.apps.list.v1"}) + +appstoreconnect(op="call", args={"method_id": "appstoreconnect.customerreviews.list.v1", "app_id": "app_id", "filter[rating]": [1, 2], "sort": "-createdDate", "limit": 100}) + +google_play(op="call", args={"method_id": "google_play.reviews.list.v1", "packageName": "com.example.app", "translationLanguage": "en", "maxResults": 100}) + +google_play(op="call", args={"method_id": "google_play.reviews.get.v1", "packageName": "com.example.app", "reviewId": "review_id"}) + +google_play(op="call", args={"method_id": "google_play.edits.details.get.v1", "packageName": "com.example.app", "editId": "edit_id"}) +``` diff --git a/flexus_simple_bots/researcher/skills/_signal-competitive-web/RESEARCH.md b/flexus_simple_bots/researcher/skills/_signal-competitive-web/RESEARCH.md new file mode 100644 index 00000000..8c97e60b --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_signal-competitive-web/RESEARCH.md @@ -0,0 +1,893 @@ +# Research: signal-competitive-web + +**Skill path:** `flexus-client-kit/flexus_simple_bots/researcher/skills/signal-competitive-web/` +**Bot:** researcher (researcher | strategist | executor) +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`signal-competitive-web` detects competitive traction signals using external web-traffic intelligence plus marketplace demand indicators. It is useful when a team needs directional answers to questions like "which competitor is accelerating," "which acquisition channels are fragile," and "is category demand rising or just rotating across sellers." + +This skill is high-impact but high-risk for false certainty. Similarweb/Semrush traffic values are modeled estimates, not first-party analytics; marketplace indicators (Amazon BSR, eBay sold data proxies) are also partial views. Research in 2024-2026 consistently shows strong value for trend direction and relative movement, while also warning that absolute values, cross-provider mixing, and single-source interpretation create avoidable decision errors. + +The practical goal of this research is to make SKILL instructions operationally safe: evidence-first collection, explicit confidence ranges, contradiction handling, policy/access constraints, and anti-pattern blocks that prevent common mistakes. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024-2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- No generic filler ("it is important to...", "best practices suggest...") without concrete backing +- No invented tool names, method IDs, or API endpoints - only verified real ones +- Contradictions between sources are explicitly noted, not silently resolved +- Volume: findings section should be 800-4000 words (too short = shallow, too long = unsynthesized) + +Gate check for this file: passed. Contradictions are called out explicitly (accuracy claims, restricted API access vs public expectations, and leading vs lagging signal disagreements), and older references are marked evergreen. + +--- + +## Research Angles + +Each core angle was researched by a separate sub-agent, plus one additional domain-specific angle. + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +Practitioner consensus in 2024-2026 is to treat external traffic intelligence as directional context, not absolute truth. Similarweb and Semrush both document differences from Google Analytics and recommend relative trend analysis; practical workflow is "within-provider trend first, cross-provider triangulation second," not raw-number pooling. + +Methodology updates in 2024 matter operationally. Similarweb's 2024 data-version update reran history with broader coverage and algorithm changes. This is a structural break risk: trend pipelines that do not mark July 2024 as a re-baseline point can interpret methodology change as market movement. + +On Amazon, good practice separates high-frequency leading indicators from slower confirmation indicators. Best Sellers / Movers & Shakers can show near-real-time movement, while Search Query Performance/Search Catalog Performance reports in SP-API are period-bounded (`WEEK`, `MONTH`, `QUARTER`) and should be interpreted as lagged confirmation rather than same-day truth. + +On eBay, Product Research/Terapeak-style sold-data views are higher-value demand evidence than active-listing search alone. Completed listings are short-window context; stronger demand inference uses sold-price and sell-through context where access is available. + +Triangulation is not optional. Independent benchmark studies continue to show material deviation between modeled web-traffic estimates and first-party analytics. The robust workflow is to combine independent signal families (traffic direction + marketplace movement + price/offer behavior) and downgrade confidence when they diverge. + +**Sources:** +- Similarweb, *Similarweb vs Google Analytics* (2025): [https://www.similarweb.com/blog/daas/data-basics/similarweb-vs-google-analytics](https://www.similarweb.com/blog/daas/data-basics/similarweb-vs-google-analytics) +- Similarweb support, *2024 Data Version Update* (2024): [https://support.similarweb.com/hc/en-us/articles/18876356573725-Everything-you-need-to-know-about-Similarweb-s-2024-Data-Version-Update](https://support.similarweb.com/hc/en-us/articles/18876356573725-Everything-you-need-to-know-about-Similarweb-s-2024-Data-Version-Update) +- Semrush KB, *Traffic & Market vs GA* (evergreen vendor doc): [https://www.semrush.com/kb/924-traffic-analytics-google-analytics](https://www.semrush.com/kb/924-traffic-analytics-google-analytics) +- Semrush KB, *How traffic intelligence is modeled* (evergreen vendor doc): [https://www.semrush.com/kb/1211-how-semrush-turns-traffic-data-into-traffic-intelligence](https://www.semrush.com/kb/1211-how-semrush-turns-traffic-data-into-traffic-intelligence) +- Amazon SP-API changelog, new BA report types (2025): [https://developer-docs.amazon.com/sp-api/changelog/update-added-new-search-query-performance-and-search-catalog-performance-analytics-report-types](https://developer-docs.amazon.com/sp-api/changelog/update-added-new-search-query-performance-and-search-catalog-performance-analytics-report-types) +- Amazon SP-API analytics report-type values (evergreen API reference): [https://developer-docs.amazon.com/sp-api/docs/report-type-values-analytics](https://developer-docs.amazon.com/sp-api/docs/report-type-values-analytics) +- Amazon Best Sellers (live 2026): [https://www.amazon.com/bestsellers](https://www.amazon.com/bestsellers) +- Amazon Movers & Shakers (live 2026): [https://www.amazon.com/gp/movers-and-shakers/](https://www.amazon.com/gp/movers-and-shakers/) +- Amazon seller blog, BSR interpretation (2025): [https://sell.amazon.com/blog/amazon-best-sellers-rank](https://sell.amazon.com/blog/amazon-best-sellers-rank) +- eBay help, Terapeak/Product Research (updated live, 2024-2026 use): [https://www.ebay.com/help/selling/selling-tools/terapeak-research?id=4853&locale=en-US](https://www.ebay.com/help/selling/selling-tools/terapeak-research?id=4853&locale=en-US) +- Springer ICANTCI study on GA vs Similarweb (2025): [https://link.springer.com/chapter/10.1007/978-3-031-86072-0_5](https://link.springer.com/chapter/10.1007/978-3-031-86072-0_5) +- PLOS/PubMed traffic comparison (2022, evergreen): [https://pubmed.ncbi.nlm.nih.gov/35622858/](https://pubmed.ncbi.nlm.nih.gov/35622858/) + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +The core stack for this skill is still valid: Similarweb for web-traffic intelligence, Amazon SP-API for catalog and offer context, and eBay APIs for listing discovery plus restricted marketplace-sales intelligence. The key update is that access and interpretation constraints are more important than adding more vendors. + +Similarweb API V5 remains active with ongoing changelog activity in 2024-2026. It provides website traffic/engagement and related capabilities, but with plan-gated access, metric-credit billing, and documented rate/concurrency constraints. Practical implication: query planning and metric budget discipline are required in production. + +Amazon SP-API gives strong building blocks for marketplace demand context, but not "web traffic." Verified operation families relevant here include Catalog Items (`searchCatalogItems`, `getCatalogItem`) and Product Pricing operations (`getItemOffers`, batch variants). Usage plans are operation-specific and often low by default; deprecations/removals continued through 2024-2026, so version pinning is mandatory. + +eBay Browse `search` is strong for active listing surface discovery but is not a full sold-history analytics endpoint. eBay Marketplace Insights is materially more relevant for transaction-style intelligence, but access is restricted and governed by stricter licensing terms. This creates a frequent pipeline pattern: open Browse for broad coverage, restricted Insights where entitled. + +Adjacent alternatives (Semrush Trends API, DataForSEO clickstream) are useful for triangulation and geo/keyword demand context, but they do not replace marketplace sold-item intelligence. The best use is as corroboration when primary providers are missing coverage. + +**Capability matrix (practical summary):** + +- Similarweb API: strong for web trend direction and channel/geo mix; weak for transaction-ground-truth. +- Amazon SP-API: strong for catalog/offer/marketplace context; weak for direct competitor web traffic. +- eBay Browse: strong for active listing inventory signals; weak for historical sold-depth. +- eBay Marketplace Insights: strong for richer market insight where approved; weak in availability due restrictions. +- Semrush/DataForSEO: strong for supplemental trend triangulation; weak as sole decision basis. + +**Sources:** +- Similarweb developer docs: [https://developers.similarweb.com/docs/similarweb-web-traffic-api](https://developers.similarweb.com/docs/similarweb-web-traffic-api) +- Similarweb changelog: [https://developers.similarweb.com/changelog](https://developers.similarweb.com/changelog) +- Similarweb data credits model: [https://docs.similarweb.com/api-v5/guides/data-credits-calculations](https://docs.similarweb.com/api-v5/guides/data-credits-calculations) +- Similarweb pricing/packages: [https://www.similarweb.com/corp/pricing/](https://www.similarweb.com/corp/pricing/) and [https://www.similarweb.com/packages/marketing/](https://www.similarweb.com/packages/marketing/) +- Amazon SP-API usage plans/rate limits (evergreen API reference): [https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits](https://developer-docs.amazon.com/sp-api/docs/usage-plans-and-rate-limits) +- Amazon SP-API Catalog Items v2022-04-01 reference (evergreen API reference): [https://developer-docs.amazon.com/sp-api/docs/catalog-items-api-v2022-04-01-reference](https://developer-docs.amazon.com/sp-api/docs/catalog-items-api-v2022-04-01-reference) +- Amazon SP-API `searchCatalogItems`: [https://developer-docs.amazon.com/sp-api/reference/searchcatalogitems](https://developer-docs.amazon.com/sp-api/reference/searchcatalogitems) +- Amazon SP-API `getCompetitiveSummary` (pricing family): [https://developer-docs.amazon.com/sp-api/reference/getcompetitivesummary](https://developer-docs.amazon.com/sp-api/reference/getcompetitivesummary) +- Amazon deprecations + 2025 reminder: [https://developer-docs.amazon.com/sp-api/docs/sp-api-deprecations](https://developer-docs.amazon.com/sp-api/docs/sp-api-deprecations), [https://developer-docs.amazon.com/sp-api/changelog/deprecation-reminders-february-26-2025](https://developer-docs.amazon.com/sp-api/changelog/deprecation-reminders-february-26-2025) +- eBay Browse `search`: [https://developer.ebay.com/api-docs/buy/browse/resources/item_summary/methods/search](https://developer.ebay.com/api-docs/buy/browse/resources/item_summary/methods/search) +- eBay API call limits: [https://developer.ebay.com/develop/get-started/api-call-limits](https://developer.ebay.com/develop/get-started/api-call-limits) +- eBay Marketplace Insights overview: [https://developer.ebay.com/api-docs/buy/marketplace-insights/overview.html](https://developer.ebay.com/api-docs/buy/marketplace-insights/overview.html) +- eBay API License Agreement (2025): [https://developer.ebay.com/join/api-license-agreement](https://developer.ebay.com/join/api-license-agreement) +- Semrush API basics (updated 2026): [https://developer.semrush.com/api/basics/how-to-get-api](https://developer.semrush.com/api/basics/how-to-get-api) +- Semrush Trends API overview: [https://developer.semrush.com/api/v3/trends/welcome-to-trends-api/](https://developer.semrush.com/api/v3/trends/welcome-to-trends-api/) +- DataForSEO clickstream overview and limits: [https://docs.dataforseo.com/v3/keywords_data/clickstream_data/overview/](https://docs.dataforseo.com/v3/keywords_data/clickstream_data/overview/) and [https://dataforseo.com/help-center/rate-limits-and-request-limits](https://dataforseo.com/help-center/rate-limits-and-request-limits) + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +Interpretation quality is mostly about comparability and sample adequacy. Similarweb explicitly signals higher confidence above larger monthly-visit bases and warns about low-traffic instability. In practice, signals from very small domains are often unusable for hard decisions unless corroborated. + +Google Trends is frequently misused in this domain. Its values are normalized (`0-100`) within each query context and are not absolute demand. "Breakout" is a specific >5000% growth label in rising related queries; it can be a useful spike detector but is noisy without confirmation from independent data. + +GA4 anomaly detection guidance is useful as a general interpretation pattern even when analyzing external datasets: meaningful anomalies require adequate historical windows and sufficient segment size. The documented training windows (2 weeks hourly, 90 days daily, 32 weeks weekly) are a practical reminder to avoid overreacting to short, uncontextualized windows. + +Seasonality and event effects remain a top source of false positives. Prime Day/holiday windows can generate large but temporary movement; consistent methodology compares same-period year-over-year or multi-period smoothed baselines before claiming structural demand change. + +Cross-provider disagreement is expected, not exceptional. Vendor docs emphasize model quality, while independent studies continue to show material variance from first-party systems. The correct response is a confidence rubric with explicit contradiction penalties rather than choosing one provider as absolute truth. + +**Signal vs noise heuristics (operational):** + +- Robust: multi-period direction agreement across at least two independent signal families. +- Robust: marketplace movement with consistent price/availability context. +- Noisy: one-period spikes, low-sample domains, un-rebased post-methodology-change jumps. +- Noisy: mixed provider absolute numbers presented as directly comparable. + +**Sources:** +- Similarweb data accuracy and confidence notes (evergreen vendor docs): [https://support.similarweb.com/hc/en-us/articles/360002219177](https://support.similarweb.com/hc/en-us/articles/360002219177) and [https://support.similarweb.com/hc/en-us/articles/360002329778-Similarweb-vs-Direct-Measurement](https://support.similarweb.com/hc/en-us/articles/360002329778-Similarweb-vs-Direct-Measurement) +- Similarweb 2024 methodology refresh: [https://support.similarweb.com/hc/en-us/articles/18876356573725-Everything-you-need-to-know-about-Similarweb-s-2024-Data-Version-Update](https://support.similarweb.com/hc/en-us/articles/18876356573725-Everything-you-need-to-know-about-Similarweb-s-2024-Data-Version-Update) +- Google Trends data FAQ (evergreen): [https://support.google.com/trends/answer/4365533?hl=en](https://support.google.com/trends/answer/4365533?hl=en) +- Google Trends breakout definition (evergreen): [https://support.google.com/trends/answer/4355000?hl=en](https://support.google.com/trends/answer/4355000?hl=en) +- Google Analytics 4 anomaly detection (evergreen GA4 doc): [https://support.google.com/analytics/answer/9517187](https://support.google.com/analytics/answer/9517187) +- Google Analytics 4 trend-change detection: [https://support.google.com/analytics/answer/12207035?hl=en](https://support.google.com/analytics/answer/12207035?hl=en) +- Semrush data source methodology (evergreen vendor doc with 2026 stats context): [https://www.semrush.com/kb/998-where-does-semrush-data-come-from](https://www.semrush.com/kb/998-where-does-semrush-data-come-from) +- Reuters + Adobe Prime Day signal context (2024): [https://www.reuters.com/business/retail-consumer/amazon-prime-day-boosts-us-online-sales-142-bln-adobe-says-2024-07-18/](https://www.reuters.com/business/retail-consumer/amazon-prime-day-boosts-us-online-sales-142-bln-adobe-says-2024-07-18/) +- Springer ICANTCI study (2025): [https://link.springer.com/chapter/10.1007/978-3-031-86072-0_5](https://link.springer.com/chapter/10.1007/978-3-031-86072-0_5) +- PLOS/PubMed comparison study (2022, evergreen): [https://pubmed.ncbi.nlm.nih.gov/35622858/](https://pubmed.ncbi.nlm.nih.gov/35622858/) + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +1) **Cross-provider raw-number mixing** +Detection: dashboards that combine Similarweb visits, Semrush visits, and search-traffic proxies as if same unit. +Consequence: false rank ordering and noisy strategic actions. +Mitigation: compare within-provider indexed trends, then triangulate direction only. + +2) **Point-estimate absolutism** +Detection: one "true traffic number" without confidence range or source caveats. +Consequence: overconfident recommendations from estimated data. +Mitigation: always include uncertainty bands and confidence tier. + +3) **BSR as direct demand truth** +Detection: statements mapping BSR movement directly to stable unit demand. +Consequence: demand overstatement during event windows. +Mitigation: combine BSR movement with offer, price, and period-aligned context. + +4) **Ignoring seasonality/event windows** +Detection: Prime Day/Cyber period interpreted as structural baseline shift. +Consequence: over-forecasting and post-event whiplash. +Mitigation: event-aware baseline plus same-period YoY checks. + +5) **Bot/referral spike blindness** +Detection: sharp traffic spikes with weak engagement/marketplace corroboration. +Consequence: phantom-demand interpretation. +Mitigation: add hygiene checks and corroboration requirement. + +6) **Normalized-index misuse** +Detection: Google Trends `0-100` treated as absolute volume; separate query windows compared as equal scales. +Consequence: distorted TAM and trend strength claims. +Mitigation: preserve consistent query context and use Trends as directional only. + +7) **Policy/access blind spots** +Detection: 403/429/schema changes interpreted as market signals. +Consequence: false decline/volatility conclusions. +Mitigation: classify collection artifacts separately (`access_artifact`, `throttle_artifact`, `schema_change_artifact`). + +8) **Review/social proxy contamination** +Detection: review/follower spikes detached from independent market signals. +Consequence: manipulated-demand false positives. +Mitigation: cross-signal corroboration and integrity-risk flags. + +**Bad output vs good output examples (short):** + +- Bad: "A is 2x bigger than B because one provider shows 2x visits." + Good: "Provider estimates are directional; both providers show A accelerating vs B in the same period." +- Bad: "BSR up means permanent demand increase." + Good: "BSR improvement is a leading signal; confidence stays medium until multi-period confirmation." +- Bad: "Traffic spike proves channel-market fit." + Good: "Spike is flagged as potential bot/referral artifact until engagement and marketplace evidence align." + +**Sources:** +- Similarweb methodology and direct-measurement caveats: [https://support.similarweb.com/hc/en-us/articles/360001631538-Similarweb-Data-Methodology](https://support.similarweb.com/hc/en-us/articles/360001631538-Similarweb-Data-Methodology), [https://support.similarweb.com/hc/en-us/articles/360002329778-Similarweb-vs-Direct-Measurement](https://support.similarweb.com/hc/en-us/articles/360002329778-Similarweb-vs-Direct-Measurement) +- Semrush clickstream explainer (2025): [https://www.semrush.com/blog/what-is-clickstream-data/](https://www.semrush.com/blog/what-is-clickstream-data/) +- Amazon BSR explainer (2025): [https://sell.amazon.com/blog/amazon-best-sellers-rank](https://sell.amazon.com/blog/amazon-best-sellers-rank) +- Amazon Movers & Shakers (live 2026): [https://www.amazon.com/gp/movers-and-shakers](https://www.amazon.com/gp/movers-and-shakers) +- Google Trends FAQ: [https://support.google.com/trends/answer/4365533?hl=en](https://support.google.com/trends/answer/4365533?hl=en) +- Imperva Bad Bot Report (2024): [https://www.imperva.com/resources/resource-library/reports/2024-bad-bot-report/](https://www.imperva.com/resources/resource-library/reports/2024-bad-bot-report/) +- FTC fake review final rule (2024): [https://www.ftc.gov/news-events/news/press-releases/2024/08/federal-trade-commission-announces-final-rule-banning-fake-reviews-testimonials](https://www.ftc.gov/news-events/news/press-releases/2024/08/federal-trade-commission-announces-final-rule-banning-fake-reviews-testimonials) +- eBay API limits and restricted API governance: [https://developer.ebay.com/develop/get-started/api-call-limits](https://developer.ebay.com/develop/get-started/api-call-limits), [https://developer.ebay.com/join/api-license-agreement](https://developer.ebay.com/join/api-license-agreement) +- SparkToro comparison study (older, evergreen): [https://sparktoro.com/blog/which-3rd-party-traffic-estimate-best-matches-google-analytics/](https://sparktoro.com/blog/which-3rd-party-traffic-estimate-best-matches-google-analytics/) +- Ahrefs estimate-accuracy study (older, evergreen): [https://ahrefs.com/blog/traffic-estimations-accuracy/](https://ahrefs.com/blog/traffic-estimations-accuracy/) + +--- + +### Angle 5+: Policy, Access, and Compliance Constraints +> Additional domain-specific angle: legal and platform-policy constraints that affect data collection validity, interpretation, and output safety. + +**Findings:** + +Amazon SP-API policy boundaries are tighter than many "competitive intel" assumptions. AUP and policy docs restrict certain data-use patterns, while restricted operations require explicit role approval and restricted-data tokens. Skill instructions must fail closed on entitlement uncertainty rather than silently producing partial outputs. + +eBay's 2025 API License Agreement materially emphasizes restricted APIs and use constraints for market/pricing behavior data. This is operationally important for Marketplace Insights: entitlement must be explicit, and downstream redistribution/derived-analysis use needs policy-aware checks. + +Data-handling and schema-change policy events can mimic market events. eBay's 2025 data-handling update (jurisdiction-dependent field behavior changes) is a concrete example of why schema-change detectors belong in signal pipelines. + +Similarweb's legal/terms framing reinforces that traffic values are estimated and license-governed. This does not reduce value for directional analysis, but it requires explicit "estimated" labeling, uncertainty communication, and controlled data-sharing behavior. + +Regulatory enforcement hardened in 2024-2026 against fake reviews and deceptive social proof (FTC final rule and UK/CMA actions). Any demand signal pipeline that consumes reviews/social cues should include integrity-risk flags and corroboration requirements. + +**Sources:** +- Amazon SP-API policies/agreements index: [https://developer-docs.amazon.com/sp-api/docs/policies-and-agreements](https://developer-docs.amazon.com/sp-api/docs/policies-and-agreements) +- Amazon restricted data token guide: [https://developer-docs.amazon.com/sp-api/docs/authorization-with-the-restricted-data-token](https://developer-docs.amazon.com/sp-api/docs/authorization-with-the-restricted-data-token) +- Amazon AUP: [https://sellercentral.amazon.com/mws/static/policy?documentType=AUP&locale=en_US](https://sellercentral.amazon.com/mws/static/policy?documentType=AUP&locale=en_US) +- Amazon DPP: [https://sellercentral.amazon.com/mws/static/policy?documentType=DPP&locale=en_US](https://sellercentral.amazon.com/mws/static/policy?documentType=DPP&locale=en_US) +- eBay API License Agreement (Sept 2025): [https://developer.ebay.com/join/api-license-agreement](https://developer.ebay.com/join/api-license-agreement) +- eBay data-handling update (effective Sept 2025): [https://developer.ebay.com/api-docs/static/data-handling-update.html](https://developer.ebay.com/api-docs/static/data-handling-update.html) +- Similarweb Terms (updated 2025): [https://www.similarweb.com/corp/legal/terms/](https://www.similarweb.com/corp/legal/terms/) +- FTC fake reviews final rule (2024): [https://www.ftc.gov/news-events/news/press-releases/2024/08/federal-trade-commission-announces-final-rule-banning-fake-reviews-testimonials](https://www.ftc.gov/news-events/news/press-releases/2024/08/federal-trade-commission-announces-final-rule-banning-fake-reviews-testimonials) +- Federal Register final rule publication (2024): [https://www.federalregister.gov/documents/2024/08/22/2024-18519/trade-regulation-rule-on-the-use-of-consumer-reviews-and-testimonials](https://www.federalregister.gov/documents/2024/08/22/2024-18519/trade-regulation-rule-on-the-use-of-consumer-reviews-and-testimonials) +- UK fake reviews/hidden fees enforcement (2025): [https://www.gov.uk/government/news/fake-reviews-and-sneaky-hidden-fees-banned-once-and-for-all](https://www.gov.uk/government/news/fake-reviews-and-sneaky-hidden-fees-banned-once-and-for-all) +- CMA undertakings with Google/Amazon on fake reviews (2025): [https://www.gov.uk/government/news/cma-secures-important-changes-from-google-to-tackle-fake-reviews](https://www.gov.uk/government/news/cma-secures-important-changes-from-google-to-tackle-fake-reviews), [https://www.gov.uk/government/news/amazon-gives-undertakings-to-cma-to-curb-fake-reviews](https://www.gov.uk/government/news/amazon-gives-undertakings-to-cma-to-curb-fake-reviews) + +--- + +## Synthesis + +Across sources, the strongest pattern is that this skill should optimize for decision quality, not data volume. In 2024-2026, external web-traffic and marketplace signals became easier to access, but also more likely to be misinterpreted if methodology changes, policy constraints, and cross-source disagreement are not explicitly modeled. + +The key contradiction is not "which provider is right," but "which provider is fit for which claim." Vendor documentation highlights robust estimation capability; independent and comparative studies show meaningful deviation from first-party analytics. These two statements can both be true. The operational consequence is a confidence system with contradiction penalties, not provider absolutism. + +A second important contradiction is lead-vs-lag signal timing. Amazon Best Sellers/Movers can move quickly, while periodized analytics and some marketplace evidence arrive slower. Teams that do not separate leading and confirming indicators overreact to transient movement. The best practice is two-speed interpretation with persistence checks. + +The research also shows policy/access as a first-class modeling concern. Restricted API entitlement, deprecations, dynamic limits, and legal use boundaries are not administrative footnotes; they directly affect whether apparent signal shifts are market reality or collection artifacts. This must be encoded in SKILL methodology and schema. + +Net result: SKILL.md should move from "tool list + heuristics" to a rigorous evidence protocol: preflight access checks, triangulation workflow, explicit confidence rubric, anti-pattern detection blocks, and structured artifact metadata for provenance, contradictions, and policy-distortion flags. + +--- + +## Recommendations for SKILL.md + +- [ ] Add an explicit triangulation protocol (traffic direction + marketplace demand + offer/price context) with mandatory persistence checks before verdicts. +- [ ] Replace inaccurate tool argument examples with verified method syntax used by current integrations (`query`/`asin` for Amazon and eBay wrappers). +- [ ] Add a preflight step (`status` / `list_methods`) and entitlement-awareness guidance before data collection. +- [ ] Add a structural-break rule for provider model updates (especially Similarweb Jul 2024) and require re-baselining. +- [ ] Add a confidence rubric (0-1) with bins and contradiction penalties. +- [ ] Add signal-vs-noise heuristics, including low-sample safeguards and seasonality/event controls. +- [ ] Add anti-pattern warning blocks with detection signal, consequence, mitigation. +- [ ] Add policy/access guardrails for restricted APIs, ToS constraints, and fake-review contamination risk. +- [ ] Expand output quality requirements: explicit estimated labels, source provenance, data freshness, contradiction section, and `insufficient_data` handling. +- [ ] Extend artifact schema with evidence references, freshness metadata, comparability context, and policy-distortion flags. + +--- + +## Draft Content for SKILL.md + +> This is the most important section. For every recommendation above, write the actual text that should go into SKILL.md. + +### Draft: Core mode and evidence policy + +--- +### Core mode: evidence-first triangulation + +You are detecting competitive traction signals from **estimated external data**, not first-party truth. Every claim must be tied to at least one concrete data point and at least one named provider. You must never present estimated traffic numbers as ground truth. + +Your default evidence rule is: +1. **Collect directional web-traffic evidence** (trend, channel mix, geography) from Similarweb. +2. **Collect marketplace demand evidence** (catalog/offer movement for Amazon and listing/sales context for eBay). +3. **Triangulate direction** across signal families before final classification. + +A signal is actionable only when it persists across at least two observation points in the same direction, or when one strong signal is corroborated by an independent signal family. If signals conflict, you downgrade confidence and explicitly describe the conflict instead of forcing a single answer. + +Always label modeled values with `estimated` and provider name (for example: `"2.4M visits/mo estimated (Similarweb)"`). +--- + +### Draft: Data collection workflow + +--- +### Collection workflow (must follow in order) + +Before collecting any signal data, run a preflight: +1. Confirm tool availability and method names with provider `status`/`list_methods` calls. +2. Confirm time window, geography, and category scope for this run. +3. Confirm you can retrieve at least one traffic source and one marketplace source. + +Then execute this sequence: + +1. **Web traffic baseline** + - Pull traffic and engagement for each target domain. + - Pull traffic sources and geography in the same date window. + - Pull similar-sites to discover adjacent competitors only after primary competitors are analyzed. + - Compute directional changes (MoM or rolling period trend) rather than relying on absolute values. + +2. **Amazon marketplace context** + - Use catalog search to discover ASINs for the category and competitor-relevant products. + - Resolve critical ASIN details when needed. + - Pull offer snapshots for selected ASINs to understand price/offer pressure context. + - Treat BSR changes as leading indicators, not standalone demand proof. + +3. **eBay marketplace context** + - Use Browse search for active listing inventory and price context. + - Use Marketplace Insights sold-item search where entitled; if access is blocked, explicitly record restricted-access limitation. + - Never treat missing restricted API access as demand decline. + +4. **Triangulation and classification** + - If traffic trend and marketplace trend agree for two consecutive checks: classify as stronger traction signal. + - If they disagree: classify as mixed signal and downgrade confidence. + - If data is sparse, low-sample, or rate-limited: return directional-only output with explicit limitations. + +Do not skip directly from a single provider response to a final "market pull" verdict. +--- + +### Draft: Interpretation rules and confidence scoring + +--- +### Data interpretation rules + +Use these interpretation rules for every run: + +1. **Cross-provider comparability** + - Compare direction within each provider first. + - Do not compare raw absolute values across different providers as equivalent units. + - If cross-provider direction conflicts, explain both and lower confidence. + +2. **Sample and quality safeguards** + - Treat low-volume domains as lower-confidence signals (Similarweb small-site caveat). + - Require a second source for any unusually large jump. + - Mark post-methodology-update periods as potential structural breaks until re-baselined. + +3. **Seasonality and event controls** + - Flag known event windows (for example, Prime Day, holiday periods). + - Prefer same-period comparisons (YoY or equivalent seasonal window) before claiming structural demand change. + - Single-period spikes are provisional by default. + +4. **Marketplace signal interpretation** + - Amazon BSR movement indicates rank movement, not direct unit volume. + - eBay active listing counts indicate supply pressure, not guaranteed sell-through. + - Marketplace Insights sold data (if available) is stronger demand evidence than listing-only signals. + +### Confidence rubric (0 to 1) + +Score confidence using weighted evidence: + +- `source_quality` (0.00-0.30): first-party-like marketplace evidence > modeled external estimates. +- `cross_signal_agreement` (0.00-0.25): independent signal families agree on direction. +- `temporal_persistence` (0.00-0.20): signal persists over at least two periods. +- `scope_alignment` (0.00-0.10): geo/time/category scope is consistent. +- `data_freshness` (0.00-0.10): evidence is recent within run window. +- `methodology_stability` (0.00-0.05): no unresolved provider model-break effects. + +Apply penalties: +- `-0.15` if unresolved cross-provider contradiction remains. +- `-0.10` if one core signal family is missing. +- `-0.10` if restricted-access or rate-limit artifacts materially reduce coverage. + +Confidence bins: +- `0.85-1.00`: high confidence. +- `0.65-0.84`: moderate confidence. +- `0.45-0.64`: directional only. +- `<0.45`: low confidence; avoid strong recommendations. + +If confidence is below `0.45`, use `result_state="insufficient_data"` unless a high-impact signal is independently corroborated. +--- + +### Draft: Available Tools + +--- +## Available Tools + +Use only verified method IDs. Do not invent methods or endpoint names. Always prefer `op="status"` and `op="list_methods"` when uncertain. + +```python +# Similarweb: traffic direction and channel composition +similarweb( + op="call", + args={ + "method_id": "similarweb.traffic_and_engagement.get.v1", + "domain": "competitor.com", + "start_date": "2025-01", + "end_date": "2025-12", + "country": "us", + "granularity": "monthly" + } +) + +similarweb( + op="call", + args={ + "method_id": "similarweb.traffic_sources.get.v1", + "domain": "competitor.com", + "start_date": "2025-01", + "end_date": "2025-12", + "country": "us" + } +) + +similarweb( + op="call", + args={ + "method_id": "similarweb.traffic_geography.get.v1", + "domain": "competitor.com", + "start_date": "2025-01", + "end_date": "2025-12" + } +) + +similarweb( + op="call", + args={ + "method_id": "similarweb.similar_sites.get.v1", + "domain": "competitor.com" + } +) + +# Amazon integration wrappers (verified local method syntax) +amazon(op="status") +amazon(op="list_methods") + +amazon( + op="call", + args={ + "method_id": "amazon.catalog.search_items.v1", + "query": "product category", + "limit": 10 + } +) + +amazon( + op="call", + args={ + "method_id": "amazon.catalog.get_item.v1", + "asin": "B0EXAMPLE123" + } +) + +amazon( + op="call", + args={ + "method_id": "amazon.pricing.get_item_offers_batch.v1", + "asin": "B0EXAMPLE123" + } +) + +amazon( + op="call", + args={ + "method_id": "amazon.pricing.get_listing_offers_batch.v1", + "asin": "B0EXAMPLE123" + } +) + +# eBay integration wrappers (verified local method syntax) +ebay(op="status") +ebay(op="list_methods") + +ebay( + op="call", + args={ + "method_id": "ebay.browse.search.v1", + "query": "product category", + "limit": 25 + } +) + +ebay( + op="call", + args={ + "method_id": "ebay.marketplace_insights.item_sales_search.v1", + "query": "product category", + "limit": 25 + } +) +``` + +Tool usage rules: +1. Use Similarweb for directional trends and channel/geo patterns, not transaction truth. +2. Use Amazon methods for catalog and offer context; these are marketplace signals, not direct web traffic. +3. Use eBay Browse for active listing context; use Marketplace Insights sold data only when access is approved. +4. If a method returns `403`, `429`, or provider auth failure, record that as a collection limitation and continue with available sources. +5. Do not replace missing methods with guessed equivalents. +--- + +### Draft: Anti-pattern warning blocks + +```md +> [!WARNING] Anti-pattern: Cross-Provider Raw Number Mixing +> **What it looks like:** direct comparison of Similarweb totals vs other provider totals as equivalent units. +> **Detection signal:** final narrative says "A is 2x B" using mixed-provider absolute numbers. +> **Consequence:** false ranking and unstable strategy decisions. +> **Mitigation:** compare direction inside each provider first, then triangulate agreement/disagreement. +``` + +```md +> [!WARNING] Anti-pattern: One-Period Spike Overreaction +> **What it looks like:** single weekly spike labeled as "market pull." +> **Detection signal:** no persistence check, no event-window annotation. +> **Consequence:** tactical overreaction to temporary events. +> **Mitigation:** require two-period persistence or independent corroboration before strong claims. +``` + +```md +> [!WARNING] Anti-pattern: BSR Equals Demand +> **What it looks like:** BSR movement converted directly into demand magnitude. +> **Detection signal:** BSR cited as sole marketplace evidence. +> **Consequence:** demand overstatement, especially during event windows. +> **Mitigation:** pair BSR with offer context, price movement, and additional marketplace indicators. +``` + +```md +> [!WARNING] Anti-pattern: Policy-Blind Missing Data Interpretation +> **What it looks like:** restricted-access failures treated as market decline. +> **Detection signal:** sudden null/empty fields coincide with 403/entitlement changes. +> **Consequence:** fabricated decline signals and incorrect recommendations. +> **Mitigation:** classify as access artifact and lower confidence; do not infer demand from missing restricted data. +``` + +```md +> [!WARNING] Anti-pattern: Review/Social Proxy Contamination +> **What it looks like:** review/follower spikes used as direct demand proof. +> **Detection signal:** review signal diverges from marketplace and traffic direction. +> **Consequence:** manipulated-demand false positives. +> **Mitigation:** add integrity-risk flag and require independent corroboration before inclusion. +``` + +### Draft: Compliance and access guardrails + +--- +### Policy, legal, and entitlement guardrails + +You must use only data and APIs that are authorized for this use case. If entitlement is unknown or restricted, fail closed and log the limitation. + +Required guardrails: +1. Run entitlement preflight before restricted endpoints (especially eBay Marketplace Insights and any restricted Amazon operations). +2. Keep data-use within provider terms and license constraints; do not assume redistribution or AI-training rights. +3. Treat policy-induced schema changes and masking changes as non-market artifacts. +4. Avoid personal data ingestion where possible; if unavoidable, apply minimization and retention constraints. +5. Include an explicit disclaimer in output metadata: + - "Estimated external intelligence; not first-party ground truth." + - "Output is for competitive intelligence, not legal advice." + - "Respect platform terms and restricted API licenses." + +If compliance/entitlement status cannot be verified, downgrade to descriptive analysis only and avoid strong recommendations. +--- + +### Draft: Output quality and insufficient-data rules + +--- +### Output quality standard + +Minimum quality bar for every run: +- At least one traffic evidence item and one marketplace evidence item per target. +- Explicit provider names and `estimated` labels on modeled metrics. +- Time-window and geography scope stated in output. +- Confidence score and confidence tier included. +- At least one listed limitation when confidence is below high. +- Contradictions section included when signals disagree. + +When to use each `result_state`: +- `ok`: enough evidence for directional interpretation and confidence assignment. +- `zero_results`: query returned valid but empty result set. +- `insufficient_data`: coverage, consistency, or quality is too weak for reliable conclusions. +- `technical_failure`: provider/tool failure prevented core data retrieval. + +Use `insufficient_data` when: +- key sources are missing after retries, +- contradictions remain unresolved, +- access restrictions materially reduce evidence quality, +- or low-sample instability dominates the run. +--- + +### Draft: Schema additions + +> Full JSON Schema fragment for updated/expanded artifact fields. + +```json +{ + "signal_competitive_web": { + "type": "object", + "required": [ + "targets", + "time_window", + "result_state", + "signals", + "confidence", + "confidence_tier", + "limitations", + "next_checks", + "source_evidence", + "contradictions", + "policy_flags" + ], + "additionalProperties": false, + "properties": { + "targets": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Competitor domains or product categories analyzed in this run." + }, + "time_window": { + "type": "object", + "required": [ + "start_date", + "end_date" + ], + "additionalProperties": false, + "properties": { + "start_date": { + "type": "string", + "description": "Inclusive run start date (YYYY-MM-DD or YYYY-MM)." + }, + "end_date": { + "type": "string", + "description": "Inclusive run end date (YYYY-MM-DD or YYYY-MM)." + } + } + }, + "result_state": { + "type": "string", + "enum": [ + "ok", + "zero_results", + "insufficient_data", + "technical_failure" + ], + "description": "Final run status after collection and quality checks." + }, + "signals": { + "type": "array", + "description": "Structured signal claims produced for this run.", + "items": { + "type": "object", + "required": [ + "signal_type", + "description", + "strength", + "target", + "provider", + "data_point", + "evidence_refs", + "freshness_days", + "is_estimated" + ], + "additionalProperties": false, + "properties": { + "signal_type": { + "type": "string", + "enum": [ + "competitor_traffic_growth", + "competitor_traffic_decline", + "channel_concentration", + "new_competitor_emerging", + "marketplace_demand_growing", + "marketplace_demand_declining", + "price_compression", + "similar_sites_active" + ], + "description": "Normalized signal category used for downstream filtering." + }, + "description": { + "type": "string", + "description": "Human-readable explanation of the signal and why it matters." + }, + "strength": { + "type": "string", + "enum": [ + "strong", + "moderate", + "weak" + ], + "description": "Signal strength assigned after triangulation and quality checks." + }, + "target": { + "type": "string", + "description": "Domain, brand, or category to which this signal applies." + }, + "provider": { + "type": "string", + "description": "Primary provider used for this signal (for example Similarweb, Amazon, eBay)." + }, + "data_point": { + "type": "string", + "description": "Key metric with units and estimate label, e.g. '2.4M visits/mo estimated (Similarweb)'." + }, + "evidence_refs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of source_evidence records that support this signal." + }, + "freshness_days": { + "type": "integer", + "minimum": 0, + "description": "Age of the underlying evidence in days at time of artifact creation." + }, + "is_estimated": { + "type": "boolean", + "description": "True when metric is modeled/estimated rather than first-party observed." + } + } + } + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Composite confidence score for the overall run." + }, + "confidence_tier": { + "type": "string", + "enum": [ + "high", + "moderate", + "directional", + "low" + ], + "description": "Confidence bin derived from confidence score and contradiction penalties." + }, + "source_evidence": { + "type": "array", + "description": "Evidence records with provenance and access metadata.", + "items": { + "type": "object", + "required": [ + "evidence_id", + "provider", + "method_id", + "captured_at", + "scope", + "access_level" + ], + "additionalProperties": false, + "properties": { + "evidence_id": { + "type": "string", + "description": "Stable ID referenced by signals.evidence_refs." + }, + "provider": { + "type": "string", + "description": "Data provider name." + }, + "method_id": { + "type": "string", + "description": "Tool method used to retrieve this evidence." + }, + "captured_at": { + "type": "string", + "description": "ISO-8601 timestamp when evidence was collected." + }, + "scope": { + "type": "object", + "required": [ + "geo", + "period" + ], + "additionalProperties": false, + "properties": { + "geo": { + "type": "string", + "description": "Geography scope used for this evidence." + }, + "period": { + "type": "string", + "description": "Time scope used for this evidence (e.g., 2025-01..2025-12)." + } + } + }, + "access_level": { + "type": "string", + "enum": [ + "public", + "standard_api", + "restricted_api" + ], + "description": "Access tier used for retrieval, useful for entitlement auditing." + } + } + } + }, + "contradictions": { + "type": "array", + "description": "Explicitly logged source disagreements affecting confidence.", + "items": { + "type": "object", + "required": [ + "topic", + "sources", + "impact" + ], + "additionalProperties": false, + "properties": { + "topic": { + "type": "string", + "description": "Short contradiction topic label." + }, + "sources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Provider/source names involved in the disagreement." + }, + "impact": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ], + "description": "Estimated impact of this contradiction on recommendation reliability." + } + } + } + }, + "policy_flags": { + "type": "array", + "description": "Policy and access artifacts that could bias interpretation.", + "items": { + "type": "string", + "enum": [ + "throttle_or_access_artifact", + "schema_change_artifact", + "restricted_api_unavailable", + "review_integrity_risk", + "ad_attribution_bias", + "methodology_break_window" + ] + } + }, + "limitations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Human-readable limitations affecting interpretation." + }, + "next_checks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Concrete follow-up checks to resolve uncertainty in future runs." + } + } + } +} +``` + +--- + +## Gaps & Uncertainties + +- Public, up-to-date, independent 2026 benchmarks quantifying error distributions by provider and vertical are limited; most robust comparisons remain vendor-authored or older peer-reviewed work (marked evergreen when used). +- eBay Marketplace Insights endpoint-level behavior is less transparently documented for non-approved developers; entitlement and payload shape can vary by account and contract. +- There is no official deterministic conversion formula from Amazon BSR to unit sales; any mapping remains model-based and should be treated as inference. +- Similarweb and Semrush methodology pages are living docs; for production SKILL instructions, model-update checkpoints should be revisited quarterly. +- Some policy/terms pages are undated live documents; they should be re-validated before strict policy assertions in production policy text. diff --git a/flexus_simple_bots/researcher/skills/_signal-competitive-web/SKILL.md b/flexus_simple_bots/researcher/skills/_signal-competitive-web/SKILL.md new file mode 100644 index 00000000..89a14044 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_signal-competitive-web/SKILL.md @@ -0,0 +1,68 @@ +--- +name: signal-competitive-web +description: Competitor web traffic and marketplace demand signal detection +--- + +You are detecting competitive traction signals from web traffic and marketplace data for one competitor set or category per run. + +Core mode: evidence-first. Traffic and marketplace data are estimates, not ground truth. Always state confidence range. Do not compare raw numbers across providers — each has its own methodology. + +## Methodology + +### Competitor web traffic +Use `similarweb` to benchmark competitor traffic volume, trends, and acquisition channels. + +Key questions: +- Is competitor traffic growing or shrinking over the last 3-6 months? +- What channels drive their traffic (SEO vs paid vs direct vs referral)? +- Which geographies are they strong in? +- Are there new or similar sites gaining traction? + +Patterns to detect: +- Competitor acceleration: >20% MoM traffic growth = market pull +- Competitor contraction: sustained drop = market share opportunity +- Channel concentration: competitor relies heavily on one channel = vulnerability +- Traffic similarity: discover adjacent competitors via `similar_sites` + +### Marketplace demand +Use `amazon` catalog and pricing APIs to detect product category demand: +- Category search volume via product counts and rank +- Pricing pressure: price spread between top sellers vs median +- Best-seller rank trends: consistent rank improvement = growing demand + +Use `ebay` marketplace insights to see actual transaction velocity in a category. + +### Data interpretation rules +- Similarweb numbers have ±20% variance — use for direction, not absolute truth +- Amazon BSR (Best Seller Rank) changes daily — compare relative rank, not absolute +- Never cite traffic numbers without stating "estimated" and provider name + +## Recording + +``` +write_artifact( + artifact_type="signal_competitive_web", + path="/signals/competitive-web-{YYYY-MM-DD}", + data={...} +) +``` + +## Available Tools + +``` +similarweb(op="call", args={"method_id": "similarweb.traffic_and_engagement.get.v1", "domain": "competitor.com", "start_date": "2024-01", "end_date": "2024-12", "country": "us", "granularity": "monthly"}) + +similarweb(op="call", args={"method_id": "similarweb.traffic_sources.get.v1", "domain": "competitor.com", "start_date": "2024-01", "end_date": "2024-12", "country": "us"}) + +similarweb(op="call", args={"method_id": "similarweb.traffic_geography.get.v1", "domain": "competitor.com", "start_date": "2024-01", "end_date": "2024-12"}) + +similarweb(op="call", args={"method_id": "similarweb.similar_sites.get.v1", "domain": "competitor.com"}) + +amazon(op="call", args={"method_id": "amazon.catalog.search_items.v1", "keywords": "product category", "marketplaceIds": ["ATVPDKIKX0DER"]}) + +amazon(op="call", args={"method_id": "amazon.pricing.get_item_offers_batch.v1", "requests": [{"uri": "/products/pricing/v0/items/{asin}/offers", "method": "GET", "MarketplaceId": "ATVPDKIKX0DER", "ItemCondition": "New"}]}) + +ebay(op="call", args={"method_id": "ebay.browse.search.v1", "q": "product category", "sort": "newlyListed", "limit": 25}) + +ebay(op="call", args={"method_id": "ebay.marketplace_insights.item_sales_search.v1", "q": "product category", "limit": 25}) +``` diff --git a/flexus_simple_bots/researcher/skills/_signal-news-events/RESEARCH.md b/flexus_simple_bots/researcher/skills/_signal-news-events/RESEARCH.md new file mode 100644 index 00000000..3c55ed2d --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_signal-news-events/RESEARCH.md @@ -0,0 +1,272 @@ +# Research: signal-news-events + +**Skill path:** `flexus_simple_bots/researcher/skills/signal-news-events/` +**Bot:** researcher +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`signal-news-events` detects news/event momentum for one query scope per run. + +The existing skill has good provider coverage but needs stronger quality gates: dedupe, corroboration, degraded-mode handling, contradiction logging, and provenance. + +This research was produced from template + brief + skill context, using five internal sub-research angles. + +--- + +## Definition of Done + +- [x] At least 4 distinct research angles are covered +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to +- [x] Tool/API landscape maps at least 3-5 options +- [x] At least one failure mode is documented +- [x] Schema recommendations are grounded in real data shapes +- [x] Gaps section lists uncertainty honestly +- [x] Findings are 2024-2026 or explicitly evergreen + +--- + +## Quality Gates + +- No generic filler without concrete backing: **passed** +- No invented tool names, method IDs, or API endpoints: **passed** +- Contradictions between sources noted explicitly: **passed** +- Findings volume target met: **passed** + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices + +**Findings:** + +Mature workflows are staged: collect -> dedupe -> corroborate -> interpret -> verdict. Raw article count alone is not a valid signal. 2025 EIOS evaluations reinforce measuring monitoring quality with detection/timeliness, not just volume. + +Google Trends and Wikimedia pageviews are useful confirmation layers, but both are non-absolute proxies (normalized/sampled trend index; pageview traffic-class semantics). Platform reach and trust can diverge, so reach and credibility should be scored separately. + +**Sources:** +- https://bmcpublichealth.biomedcentral.com/articles/10.1186/s12889-025-21998-9 +- https://iris.who.int/server/api/core/bitstreams/f2febe97-8fcc-47d7-9019-2285d74fa4ce/content +- https://support.google.com/trends/answer/4365533?hl=en +- https://doc.wikimedia.org/generated-data-platform/aqs/analytics-api/concepts/page-views.html +- https://reutersinstitute.politics.ox.ac.uk/digital-news-report/2025/dnr-executive-summary +- https://www.ap.org/verify + +--- + +### Angle 2: Tool & API Landscape + +**Findings:** + +A practical stack combines broad feed + event clustering + attention proxy. + +| Provider | Verified endpoint/method | Typical role | +| --- | --- | --- | +| GDELT DOC 2.0 | `GET https://api.gdeltproject.org/api/v2/doc/doc` | High-breadth global scan | +| Event Registry | `GET /api/v1/article/getArticles`, `GET /api/v1/event/getEvents` | Event clustering and aggregation | +| NewsAPI | `GET /v2/everything`, `GET /v2/top-headlines` | Broad retrieval | +| GNews | `GET /api/v4/search`, `GET /api/v4/top-headlines` | Independent corroboration | +| NewsData.io | `GET /api/1/latest`, `GET /api/1/archive`, `GET /api/1/count` | Breadth + archive | +| Newscatcher v3 | `POST /api/search`, `POST /api/latest_headlines` | Rich filtered retrieval | +| Wikimedia AQS | `GET /metrics/pageviews/per-article/...` | Public-interest confirmation | + +Perigon is available in current skill tooling, but endpoint-level public docs were less transparent in this pass. + +**Sources:** +- https://blog.gdeltproject.org/gdelt-doc-2-0-api-debuts/ +- https://eventregistry.org/static/api.yaml +- https://newsapi.org/docs/endpoints/everything +- https://newsapi.org/docs/endpoints/top-headlines +- https://docs.gnews.io/endpoints/search-endpoint +- https://docs.gnews.io/endpoints/top-headlines-endpoint +- https://newsdata.io/documentation +- https://www.newscatcherapi.com/docs/v3/api-reference/endpoints/search/search-articles-post +- https://www.newscatcherapi.com/docs/v3/api-reference/overview/rate-limits +- https://www.mediawiki.org/wiki/Wikimedia_APIs/Rate_limits + +--- + +### Angle 3: Data Interpretation & Signal Quality + +**Findings:** + +Interpretation failures dominate production errors. Relative trend movement and raw counts are not interchangeable. Always inspect raw + deduped counts and require persistence checks (`7d`, `30d`, `90d`). Source independence matters more than outlet count because syndication inflates naive volume. + +**Common misreads:** +- "Trend up" means absolute demand up. +- Many articles means many independent confirmations. +- Coverage down means true decline (may be quota/truncation). + +**Sources:** +- https://blog.gdeltproject.org/gdelt-2-0-api-now-supports-raw-result-counts/ +- https://blog.gdeltproject.org/gcp-timeseries-api-explorations-time-horizons-trending-versus-breaking-news/ +- https://help.eventregistry.org/media-monitoring-hiding-article-duplicates/ +- https://aclanthology.org/2024.findings-emnlp.275/ + +--- + +### Angle 4: Failure Modes & Anti-Patterns + +**Findings:** + +Key anti-patterns: duplicate inflation, single-provider certainty, PR-wave contamination, rate-limit blindness, stale recirculation, virality-as-truth, AI-summary substitution, timezone drift. + +**Anti-pattern template for skill:** what it looks like -> detection signal -> consequence -> exact mitigation. + +**Sources:** +- https://help.eventregistry.org/media-monitoring-hiding-article-duplicates/ +- https://www.newscatcherapi.com/docs/v3/documentation/guides-and-concepts/articles-deduplication +- https://newsapi.org/docs/errors +- https://docs.gnews.io/error-handling +- https://www.reuters.com/fact-check/old-video-istanbul-protest-shared-new-following-may-26-rafah-strike-2024-05-31/ +- https://www.reuters.com/world/uk/pm-starmer-warns-social-media-firms-after-southport-misinformation-fuels-uk-2024-08-01/ +- https://www.bbc.com/news/articles/cd0elzk24dno + +--- + +### Angle 5+: Integration, Governance, and Compliance + +**Findings:** + +Provider terms and collection constraints can invalidate otherwise correct technical outputs; governance uncertainty must be explicit in artifacts. Major collection degradation should force `insufficient_data` over directional claims. + +Provenance fields are mandatory for reproducibility: query hash, provider/method, retrieval time, source link, and collection notes. + +**Sources:** +- https://newsapi.org/terms +- https://foundation.wikimedia.org/wiki/Policy:User-Agent_policy +- https://raw.githubusercontent.com/cloudevents/spec/v1.0/spec.md +- https://www.w3.org/TR/prov-dm/ +- https://c2pa.org/specifications/specifications/2.0/specs/C2PA_Specification.html +- https://commission.europa.eu/news/ai-act-enters-force-2024-08-01_en +- https://www.edpb.europa.eu/news/news/2024/edpb-opinion-ai-models-gdpr-principles-support-responsible-ai_en + +--- + +## Synthesis + +Reliable news-event signaling is a quality-gated inference problem, not a feed-retrieval problem. Tooling is mature, but provider constraints make infrastructure state part of analytical state. + +Best outcomes come from enforcing: dedupe, independent corroboration, persistence checks, contradiction logging, and explicit degraded-mode behavior. + +--- + +## Recommendations for SKILL.md + +- [x] Add strict staged pipeline (`collect -> dedupe -> corroborate -> score -> verdict`) +- [x] Add hard gates before `strong` classification +- [x] Add provider-run telemetry + degraded-mode handling +- [x] Expand tools guidance with verified endpoint behavior and fallback order +- [x] Add explicit interpretation rubric + confidence caps +- [x] Add named anti-pattern warning blocks +- [x] Add contradiction/uncertainty policy +- [x] Expand schema with provenance and rights uncertainty + +--- + +## Draft Content for SKILL.md + +### Draft: Core mode and quality gates + +Use an evidence-first operating contract. You must not call a signal strong unless dedupe, corroboration, and collection-quality gates pass. + +Before any strong claim, verify all: +1. At least two independent providers support the claim. +2. Deduped counts still support the claim. +3. Collection quality is acceptable (no unresolved major throttling/truncation). +4. Source diversity is sufficient. +5. Contradictions and unresolved assumptions are explicitly logged. + +If any check fails, downgrade strength and consider `result_state=\"insufficient_data\"`. + +### Draft: Methodology sequence + +Run this exact sequence: +1. Scope lock (`query`, `geo_scope`, `time_window`, `event_intent`) +2. Multi-provider collection (at least 2 broad providers + at least 1 event provider) +3. Dedupe/normalize (raw vs deduped counts + source diversity) +4. Corroboration gate (`independent_provider_count >= 2`) +5. Window interpretation (`7d`, `30d`, `90d` burst vs persistence) +6. Independent attention confirmation (Wikimedia pageviews direction) +7. Quality-adjusted scoring + confidence assignment +8. Result-state assignment (`ok`, `zero_results`, `insufficient_data`, `technical_failure`) + +When provider limits degrade coverage, record exact degradation and avoid directional overclaims. + +### Draft: Available Tools text + +```python +newsapi(op="call", args={"method_id":"newsapi.everything.v1","q":"your query","language":"en","from":"2026-02-01","sortBy":"publishedAt"}) +gnews(op="call", args={"method_id":"gnews.search.v1","q":"your query","lang":"en","country":"us","max":25}) +newsdata(op="call", args={"method_id":"newsdata.news.search.v1","q":"your query","language":"en"}) +newscatcher(op="call", args={"method_id":"newscatcher.search.v1","q":"your query","lang":"en","page_size":100}) +gdelt(op="call", args={"method_id":"gdelt.doc.search.v1","query":"your query","mode":"artlist","maxrecords":100,"timespan":"30d"}) +event_registry(op="call", args={"method_id":"event_registry.article.get_articles.v1","keyword":"your query","lang":"eng","dataType":["news"],"dateStart":"2026-02-01","dateEnd":"2026-03-05"}) +event_registry(op="call", args={"method_id":"event_registry.event.get_events.v1","keyword":"your query","lang":"eng"}) +wikimedia(op="call", args={"method_id":"wikimedia.pageviews.per_article.v1","article":"Article_Title","project":"en.wikipedia.org","granularity":"daily","start":"2026020100","end":"2026030500"}) +``` + +Runtime requirements: +- Never hide provider failures. +- If multiple core providers degrade, use `insufficient_data`. +- Preserve provider-level status for auditability. + +### Draft: Interpretation + anti-pattern blocks + +Required scoring fields: +- `article_count_raw` +- `article_count_deduped` +- `duplicate_ratio` +- `independent_provider_count` +- `unique_source_domains` + +Confidence caps: +- Cap confidence at `0.80` if major contradiction is unresolved. +- Cap confidence at `0.80` if major collection degradation exists. +- Cap confidence at `0.80` if core evidence is single-provider. + +Named warning blocks: +- Duplicate inflation +- Quota-induced decline +- Virality-as-truth +- AI-summary substitution + +For each warning include: detection signal, consequence, and exact mitigation steps. + +### Draft: Schema additions + +```json +{ + "signal_news_events": { + "type": "object", + "required": ["query", "time_window", "result_state", "provider_runs", "signals", "confidence", "limitations", "next_checks", "provenance"], + "additionalProperties": false, + "properties": { + "query": {"type": "string"}, + "time_window": {"type": "object", "required": ["start_date", "end_date"], "additionalProperties": false, "properties": {"start_date": {"type": "string"}, "end_date": {"type": "string"}}}, + "result_state": {"type": "string", "enum": ["ok", "zero_results", "insufficient_data", "technical_failure"]}, + "provider_runs": {"type": "array", "items": {"type": "object", "required": ["provider", "method_id", "api_status", "article_count_raw", "article_count_deduped", "degraded_mode"], "additionalProperties": false}}, + "signals": {"type": "array", "items": {"type": "object", "required": ["signal_type", "description", "strength", "evidence", "corroboration"], "additionalProperties": false}}, + "confidence": {"type": "object", "required": ["value", "band", "rationale", "unresolved_assumptions"], "additionalProperties": false}, + "limitations": {"type": "array", "items": {"type": "string"}}, + "next_checks": {"type": "array", "items": {"type": "string"}}, + "provenance": {"type": "object", "required": ["generated_at_utc", "query_hash", "api_docs_verified", "collection_notes", "rights_uncertainty"], "additionalProperties": false} + } + } +} +``` + +--- + +## Gaps & Uncertainties + +- Provider-overlap benchmarks are sparse; dedupe thresholds remain environment-specific. +- Some docs are evergreen/undated and can drift from runtime behavior. +- Perigon endpoint details were not fully transparent via public docs in this pass. +- Attention proxies measure attention, not commercial intent. +- Governance/legal interpretation is jurisdiction-specific; this is operational guidance, not legal advice. diff --git a/flexus_simple_bots/researcher/skills/_signal-news-events/SKILL.md b/flexus_simple_bots/researcher/skills/_signal-news-events/SKILL.md new file mode 100644 index 00000000..511be80b --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_signal-news-events/SKILL.md @@ -0,0 +1,73 @@ +--- +name: signal-news-events +description: News and event signal detection — media volume, topic momentum, event clustering, public interest +--- + +You are detecting news and event signals for one query scope per run. + +Core mode: evidence-first. Media volume alone is not a signal — assess whether coverage reflects genuine market interest or is a one-off event. Cross-validate across at least two independent news providers before calling a signal strong. + +## Methodology + +### Media volume and momentum +Use multiple news APIs to measure how much coverage a topic is receiving and whether it's growing, stable, or declining. + +Volume benchmarking approach: +- Count articles in past 7 / 30 / 90 days across providers +- Compare periods: is coverage accelerating? +- Detect: announcement clusters (product launches, regulatory changes, funding rounds) + +### Event clustering +Use `gdelt` for large-scale event detection across thousands of sources globally. Events with high `goldstein_scale` impact scores indicate significant market events. + +Use `event_registry` to cluster articles around specific events and identify the most-covered story. + +### Topic sentiment +Many news APIs return tone/sentiment. Negative coverage concentration (regulatory issues, failures, controversies) is also a signal — it indicates market tension. + +### Public interest proxy +Use `wikimedia.pageviews.per_article.v1` as a demand proxy: Wikipedia page views correlate with genuine public interest and are not manipulable by marketing budgets. + +### Provider selection heuristics +- High-volume scan: `newsapi`, `gnews`, `newsdata` +- Event clustering: `gdelt`, `event_registry` +- Niche/trade coverage: `newscatcher`, `perigon` +- Public interest baseline: `wikimedia` + +## Recording + +``` +write_artifact( + artifact_type="signal_news_events", + path="/signals/news-events-{YYYY-MM-DD}", + data={...} +) +``` + +One artifact per query scope per run. + +## Available Tools + +``` +gdelt(op="call", args={"method_id": "gdelt.doc.search.v1", "query": "your query", "mode": "artlist", "maxrecords": 25, "timespan": "30d"}) + +gdelt(op="call", args={"method_id": "gdelt.events.search.v1", "query": "your query", "maxrecords": 25}) + +event_registry(op="call", args={"method_id": "event_registry.article.get_articles.v1", "keyword": "your query", "lang": "eng", "dataType": ["news"], "dateStart": "2024-01-01", "dateEnd": "2024-12-31"}) + +event_registry(op="call", args={"method_id": "event_registry.event.get_events.v1", "keyword": "your query", "lang": "eng"}) + +newsapi(op="call", args={"method_id": "newsapi.everything.v1", "q": "your query", "language": "en", "from": "2024-01-01", "sortBy": "publishedAt"}) + +newsapi(op="call", args={"method_id": "newsapi.top_headlines.v1", "q": "your query", "language": "en"}) + +gnews(op="call", args={"method_id": "gnews.search.v1", "q": "your query", "lang": "en", "country": "us", "max": 10}) + +newsdata(op="call", args={"method_id": "newsdata.news.search.v1", "q": "your query", "language": "en"}) + +newscatcher(op="call", args={"method_id": "newscatcher.search.v1", "q": "your query", "lang": "en", "page_size": 25}) + +perigon(op="call", args={"method_id": "perigon.all.search.v1", "q": "your query", "language": "en"}) + +wikimedia(op="call", args={"method_id": "wikimedia.pageviews.per_article.v1", "article": "Article_Title", "project": "en.wikipedia.org", "granularity": "monthly", "start": "2024010100", "end": "2024120100"}) +``` diff --git a/flexus_simple_bots/researcher/skills/_signal-professional-network/RESEARCH.md b/flexus_simple_bots/researcher/skills/_signal-professional-network/RESEARCH.md new file mode 100644 index 00000000..041e2f14 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_signal-professional-network/RESEARCH.md @@ -0,0 +1,890 @@ +# Research: signal-professional-network + +**Skill path:** `flexus-client-kit/flexus_simple_bots/researcher/skills/signal-professional-network/` +**Bot:** researcher +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`signal-professional-network` detects organization-level professional network signals from LinkedIn activity for one company set or one industry topic per run. The current skill is already focused on post themes, follower momentum, and engagement quality, but it is too optimistic about metric comparability and too light on API/access constraints. + +2024-2026 evidence points to three practical changes needed for authoritative output quality: +- LinkedIn organization analytics should be interpreted with explicit lag and denominator awareness (impressions-based and follower-based rates are not interchangeable). +- API reality matters as much as analysis logic: version headers, access tiers, permissions, and data retention rules can invalidate implementation assumptions. +- "Strong signal" decisions now require quality controls against vanity engagement, plan-gated analytics differences, and compliance-unsafe data usage patterns. + +This research expands the skill with source-backed methodology, tooling constraints, interpretation thresholds, anti-pattern guardrails, and paste-ready `SKILL.md` draft sections. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024-2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- No generic filler without concrete backing: **passed** +- No invented tool names, method IDs, or API endpoints: **passed** +- Contradictions between sources are explicitly noted: **passed** +- Volume target met (Findings sections 800-4000 words): **passed** + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +- Practitioners are converging on a three-window workflow for organization signal tracking: near-term check (7-14 days), operating window (30 days), and baseline window (90-365 days). LinkedIn page analytics surfaces are naturally aligned to 30-day and 365-day comparisons, and competitor trending views are explicitly refreshed daily for the past 30 days. +- Decision quality improves when teams enforce a "D-2 cutoff" rule for performance verdicts, because LinkedIn states content metrics (except reactions/comments) can take up to 48 hours to reflect. +- Better teams keep the competitor cohort fixed for at least one full 30-day cycle before changing peers, because competitor rank/order instability can create false "theme shift" conclusions. +- Theme analysis quality increases when each detected topic is segmented at least once by native professional dimensions (Location, Seniority, Job function, Industry, Company size), instead of relying only on global page averages. +- Follower momentum should be normalized by page size tier, not interpreted as absolute gains. Small pages can show high percent growth with low absolute increments; large pages can have meaningful momentum with lower percentage growth. +- Format normalization is now required for fair interpretation. Recent benchmark studies show material engagement spread by format (multi-image, documents, video, static image, polls, text), so a "topic is weak" claim without format adjustment is often wrong. +- In 2024-2026 practice, "conversation depth" (comment behavior) is increasingly weighted above raw reaction totals for quality judgments, aligned with LinkedIn's public framing of deeper engagement and comments growth. +- Search-appearance data is increasingly used as a secondary signal when engagement is mixed: if topic-keyword discoverability rises while engagement lags, practitioners mark it as "visibility-leading, conversion-lagging" instead of dropping the topic. +- LinkedIn's own 2025 business highlights report comments growth and continued video upload growth, which implies methodological updates: video and comment-intensity should be mandatory dimensions in organization signal scans, not optional add-ons. + +**Contradictions / nuances to encode:** + +- Benchmarks disagree on "good engagement" because denominator definitions differ (`by impressions` vs `by followers`), so thresholds must be metric-specific. +- LinkedIn provides metric definitions and timing behavior but not ranking-weight formulas; operational thresholds are heuristics, not official platform cutoffs. +- Premium and non-premium page analytics surfaces differ, so run-level limitations must state whether "missing insight" is actual absence or plan-gated visibility. + +**Sources:** +- [LinkedIn Page analytics](https://www.linkedin.com/help/linkedin/answer/a547077) +- [Content analytics for your LinkedIn Page](https://www.linkedin.com/help/linkedin/answer/a564051) +- [Competitor analytics for your LinkedIn Page](https://www.linkedin.com/help/lms/answer/a553615/competitor-analytics-for-your-linkedin-page?lang=en) +- [Follower analytics for your LinkedIn Page](https://www.linkedin.com/help/linkedin/answer/a570460) +- [Visitor analytics for your LinkedIn Page](https://www.linkedin.com/help/linkedin/answer/a570455) +- [Search Appearances analytics for your LinkedIn Page](https://www.linkedin.com/help/linkedin/answer/a7473929) +- [Socialinsider LinkedIn Benchmarks (2025)](https://www.socialinsider.io/social-media-benchmarks/linkedin) +- [Q1 Business Highlights (LinkedIn, 2025)](https://news.linkedin.com/2025/Q1-Business-Highlights) +- [LinkedIn video growth update (2025)](https://www.linkedin.com/pulse/up-36-over-last-year-video-linkedin-booming-time-now-somasundaram-yqbzc) +- [Leveraging dwell-time on LinkedIn feed (evergreen)](https://www.linkedin.com/blog/engineering/feed/leveraging-dwell-time-to-improve-member-experiences-on-the-linkedin-feed) + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +- Native LinkedIn Page analytics remains the highest-trust baseline for owned page monitoring, with distinct modules for content, followers, visitors, search appearances, and competitors. +- Premium Company Page materially expands competitor and visitor insight surfaces, so "tool capability" must be assessed against subscription tier, not only product category. +- Sales Navigator is a complementary signal layer for company/account-level momentum events, but it should not be treated as a replacement for Page analytics. +- LinkedIn Community Management APIs are practical for productized org monitoring, but access is vetted and tiered; implementation requires app review, approved use case, and ongoing compliance posture. +- LinkedIn rate limits are endpoint-specific and not fully published in docs; practical operations must read quotas from Developer Portal analytics instead of assuming static global values. +- LinkedIn versioning is not optional: missing or deprecated `Linkedin-Version` headers can fail calls. API strategy must include yearly upgrade planning. +- External platforms (Buffer, Metricool, Sprout, Sprinklr, Brandwatch, Meltwater, Hootsuite) differ most on historical depth, permissions footprint, and LinkedIn content field availability. Marketing pages often overstate "coverage"; documentation caveats matter more than feature headlines. +- Enterprise tooling should be treated as "workflow amplifiers" over official API/network constraints, not replacements for them. The base constraint set remains LinkedIn scopes, terms, and retention limits. + +| Provider | Primary strength | Practical limitation | Operational note | +|---|---|---|---| +| LinkedIn Page Analytics (native) | Highest-fidelity owned-page signals | Plan-gated depth in some modules | Best baseline for methodology and thresholds | +| Premium Company Page | Stronger competitor/visitor insight surfaces | Paid tier required | Must tag run with premium/non-premium availability | +| Sales Navigator | Account/lead/company change signals | Separate product and scope | Use as context layer, not direct engagement source | +| LinkedIn Community Mgmt API | Programmatic publishing + analytics endpoints | Access vetting and tier upgrade process | Essential for scalable pipeline builds | +| Buffer | Lightweight publishing + basic analytics | LinkedIn feature and history constraints | Good for SMB ops; weaker deep analytics | +| Metricool | Unified dashboard/reporting and competitor snapshots | History/sync depth varies by plan and API return | Validate historical backfill assumptions | +| Sprout / Sprinklr / Brandwatch / Meltwater / Hootsuite | Enterprise workflow, governance, reporting | Contract/plan-specific limits and content restrictions | Check field-level availability and export/legal constraints | + +**Sources:** +- [LinkedIn Page analytics](https://www.linkedin.com/help/linkedin/answer/a547077) +- [Competitor analytics for your LinkedIn Page](https://www.linkedin.com/help/lms/answer/a553615/competitor-analytics-for-your-linkedin-page?lang=en) +- [Premium Insights on Company Pages (Sales Navigator)](https://www.linkedin.com/help/sales-navigator/answer/a565340/premium-insights-on-company-pages-overview?lang=en) +- [Community Management App Review](https://learn.microsoft.com/en-us/linkedin/marketing/community-management-app-review) +- [LinkedIn API rate limits](https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/rate-limits) +- [LinkedIn Marketing API versioning](https://learn.microsoft.com/en-us/linkedin/marketing/versioning?view=li-lms-2025-10) +- [Buffer + LinkedIn support](https://support.buffer.com/article/560-using-linkedin-with-buffer) +- [Metricool historical data](https://help.metricool.com/en/article/historical-data-available-rn3q49/) +- [Sprinklr LinkedIn limitations](https://www.sprinklr.com/help/articles/capabilities-and-limitations/linkedin-capabilities-and-limitations/649aeadbefca565f6513b912) +- [Brandwatch LinkedIn data restrictions](https://developers.brandwatch.com/docs/data-restrictions) +- [Meltwater API usage limits](https://developer.meltwater.com/docs/meltwater-api/getting-started/usage-limits/) + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +- LinkedIn defines engagement rate as interactions per impressions for page content analytics. This should be the primary post-quality denominator for organization-level signal interpretation. +- Impressions and members-reached values in LinkedIn page analytics are estimates, so small point changes should be treated as noise unless sustained across multiple windows. +- A practical 2025 benchmark anchor from large-sample studies is around ~5% engagement by impressions for LinkedIn business content, but this varies by format and page cohort. +- Format-level spread is material (multi-image and native documents often above baseline; text lower), so comparisons should be intra-format first, cross-format second. +- Follower-based engagement metrics remain useful for account-size normalization, but they answer a different question than impression-based quality. Mixing them without labeling causes false conclusions. +- Follower growth interpretation is heavily size-dependent: smaller pages can post stronger percentage growth while larger pages show lower percent growth with larger absolute gains. +- Comments are rising as a strategic signal dimension (official LinkedIn business updates cite comments growth), so frameworks that rely mostly on likes/reactions are now under-sensitive. +- Organic page analytics and Campaign Manager views can diverge for boosted content; cross-surface reconciliation is required before declaring performance shifts. +- Confidence should be capped when interpretation uses one provider, one denominator, or one short window. + +**Practical threshold guidance (defaults, tune by niche):** + +- `Strong content-quality signal` (org-level): engagement by impressions >= page 30-day median and sustained in at least 2 snapshots. +- `Emerging topic priority`: same topic appears in >=3 posts across >=2 competitor orgs within 30 days and at least one post crosses benchmark-adjusted engagement threshold. +- `Follower momentum growth`: monthly follower growth above peer size-tier baseline OR above organization's own trailing baseline for 2 consecutive windows. +- `Debate-quality uplift`: comments-per-1k impressions rises while reactions-per-1k impressions is flat (often indicates stronger active interest). + +**Misinterpretations to avoid:** + +- Misread: "Engagement is up" while switching denominator from followers to impressions. +- Misread: Treating 24-48h metrics as final verdict despite published lag behavior. +- Misread: Calling trend from a single viral outlier post. +- Misread: Comparing boosted performance with organic-only metrics without noting source surface. +- Misread: Treating estimated impression changes as definitive when change magnitude is within noise range. + +**Sources:** +- [Content analytics for your LinkedIn Page](https://www.linkedin.com/help/linkedin/answer/a564051) +- [Follower analytics for your LinkedIn Page](https://www.linkedin.com/help/linkedin/answer/a570460) +- [Competitor analytics for your LinkedIn Page](https://www.linkedin.com/help/lms/answer/a553615/competitor-analytics-for-your-linkedin-page?lang=en) +- [Socialinsider LinkedIn Benchmarks (2025)](https://www.socialinsider.io/social-media-benchmarks/linkedin) +- [Rival IQ 2024 LinkedIn Benchmark Report](https://www.rivaliq.com/blog/linkedin-benchmark-report/) +- [Q1 Business Highlights (LinkedIn, 2025)](https://news.linkedin.com/2025/Q1-Business-Highlights) +- [Hootsuite benchmark framing (denominator caveats)](https://blog.hootsuite.com/calculate-engagement-rate/) + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +- **Unauthorized scraping/automation anti-pattern** + - What it looks like: browser bots, crawlers, scripted actions, or extensions that scrape/automate LinkedIn UI. + - Detection signal: high-frequency non-human interaction patterns, repeated account restrictions. + - Consequence: account/API access risk; hard policy violations. + - Mitigation: use approved API pathways only; remove unapproved automation; keep auditable access logs. + +- **Metric denominator conflation** + - What it looks like: mixing engagement-by-impressions, engagement-by-followers, and raw counts as if equivalent. + - Detection signal: one score changes denominators by section without disclosure. + - Consequence: false trend calls and wrong prioritization. + - Mitigation: require metric-definition labels for each claim; compare like-for-like only. + +- **Recency overreaction** + - What it looks like: strategy shifts based on <48h windows or one outlier post. + - Detection signal: recommendation references only latest week and no baseline. + - Consequence: volatility chasing. + - Mitigation: require 30d + baseline window and D-2 lag guard before strong verdicts. + +- **Plan-surface blindness** + - What it looks like: assuming analytics parity between free and premium company pages. + - Detection signal: missing module data interpreted as "no signal." + - Consequence: false negatives. + - Mitigation: record plan tier and mark unavailable surfaces explicitly in limitations. + +- **Compliance-unsafe member data handling** + - What it looks like: exporting/combining member activity data to CRM or lead enrichment. + - Detection signal: downstream datasets include member-level LinkedIn social activity outside allowed use. + - Consequence: terms breach and API loss risk. + - Mitigation: strict purpose binding, retention controls, no transfer/combination where prohibited. + +- **Retention rule violations** + - What it looks like: indefinite storage of member social activity or profile fields. + - Detection signal: no TTL/delete job for member activity cache. + - Consequence: direct violation of documented storage requirements. + - Mitigation: field-level retention matrix and automated purge. + +- **Official API vs wrapper confusion** + - What it looks like: treating third-party scraping APIs as equivalent to official LinkedIn APIs. + - Detection signal: endpoints are provider URLs but labeled "LinkedIn API." + - Consequence: fragility, legal risk, broken assumptions on scopes/versioning. + - Mitigation: separate "official" and "third-party" evidence classes in both logic and reporting. + +**Risk prioritization (highest impact first):** +1. Unauthorized scraping/automation +2. Compliance-unsafe member-data usage +3. Retention/deletion non-compliance +4. Denominator conflation in interpretation +5. Recency overreaction and volatility chasing + +**Sources:** +- [Prohibited software and extensions](https://www.linkedin.com/help/linkedin/answer/a1341387/prohibited-software-and-extensions?lang=en) +- [LinkedIn User Agreement](https://www.linkedin.com/legal/user-agreement) +- [Restricted uses of LinkedIn Marketing APIs and data](https://learn.microsoft.com/en-us/linkedin/marketing/restricted-use-cases?view=li-lms-2026-01) +- [LinkedIn Marketing API Program data storage requirements](https://learn.microsoft.com/en-us/linkedin/marketing/data-storage-requirements?view=li-lms-2025-10) +- [LinkedIn Marketing API Terms](https://www.linkedin.com/legal/l/marketing-api-terms) +- [LinkedIn API Terms of Use](https://www.linkedin.com/legal/l/api-terms-of-use) +- [ICO and global privacy authorities joint follow-up statement (2024)](https://ico.org.uk/about-the-ico/media-centre/news-and-blogs/2024/10/global-privacy-authorities-issue-follow-up-joint-statement-on-data-scraping-after-industry-engagement/) + +--- + +### Angle 5+: Official API Endpoint Reality & Integration Constraints +> Domain-specific additional angle: exact endpoint verification, permission/tier constraints, and migration nuances for organization-level LinkedIn signal collection. + +**Findings:** + +- Official org post creation/retrieval is documented on `https://api.linkedin.com/rest/posts`, and Posts API is positioned as the replacement for legacy `ugcPosts`. +- Organization-level engagement surfaces are split: `socialActions` is still documented, while newer docs also emphasize `reactions` and `socialMetadata`. Integrations must treat this as migration overlap, not contradiction-free replacement. +- `organizationalEntityFollowerStatistics` remains core for follower demographics and gains, but docs explicitly state total follower counts are no longer returned there; total follower count now belongs to `networkSizes`. +- `organizationalEntityShareStatistics` is organic-only, has a rolling 12-month limit, and should not be mixed with paid performance without explicit separation. +- LinkedIn versioning must be sent in request headers (`Linkedin-Version`) and older versions can be sunset. Unversioned calls are not safe defaults. +- Rate limits are endpoint-specific and not publicly enumerated as universal static values; teams must inspect Developer Portal analytics and design adaptive quota handling. +- App review and tiering gate practical access: development and standard tiers, use-case review, and screencast verification are part of real integration delivery. +- Permission scope naming and migration notes show drift across docs (`*_social` vs `*_social_feed`), so implementation should validate scopes against currently approved app scopes before rollout. +- Official and third-party endpoints must be clearly separated in reporting; wrapper APIs are provider APIs, not LinkedIn official APIs. + +**Verified endpoint/method map (official):** + +| Surface | Verified endpoint | Purpose | Notes | +|---|---|---|---| +| Posts | `POST /rest/posts` | Create org/member posts | Replaces legacy `ugcPosts` workflow | +| Posts finder | `GET /rest/posts?...&q=author` | Retrieve posts by author URN | Count/sort controls documented | +| Social actions | `GET /rest/socialActions/{urn}` | Aggregate likes/comments summary | Legacy + still documented | +| Reactions | `GET/POST /rest/reactions...` | Reaction read/write | Replaces likes behavior | +| Social metadata | `GET /rest/socialMetadata/{urn}` | Reaction/comment summaries | Supports comments-state controls | +| Follower statistics | `GET /rest/organizationalEntityFollowerStatistics?...` | Lifetime/time-bound follower stats | No `totalFollowerCounts` now | +| Share statistics | `GET /rest/organizationalEntityShareStatistics?...` | Org share engagement stats | Organic only, rolling 12 months | +| Network size | `GET /rest/networkSizes/urn:li:organization:{id}?edgeType=COMPANY_FOLLOWED_BY_MEMBER` | Total follower size | EdgeType migration nuance by version | +| Org ACLs | `GET /rest/organizationAcls?...` | Role/access checks | Needed before role-gated operations | + +**Sources:** +- [Posts API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api?view=li-lms-2025-10) +- [Content API migration guide](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/contentapi-migration-guide?view=li-lms-2025-10) +- [Social Actions API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/network-update-social-actions?view=li-lms-2025-10) +- [Reactions API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/reactions-api?view=li-lms-2025-09) +- [Social Metadata API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/social-metadata-api?view=li-lms-2025-09) +- [Organization Follower Statistics](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/follower-statistics?view=li-lms-2026-01) +- [Organization Share Statistics](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/share-statistics?view=li-lms-2025-11) +- [Organization Lookup API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/organization-lookup-api?view=li-lms-2025-10) +- [LinkedIn API rate limits](https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/rate-limits) +- [LinkedIn Marketing API versioning](https://learn.microsoft.com/en-us/linkedin/marketing/versioning?view=li-lms-2025-10) +- [Marketing API access tiers](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/marketing-tiers?view=li-lms-2024-11) +- [Community Management App Review](https://learn.microsoft.com/en-us/linkedin/marketing/community-management-app-review) + +--- + +## Synthesis + +The most important synthesis point is that professional-network signal quality depends less on "more data" and more on denominator discipline plus API reality. The same organization can look strong or weak depending on whether you compare by impressions, followers, or raw engagement counts. A reliable skill must force denominator labeling and avoid mixed-metric verdicts. + +The second clear pattern is access and integration governance. In 2024-2026, LinkedIn documentation is explicit on versioned calls, endpoint-specific limits, and restricted data use. This means a technically elegant scoring system can still fail operationally if it ignores tier/scopes, version headers, or retention boundaries. Robust `SKILL.md` guidance needs to define both analysis logic and collection legality. + +The third pattern is methodological: comments and video are no longer optional side signals. Official business updates and benchmark studies both indicate stronger conversation and video momentum, so theme classification should explicitly track comment-intensity and format mix. Post frequency alone is insufficient as a market-priority proxy. + +The biggest contradiction to preserve (not hide) is that benchmark thresholds vary across reports due to metric definitions, cohort windows, and sampling methods. The right implementation response is confidence tiering and explicit source/denominator metadata, not forced numeric agreement. + +Overall, the skill should evolve from "collect posts, likes, followers" into a controlled evidence pipeline: verify auth/access, gather segmented windows, normalize metrics, classify with explicit confidence, and publish limitations that tell the user what remains uncertain. + +--- + +## Recommendations for SKILL.md + +> Concrete, actionable list of what should change / be added in the SKILL.md based on research. +> Each item has corresponding draft content below. + +- [x] Replace current methodology bullets with a staged workflow that includes lag handling, fixed windows, and denominator normalization. +- [x] Add a strict evidence contract that separates metric classes (`observed`, `estimated`, `derived`) and disallows denominator mixing. +- [x] Expand tool usage guidance to include required call sequencing, auth preflight, and fallback behavior using only existing connector methods. +- [x] Add API reality notes: version header expectations, scope/tier constraints, and "official vs wrapper" distinctions. +- [x] Add interpretation defaults with benchmark-anchored thresholds and explicit confidence downgrade rules. +- [x] Add named anti-pattern warning blocks with detection signal, consequence, and mitigation steps. +- [x] Add compliance and retention guardrails for member-level data and restricted use-cases. +- [x] Expand `result_state` and confidence policy to handle contradictions, sparse data, and technical access failures. +- [x] Expand artifact schema with evidence provenance, denominator metadata, contradiction records, and benchmark context. +- [x] Add an output-quality checklist that must pass before `write_artifact`. + +--- + +## Draft Content for SKILL.md + +> This is the most important section. For every recommendation above, this provides paste-ready text to include in `SKILL.md`. + +### Draft: Core operating principle and evidence contract + +--- +You are detecting organization-level professional network signals on LinkedIn for one company set or one industry topic per run. + +Your primary rule is **evidence fidelity before conclusion strength**. If metric definitions are mixed or source constraints are unclear, you must reduce confidence and explicitly state limitations. + +Before writing any signal, label every evidence item with: +- `metric_denominator`: one of `impressions`, `followers`, `raw_count`, `time_normalized_rate`. +- `data_class`: one of `observed`, `estimated`, `derived`. +- `source_surface`: one of `linkedin_page_analytics`, `linkedin_api`, `third_party_platform`. + +You must never compare unlike denominators in the same verdict sentence. If you need cross-metric context, first normalize and then state the transformation rule. + +You must treat all "impressions" and "members reached" values as estimates unless the source explicitly says otherwise. + +If you cannot verify auth/access tier or method availability, return `result_state: auth_required` or `result_state: technical_failure` instead of inventing data. +--- + +### Draft: Methodology workflow (collection -> normalization -> classification -> verdict) + +--- +### Methodology + +Run this exact sequence every time: + +1. **Preflight and scope lock** + - Confirm one run scope only: one company set OR one industry topic. + - Call `linkedin_b2b(op="status")` first. + - If status indicates disconnected/auth required, stop collection and return a clear auth instruction. + - Record run constraints up front: time window, organization list, and any unavailable modules. + +2. **Collect organization post evidence** + - For each organization, retrieve recent posts via `linkedin_b2b.organization_posts.list.v1`. + - Keep a consistent operating window (`last 30 days`) and at least one baseline context window (`last 90-365 days`) when available. + - Tag each post by topic theme and post format before performance comparison. + - Do not score cross-format performance without normalization. + +3. **Collect engagement quality evidence** + - Pull post-level social metadata via `linkedin_b2b.social_metadata.get.v1`. + - Prioritize comments and repost dynamics for debate/interest quality, not reactions alone. + - When possible, compute per-1k-impressions style rates from available fields rather than using raw totals. + +4. **Collect follower momentum evidence** + - Pull follower stats via `linkedin_b2b.followers.stats.get.v1`. + - Evaluate momentum as change-rate against the organization's own trailing baseline and peer size-tier context. + - Do not treat absolute follower additions as comparable across differently sized pages. + +5. **Apply lag and stability guard** + - LinkedIn notes that many content metrics can lag up to 48 hours. Treat the freshest 48h as provisional unless only comments/reactions are being inspected. + - Require signal persistence across at least two snapshots before labeling `strong`. + +6. **Classify signals** + - `topic_priority`: recurring topic appears across multiple orgs and meets normalized engagement quality expectations. + - `pain_acknowledgment`: pain/problem language appears and attracts above-baseline discussion quality. + - `momentum_growth` / `momentum_decline`: follower and engagement trend move coherently across windows. + - `low_engagement`: evidence is broadly weak after normalization and lag handling. + +7. **Write verdict with uncertainty** + - If metrics conflict by denominator or window, include contradiction entry and downgrade confidence. + - If data is sparse or gated, use `insufficient_data` instead of overconfident narrative. + +Decision defaults: +- Strong signal requires agreement across at least two independent dimensions (e.g., topic recurrence + engagement quality, or follower momentum + discussion depth). +- One-outlier-post runs cannot produce `strong`. +- If only one organization has usable data, cap confidence at medium and state representativeness limits. +--- + +### Draft: Available Tools section text (verified methods only) + +--- +## Available Tools + +Use only verified connector methods. Do not invent method IDs. If uncertain, call `linkedin_b2b(op="help")` first and work only with listed methods. + +```python +linkedin_b2b(op="status") +linkedin_b2b(op="help") + +linkedin_b2b( + op="call", + args={ + "method_id": "linkedin_b2b.organization_posts.list.v1", + "organization_id": "12345", + "count": 20 + } +) + +linkedin_b2b( + op="call", + args={ + "method_id": "linkedin_b2b.social_metadata.get.v1", + "post_urn": "urn:li:share:12345" + } +) + +linkedin_b2b( + op="call", + args={ + "method_id": "linkedin_b2b.followers.stats.get.v1", + "organization_id": "12345" + } +) +``` + +Call sequencing: +1. `status` -> verify auth/connection. +2. posts list -> build post/topic universe. +3. social actions -> enrich quality metrics. +4. follower stats -> momentum context. +5. If any step fails, continue with available steps but downgrade confidence and write explicit `limitations`. + +Fallback behavior: +- If `status` is auth-required: stop and request LinkedIn reconnection. +- If posts are available but social actions fail: allow weak/moderate topic inference only. +- If follower stats fail: block momentum labels and add `next_checks` item. +--- + +### Draft: API reality notes for implementation safety + +--- +### API and access reality (must-follow) + +Even when connector methods abstract endpoint details, your reasoning must align with LinkedIn API realities: + +- LinkedIn Marketing APIs are versioned and require a `Linkedin-Version` header in direct integrations. +- Endpoint limits are endpoint-specific and discovered in Developer Portal analytics, not from one global hardcoded number. +- Organization analytics endpoints and permissions are role/scoped. If role/scope preconditions are not met, do not infer from partial failures. +- Official API endpoints and third-party wrappers are not equivalent evidence classes. If wrapper output is used in future connector versions, mark it `source_surface: third_party_platform`. +- Keep migration awareness in notes: docs contain legacy/new overlap (`socialActions` plus `reactions`/`socialMetadata`). Do not assume one page implies immediate global deprecation. +--- + +### Draft: Interpretation rules and confidence policy + +--- +### Signal interpretation rules + +Use these metric interpretation defaults: + +1. **Denominator discipline** + - Engagement by impressions evaluates content quality per exposure. + - Engagement by followers evaluates audience-normalized interaction. + - Raw interactions are volume indicators, not quality indicators. + - Never compare these as interchangeable. + +2. **Window discipline** + - Use at least one operating window (30d) and one baseline window (90d+ when available). + - Treat freshest 48h non-comment/non-reaction metrics as provisional. + +3. **Format normalization** + - Compare theme performance inside the same format first (video vs video, document vs document). + - Cross-format theme conclusions require explicit normalization note. + +4. **Momentum discipline** + - Follower momentum requires relative context (size tier or own historical baseline). + - Absolute follower gains are not enough. + +5. **Conversation quality** + - Weight comments/reposts more than raw reactions when assessing depth of interest. + - A reaction-only spike with flat comments is weaker evidence than a comment-led increase. + +Confidence scoring defaults: +- Start at `0.50`. +- +0.10 if denominator consistency is preserved. +- +0.10 if 30d and baseline windows both available. +- +0.10 if at least two organizations show aligned direction. +- +0.10 if at least two dimensions agree (theme + engagement or momentum + discussion). +- -0.10 for each unresolved contradiction. +- -0.15 if core module missing (posts or social actions). +- -0.10 if data is within lag/provisional window. + +Confidence grade mapping: +- `high`: 0.80-1.00 +- `medium`: 0.60-0.79 +- `low`: 0.40-0.59 +- `insufficient`: <0.40 +--- + +### Draft: Anti-pattern warning blocks + +--- +### WARNING: Denominator Mixing +**What it looks like:** You compare impression-based and follower-based engagement as if they were the same metric. +**Detection signal:** Verdict text uses one "engagement trend" claim but cites mismatched formulas. +**Consequence:** False direction calls and bad prioritization. +**Mitigation:** Label denominator per metric, compare like-for-like, and include normalization note for cross-metric context. + +### WARNING: Freshness Illusion +**What it looks like:** You call winners/losers from the newest 24-48h slice. +**Detection signal:** Strong verdict references only latest partial window. +**Consequence:** Noise-driven decisions. +**Mitigation:** Apply D-2 guard, require persistence across snapshots, downgrade confidence when provisional. + +### WARNING: Vanity Engagement Trap +**What it looks like:** High reactions are treated as strong demand without discussion depth. +**Detection signal:** Reactions spike while comments/reposts remain flat, yet output says "strong market pull." +**Consequence:** Inflated confidence and false topic prioritization. +**Mitigation:** Require conversation-quality co-signal before strong label. + +### WARNING: Plan-Tier Blindness +**What it looks like:** Missing analytics modules are interpreted as no signal. +**Detection signal:** No mention of free vs premium feature availability in limitations. +**Consequence:** False negatives due to entitlement gaps. +**Mitigation:** Record subscription/tier context and mark unavailable surfaces explicitly. + +### WARNING: Noncompliant Data Use +**What it looks like:** Member-level social activity is exported/combined into CRM or lead enrichment. +**Detection signal:** Artifact or downstream note references member data transfer to sales/recruiting workflows. +**Consequence:** Terms breach and potential API suspension. +**Mitigation:** Enforce purpose-limited use, no prohibited transfer/combining, retention controls by field type. +--- + +### Draft: Compliance and data retention guardrails + +--- +### Compliance guardrails (required) + +Before any run: +- Confirm collection method is approved API access, not UI scraping or unapproved automation. +- Confirm use-case stays within allowed page/profile management and analytics boundaries. + +Data-handling constraints: +- Treat member-level profile/activity data as restricted. +- Apply documented retention windows (for example, short caching windows for member profile/activity where applicable; longer windows for organization admin/reporting data per LinkedIn storage requirements). +- Implement deletion workflows and avoid indefinite caches of restricted fields. + +Use-case restrictions: +- Do not use community management member data for advertising, sales prospecting, recruiting enrichment, CRM append, or audience list building where prohibited. +- Do not combine restricted member data with third-party datasets to build derivative profiles. + +Operational behavior: +- If requested task implies restricted use, return `insufficient_data` with explicit policy limitation instead of attempting workaround collection. +--- + +### Draft: Result state policy + +--- +### Result state policy + +Use these states consistently: +- `ok`: sufficient, coherent evidence; no major unresolved contradictions. +- `zero_results`: query executed correctly but returned no relevant posts/signals. +- `insufficient_data`: data exists but quality/coverage cannot support defensible verdict. +- `technical_failure`: connector/tool failure prevented analysis. +- `auth_required`: LinkedIn auth missing/expired. +- `ok_with_conflicts` (recommended addition): useful evidence exists but meaningful contradictions remain unresolved. + +When contradictions exist: +- Keep useful signals if still evidence-backed. +- Add contradiction note and confidence downgrade. +- Avoid binary "signal/no-signal" claims. +--- + +### Draft: Schema additions + +> Full JSON Schema fragment for recommended `signal_professional_network` upgrades. + +```json +{ + "signal_professional_network": { + "type": "object", + "required": [ + "organizations", + "time_window", + "result_state", + "signals", + "confidence", + "confidence_grade", + "limitations", + "next_checks", + "evidence_summary" + ], + "additionalProperties": false, + "properties": { + "organizations": { + "type": "array", + "description": "LinkedIn organization URNs or names analyzed in this run.", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "time_window": { + "type": "object", + "required": [ + "start_date", + "end_date" + ], + "additionalProperties": false, + "properties": { + "start_date": { + "type": "string", + "description": "ISO date (YYYY-MM-DD) for analysis start." + }, + "end_date": { + "type": "string", + "description": "ISO date (YYYY-MM-DD) for analysis end." + } + } + }, + "result_state": { + "type": "string", + "description": "Overall run outcome quality and completeness.", + "enum": [ + "ok", + "ok_with_conflicts", + "zero_results", + "insufficient_data", + "technical_failure", + "auth_required" + ] + }, + "signals": { + "type": "array", + "description": "Structured organization-level signals emitted by the run.", + "items": { + "type": "object", + "required": [ + "signal_type", + "description", + "strength", + "organization", + "evidence", + "metric_denominator", + "data_class", + "source_surface" + ], + "additionalProperties": false, + "properties": { + "signal_type": { + "type": "string", + "enum": [ + "topic_priority", + "pain_acknowledgment", + "momentum_growth", + "momentum_decline", + "product_launch_signal", + "hiring_surge", + "low_engagement", + "conversation_depth_increase", + "format_shift_signal" + ], + "description": "Signal category derived from post, engagement, and follower evidence." + }, + "description": { + "type": "string", + "description": "Human-readable explanation of what changed and why it matters." + }, + "strength": { + "type": "string", + "enum": [ + "strong", + "moderate", + "weak" + ], + "description": "Signal strength after quality gate checks." + }, + "organization": { + "type": "string", + "description": "Organization name or URN tied to this signal." + }, + "evidence": { + "type": "string", + "description": "Concise evidence snippet: post summary and/or metric fact." + }, + "metric_denominator": { + "type": "string", + "enum": [ + "impressions", + "followers", + "raw_count", + "time_normalized_rate" + ], + "description": "Metric denominator used for this signal; prevents formula mixing." + }, + "data_class": { + "type": "string", + "enum": [ + "observed", + "estimated", + "derived" + ], + "description": "Evidence class for reliability context." + }, + "source_surface": { + "type": "string", + "enum": [ + "linkedin_page_analytics", + "linkedin_api", + "third_party_platform" + ], + "description": "Source surface used to derive this signal." + }, + "post_refs": { + "type": "array", + "description": "Optional list of post/activity references supporting this signal.", + "items": { + "type": "string" + } + }, + "metric_snapshot": { + "type": "object", + "description": "Optional metric payload used in this signal.", + "required": [ + "window_label" + ], + "additionalProperties": false, + "properties": { + "window_label": { + "type": "string", + "description": "Window label such as 30d, 90d, baseline." + }, + "engagement_rate": { + "type": "number", + "description": "Engagement rate used for this signal, if available." + }, + "comment_count": { + "type": "integer", + "minimum": 0, + "description": "Comment volume supporting conversation-quality interpretation." + }, + "follower_delta_pct": { + "type": "number", + "description": "Percent follower change for momentum inference." + } + } + } + } + } + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Numeric confidence after consistency and contradiction checks." + }, + "confidence_grade": { + "type": "string", + "enum": [ + "high", + "medium", + "low", + "insufficient" + ], + "description": "Bucketed confidence label derived from numeric confidence." + }, + "limitations": { + "type": "array", + "description": "Explicit caveats that constrain interpretation quality.", + "items": { + "type": "string" + } + }, + "next_checks": { + "type": "array", + "description": "Concrete follow-up checks to reduce uncertainty on next run.", + "items": { + "type": "string" + } + }, + "evidence_summary": { + "type": "object", + "required": [ + "organizations_covered", + "posts_analyzed", + "signals_emitted" + ], + "additionalProperties": false, + "properties": { + "organizations_covered": { + "type": "integer", + "minimum": 0, + "description": "Number of organizations with usable evidence." + }, + "posts_analyzed": { + "type": "integer", + "minimum": 0, + "description": "Count of posts considered in this run." + }, + "signals_emitted": { + "type": "integer", + "minimum": 0, + "description": "Count of signals produced after filtering." + }, + "contradictions_found": { + "type": "integer", + "minimum": 0, + "description": "Number of unresolved contradictions affecting confidence." + } + } + }, + "benchmark_context": { + "type": "object", + "additionalProperties": false, + "description": "Optional benchmark references used for thresholding this run.", + "properties": { + "engagement_baseline_note": { + "type": "string", + "description": "Text note about benchmark denominator and source." + }, + "follower_growth_baseline_note": { + "type": "string", + "description": "Text note about follower growth baseline and size-tier context." + } + } + }, + "contradictions": { + "type": "array", + "description": "Optional explicit source/metric conflicts that remain unresolved.", + "items": { + "type": "object", + "required": [ + "topic", + "conflict_note", + "impact_on_confidence" + ], + "additionalProperties": false, + "properties": { + "topic": { + "type": "string", + "description": "Conflict topic, e.g. denominator mismatch or window mismatch." + }, + "conflict_note": { + "type": "string", + "description": "What conflicts and why it cannot be resolved in current run." + }, + "impact_on_confidence": { + "type": "string", + "enum": [ + "minor", + "moderate", + "major" + ], + "description": "Severity of this conflict's impact on confidence." + } + } + } + } + } + } +} +``` + +### Draft: Recording and pre-write checklist + +--- +### Recording + +After completing analysis, call: + +```python +write_artifact( + artifact_type="signal_professional_network", + path="/signals/professional-network-{YYYY-MM-DD}", + data={...} +) +``` + +Before writing the artifact, verify all of the following: + +1. You ran `linkedin_b2b(op="status")` and handled auth state correctly. +2. You used consistent denominator labels for every emitted signal. +3. You did not assign `strong` based on a single post or single source. +4. You applied lag guard for freshest non-comment/non-reaction metrics. +5. You recorded plan/tier or module availability limits where relevant. +6. You included contradictions when metrics disagree. +7. `result_state`, `confidence`, and `confidence_grade` are internally consistent. +8. `limitations` and `next_checks` are concrete (no generic filler). + +If any check fails, downgrade confidence and keep the run transparent instead of overfitting a narrative. +--- + +## Gaps & Uncertainties + +- LinkedIn documentation exposes some overlapping or migrating social endpoint surfaces (`socialActions` and newer reaction/metadata surfaces), but exact connector implementation mapping can differ by app scope and migration state. +- Public benchmark reports differ by cohort and denominator definitions, so absolute cross-report thresholds are not universally portable. The skill should keep defaults tunable. +- Third-party platform capabilities and pricing/limits are frequently plan- and contract-dependent; behavior should be re-validated during implementation, not assumed from marketing pages. +- Several API/legal rules are intentionally broad (e.g., restricted use language), so edge-case legal interpretation may require counsel for production deployments. +- Some foundational feed-quality sources are evergreen rather than 2024-2026 publications; they are used as conceptual support, not sole evidence for current numeric thresholds. diff --git a/flexus_simple_bots/researcher/skills/_signal-professional-network/SKILL.md b/flexus_simple_bots/researcher/skills/_signal-professional-network/SKILL.md new file mode 100644 index 00000000..ba20cc06 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_signal-professional-network/SKILL.md @@ -0,0 +1,56 @@ +--- +name: signal-professional-network +description: Professional network signal detection — organization activity, content themes, and follower momentum on LinkedIn +--- + +You are detecting professional network signals from organization-level activity for one company set or industry topic per run. + +Core mode: evidence-first. LinkedIn organic reach is gated — a company with few followers produces low-signal posts regardless of content quality. Always note follower count alongside engagement metrics. Low-follower organizations produce structurally weak signals. + +## Methodology + +### Organization post themes +What topics are organizations in your target market talking about? Recurring themes in posts = priority areas for these organizations. + +Analyze: +- Post topics: product launches, hiring announcements, thought leadership, partnerships +- Tone: are posts celebrating success or addressing problems? +- CTA patterns: what are they selling to their audiences? + +Use `linkedin_b2b.organization_posts.list.v1` to retrieve recent posts from target organizations. + +### Follower momentum +Follower growth rate signals company momentum and market positioning credibility. +Use `linkedin_b2b.followers.stats.get.v1` to retrieve follower count and growth data. + +### Social engagement quality +Use `linkedin_b2b.social_metadata.get.v1` to get likes, comments, and share totals per post. +High comment count (not just likes) = post is generating real debate or strong interest. + +### Signal interpretation rules +- Post frequency >3/week from multiple competitors on same topic = topic is a current priority in the market +- High engagement on a pain-describing post = audience recognizes the pain +- Low engagement across all posts = LinkedIn is not the right channel for this market +- Follower surge (>5% MoM) = company is gaining market relevance + +## Recording + +``` +write_artifact( + artifact_type="signal_professional_network", + path="/signals/professional-network-{YYYY-MM-DD}", + data={...} +) +``` + +## Available Tools + +``` +linkedin_b2b(op="call", args={"method_id": "linkedin_b2b.organization_posts.list.v1", "organization_id": "12345", "count": 20}) + +linkedin_b2b(op="call", args={"method_id": "linkedin_b2b.social_metadata.get.v1", "post_urn": "urn:li:share:12345"}) + +linkedin_b2b(op="call", args={"method_id": "linkedin_b2b.followers.stats.get.v1", "organization_id": "12345"}) +``` + +Note: LinkedIn B2B API requires OAuth — call `linkedin_b2b(op="status")` first to verify connection. If `AUTH_REQUIRED` is returned, inform the user to connect their LinkedIn account. diff --git a/flexus_simple_bots/researcher/skills/_signal-reviews-voice/RESEARCH.md b/flexus_simple_bots/researcher/skills/_signal-reviews-voice/RESEARCH.md new file mode 100644 index 00000000..808c054d --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_signal-reviews-voice/RESEARCH.md @@ -0,0 +1,1202 @@ +# Research: signal-reviews-voice + +**Skill path:** `flexus_simple_bots/researcher/skills/signal-reviews-voice/` +**Bot:** researcher +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`signal-reviews-voice` detects voice-of-market signals from review platforms for one target scope per run (company, product, competitor set, or category). The skill should identify recurring pain themes, satisfaction gaps, and competitive weaknesses while staying evidence-first and explicit about uncertainty. + +The current `SKILL.md` already has a useful baseline (provider selection, theme extraction, rating-distribution patterns, and artifact schema), but it still mixes internal wrapper method IDs with endpoint-like names and does not yet encode 2024-2026 policy/compliance and trust-signal constraints strongly enough. This research focuses on practical methodology, verified API/tool landscape, interpretation quality, and anti-pattern prevention so a future `SKILL.md` can be operationally safe and audit-ready. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024-2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- No generic filler without concrete backing: **passed** +- No invented tool names, method IDs, or API endpoints: **passed** (wrapper aliases are labeled as internal) +- Contradictions between sources are explicitly noted: **passed** +- Volume target met (Findings sections 800-4000 words): **passed** + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +- Mature review-intelligence workflows now put **policy/compliance gating before sentiment scoring**. 2024-2025 regulator and platform enforcement tightened expectations around fake reviews, suppression, and incentive misuse, so ingestion pipelines now need explicit exclusion rules and audit logs before any signal scoring. +- Method quality depends on **channel-specific solicitation logic**. There is a practical contradiction across ecosystems: some channels allow controlled invitation programs, while others (for example, Yelp) discourage asking for reviews directly. Effective practice is per-channel collection policy, not one solicitation workflow across providers. +- Practitioners increasingly require **source metadata as first-class evidence**, not optional context. High-quality outputs store provider, invite source, moderation status, recommendation status, and incentive disclosure status per record, then report source-mix alongside any aggregate conclusion. +- Cross-provider analytics is moving to a **two-layer scoring model**: keep platform-native metrics for operational context, then compute an internal normalized signal layer for cross-platform comparisons. This is required because platform scoring and moderation mechanics differ materially. +- Sample-size handling is operationalized as explicit decision rules. A practical baseline used in review-signal workflows is: `<10` reviews = insufficient evidence; `10-19` = directional only; `>=20` = reportable with confidence scoring. Low-N windows should use conservative shrinkage/interval logic rather than raw mean ranking. +- Practitioners separate **provisional vs settled data** because moderation lag can alter published review sets after collection. Trend alerts and strategic conclusions should run on settled windows, not latest-hour snapshots during active moderation. +- Fraud triage has shifted from sentiment-only checks to **pattern-based integrity checks**: sudden polarity spikes, duplicate/near-duplicate bursts, abnormal account behaviors, and media-triggered brigading windows all trigger hold states before narrative conclusions. +- Response operations now follow **severity-based SLAs** tied to low-star technical complaints and recency, with channel-specific mechanics respected (for example, one-public-reply constraints in some ecosystems). +- There is a real contradiction in AI-assisted response writing: some data suggests AI-style responses can be acceptable, but consumers still penalize generic templated replies. Operational resolution is AI draft + human personalization + periodic QA. +- Stronger teams segment before actioning: provider-level, region/language, review cohort (invited/organic), and account size where available. Unsegmented global averages are now considered weak evidence for roadmap or GTM decisions. +- 2025 compliance guidance (for example, UK CMA) moves from broad principles to explicit obligations: publish policies, assess risks, detect/investigate/remediate suspicious reviews, and evidence effectiveness. This should be treated as process design input, not legal footnote. +- An evergreen methodological spine remains useful: integrity, transparency, accuracy, and traceability principles (for example, ISO 20488), then layer 2024-2026 enforcement-specific controls on top. + +**Contradictions to carry into skill logic:** + +- **Solicitation contradiction:** structured invitation programs are allowed in some ecosystems; Yelp discourages asking for reviews. Resolution: provider-specific collection policy branch, never one-size-fits-all. +- **AI response contradiction:** efficiency gains from AI drafting vs trust loss from generic replies. Resolution: require human-in-the-loop finalization for all public responses. +- **Native score contradiction:** platform-native ratings are operationally useful but not cross-platform comparable. Resolution: retain native score and add normalized cross-provider signal layer. + +**Sources:** +- [FTC final fake-reviews rule (2024)](https://www.ftc.gov/news-events/news/press-releases/2024/08/federal-trade-commission-announces-final-rule-banning-fake-reviews-testimonials) +- [FTC reviews rule Q&A (2024)](https://www.ftc.gov/business-guidance/resources/consumer-reviews-testimonials-rule-questions-answers) +- [UK CMA fake-reviews guidance (2025)](https://www.gov.uk/government/publications/fake-reviews-cma208/short-guide-for-businesses-publishing-consumer-reviews-and-complying-with-consumer-protection-law) +- [Google Maps anti-abuse update (2024)](https://blog.google/products/maps/how-machine-learning-keeps-contributed-content-helpful) +- [Google Business Profile restrictions](https://support.google.com/business/answer/14114287?hl=en) +- [Trustpilot transparency report (2024)](https://assets.ctfassets.net/b7g9mrbfayuu/7p63VLqZ9vmU2TB65dVdnF/6e47d9ee81c145b5e3d1e16f81bba89a/Trustpilot_Transparency_Report_2024.pdf) +- [Trustpilot business guidelines (2026)](https://corporate.trustpilot.com/legal/for-businesses/guidelines-for-businesses/feb-2026) +- [Yelp do-not-ask policy](https://www.yelp-support.com/article/Don-t-Ask-for-Reviews%3Fl%3Den_US) +- [Yelp trust and safety report (2025)](https://trust.yelp.com/trust-and-safety-report/2025-report/) +- [G2 review validity](https://sell.g2.com/review-validity) +- [G2 review status and timelines](https://documentation.g2.com/help/docs/understanding-review-statuses-and-timelines) +- [BrightLocal review survey (2024)](https://www.brightlocal.com/research/local-consumer-review-survey-2024/) +- [BrightLocal review survey (2026)](https://www.brightlocal.com/research/local-consumer-review-survey) +- [NIST GenAI risk framework profile (2024)](https://www.nist.gov/publications/artificial-intelligence-risk-management-framework-generative-artificial-intelligence) +- [ISO 20488 (2018, evergreen)](https://www.iso.org/standard/68193.html) + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +- Real-world review signal stacks usually combine three layers: (1) review-source providers, (2) optional enrichment/NLP services, and (3) governance/normalization logic. +- Trustpilot, Yelp, Google Business Profile, G2, App Store Connect, and Google Play have documented retrieval/reply surfaces, but permissions and access models differ heavily. +- Cross-platform comparability fails when teams ignore provider constraints: partial review returns, plan-gated endpoints, owner-only APIs, moderation delays, and quota behavior. +- Text analytics APIs (AWS Comprehend, Google Cloud Natural Language, Azure AI Language) are useful for enrichment only; they are not review sources and should not be treated as independent market evidence. +- A key practical gap: publicly documented **Capterra review-retrieval endpoints** were not verified in official public docs in this pass. Treat Capterra connector methods as internal/private-contract dependent unless connector docs prove otherwise. +- Internal wrapper method IDs can be used safely only when paired with a verified vendor operation string (`HTTP method + URL/path`) and source URL. + +| Platform | Verified operation examples | Auth/access pattern | Published limits/constraints | Practical use | Key limitation | +|---|---|---|---|---|---| +| Trustpilot | `GET https://api.trustpilot.com/v1/business-units/find`; `GET https://datasolutions.trustpilot.com/v1/business-units/{businessUnitId}/reviews` | API key + OAuth depending on API tier | Rate guidance documented; private APIs permissioned | B2B/B2C review retrieval and response workflows | Access tier and permission setup vary | +| Yelp Fusion | `GET https://api.yelp.com/v3/businesses/search`; `GET https://api.yelp.com/v3/businesses/{business_id_or_alias}/reviews` | Bearer API key | Plan/QPS constraints and endpoint permission caveats | Local/service competitor scan + excerpts | Reviews endpoint is limited and plan-gated | +| Google Business Profile | `GET https://mybusiness.googleapis.com/v4/{parent=accounts/*/locations/*}/reviews`; `PUT .../reviews/*/reply` | OAuth (`business.manage`) | Usage limits documented (`QPM`, edits/min) | Owner-managed location review monitoring and reply | Not a competitor-intel feed | +| Google Places API | `GET https://places.googleapis.com/v1/{name=places/*}` | API key or OAuth + field mask | Quota and billing by method/SKU | Supplemental public place context | Not moderation/reply management | +| G2 API v2 | `GET https://data.g2.com/api/v2/vendors`; `GET https://data.g2.com/api/v2/products/{product_id}/reviews` | Token + account permissions | Published rate limits and permission gating | B2B software review intelligence | Endpoint availability depends on account access | +| App Store Connect | `GET /v1/apps/{id}/customerReviews`; review response operations | JWT (ES256 private key) | Rolling rate-limit headers + role requirements | iOS app review ingestion/reply for owned apps | Not for competitor app internals | +| Google Play Developer API | `GET .../applications/{packageName}/reviews`; `POST .../{reviewId}:reply` | OAuth (`androidpublisher`) | Quota buckets by API family | Android app review ingestion/reply for owned apps | Owned app scope only | +| Bazaarvoice Conversations | `GET .../data/reviews.json` | API passkey | Rate-limit headers and key governance | Retail review retrieval (where contracted) | Contract and key governance required | +| AWS Comprehend | `DetectSentiment` | SigV4 | size/throttle constraints | Sentiment enrichment for review text | Not a source of review data | +| Google Cloud Natural Language | `POST https://language.googleapis.com/v1/documents:analyzeSentiment` | OAuth/API auth | quotas and content limits | Sentiment enrichment | Not a source of review data | +| Azure AI Language | `POST {Endpoint}/language/:analyze-text?api-version=2025-11-01` | Key or bearer | tier-based RPS/RPM limits | Sentiment/opinion enrichment | Not a source of review data | + +**No official public API cases (explicit):** + +- Capterra public products/reviews retrieval endpoint could not be verified in official public docs in this pass. +- For this skill, treat `capterra.*` as **connector-dependent** and require runtime connector documentation before use. +- If unavailable, record provider as `unsupported_or_unverifiable` and continue with Trustpilot/G2/Yelp plus limitations. + +**Sources:** +- [Trustpilot Business Units API](https://developers.trustpilot.com/business-units-api-(public)/) +- [Trustpilot Data Solutions API](https://developers.trustpilot.com/data-solutions-api/) +- [Trustpilot authentication](https://developers.trustpilot.com/authentication) +- [Trustpilot rate limiting](https://developers.trustpilot.com/rate-limiting/) +- [Yelp business search endpoint](https://docs.developer.yelp.com/reference/v3_business_search) +- [Yelp business reviews endpoint](https://docs.developer.yelp.com/reference/v3_business_reviews) +- [Yelp rate limiting](https://docs.developer.yelp.com/docs/fusion-rate-limiting) +- [Google Business Profile reviews.list](https://developers.google.com/my-business/reference/rest/v4/accounts.locations.reviews/list) +- [Google Business Profile updateReply](https://developers.google.com/my-business/reference/rest/v4/accounts.locations.reviews/updateReply) +- [Google Business Profile limits](https://developers.google.com/my-business/content/limits) +- [Google Places place details](https://developers.google.com/maps/documentation/places/web-service/place-details) +- [G2 API v2 docs](https://data.g2.com/api/v2/docs/index.html) +- [G2 OpenAPI v2](https://data.g2.com/openapi/v2.yaml) +- [App Store Connect customer reviews](https://developer.apple.com/documentation/appstoreconnectapi/list_all_customer_reviews_for_an_app) +- [App Store Connect rate limits](https://developer.apple.com/documentation/appstoreconnectapi/identifying-rate-limits) +- [Google Play reviews.list](https://developers.google.com/android-publisher/api-ref/rest/v3/reviews/list) +- [Google Play reviews.reply](https://developers.google.com/android-publisher/api-ref/rest/v3/reviews/reply) +- [Google Play quotas](https://developers.google.com/android-publisher/quotas) +- [Bazaarvoice retrieve reviews](https://developers.bazaarvoice.com/v1.0-ConversationsAPI/reference/get_data-reviews-json) +- [AWS DetectSentiment](https://docs.aws.amazon.com/comprehend/latest/APIReference/API_DetectSentiment.html) +- [Google NLP analyzeSentiment](https://cloud.google.com/natural-language/docs/reference/rest/v1/documents/analyzeSentiment) +- [Azure analyze-text REST 2025-11-01](https://learn.microsoft.com/en-us/rest/api/language/analyze-text/analyze-text/analyze-text?view=rest-language-analyze-text-2025-11-01) +- [Gartner Digital Markets buyer discovery API](https://datainsights.gartner.com/api/buyerdiscovery/swagger/index.html) + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +- Interpretation quality now starts with a **hard integrity gate**: fake, manipulated, or policy-breaching reviews are excluded before sentiment/theme aggregation. +- Platform intervention states (warnings/restrictions/suspicious-activity states) should switch a target to **hold** for strategic conclusions; data can still be logged, but final signal claims are deferred. +- Trust strata matter: recommended/retained reviews and filtered/removed reviews should not carry equal weight. Even when exact weighting differs by team, weighting logic must be explicit. +- Raw mean shifts are weak for low-N segments. Confidence-aware methods (for example Wilson intervals for proportions) are preferred over naive average comparison. +- Trend quality improves with dual windows: short detection window (for fast anomaly detection) plus longer baseline window for stability. +- Review-bombing and brigading detection should include both numerical bursts and context cues (media events, policy disputes, coordinated posting patterns). +- De-duplication is mandatory before theme prevalence scoring; duplicate bursts otherwise inflate false certainty. +- Language handling affects reliability materially; multilingual segments should be analyzed separately before global roll-up. Low-resource language translation paths need explicit confidence downgrades. +- Cross-platform normalization should compare internal standardized changes (for example within-platform trend deltas) rather than direct star-average equivalence across providers. +- Selection bias remains large in reviews (including J-shaped distributions and participation effects), so outputs should report distribution shape and uncertainty, not just average rating. +- Practical confidence scoring should combine sample adequacy, source integrity, temporal stability, cross-source agreement, and language coverage. + +**Signal vs noise guidance to encode directly:** + +- **Signal:** recurring theme increases over two windows, cross-provider agreement, stable moderation state, and adequate sample. +- **Noise:** one-day polarity spike during active intervention state, duplicate bursts, or single-provider anomaly with no corroboration. +- **Do-not-conclude state:** intervention active, sample below threshold, unresolved source contradiction, or language/coverage gap. + +**Sources:** +- [FTC reviews rule Q&A (2024)](https://www.ftc.gov/business-guidance/resources/consumer-reviews-testimonials-rule-questions-answers) +- [Federal Register final rule (2024)](https://www.federalregister.gov/documents/2024/08/22/2024-18519/trade-regulation-rule-on-the-use-of-consumer-reviews-and-testimonials) +- [UK CMA fake review guidance (2025)](https://www.gov.uk/government/publications/fake-reviews-cma208) +- [Google consumer alerts policy](https://support.google.com/contributionpolicy/answer/15178562?hl=en) +- [Yelp recommendation software](https://trust.yelp.com/recommendation-software/) +- [Yelp trust and safety report (2024)](https://trust.yelp.com/trust-and-safety-report/2024-report/) +- [Google Maps fake review update (2025)](https://blog.google/products/maps/google-business-profiles-ai-fake-reviews) +- [MAiDE-up multilingual deception dataset (2024)](https://arxiv.org/abs/2404.12938) +- [EACL cross-lingual translation reliability (2024)](https://aclanthology.org/2024.eacl-short.28/) +- [Review-bombing analysis (2024)](https://arxiv.org/abs/2405.06306) +- [Fake review detection survey (2024)](https://www.cambridge.org/core/journals/knowledge-engineering-review/article/recent-stateoftheart-of-fake-review-detection-a-comprehensive-review/F02E8339C43A62BA63EBD54A1608F785) +- [NIST control chart guidance (evergreen)](https://www.itl.nist.gov/div898/handbook/pmc/section3/pmc31.htm) +- [NIST modified z-score guidance (evergreen)](https://www.itl.nist.gov/div898/handbook/eda/section3/eda35h.htm) +- [Wilson interval explainer (evergreen)](https://www.evanmiller.org/how-not-to-sort-by-average-rating.html) +- [J-shaped review distribution (evergreen)](https://cacm.acm.org/research/overcoming-the-j-shaped-distribution-of-product-reviews/) + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +- **Anti-pattern: Review gating and suppression.** + - Detection signal: only promoter cohorts solicited, low variance after campaigns, or moderation actions correlated to negative sentiment rather than policy violation. + - Consequence: distorted product/competitive conclusions plus legal risk. + - Mitigation: ask-eligibility parity where channel allows, strict separation between abuse moderation and sentiment preference, and audit trails for removals. + +- **Anti-pattern: Incentive-blind aggregation.** + - Detection signal: sudden positive surges tied to incentives without explicit flags. + - Consequence: inflated satisfaction signal and false PMF confidence. + - Mitigation: tag and separate incentivized reviews; downweight or exclude from core confidence score. + +- **Anti-pattern: Synthetic-review contamination.** + - Detection signal: high text similarity bursts, account anomalies, improbable timing clusters. + - Consequence: fake demand/pain narratives. + - Mitigation: duplicate/fraud checks plus manual adjudication for high-impact claims. + +- **Anti-pattern: Alert-ignorant interpretation.** + - Detection signal: dashboards omit platform warning/restriction states. + - Consequence: teams treat manipulated windows as clean market signal. + - Mitigation: intervention state must be stored and considered a hard gate for strategic verdicts. + +- **Anti-pattern: Policy drift blindness.** + - Detection signal: no policy-diff review cadence, same ingestion policy across jurisdictions. + - Consequence: compliance violations and unstable data ops. + - Mitigation: scheduled policy reviews and jurisdiction flags per provider. + +- **Anti-pattern: Single-platform truth.** + - Detection signal: one provider drives all strategic claims. + - Consequence: provider-specific moderation artifacts mistaken for market reality. + - Mitigation: require at least two independent sources for strong claims. + +- **Anti-pattern: One-number sentiment collapse.** + - Detection signal: only overall sentiment score with no aspect/theme decomposition. + - Consequence: misses actionable pain clusters. + - Mitigation: aspect/theme-level outputs with representative evidence snippets. + +- **Anti-pattern: LLM-only interpretation without calibration.** + - Detection signal: no benchmark checks or human QA path. + - Consequence: hidden misclassification with high-confidence language. + - Mitigation: confidence labeling, disagreement checks, and human review for low-confidence high-impact themes. + +- **Anti-pattern: Provenance-free reporting.** + - Detection signal: inability to trace source/time/method for any score. + - Consequence: no auditability, low stakeholder trust. + - Mitigation: include source lineage fields in all output artifacts. + +- **Anti-pattern: Enforcement-lag blindness.** + - Detection signal: no re-check after platform removals/restrictions. + - Consequence: stale contaminated signals remain in trend histories. + - Mitigation: reconciliation jobs and rolling revalidation windows. + +**Bad output vs good output (paired):** + +1. **Bad:** "Users love Product X (4.8/5), no major issues." + **Good:** "Raw average is 4.8, but current window includes intervention flags and low-N segments; confidence is moderate and strongest pain theme is onboarding latency." + +2. **Bad:** "Capterra and Yelp both show strong trend; market is clearly improving." + **Good:** "Yelp shows directional improvement with adequate sample; Capterra endpoint availability is unverifiable in this run, so cross-provider confidence remains limited." + +3. **Bad:** "AI summary says support is excellent." + **Good:** "Aspect-level analysis shows positive product reliability but mixed support response-time sentiment; low-confidence sarcasm-heavy subset routed for manual review." + +4. **Bad:** "All public reviews were scraped, so legal/compliance risk is low." + **Good:** "Collection paths are documented per provider with policy assumptions and unresolved legal constraints; unavailable APIs are marked explicitly." + +**Sources:** +- [FTC fake-reviews rule press release (2024)](https://www.ftc.gov/news-events/news/press-releases/2024/08/federal-trade-commission-announces-final-rule-banning-fake-reviews-testimonials) +- [FTC platform guidance](https://www.ftc.gov/tips-advice/business-center/guidance/featuring-online-customer-reviews-guide-platforms) +- [UK gov fake-reviews announcement (2025)](https://www.gov.uk/government/news/fake-reviews-and-sneaky-hidden-fees-banned-once-and-for-all) +- [CMA Amazon fake reviews undertakings (2025)](https://www.gov.uk/government/news/amazon-gives-undertakings-to-cma-to-curb-fake-reviews) +- [CMA Google fake reviews action (2025)](https://www.gov.uk/government/news/cma-secures-important-changes-from-google-to-tackle-fake-reviews) +- [Google fake-reviews policy page](https://support.google.com/contributionpolicy/answer/7400114) +- [Google business profile AI trust update (2025)](https://blog.google/products/maps/google-business-profiles-ai-fake-reviews) +- [Yelp guidelines](https://www.yelp.com/guidelines) +- [Yelp compensated-activity help page](https://www.yelp-support.com/article/What-if-I-m-offered-a-freebie-in-exchange-for-my-review?l=en_US) +- [Yelp consumer alerts quarterly report (2026 snapshot)](https://trust.yelp.com/consumer-alerts/quarterly-alerts/) +- [Trustpilot transparency report (2024)](https://press.trustpilot.com/transparency-report) +- [Reality check for sentiment analysis with LLMs (2024)](https://openreview.net/forum?id=FjXsarxoBG) + +--- + +### Angle 5+: Endpoint Verification & Wrapper Safety +> Domain-specific angle: method ID realism and endpoint-safe SKILL authoring so `SKILL.md` never implies invented vendor endpoints. + +**Findings:** + +- Current method IDs in `SKILL.md` should be treated as **internal wrapper aliases**, not vendor-native endpoint names. +- `trustpilot.*`, `g2.vendors.list.v1`, `g2.reviews.list.v1`, and `yelp.*` are plausible wrapper aliases because official operations exist. +- `g2.categories.benchmark.v1` was not verified as a public vendor endpoint name in available docs; closest documented benchmark-like patterns are syndication distributions, not a direct `categories/benchmark` endpoint. +- `capterra.products.list.v1` and `capterra.reviews.list.v1` are currently **publicly unverifiable** in official docs reviewed here. +- Safe skill-authoring pattern: each wrapper call must include a `vendor_operation` string with verified `HTTP method + path` and an official source URL. + +### Endpoint Verification Table + +| Current method_id | Classification | Notes | +|---|---|---| +| `trustpilot.business_units.find.v1` | internal-wrapper-only | Vendor op verified: `GET https://api.trustpilot.com/v1/business-units/find` | +| `trustpilot.reviews.list.v1` | internal-wrapper-only | Vendor op verified via Trustpilot Data Solutions review list path | +| `g2.vendors.list.v1` | internal-wrapper-only | Vendor op verified: `GET https://data.g2.com/api/v2/vendors` | +| `g2.reviews.list.v1` | internal-wrapper-only | Vendor op verified: `GET https://data.g2.com/api/v2/products/{product_id}/reviews` | +| `g2.categories.benchmark.v1` | likely invented/unverifiable | No exact public vendor endpoint found with this name | +| `yelp.businesses.search.v1` | internal-wrapper-only | Vendor op verified: `GET https://api.yelp.com/v3/businesses/search` | +| `yelp.businesses.reviews.v1` | internal-wrapper-only | Vendor op verified: `GET https://api.yelp.com/v3/businesses/{business_id_or_alias}/reviews` | +| `capterra.products.list.v1` | unverifiable | No official public endpoint verified in reviewed docs | +| `capterra.reviews.list.v1` | unverifiable | No official public endpoint verified in reviewed docs | + +### Replacement Mapping + +| Wrapper method_id | Verified vendor operation to map | Guidance | +|---|---|---| +| `trustpilot.business_units.find.v1` | `GET https://api.trustpilot.com/v1/business-units/find` | Use for business unit lookup | +| `trustpilot.reviews.list.v1` | `GET https://datasolutions.trustpilot.com/v1/business-units/{businessUnitId}/reviews` | Use for review retrieval where connector supports Data Solutions | +| `g2.vendors.list.v1` | `GET https://data.g2.com/api/v2/vendors` | Use for candidate vendor discovery | +| `g2.reviews.list.v1` | `GET https://data.g2.com/api/v2/products/{product_id}/reviews` | Primary B2B review retrieval path | +| `g2.categories.benchmark.v1` | no verified direct equivalent | Deprecate from default flow unless connector docs prove exact mapping | +| `yelp.businesses.search.v1` | `GET https://api.yelp.com/v3/businesses/search` | Use for local candidate discovery | +| `yelp.businesses.reviews.v1` | `GET https://api.yelp.com/v3/businesses/{business_id_or_alias}/reviews` | Use for excerpt-level review signals | +| `capterra.*` | no verified public endpoint | Mark unsupported/unverifiable unless runtime connector docs are provided | + +### Safe wording for SKILL.md + +Use this exact phrasing in the skill: + +> `method_id` values are internal connector aliases. They are not vendor-native endpoint names. +> Every call must include a verified `vendor_operation` (`HTTP method + URL path`) and source URL in tool notes. +> If a vendor operation cannot be verified in official docs, mark the provider as `unsupported_or_unverifiable` and do not synthesize substitute endpoint names. + +**Sources:** +- [Trustpilot Business Units API](https://developers.trustpilot.com/business-units-api-(public)/) +- [Trustpilot Data Solutions API](https://developers.trustpilot.com/data-solutions-api/) +- [Yelp business search](https://docs.developer.yelp.com/reference/v3_business_search) +- [Yelp business reviews](https://docs.developer.yelp.com/reference/v3_business_reviews) +- [G2 API v2 docs](https://data.g2.com/api/v2/docs/index.html) +- [G2 OpenAPI v2](https://data.g2.com/openapi/v2.yaml) +- [G2 syndication docs](https://data.g2.com/api/docs) +- [Gartner Digital Markets buyer discovery docs](https://datainsights.gartner.com/api/buyerdiscovery/swagger/index.html) +- [Gartner Digital Markets reviews hub](https://digital-markets.gartner.com/reviews-hub-index) + +--- + +## Synthesis + +The core pattern across all angles is that review intelligence in 2024-2026 is no longer just a text analytics task; it is a governance problem plus an interpretation problem. If the skill does not explicitly encode legal/policy gating, moderation-state awareness, and source provenance, it will produce polished but unreliable outputs. + +Methodologically, the strongest shift is from "collect and summarize" to "collect, validate, normalize, and then conclude." The contradiction between platform ecosystems (especially around solicitation rules and recommendation filters) means the skill must branch per provider rather than forcing one universal review workflow. + +Tooling findings show that official operations exist for key providers, but wrapper aliases can hide endpoint realism issues. The skill should not imply vendor endpoint certainty where none exists (notably Capterra public retrieval in this pass). Explicit `vendor_operation` mapping and unsupported-provider states are necessary to avoid accidental hallucination. + +Interpretation and anti-pattern findings reinforce the same point: confidence has to be earned by sample adequacy, source integrity, temporal stability, and cross-source agreement. The most actionable update is to make uncertainty explicit and to block high-confidence verdicts when integrity gates fail. + +--- + +## Recommendations for SKILL.md + +> Concrete, actionable list of what should change / be added in the SKILL.md based on research. +> Each item here has a corresponding draft in the section below. + +- [x] Rewrite core mode to include compliance and integrity gating before any sentiment/theme analysis. +- [x] Replace current methodology with a staged evidence workflow: preflight -> collect -> normalize -> detect -> validate -> conclude. +- [x] Add provider-specific collection policy branching (especially solicitation and moderation constraints). +- [x] Add explicit sample-size gates and confidence policy for low-N windows. +- [x] Add cross-platform normalization rules and contradiction tracking. +- [x] Replace endpoint-like ambiguity with wrapper-alias safety pattern (`method_id` + verified `vendor_operation`). +- [x] Deprecate unverifiable default methods (for example, `g2.categories.benchmark.v1`) unless connector docs prove mapping. +- [x] Add unsupported-provider behavior for publicly unverifiable connectors (current Capterra case). +- [x] Expand anti-pattern section into named warning blocks with detection signal, consequence, and mitigation. +- [x] Expand artifact schema with source lineage, integrity checks, contradiction records, and confidence components. +- [x] Add output quality checklist and hard stop conditions for invalid/incomplete evidence. + +--- + +## Draft Content for SKILL.md + +> This is the most important section. For every recommendation above, write the actual text that should go into SKILL.md. + +### Draft: Core mode rewrite (evidence and integrity contract) + +--- +You are detecting voice-of-market signals from review platforms for one target scope per run. Your primary rule is: **integrity before interpretation**. + +Reviews are opinions, not facts. You must only produce strong conclusions when evidence quality passes all integrity gates. If integrity gates fail, your result must downgrade confidence, switch to a hold state, or return insufficient data. + +Before analyzing any sentiment/theme, run this pre-analysis contract: + +1. Confirm collection method is policy-compliant for each provider in scope. +2. Confirm no known fake/synthetic/manipulated records are included in core evidence. +3. Confirm source lineage (provider, time window, retrieval path) is recorded. +4. Confirm sample size gate per provider is met, or mark as directional only. +5. Confirm intervention/moderation state is checked for each provider window. + +If any one of these fails and cannot be repaired during the run, you must not output a high-confidence recommendation. +--- + +### Draft: Methodology rewrite (staged workflow) + +--- +### Methodology + +Use this exact sequence in every run. + +#### Step 1: Scope and policy preflight + +Before calling any provider tool, declare: +- target entity/category, +- geography and language scope, +- time window, +- provider list. + +Then run a policy preflight: +- verify provider collection policy assumptions, +- verify whether solicitation restrictions affect interpretation, +- verify whether the provider is owner-managed only or competitor-observable, +- verify whether public endpoint mapping is known. + +If a provider fails preflight, mark that provider as unavailable and continue only if remaining coverage is sufficient. + +#### Step 2: Discover and resolve target identities + +Map your target to provider-specific IDs (business unit ID, product ID, business alias, etc.) before fetching reviews. + +Rules: +- Do not guess IDs from names alone when a lookup operation exists. +- If multiple candidates match, keep all candidates in a short-list and require disambiguation by domain/category context. +- If the ID cannot be resolved reliably, do not fake a fallback ID; mark provider unresolved. + +#### Step 3: Collect review records with lineage + +For each successful provider target: +- collect review records for the selected time window, +- store retrieval timestamp, +- store provider and retrieval path metadata, +- store moderation/intervention flags when available. + +You should retain enough metadata to reproduce the run later. If reproducibility metadata is missing, confidence must be reduced. + +#### Step 4: Normalize and quality-check + +Normalize records into a common shape: +- provider, +- rating scale normalized to 1-5 if needed, +- timestamp, +- language, +- review text, +- invite/incentive flags (if available), +- moderation/recommendation status (if available). + +Run quality checks: +- deduplicate exact and near-duplicate records, +- remove policy-invalid records from core evidence, +- separate provisional data from settled windows, +- label unknown fields explicitly rather than filling defaults silently. + +#### Step 5: Extract themes and compute distributions + +Compute: +- star distribution by provider and overall, +- low-star concentration (`1-2` share), +- high-star concentration (`4-5` share), +- recurring pain themes and satisfaction themes with evidence counts. + +Theme extraction rules: +- require recurring language across multiple records before creating a theme, +- preserve representative evidence snippets, +- avoid creating themes from single outlier phrasing, +- keep confidence lower for mixed-language or low-text windows. + +#### Step 6: Cross-provider comparison and trend analysis + +Compare providers only after normalization. + +Use two windows: +- short window for fresh movement detection, +- longer baseline window for trend stability. + +Signal rules: +- classify shifts as directional only if sample gate is borderline, +- classify as strong only if multiple providers agree or one provider has very strong clean evidence with no intervention flags, +- track contradictions rather than hiding them. + +#### Step 7: Confidence scoring and verdict + +Build confidence from: +- sample adequacy, +- source integrity, +- temporal stability, +- cross-source agreement, +- language coverage. + +Then output one of: +- `ok`, +- `ok_with_conflicts`, +- `insufficient_data`, +- `zero_results`, +- `policy_restricted`, +- `technical_failure`. + +Do not output `ok` if any hard stop condition applies. + +#### Step 8: Record limitations and next checks + +Always include: +- what was not observable, +- which provider claims were unverifiable, +- which additional checks could raise confidence in the next run. + +Do NOT: +- treat one provider as market truth, +- treat low-N averages as conclusive, +- ignore platform intervention states, +- hide contradictions between sources. +--- + +### Draft: Provider selection and sample policy + +--- +### Provider selection + +Select providers by domain and visibility: + +- B2B software: prioritize `g2` and `trustpilot` when available. +- Local/consumer service: prioritize `yelp` and `trustpilot`. +- Owned app products: use app-store specific skills and APIs (`App Store Connect`, `Google Play`) when in scope. +- Capterra-family signals: include only when connector endpoint mapping is verified at runtime; otherwise mark as unavailable. + +### Minimum sample policy + +Apply per-provider sample policy before strong conclusions: + +- `<10` reviews in window: `insufficient_data` for provider-level claims. +- `10-19` reviews: directional only; no strong ranking claims. +- `>=20` reviews: eligible for provider-level reporting with confidence scoring. + +For cross-provider conclusions: +- require at least two providers meeting minimum policy, or +- one provider with strong sample and no integrity flags, plus explicit limitation note. + +### Source-mix policy + +Always report source mix: +- provider share, +- invited/organic mix where available, +- incentivized/disclosed share where available, +- moderation/intervention status. + +Never hide a provider with contradictory signal. Contradiction must be represented explicitly in output. +--- + +### Draft: Available Tools section rewrite (wrapper-safe, endpoint-verified) + +--- +## Available Tools + +`method_id` values are internal connector aliases. They are not vendor-native endpoint names. + +For every call, include a `vendor_operation` note in args or call commentary that specifies the verified vendor operation (`HTTP method + URL/path`). If you cannot provide a verified vendor operation from official docs, treat the provider as unavailable for this run. + +### Trustpilot + +```python +trustpilot( + op="call", + args={ + "method_id": "trustpilot.business_units.find.v1", + "vendor_operation": "GET https://api.trustpilot.com/v1/business-units/find", + "name": "company name" + } +) +``` + +```python +trustpilot( + op="call", + args={ + "method_id": "trustpilot.reviews.list.v1", + "vendor_operation": "GET https://datasolutions.trustpilot.com/v1/business-units/{businessUnitId}/reviews", + "businessUnitId": "unit_id", + "language": "en" + } +) +``` + +### G2 + +```python +g2( + op="call", + args={ + "method_id": "g2.vendors.list.v1", + "vendor_operation": "GET https://data.g2.com/api/v2/vendors", + "filter[name]": "product name" + } +) +``` + +```python +g2( + op="call", + args={ + "method_id": "g2.reviews.list.v1", + "vendor_operation": "GET https://data.g2.com/api/v2/products/{product_id}/reviews", + "filter[product_id]": "product_id", + "page[size]": 25 + } +) +``` + +Do not use `g2.categories.benchmark.v1` unless your runtime connector docs explicitly map it to a verified vendor operation. + +### Yelp + +```python +yelp( + op="call", + args={ + "method_id": "yelp.businesses.search.v1", + "vendor_operation": "GET https://api.yelp.com/v3/businesses/search", + "term": "business type", + "location": "New York" + } +) +``` + +```python +yelp( + op="call", + args={ + "method_id": "yelp.businesses.reviews.v1", + "vendor_operation": "GET https://api.yelp.com/v3/businesses/{business_id_or_alias}/reviews", + "id": "business_id" + } +) +``` + +### Capterra (connector-dependent) + +```python +# Use only if connector documentation in your runtime environment +# proves a verified vendor operation mapping. +capterra( + op="call", + args={ + "method_id": "capterra.reviews.list.v1", + "vendor_operation": "UNVERIFIABLE_PUBLIC_ENDPOINT", + "productId": "product_id" + } +) +``` + +If Capterra endpoint mapping is not verifiable, set provider status to `unsupported_or_unverifiable` and proceed with available providers. + +### Call sequencing guidance + +1. Resolve provider target IDs first. +2. Fetch reviews with explicit time window and language where supported. +3. Store provider-level metadata and retrieval lineage. +4. Run quality gates before aggregation. +5. Never infer endpoint behavior from wrapper name alone. +--- + +### Draft: Interpretation rubric and confidence scoring + +--- +### Data interpretation and signal quality + +Use this rubric before concluding: + +#### 1) Hard invalidity checks + +If evidence violates integrity rules (fake/manipulated/undisclosed incentive abuse), exclude from core analysis. + +#### 2) Intervention-state check + +If provider intervention/warning/restriction state is active for the target window, switch to `hold` logic: +- collect and log evidence, +- avoid high-confidence strategic conclusions, +- require follow-up window. + +#### 3) Sample adequacy check + +Apply minimum sample policy and do not rank low-N windows as if stable. + +#### 4) Distribution + theme agreement + +Strong claims require both: +- distribution evidence (for example low-star concentration trend), and +- recurring theme evidence with representative snippets. + +Do not rely on one signal family alone. + +#### 5) Cross-source agreement + +If provider signals disagree: +- record contradiction explicitly, +- downgrade confidence, +- avoid one-sided narrative. + +#### 6) Confidence scoring model + +Score each dimension from `0` to `1`: +- `sample_adequacy`, +- `source_integrity`, +- `temporal_stability`, +- `cross_source_agreement`, +- `language_coverage`. + +Compute weighted confidence: + +`confidence = (0.25 * sample_adequacy) + (0.25 * source_integrity) + (0.20 * temporal_stability) + (0.20 * cross_source_agreement) + (0.10 * language_coverage)` + +Label: +- `high` when `>= 0.75`, +- `medium` when `0.55-0.74`, +- `low` when `< 0.55`. + +You may adjust weights only when the run has explicit rationale; never hide weight changes. + +#### 7) Signal-type interpretation guidance + +- `pain_cluster`: require recurring low-star concentration plus recurring pain themes. +- `satisfaction_gap`: detect decreasing trend in satisfaction indicators and growing complaint themes. +- `competitor_weakness`: require provider evidence that competitor pain is concentrated in a specific feature/service area. +- `improving_trend` / `declining_trend`: require multi-window support; one burst window is not enough. +- `feature_gap`: require repeated "missing/wish/cannot" phrasing across independent records. + +#### 8) Stop conditions + +Do not conclude if: +- active intervention state and no settled follow-up, +- unresolved target ID ambiguity, +- insufficient sample across all providers, +- unresolved major contradiction with no tie-break evidence. +--- + +### Draft: Anti-pattern warning blocks + +--- +### Named anti-pattern warnings + +#### Warning: Review Gating Bias +- **What it looks like:** only likely-happy users are invited; negative voices are under-collected. +- **Detection signal:** abnormal post-campaign positivity and low variance. +- **Consequence if missed:** false product confidence, legal exposure. +- **Mitigation:** apply channel-compliant broad eligibility; separate abuse moderation from sentiment preference. + +#### Warning: Incentive Blindness +- **What it looks like:** incentivized records merged into core score without flags. +- **Detection signal:** positive spikes during incentive periods with missing metadata. +- **Consequence if missed:** inflated satisfaction signal. +- **Mitigation:** explicit incentive tagging and separate reporting lanes. + +#### Warning: Wrapper-as-Endpoint Hallucination +- **What it looks like:** method IDs treated as proof of vendor endpoint existence. +- **Detection signal:** output references endpoint-like strings with no official docs. +- **Consequence if missed:** fabricated evidence chain. +- **Mitigation:** require `vendor_operation` mapping and source URL for each method. + +#### Warning: Single-Provider Overreach +- **What it looks like:** one platform drives all market conclusions. +- **Detection signal:** no corroboration attempt, no cross-provider limitations. +- **Consequence if missed:** platform artifact mistaken for market truth. +- **Mitigation:** require multi-provider confirmation for strong claims. + +#### Warning: Alert-Ignorant Trending +- **What it looks like:** trend claims published during moderation/intervention events. +- **Detection signal:** no intervention field in records or output. +- **Consequence if missed:** contaminated trend story. +- **Mitigation:** intervention-state gating and follow-up windows. + +#### Warning: One-Score Sentiment Collapse +- **What it looks like:** only global sentiment without theme/aspect decomposition. +- **Detection signal:** no theme distribution and no evidence snippets. +- **Consequence if missed:** non-actionable insight. +- **Mitigation:** require theme clusters with prevalence and quotes/snippets. + +#### Warning: Low-N Ranking +- **What it looks like:** ranking providers despite insufficient review counts. +- **Detection signal:** confidence language stronger than sample supports. +- **Consequence if missed:** unstable decisions. +- **Mitigation:** enforce `<10` insufficient, `10-19` directional policy. + +#### Warning: Contradiction Erasure +- **What it looks like:** only supporting sources are shown in final narrative. +- **Detection signal:** missing contradiction fields despite mixed evidence. +- **Consequence if missed:** overconfident and brittle recommendations. +- **Mitigation:** add explicit contradiction records and resolution plan. +--- + +### Draft: Result-state policy and escalation rules + +--- +### Result state policy + +Use these states exactly: + +- `ok`: adequate evidence, no hard integrity blockers, contradictions manageable. +- `ok_with_conflicts`: useful evidence exists but contradictions materially reduce certainty. +- `zero_results`: provider queries succeeded but returned no records. +- `insufficient_data`: records exist but sample/quality gates not met. +- `policy_restricted`: evidence cannot be used safely because policy/integrity constraints dominate. +- `technical_failure`: retrieval or connector failure prevented reliable analysis. + +### Escalation policy + +If result is `ok_with_conflicts`, `insufficient_data`, `policy_restricted`, or `technical_failure`, always include: +- minimum next checks required, +- what missing evidence would change verdict, +- whether rerun window should be extended. + +Never return strategic "strong signal" language when state is not `ok`. +--- + +### Draft: Schema additions + +Write the following schema fragment into `SKILL.md` artifact schema section. + +```json +{ + "signal_reviews_voice": { + "type": "object", + "required": [ + "target", + "analysis_scope", + "time_window", + "result_state", + "source_summary", + "sources", + "signals", + "signal_quality", + "limitations", + "next_checks" + ], + "additionalProperties": false, + "properties": { + "target": { + "type": "string", + "description": "Company, product, competitor set, or category being analyzed." + }, + "analysis_scope": { + "type": "object", + "required": [ + "mode", + "region", + "language" + ], + "additionalProperties": false, + "properties": { + "mode": { + "type": "string", + "enum": [ + "single_company", + "single_product", + "competitor_set", + "category_scan" + ], + "description": "Scope mode for this run." + }, + "region": { + "type": "string", + "description": "Primary geographic scope used for provider retrieval and interpretation." + }, + "language": { + "type": "string", + "description": "Primary analysis language (for example en, fr, de)." + } + } + }, + "time_window": { + "type": "object", + "required": [ + "start_date", + "end_date" + ], + "additionalProperties": false, + "properties": { + "start_date": { + "type": "string", + "description": "ISO date for window start." + }, + "end_date": { + "type": "string", + "description": "ISO date for window end." + } + } + }, + "result_state": { + "type": "string", + "enum": [ + "ok", + "ok_with_conflicts", + "zero_results", + "insufficient_data", + "policy_restricted", + "technical_failure" + ], + "description": "Overall run status reflecting data sufficiency and integrity." + }, + "source_summary": { + "type": "object", + "required": [ + "providers_attempted", + "providers_successful", + "total_reviews_collected" + ], + "additionalProperties": false, + "properties": { + "providers_attempted": { + "type": "integer", + "minimum": 0, + "description": "How many providers were attempted in the run." + }, + "providers_successful": { + "type": "integer", + "minimum": 0, + "description": "How many providers returned usable records." + }, + "total_reviews_collected": { + "type": "integer", + "minimum": 0, + "description": "Total raw reviews collected before dedup and filtering." + } + } + }, + "sources": { + "type": "array", + "description": "Per-provider retrieval and lineage records.", + "items": { + "type": "object", + "required": [ + "provider", + "wrapper_method_id", + "vendor_operation", + "retrieval_state", + "reviews_collected", + "sample_class", + "intervention_state" + ], + "additionalProperties": false, + "properties": { + "provider": { + "type": "string", + "enum": [ + "trustpilot", + "g2", + "yelp", + "capterra", + "other" + ], + "description": "Source provider name." + }, + "wrapper_method_id": { + "type": "string", + "description": "Internal connector method alias used for retrieval." + }, + "vendor_operation": { + "type": "string", + "description": "Verified vendor HTTP operation string (method + URL/path)." + }, + "retrieval_state": { + "type": "string", + "enum": [ + "ok", + "zero_results", + "unsupported_or_unverifiable", + "permission_denied", + "rate_limited", + "technical_failure" + ], + "description": "Provider retrieval outcome." + }, + "reviews_collected": { + "type": "integer", + "minimum": 0, + "description": "Raw count of collected reviews from this provider." + }, + "sample_class": { + "type": "string", + "enum": [ + "organic", + "invited", + "incentivized_disclosed", + "mixed_or_unknown" + ], + "description": "Best-known sampling class for this provider set in this run." + }, + "intervention_state": { + "type": "string", + "enum": [ + "none_detected", + "warning_present", + "restriction_present", + "unknown" + ], + "description": "Whether provider/platform intervention signals were detected in this window." + } + } + } + }, + "signals": { + "type": "array", + "description": "Detected market signals after quality gates.", + "items": { + "type": "object", + "required": [ + "signal_type", + "signal_summary", + "strength", + "confidence", + "providers", + "evidence_count", + "themes" + ], + "additionalProperties": false, + "properties": { + "signal_type": { + "type": "string", + "enum": [ + "pain_cluster", + "satisfaction_gap", + "competitor_weakness", + "improving_trend", + "declining_trend", + "feature_gap" + ], + "description": "Signal category." + }, + "signal_summary": { + "type": "string", + "description": "One-paragraph explanation of what changed and why it matters." + }, + "strength": { + "type": "string", + "enum": [ + "strong", + "moderate", + "weak" + ], + "description": "Signal strength label derived from evidence quality and agreement." + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Numeric confidence score for this signal." + }, + "providers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Providers contributing evidence for this signal." + }, + "evidence_count": { + "type": "integer", + "minimum": 0, + "description": "Number of contributing review records after dedup and filtering." + }, + "themes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Theme tags supporting this signal (for example onboarding, pricing, reliability)." + }, + "representative_snippets": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Short evidence snippets used to justify the signal." + } + } + } + }, + "signal_quality": { + "type": "object", + "required": [ + "sample_adequacy", + "source_integrity", + "temporal_stability", + "cross_source_agreement", + "language_coverage", + "overall_confidence_label" + ], + "additionalProperties": false, + "properties": { + "sample_adequacy": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Quality score for sample size sufficiency." + }, + "source_integrity": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Quality score for policy/integrity cleanliness of included evidence." + }, + "temporal_stability": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Quality score for trend stability across windows." + }, + "cross_source_agreement": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Quality score for directional agreement across providers." + }, + "language_coverage": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Quality score for language coverage and analysis reliability." + }, + "overall_confidence_label": { + "type": "string", + "enum": [ + "high", + "medium", + "low" + ], + "description": "Human-readable confidence label derived from weighted scoring." + } + } + }, + "contradictions": { + "type": "array", + "description": "Explicitly tracked conflicts between providers or evidence classes.", + "items": { + "type": "object", + "required": [ + "topic", + "source_a", + "source_b", + "impact", + "resolution_plan" + ], + "additionalProperties": false, + "properties": { + "topic": { + "type": "string", + "description": "Contradiction topic (for example trend direction, theme prevalence)." + }, + "source_a": { + "type": "string", + "description": "First contradictory evidence reference." + }, + "source_b": { + "type": "string", + "description": "Second contradictory evidence reference." + }, + "impact": { + "type": "string", + "description": "How the contradiction affects confidence and recommendations." + }, + "resolution_plan": { + "type": "string", + "description": "What follow-up data or checks are required to resolve the conflict." + } + } + } + }, + "limitations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Known limitations and blind spots for this run." + }, + "next_checks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific follow-up checks to increase confidence in the next pass." + } + } + } +} +``` + +### Draft: Output quality checklist block + +--- +### Output quality checklist (required before artifact write) + +Before writing `signal_reviews_voice`: + +- Confirm at least one provider succeeded, or return `zero_results`/`technical_failure`. +- Confirm sample-size policy is applied per provider. +- Confirm all strong claims have evidence counts and provider lineage. +- Confirm contradictions are represented, not suppressed. +- Confirm unsupported/unverifiable providers are labeled explicitly. +- Confirm method aliases are not presented as vendor-native endpoints. +- Confirm limitations and next checks are specific and actionable. + +If any checklist item fails, do not write `ok` state. +--- + +--- + +## Gaps & Uncertainties + +- Publicly documented Capterra review/product retrieval endpoints were not verified in official public docs during this pass; connector-private mappings may exist but are uncertain. +- Exact provider-level weighting formulas for trust/recommendation/moderation signals are generally not fully disclosed, so weighting recommendations remain implementation-level heuristics. +- Some platform policy pages are undated but current as-of-access; where specific revision timestamps are missing, these are treated as evergreen operational references. +- Quantitative thresholds for fake-review detection are domain-specific and often proprietary; this research documents robust gating patterns but not a universal detector threshold. +- App-store review APIs are relevant for adjacent skills and owned-app contexts, but this specific skill may intentionally scope those out depending on bot architecture. diff --git a/flexus_simple_bots/researcher/skills/_signal-reviews-voice/SKILL.md b/flexus_simple_bots/researcher/skills/_signal-reviews-voice/SKILL.md new file mode 100644 index 00000000..bdec0e6e --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_signal-reviews-voice/SKILL.md @@ -0,0 +1,68 @@ +--- +name: signal-reviews-voice +description: Customer review and voice-of-market signal detection — pain themes, satisfaction gaps, competitive sentiment +--- + +You are detecting voice-of-market signals from review platforms for one category or competitor set per run. + +Core mode: evidence-first. Reviews are opinions, not facts — track patterns across many reviews, not individual outliers. Require ≥10 reviews per provider before drawing conclusions. Flag small sample sizes explicitly. + +## Methodology + +### Rating distribution analysis +Low-rating concentration (1-2 stars) = pain signal. High-rating concentration (4-5 stars) = satisfaction signal. + +Calculate: % of reviews at each star level. Benchmark against category average if available. + +Patterns to detect: +- Pain cluster: >30% reviews at 1-2 stars +- Satisfaction gap: avg rating dropping over time (indicates deteriorating product/service) +- Competitor weakness: specific competitor has high pain concentration in a feature area + +### Theme extraction +Scan review text for recurring pain language: +- "Wish it could...", "Can't figure out...", "Missing...", "Switched because...", "Cancelling because..." +- Group into: UX, pricing, support, reliability, feature gaps, onboarding, integration issues + +### Competitive benchmarking +Compare multiple competitors on the same dimensions. Which competitor has the most complaints about feature X? That's a displacement opportunity. + +### Time trend +Check whether reviews from the past 90 days differ from 90-180 days ago. Improving trend = competitor getting stronger. Declining trend = opening for displacement. + +### Provider selection +- SaaS/B2B: `g2` (most detailed), `capterra` (if enterprise focus) +- Consumer/local: `trustpilot`, `yelp` +- Mobile apps: handled by `pain-voice-of-customer` skill (appstoreconnect, google_play) + +## Recording + +``` +write_artifact( + artifact_type="signal_reviews_voice", + path="/signals/reviews-voice-{YYYY-MM-DD}", + data={...} +) +``` + +## Available Tools + +``` +trustpilot(op="call", args={"method_id": "trustpilot.business_units.find.v1", "name": "company name"}) + +trustpilot(op="call", args={"method_id": "trustpilot.reviews.list.v1", "businessUnitId": "unit_id", "stars": [1, 2], "language": "en"}) + +g2(op="call", args={"method_id": "g2.vendors.list.v1", "filter[name]": "product name"}) + +g2(op="call", args={"method_id": "g2.reviews.list.v1", "filter[product_id]": "product_id", "page[size]": 25}) + +g2(op="call", args={"method_id": "g2.categories.benchmark.v1", "filter[category_id]": "category_id"}) + +yelp(op="call", args={"method_id": "yelp.businesses.search.v1", "term": "business type", "location": "New York"}) + +yelp(op="call", args={"method_id": "yelp.businesses.reviews.v1", "id": "business_id"}) + +capterra(op="call", args={"method_id": "capterra.products.list.v1", "filter": "product name"}) + +capterra(op="call", args={"method_id": "capterra.reviews.list.v1", "productId": "product_id"}) +``` diff --git a/flexus_simple_bots/researcher/skills/_signal-social-community/RESEARCH.md b/flexus_simple_bots/researcher/skills/_signal-social-community/RESEARCH.md new file mode 100644 index 00000000..e1c71ba1 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_signal-social-community/RESEARCH.md @@ -0,0 +1,311 @@ +# Research: signal-social-community + +**Skill path:** `flexus_simple_bots/researcher/skills/signal-social-community/` +**Bot:** researcher +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`signal-social-community` detects social/community signals for one query scope per run: sentiment direction, velocity bursts, narrative drift, pain language, and launch interest across Reddit, X, YouTube, TikTok, Product Hunt, Instagram, and Pinterest. + +This research was produced from template + brief + skill context, using five internal sub-research angles (methodology, tools/APIs, interpretation, anti-patterns, governance). The target is a safer `SKILL.md`: evidence-first, policy-aware, contradiction-explicit, and free of invented methods/endpoints. + +--- + +## Definition of Done + +- [x] At least 4 distinct research angles are covered +- [x] Each finding has source URLs or named references +- [x] Methodology includes practical execution rules +- [x] Tool/API landscape includes concrete options and caveats +- [x] Failure modes and anti-patterns are explicit +- [x] Schema recommendations map to realistic data shapes +- [x] Gaps/uncertainties are explicit +- [x] Findings prioritize 2024-2026 (evergreen marked when used) + +--- + +## Quality Gates + +- No generic filler without backing: **passed** +- No invented tool names, method IDs, or API endpoints: **passed** +- Contradictions are explicit: **passed** +- Draft content is the largest section: **passed** +- Internal sub-research angles >= 4: **passed** (5 used) + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices + +**Findings:** + +- Strong workflows are staged: scope lock -> collect -> qualify -> authenticity checks -> corroborate -> classify -> record. +- Platform eligibility/policy now materially affects methodology quality (especially Reddit, TikTok research surfaces, and Meta research tools). +- X engagement observability changed in 2024 (likes visibility), so reply/repost/quote and count trends matter more for inference. +- YouTube quota asymmetry forces sparse discovery and focused enrichment. +- TikTok research should be interpreted with freshness caution (provisional vs finalized reads). +- Cross-platform metric semantics are non-equivalent; normalization is required before comparison. +- Policy/moderation regime changes should be treated as trend-break candidates. + +**Sources:** +- [Reddit Developer Terms (2024)](https://redditinc.com/policies/developer-terms) +- [X API rate limits](https://docs.x.com/x-api/fundamentals/rate-limits) +- [AP: X hid public likes (2024)](https://apnews.com/article/x-twitter-hides-likes-social-media-29d40153597220bd05afa6221f658c92) +- [YouTube quota costs](https://developers.google.com/youtube/v3/determine_quota_cost) +- [TikTok Research API](https://developers.tiktok.com/products/research-api/) +- [TikTok Research API FAQ](https://developers.tiktok.com/doc/research-api-faq) +- [Meta Content Library and API](https://transparency.meta.com/researchtools/meta-content-library/) + +--- + +### Angle 2: Tool & API Landscape + +**Findings:** + +- Practical stack: native platform APIs for precision + optional listening suites for operational breadth. +- X, YouTube, and TikTok each have materially different throughput/access constraints. +- Product Hunt is GraphQL-first and complexity-budgeted. +- Instagram hashtag/media surfaces are permission-gated. +- Reddit access is policy-bound and should use current docs/runtime headers over legacy assumptions. +- Third-party suites (Brandwatch/Sprout/Meltwater/Talkwalker) are useful but must expose provenance/completeness metadata. + +| Provider | Verified surface examples | Typical caveat | +|---|---|---| +| X API v2 | `GET /2/tweets/search/recent`, `GET /2/tweets/counts/recent` | tier/endpoint windows | +| YouTube Data API | `search.list`, `videos.list`, `commentThreads.list` | quota asymmetry | +| TikTok Research API | `POST /v2/research/video/query/`, `POST /v2/research/video/comment/list/` | access + lag | +| Product Hunt API | `POST /v2/api/graphql` | complexity windows | +| Instagram Graph API | `ig_hashtag_search`, hashtag media edges | permissions | +| Reddit Data API | OAuth-based documented surfaces | policy/rate constraints | + +**Sources:** +- [X recent search quickstart](https://docs.x.com/x-api/posts/search/quickstart/recent-search) +- [YouTube `search.list`](https://developers.google.com/youtube/v3/docs/search/list) +- [YouTube `videos.list`](https://developers.google.com/youtube/v3/docs/videos/list) +- [YouTube `commentThreads.list`](https://developers.google.com/youtube/v3/docs/commentThreads/list) +- [TikTok query videos docs](https://developers.tiktok.com/doc/research-api-specs-query-videos/) +- [TikTok query comments docs](https://developers.tiktok.com/doc/research-api-specs-query-video-comments/) +- [Product Hunt API docs](https://api.producthunt.com/v2/docs) +- [Instagram hashtag search reference](https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-hashtag-search/) +- [Reddit Data API Wiki](https://support.reddithelp.com/hc/en-us/articles/16160319875092-Reddit-Data-API-Wiki) + +--- + +### Angle 3: Data Interpretation & Signal Quality + +**Findings:** + +- Integrity checks should precede sentiment/velocity interpretation. +- Single-window spikes are hypotheses, not trend confirmation. +- Cross-platform claims require within-platform normalization first. +- Contradictions should be preserved and confidence-adjusted, not hidden. +- Reach/likes/follower growth are weak alone; stronger claims need corroboration and authenticity checks. +- Non-probability sampling caveats should be explicit in output. + +**Common misreads to block:** + +- "High reach means support." +- "Follower growth proves demand." +- "No detected bots means clean data." +- "Cross-platform engagement rates are directly comparable." + +**Sources:** +- [Meta inauthentic behavior policy](https://transparency.meta.com/policies/community-standards/inauthentic-behavior/) +- [Meta Q4 2024 adversarial report](https://transparency.meta.com/sr/Q4-2024-Adversarial-threat-report/) +- [TikTok integrity and authenticity guideline](https://www.tiktok.com/community-guidelines/en/integrity-authenticity/) +- [YouTube fake engagement policy](https://support.google.com/youtube/answer/3399767) +- [AAPOR margin of error explainer](https://aapor.org/wp-content/uploads/2023/01/Margin-of-Sampling-Error-508.pdf) +- [Reuters Digital News Report 2024](https://reutersinstitute.politics.ox.ac.uk/digital-news-report/2024/dnr-executive-summary) +- [Ofcom Online Nation 2024](https://www.ofcom.org.uk/siteassets/resources/documents/research-and-data/online-research/online-nation/2024/online-nation-2024-report.pdf?v=386238) + +--- + +### Angle 4: Failure Modes & Anti-Patterns + +**Findings:** + +- Astroturfed velocity mistaken for demand +- Amplification rings treated as consensus +- Single-platform monoculture +- Engagement-bait interpreted as intent +- Rate-limit truncation treated as complete data +- Ingest-lag confusion +- Snapshot non-reproducibility (no query/provenance ledger) +- Sentiment portability errors across languages/domains +- Policy-last implementation + +**Sources:** +- [FTC fake reviews rule (2024)](https://www.ftc.gov/news-events/news/press-releases/2024/08/federal-trade-commission-announces-final-rule-banning-fake-reviews-testimonials) +- [Federal Register final rule text (2024)](https://www.federalregister.gov/documents/2024/08/22/2024-18519/trade-regulation-rule-on-the-use-of-consumer-reviews-and-testimonials) +- [Reddit Data API Terms](https://redditinc.com/policies/data-api-terms) +- [TikTok Research API FAQ](https://developers.tiktok.com/doc/research-api-faq) +- [Scientific Reports coordinated sharing (2025)](https://pmc.ncbi.nlm.nih.gov/articles/PMC12053595/) + +--- + +### Angle 5+: Governance, Compliance, and Regime Shifts + +**Findings:** + +- Policy/terms drift should be assumed and periodically revalidated. +- Public availability does not remove privacy/proportionality obligations. +- Research access paths are role/scope constrained and should be metadata in outputs. +- Enforcement metrics and user-trust outcomes can diverge and should be reported separately when possible. +- Missing authorized access should reduce confidence, not trigger unofficial fallback collection. + +**Sources:** +- [Meta Platform Terms (2026)](https://developers.facebook.com/terms/dfc_platform_terms/) +- [YouTube API Services ToS revision history](https://developers.google.com/youtube/terms/revision-history) +- [EU AI Act enters force (2024)](https://commission.europa.eu/news/ai-act-enters-force-2024-08-01_en) +- [EDPB Opinion 28/2024](https://www.edpb.europa.eu/our-work-tools/our-documents/opinion-board-art-64/opinion-282024-certain-data-protection-aspects_en) +- [DSA delegated act researcher data access (2025)](https://digital-strategy.ec.europa.eu/en/news/commission-adopts-delegated-act-data-access-under-digital-services-act) + +--- + +## Synthesis + +The strongest cross-source signal is procedural: reliable social/community detection now depends on quality gates, not just data volume. Teams reduce false confidence by explicitly tracking coverage, authenticity risk, contradictions, and policy constraints. + +For this skill, the most important upgrades are a staged method, strict internal method-ID discipline, contradiction logging, anti-pattern warnings, and a richer output schema that encodes uncertainty and provenance. + +--- + +## Recommendations for SKILL.md + +- [x] Replace loose flow with staged evidence pipeline +- [x] Add hard gates for `strong` classification +- [x] Keep examples on existing internal method IDs only +- [x] Add coverage/freshness accounting and confidence penalties +- [x] Add contradiction protocol +- [x] Add anti-pattern warning blocks +- [x] Add compliance/policy-drift guardrails +- [x] Expand schema for provenance and confidence decomposition +- [x] Add pre-`write_artifact` quality checklist + +--- + +## Draft Content for SKILL.md + +### Draft: Core mode and evidence contract + +Use evidence-first execution. You must not emit a strong signal when relevance is unclear, authenticity risk is high, key platform coverage is partial/failed, or major contradictions are unresolved. + +Tag each evidence item with `source_type`, `freshness_state`, `coverage_state`, and `authenticity_risk`. If quality is constrained, downgrade strength/confidence explicitly. + +### Draft: Methodology sequence + +Run in order: +1. Scope lock (`query`, `region`, `language`, `time_window`) +2. Collection plan by platform role +3. Collection execution with explicit error logging +4. Relevance filtering (remove collisions/hijacks/off-topic) +5. Authenticity checks (repetition/synchronization/concentration) +6. Cross-platform corroboration (`weak`, `moderate`, `strong`) +7. Contradiction logging with confidence impact +8. Result-state assignment and artifact write + +Hard stops: +- policy-blocked key data with no compliant fallback +- missing evidence refs for core claims +- unresolved high authenticity risk on core narrative + +### Draft: Available Tools (existing internal method IDs only) + +```python +reddit(op="call", args={"method_id": "reddit.search.posts.v1", "q": "your query", "sort": "relevance", "t": "month"}) +reddit(op="call", args={"method_id": "reddit.subreddit.hot.v1", "subreddit": "relevant_subreddit"}) +reddit(op="call", args={"method_id": "reddit.subreddit.new.v1", "subreddit": "relevant_subreddit"}) +x(op="call", args={"method_id": "x.tweets.counts_recent.v1", "query": "your query", "granularity": "day"}) +x(op="call", args={"method_id": "x.tweets.search_recent.v1", "query": "your query", "max_results": 100}) +youtube(op="call", args={"method_id": "youtube.search.list.v1", "q": "your query", "type": "video", "order": "viewCount"}) +youtube(op="call", args={"method_id": "youtube.videos.list.v1", "id": "video_id", "part": "statistics,snippet"}) +youtube(op="call", args={"method_id": "youtube.comment_threads.list.v1", "videoId": "video_id"}) +tiktok(op="call", args={"method_id": "tiktok.research.video_query.v1", "query": {"and": [{"field_name": "keyword", "filter_value": "your query"}]}}) +producthunt(op="call", args={"method_id": "producthunt.graphql.posts.v1", "topic": "relevant-topic", "first": 20}) +instagram(op="call", args={"method_id": "instagram.hashtag.recent_media.v1", "hashtag": "yourhashtag"}) +pinterest(op="call", args={"method_id": "pinterest.trends.keywords_top.v1", "region": "US", "interests": ["your-interest"]}) +``` + +### Draft: Classification and confidence policy + +- `weak`: single-platform, sparse refs, unresolved integrity issues, or one-window spike +- `moderate`: >=2 independent platforms align; integrity acceptable +- `strong`: corroboration + persistence + acceptable authenticity risk + no unresolved major conflict + +Confidence starts at `0.50`. + +Adjust by: +- `+0.10` relevance quality high +- `+0.10` corroboration >=2 +- `+0.10` persistence across windows +- `+0.10` low authenticity risk +- `-0.10` each unresolved major contradiction +- `-0.10` each key-platform partial/failed coverage +- `-0.10` key evidence still provisional + +Caps: +- single-platform max `0.60` +- major coverage constraints max `0.75` +- unresolved high authenticity risk max `0.65` + +### Draft: Anti-pattern warning blocks + +- Single-platform overclaim +- Coverage illusion +- Rate-limit blindness +- Freshness mislabel +- Authenticity neglect +- Engagement-bait fallacy +- Snapshot non-reproducibility +- Cross-platform metric conflation +- Sentiment portability +- Policy-last execution + +For each warning include: detection signal, consequence, and exact mitigation. + +### Draft: Recording block + +```python +write_artifact( + artifact_type="signal_social_community", + path="/signals/social-community-{YYYY-MM-DD}", + data={...} +) +``` + +One artifact per query scope per run. Do not output raw JSON in chat. + +Before write, require: +- relevance checks complete +- platform coverage/freshness complete +- authenticity risk assessed +- contradictions logged +- confidence + components consistent +- limitations + next checks concrete + +### Draft: Schema additions (compact) + +```json +{ + "signal_social_community": { + "type": "object", + "required": ["query", "time_window", "result_state", "platform_coverage", "signals", "confidence", "confidence_grade", "confidence_components", "limitations", "contradictions", "next_checks"], + "additionalProperties": false + } +} +``` + +--- + +## Gaps & Uncertainties + +- Platform limits and terms are tier/version specific and change over time. +- Cross-platform metric comparability remains structurally limited. +- Bot/coordinated-behavior thresholds are context dependent. +- Some references are operational studies, not formal standards. +- This is operational research guidance, not legal advice. diff --git a/flexus_simple_bots/researcher/skills/_signal-social-community/SKILL.md b/flexus_simple_bots/researcher/skills/_signal-social-community/SKILL.md new file mode 100644 index 00000000..7cf29164 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_signal-social-community/SKILL.md @@ -0,0 +1,86 @@ +--- +name: signal-social-community +description: Social media and community signal detection — sentiment, velocity, narrative, trending topics +--- + +You are detecting social and community signals for one query scope per run. + +Core mode: evidence-first, zero invention. Filter noise aggressively — bot activity, single-account repetition, and low-engagement posts are not signals. Require multiple independent sources before calling a signal strong. + +## Methodology + +### Reddit +Subreddit relevance: does the target problem or category appear in recurring discussions in relevant subreddits? +Signal indicators: +- Thread volume: how many posts in past 30/90 days on this topic +- Engagement depth: avg comment count per thread (low comment = low interest) +- Pain language: "frustrated with", "looking for alternative to", "wish X did Y" +- Upvote patterns: highly upvoted complaints = strong pain signal + +Use `reddit.search.posts.v1` + `reddit.subreddit.hot.v1` to get current discourse. + +### X (Twitter) +Detect narrative drift and velocity bursts. +Signal indicators: +- Counts trend: `tweets.counts_recent` rising over consecutive days = emerging narrative +- Account quality: ignore accounts with <100 followers or <5 posts — they are noise +- Hashtag convergence: multiple independent accounts using same framing = signal + +Use `x.tweets.counts_recent.v1` for volume, `x.tweets.search_recent.v1` for content analysis. + +### YouTube +Content category activity. +- Creator concentration: is topic covered by many creators or only 1-2? +- Comment themes: what are viewers asking/complaining about? +- View velocity on recent uploads: fast view accumulation = active interest + +Use `youtube.search.list.v1` + `youtube.videos.list.v1` for engagement stats + `youtube.comment_threads.list.v1` for voice-of-market. + +### TikTok +Use `tiktok.research.video_query.v1` for short-form video trends. High view count + recent upload date = current relevance. + +### ProductHunt +Use `producthunt.graphql.posts.v1` to detect new product launches in the space. Upvote count and comment volume indicate market interest. + +### Instagram / Pinterest +Use for visual trend signals when the domain is lifestyle, consumer goods, design, or fashion. Lower priority for B2B. + +## Recording + +``` +write_artifact( + artifact_type="signal_social_community", + path="/signals/social-community-{YYYY-MM-DD}", + data={...} +) +``` + +One artifact per query scope per run. Do not output raw JSON in chat. + +## Available Tools + +``` +reddit(op="call", args={"method_id": "reddit.search.posts.v1", "q": "your query", "sort": "relevance", "t": "month"}) + +reddit(op="call", args={"method_id": "reddit.subreddit.hot.v1", "subreddit": "relevant_subreddit"}) + +reddit(op="call", args={"method_id": "reddit.subreddit.new.v1", "subreddit": "relevant_subreddit"}) + +x(op="call", args={"method_id": "x.tweets.counts_recent.v1", "query": "your query", "granularity": "day"}) + +x(op="call", args={"method_id": "x.tweets.search_recent.v1", "query": "your query", "max_results": 100}) + +youtube(op="call", args={"method_id": "youtube.search.list.v1", "q": "your query", "type": "video", "order": "viewCount"}) + +youtube(op="call", args={"method_id": "youtube.videos.list.v1", "id": "video_id", "part": "statistics,snippet"}) + +youtube(op="call", args={"method_id": "youtube.comment_threads.list.v1", "videoId": "video_id"}) + +tiktok(op="call", args={"method_id": "tiktok.research.video_query.v1", "query": {"and": [{"field_name": "keyword", "filter_value": "your query"}]}}) + +producthunt(op="call", args={"method_id": "producthunt.graphql.posts.v1", "topic": "relevant-topic", "first": 20}) + +instagram(op="call", args={"method_id": "instagram.hashtag.recent_media.v1", "hashtag": "yourhashtag"}) + +pinterest(op="call", args={"method_id": "pinterest.trends.keywords_top.v1", "region": "US", "interests": ["your-interest"]}) +``` diff --git a/flexus_simple_bots/researcher/skills/_signal-talent-tech/RESEARCH.md b/flexus_simple_bots/researcher/skills/_signal-talent-tech/RESEARCH.md new file mode 100644 index 00000000..0212e3bc --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_signal-talent-tech/RESEARCH.md @@ -0,0 +1,882 @@ +# Research: signal-talent-tech + +**Skill path:** `flexus_simple_bots/researcher/skills/signal-talent-tech/` +**Bot:** researcher +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`signal-talent-tech` detects hiring-demand and technology-adoption signals for one domain or technology per run. It is a cross-source signal skill: job postings reflect market demand intent, while developer activity reflects ecosystem momentum and implementation reality. + +The current `SKILL.md` has a good high-level framing (lagging hiring + leading developer activity), but it needs stronger operational guardrails so runs are reproducible, source-safe, and resistant to common data traps. This research focuses on 2024-2026 evidence for five areas: methodology, tools/APIs, interpretation quality, anti-pattern handling, and representativeness limits. + +The goal of this research is not to rewrite the skill directly. The goal is to provide enough precise draft language that a future editor can paste sections into `SKILL.md` with minimal invention and without introducing fake endpoints or unsupported confidence claims. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024-2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- No generic filler without concrete backing: **passed** +- No invented tool names, method IDs, or API endpoints: **passed** +- Contradictions between sources are explicitly noted: **passed** +- Volume target met (Findings sections 800-4000 words): **passed** + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +- Practitioners increasingly treat job posting counts as a directional demand-intent signal, not a literal opening count. Indeed explicitly documents that one posting can represent multiple openings and postings can remain visible after fill, so absolute counts are not vacancy truth. +- Method-first teams normalize before interpreting: index-based and seasonally adjusted series come first, then domain slicing by occupation/sector, then directional interpretation. Indeed's methodology revisions in late 2024 reinforced the need to annotate method breakpoints. +- Modern workflows separate "mention volume" from "usage-context quality." In 2025 analysis of AI mentions in postings, a meaningful share of postings had weak context, showing that keyword presence alone is not sufficient evidence of actual skill demand depth. +- Better hiring signal systems score at role/industry level before aggregate rollups. 2026 labor updates show AI-mention demand can grow while total hiring remains soft, so aggregate-only conclusions are frequently wrong. +- Talent supply estimation has shifted from title matching toward skills-overlap and observed transition feasibility. LinkedIn's 2025 skills-based hiring framework emphasizes overlap thresholds and observed transitions to reduce false-positive talent pool estimates. +- Skills-gap analysis quality improves when mismatch is weighted by skill relevance and outlier handling, not raw count difference. LinkedIn technical notes describe weighted shortage/surplus approaches that are more stable than naive demand-minus-supply. +- The most reliable talent-tech methodology in 2025-2026 is a leading+lagging stack: labor demand signals (job boards), ecosystem activity (GitHub), and practitioner community demand (Stack Overflow/Stack Exchange), with survey priors used as context not ground truth. +- Developer telemetry is operationally useful but scope-limited. Public GitHub activity is observable and timely, but it undercounts private enterprise work, so confidence should explicitly reflect public-only observability limits. +- Trend calls are increasingly gated by cross-source directional agreement, not exact metric matching. If two independent sources agree on direction and data quality checks pass, confidence can rise; if they diverge, contradiction must be recorded. +- Mature teams now publish confidence with evidence-class labels (`direct`, `sampled`, `modeled`) because mixing classes without labels causes overconfident and non-auditable outputs. + +**Contradictions to preserve in skill logic:** + +- Strong employer expectation for AI transformation can coexist with broad hiring weakness; domain slices may rise while totals stay flat. +- Mentions can increase while practical usage context remains weak in a non-trivial subset of postings. +- Open-source activity may indicate ecosystem momentum while missing private adoption reality. + +**Sources:** + +- [Indeed job postings tracker and methodology](https://hiring-lab.github.io/job_postings_tracker/) +- [Indeed data FAQ (posting vs opening caveats)](https://www.hiringlab.org/indeed-data-faq-2/) +- [Indeed AI mention context analysis (2025)](https://www.hiringlab.org/2025/10/28/how-employers-are-talking-about-ai-in-job-postings/) +- [Indeed labor update (2026)](https://www.hiringlab.org/2026/01/22/january-labor-market-update-jobs-mentioning-ai-are-growing-amid-broader-hiring-weakness/) +- [LinkedIn skills-based hiring report (2025)](https://economicgraph.linkedin.com/content/dam/me/economicgraph/en-us/PDF/skills-based-hiring-march-2025.pdf) +- [LinkedIn technical note: skills mismatch](https://economicgraph.linkedin.com/content/dam/me/economicgraph/en-us/PDF/technicalnote-skills-mismatch.pdf) +- [WEF Future of Jobs 2025](https://www.weforum.org/publications/the-future-of-jobs-report-2025/in-full/) +- [GitHub Octoverse 2025 (public activity scope caveats)](https://github.blog/news-insights/octoverse/octoverse-a-new-developer-joins-github-every-second-as-ai-leads-typescript-to-1/) +- [Stack Overflow survey methodology (2025)](https://survey.stackoverflow.co/2025/methodology/) +- [ILO online labor market data representativeness (evergreen)](https://webapps.ilo.org/static/english/intserv/working-papers/wp068/index.html) + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +- **Adzuna** official Jobs API exposes country-scoped endpoints with query credential auth (`app_id`, `app_key`). Verified endpoint patterns include `GET /v1/api/jobs/{country}/search/{page}` and `GET /v1/api/jobs/{country}/categories`. +- **TheirStack** provides hiring + technographic endpoints under bearer auth. Verified endpoints include `POST /v1/jobs/search`, `POST /v1/companies/search`, and `POST /v1/companies/technologies`. +- **TheirStack** rate-limit guidance is explicitly documented by plan, including `429` behavior and `RateLimit-*` response headers; this enables deterministic backoff and throughput planning. +- **CoreSignal** provides jobs and company APIs with API-key auth via request header. Verified jobs endpoints include `POST /v2/job_base/search/filter`, `POST /v2/job_base/search/es_dsl`, and `GET /v2/job_base/collect/{job_id}`. +- **CoreSignal** publishes endpoint-class rate limits (for example search vs collect), which is more actionable than single global quota assumptions and should be encoded in retry policy. +- **CoreSignal** company multi-source endpoints (`/v2/company_multi_source/...`) include fields like active postings count and technologies used, useful for employer-level adoption scoring. +- **GitHub REST API** remains the most direct source for repository, issue, and issue-event telemetry (`/repos/{owner}/{repo}`, `/repos/{owner}/{repo}/issues`, `/repos/{owner}/{repo}/issues/events`), with a separate GraphQL endpoint at `POST https://api.github.com/graphql`. +- **GitHub** publishes both primary and secondary rate-limit behavior; analysis pipelines must respect point/hour and abuse protections, not only naive requests/hour assumptions. +- **Stack Exchange API v2.3** supports question and tag analytics via endpoints such as `/2.3/questions`, `/2.3/tags`, `/2.3/tags/{tags}/info`, and `/2.3/tags/{tags}/related`, with `backoff` and quota signals in wrapper metadata. +- **Wikimedia Pageviews API** provides stable pageview time-series via `GET /api/rest_v1/metrics/pageviews/per-article/{project}/{access}/{agent}/{article}/{granularity}/{start}/{end}`. +- **Wikimedia** rate-limit documentation is partially conflicting across official pages (some guidance says no fixed hard limit while other docs describe explicit throttling and `429` behavior), so defensive client behavior is mandatory. +- De-facto 2024-2026 stack quality rule: mix job-demand source(s) + code ecosystem source(s) + Q&A activity + context proxy (for example pageviews), and never assume one API family is sufficient for robust confidence. + +| Provider | Verified API surface (examples) | Auth model | Limit caveats | +|---|---|---|---| +| Adzuna | `GET /v1/api/jobs/{country}/search/{page}`, `GET /v1/api/jobs/{country}/categories` | `app_id` + `app_key` query params | Numeric hard quota not clearly published in one canonical place; treat as uncertain | +| TheirStack | `POST /v1/jobs/search`, `POST /v1/companies/search`, `POST /v1/companies/technologies` | Bearer token | Tiered rate limits; `429` + `RateLimit-*` headers | +| CoreSignal | `POST /v2/job_base/search/filter`, `POST /v2/job_base/search/es_dsl`, `GET /v2/job_base/collect/{job_id}` | `apikey` header | Endpoint-class req/sec limits documented | +| GitHub | `GET /repos/{owner}/{repo}`, `GET /repos/{owner}/{repo}/issues`, `POST https://api.github.com/graphql` | Bearer token (or unauth for low limits) | Primary + secondary limits; GraphQL points/node constraints | +| Stack Exchange | `GET /2.3/questions`, `GET /2.3/tags/{tags}/info` | Optional OAuth/app key | Per-IP throttle, daily quota, dynamic `backoff` | +| Wikimedia | `GET /api/rest_v1/metrics/pageviews/per-article/...` | No OAuth needed for basic reads | Conflicting public rate-limit docs; use conservative pacing and user-agent policy | + +**Sources:** + +- [Adzuna search endpoint docs](https://developer.adzuna.com/docs/search) +- [Adzuna categories endpoint docs](https://developer.adzuna.com/docs/categories) +- [Adzuna endpoints index](https://api.adzuna.com/v1/doc/Endpoints.md) +- [TheirStack API reference (jobs)](https://theirstack.com/en/docs/api-reference/jobs/search_jobs_v1) +- [TheirStack API reference (companies)](https://theirstack.com/en/docs/api-reference/companies/search_companies_v1) +- [TheirStack API reference (technographics)](https://theirstack.com/en/docs/api-reference/companies/technographics_v1) +- [TheirStack rate limits](https://theirstack.com/en/docs/api-reference/rate-limit) +- [CoreSignal authorization](https://docs.coresignal.com/api-introduction/authorization) +- [CoreSignal rate limits](https://docs.coresignal.com/api-introduction/rate-limits) +- [CoreSignal base jobs API](https://docs.coresignal.com/jobs-api/base-jobs-api) +- [CoreSignal jobs search filter endpoint](https://docs.coresignal.com/jobs-api/base-jobs-api/endpoints/search-filters) +- [CoreSignal company multi-source API](https://docs.coresignal.com/company-api/multi-source-company-api/elasticsearch-dsl) +- [GitHub REST rate limits](https://docs.github.com/en/rest/overview/rate-limits-for-the-rest-api) +- [GitHub REST issues endpoint](https://docs.github.com/en/rest/issues/issues#list-repository-issues) +- [GitHub REST issue events endpoint](https://docs.github.com/en/rest/issues/events#list-issue-events-for-a-repository) +- [GitHub GraphQL rate and node limits](https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api) +- [Stack Exchange API docs root](https://api.stackexchange.com/docs) +- [Stack Exchange throttle behavior](https://api.stackexchange.com/docs/throttle) +- [Stack Exchange questions endpoint docs](https://api.stackexchange.com/docs/questions) +- [Wikimedia analytics API docs](https://doc.wikimedia.org/generated-data-platform/aqs/analytics-api/) +- [Wikimedia pageviews concept](https://doc.wikimedia.org/generated-data-platform/aqs/analytics-api/concepts/page-views.html) +- [Wikimedia API rate limits page](https://m.mediawiki.org/wiki/Wikimedia_APIs/Rate_limits) + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +- Hiring trend interpretation should default to seasonally adjusted or at least matched-period YoY comparisons. Raw month-over-month movement is vulnerable to hiring seasonality and reporting artifacts. +- Job postings are a proxy for demand intent, not exact openings; interpretation must separate directional movement from absolute level claims. +- Methodology revisions and first-release revisions are common in labor datasets. Analysts should annotate breakpoint dates and downgrade confidence around methodological transitions. +- Geographic and occupational coverage differences can distort comparisons; confidence should be lower when comparing slices with known platform coverage imbalance. +- GitHub stars and forks are attention/structure signals, not quality verdicts. They need companion maintenance metrics (issue response time, release cadence, contributor concentration). +- Issue volume must be interpreted by issue type and workflow context, because issues contain bugs, tasks, and feature requests, not only defects. +- Contributor and activity views can be scope-limited; for example, some views are capped or branch-constrained. Output should state what part of activity is actually observed. +- Stack Exchange trend signals require synonym and taxonomy handling. Naive raw tag counts can drift because tags evolve and questions are multi-tagged. +- Leading indicators should be interpreted as turning-point direction signals, not precise level forecasts. Confidence should fall when component availability is low or data is preliminary. +- Interpretation quality improves when each signal carries provenance class and a freshness marker, and when conflicts are written as contradictions rather than averaged away. + +**Common misinterpretations and corrections:** + +- Misread: "Posting count +12% means openings +12%." + Correction: posting volume is directional demand intent; opening counts require separate validation. +- Misread: "Star spike proves production adoption spike." + Correction: star movement can reflect attention events; require maintenance and usage corroboration. +- Misread: "Tag count jump means immediate labor demand jump." + Correction: taxonomy/synonym changes and multi-tag behavior can alter counts without real demand shift. +- Misread: "Leading indicator predicts exact hiring level in six months." + Correction: leading indicators are directional; level forecasting needs additional modeling and uncertainty bounds. +- Misread: "One-week trend reversal means structural shift." + Correction: require rolling windows and revision-aware checks before directional verdict changes. + +**Sources:** + +- [LinkedIn hiring rate methodology (2024)](https://economicgraph.linkedin.com/content/dam/me/economicgraph/en-us/PDF/linkedin-hiring-rate-methodology.pdf) +- [Indeed tracker methodology and revisions](https://hiring-lab.github.io/job_postings_tracker/) +- [Indeed data FAQ (posting interpretation caveats)](https://www.hiringlab.org/indeed-data-faq/) +- [BLS JOLTS FAQ (revisions)](https://www.bls.gov/jlt/jltfaq.htm) +- [GitHub stars docs](https://docs.github.com/en/get-started/exploring-projects-on-github/saving-repositories-with-stars) +- [GitHub forks docs](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) +- [GitHub issues docs](https://docs.github.com/articles/about-issues) +- [CHAOSS issue response time metric](https://chaoss.community/kb/metric-issue-response-time) +- [CHAOSS release frequency metric](https://chaoss.community/kb/metric-release-frequency) +- [Stack Exchange tag synonym type](https://api.stackexchange.com/docs/types/tag-synonym) +- [Stack Exchange data dump / SEDE caveats (evergreen)](https://meta.stackexchange.com/questions/2677/database-schema-documentation-for-the-public-data-dump-and-sede) +- [OECD CLI FAQ (2024)](https://www.oecd.org/en/data/insights/data-explainers/2024/04/composite-leading-indicators-frequently-asked-questions.html) +- [OECD CLI methodology PDF](https://www.oecd.org/content/dam/oecd/en/data/methods/OECD-System-of-Composite-Leading-Indicators.pdf) +- [OpenSSF scorecard checks](https://github.com/ossf/scorecard/blob/main/docs/checks.md) + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +- **Posting-to-opening conflation:** treating job board volume as literal vacancies creates pseudo-precision and broken forecasts. + - Detection signal: dashboard claims exact open roles from posting counts without caveat. + - Mitigation: explicitly label postings as demand intent; calibrate against official vacancy/hire datasets when possible. +- **Dedup trust fallacy:** assuming source-side dedup is perfect causes overcounting from reposts and channel duplication. + - Detection signal: unstable unique/raw ratios and repeated near-identical posting payloads. + - Mitigation: apply local semantic+metadata dedup and monitor dedup ratio drift. +- **Coverage bias blindness:** interpreting platform samples as full labor market representation. + - Detection signal: unsupported claims in sectors/regions with known weak coverage. + - Mitigation: add coverage maps, suppression rules, and confidence penalties. +- **Short-window overfitting:** reacting to weekly spikes/noise as structural trend shifts. + - Detection signal: major recommendation swings from one short window. + - Mitigation: require rolling windows, seasonality checks, and breakpoint annotation. +- **Popularity proxy abuse:** treating GitHub stars as direct quality/adoption truth. + - Detection signal: tool ranking driven by stars alone. + - Mitigation: blend with issue responsiveness, contributor health, release discipline, and security signals. +- **Q&A representativeness overreach:** treating Stack Overflow trend as whole-developer-population truth. + - Detection signal: "market-level demand" claims from single-community data. + - Mitigation: triangulate with postings and repository telemetry; state representativeness limitations. +- **Unsupported confidence math:** publishing precise confidence values with no calibration explanation. + - Detection signal: confidence decimals with no uncertainty method and no downgrade rationale. + - Mitigation: define transparent additive/penalty confidence policy and include reasons in output. +- **API truncation blindness:** silently accepting capped or partial results as complete scans. + - Detection signal: ignored `429`, timeout, backoff, or paging truncation flags. + - Mitigation: enforce pagination completion checks and coverage telemetry fields. +- **Public-only telemetry overgeneralization:** inferring private enterprise adoption from public OSS traces. + - Detection signal: enterprise adoption claim with no private-source caveat. + - Mitigation: scope label all ecosystem signals and cap confidence when only public traces exist. +- **Invented endpoint anti-pattern:** hardcoding non-existent method IDs/endpoints in skills. + - Detection signal: method syntax not present in official docs or runtime tool help. + - Mitigation: canonical endpoint map + runtime `op="help"` verification before calls. + +**Case-like examples (2024-2026):** + +- Large fake-star analyses in 2024-2025 show popularity manipulation risk in GitHub-only scoring. +- European and OECD discussions on online job-ad coverage keep emphasizing representativeness constraints despite improved timeliness. + +**Sources:** + +- [Indeed data FAQ](https://www.hiringlab.org/indeed-data-faq/) +- [Indeed vs public employment stats comparison (2024)](https://www.hiringlab.org/2024/09/20/comparing-indeed-data-with-public-employment-statistics/) +- [Eurostat online job advertisement rate](https://ec.europa.eu/eurostat/web/experimental-statistics/online-job-advertisement-rate) +- [OECD LEED reference on online job posting representativeness (2024)](https://ideas.repec.org/p/oec/cfeaaa/2024-01-en.html) +- [Google Trends data FAQ (evergreen)](https://support.google.com/trends/answer/4365533) +- [GitHub search API docs (timeouts and incomplete results)](https://docs.github.com/en/rest/search/search) +- [Stack Exchange throttle docs](https://api.stackexchange.com/docs/throttle) +- [Fake GitHub stars research summary (2025)](https://www.cs.cmu.edu/news/2025/fake-github-stars) +- [Fake stars paper (2024/2025)](https://arxiv.org/abs/2412.13459) +- [NIST AI evaluation uncertainty framing (2026)](https://www.nist.gov/publications/expanding-ai-evaluation-toolbox-statistical-models) + +--- + +### Angle 5+: Representativeness, Segmentation, and Signal Transferability +> Additional domain angle: where talent-tech signals transfer well, where they break, and how to keep outputs decision-safe. + +**Findings:** + +- Talent-tech signals are highly segment-sensitive. Country, occupation family, and seniority segmentation can reverse aggregate conclusions. +- Signals transfer poorly across labor market structures: a metric that tracks software demand in one market can be weak in regions with different posting behaviors. +- Skills-based matching improves talent pool realism, but transition friction (credentialing, domain expertise, regulated-role constraints) prevents naive transferability. +- Public developer activity is useful for momentum detection but should be tagged as "ecosystem-visible" rather than "market-total." +- Survey-based developer indicators provide directional context but carry recruitment and channel bias; they should influence priors, not final verdict alone. +- The most resilient output format is dual-layer: (1) signal statement and (2) representativeness caveat. This preserves usability while preventing overclaiming. +- Confidence should be explicitly capped when a run lacks cross-segment corroboration (for example one geography + one channel only). +- A "next_checks" field is essential for transferability risk reduction: it forces concrete follow-up data collection instead of false certainty. + +**Sources:** + +- [WEF Future of Jobs 2025](https://www.weforum.org/publications/the-future-of-jobs-report-2025/in-full/) +- [LinkedIn skills-based hiring (2025)](https://economicgraph.linkedin.com/content/dam/me/economicgraph/en-us/PDF/skills-based-hiring-march-2025.pdf) +- [LinkedIn technical note: skills mismatch](https://economicgraph.linkedin.com/content/dam/me/economicgraph/en-us/PDF/technicalnote-skills-mismatch.pdf) +- [Stack Overflow methodology (2025)](https://survey.stackoverflow.co/2025/methodology/) +- [Indeed tracker methodology and caveats](https://hiring-lab.github.io/job_postings_tracker/) +- [ILO online labor market data representativeness (evergreen)](https://webapps.ilo.org/static/english/intserv/working-papers/wp068/index.html) + +--- + +## Synthesis + +The strongest pattern across sources is that talent-tech signaling quality depends more on measurement discipline than on any single provider. Job posting data remains a high-value demand indicator, but it is not vacancy truth and can mislead when interpreted without seasonality, dedup, and coverage context. At the same time, developer telemetry is a useful leading indicator for ecosystem momentum, but it is public-surface-biased and cannot stand in for full enterprise adoption. + +Tooling has matured into a practical multi-provider stack with real endpoint-level constraints. Adzuna, TheirStack, and CoreSignal cover different slices of hiring signals and must be treated as complementary rather than interchangeable. GitHub and Stack Exchange add ecosystem and practitioner activity context, while Wikimedia offers a lightweight attention proxy. The operational risk is not "missing one provider"; it is assuming all sources have equivalent semantics, coverage, and rate-limit behavior. + +Interpretation is where most preventable failures occur. Signals drift when teams skip methodology breakpoint annotation, overreact to short windows, or treat popularity metrics as outcome metrics. Cross-source directional agreement, provenance labeling, and explicit confidence penalties are now table stakes for trustworthy analysis. + +A second major synthesis point is contradiction handling. Sources can disagree for valid reasons (for example, demand-intent rise with weak total hiring, or high star growth with weak maintenance health). The right behavior is to preserve contradiction in output and downgrade confidence, not collapse disagreement into a simplistic final score. + +Overall, `signal-talent-tech` should evolve from "collect indicators and summarize" to "collect, classify, gate, and only then verdict." This requires: stronger run protocol, endpoint-safe tool guidance, anti-pattern warning blocks, and a richer schema that stores provenance and unresolved conflicts. + +--- + +## Recommendations for SKILL.md + +> Concrete, actionable list of what should change / be added in the SKILL.md based on research. +> Each item here has a corresponding draft in the section below. + +- [x] Replace broad narrative methodology with a strict run protocol (collect -> normalize -> triangulate -> segment -> classify -> verdict). +- [x] Add evidence provenance classes (`direct`, `sampled`, `modeled`) and confidence penalties for class conflicts. +- [x] Add explicit interpretation gates for freshness, seasonality, method revisions, and coverage. +- [x] Replace unverified tool method examples with endpoint-safe guidance: canonical real endpoint map plus runtime `op="help"` method discovery. +- [x] Add practical API error-handling rules for rate limits, pagination completeness, and partial results. +- [x] Add anti-pattern warning blocks with detection signal and mitigation steps. +- [x] Expand signal taxonomy to separate demand-intent, ecosystem momentum, and representativeness risk. +- [x] Add confidence policy and result-state policy for contradictions and sparse evidence. +- [x] Expand artifact schema with provenance, endpoint trace, contradiction records, and segmentation metadata. +- [x] Add an explicit pre-write quality checklist so low-quality runs are downgraded automatically. + +--- + +## Draft Content for SKILL.md + +> This is the most important section. For every recommendation above, write the **actual text** that should go into SKILL.md - as if you were writing it. Be verbose and comprehensive. The future editor will cut what they don't need; they should never need to invent content from scratch. +> +> Rules: +> - Write full paragraphs and bullet lists, not summaries +> - For methodology changes: write the actual instruction text in second person ("You should...", "Before doing X, always verify Y") +> - For schema changes: write the full JSON fragment with all fields, types, descriptions, enums, and `additionalProperties: false` +> - For anti-patterns: write the complete warning block including detection signal and mitigation steps +> - For tool recommendations: write the actual `## Available Tools` section text with real method syntax +> - Do not hedge or abbreviate - if a concept needs 3 paragraphs to explain properly, write 3 paragraphs +> - Mark sections with `### Draft: ` headers so the editor can navigate + +### Draft: Core operating principle and evidence contract + +--- +You are detecting talent and technology adoption signals for one domain, role family, or technology per run. Your primary operating rule is **evidence-first, confidence-second, verdict-last**. + +Before writing any signal, classify each input metric into exactly one evidence class: +- `direct`: observed first-party or platform-native measurement with clear collection semantics. +- `sampled`: normalized or sampled index (for example relative trend/index systems). +- `modeled`: vendor-estimated or inferred values (for example inferred traffic, inferred skills, modeled hiring intensity). + +You must keep evidence class labels in every signal record. You must not merge classes into one implied "ground truth score." If classes disagree, preserve the contradiction and downgrade confidence. + +You should explicitly distinguish three concepts in every run: +1. **Demand intent** (employer-side signal from hiring sources), +2. **Ecosystem momentum** (developer/community activity), +3. **Representativeness risk** (how much of reality the chosen sources can see). + +A run is high quality only when all three are addressed. A run with strong demand intent and weak representativeness controls is not high confidence. +--- + +### Draft: Methodology protocol (collect -> normalize -> triangulate -> segment -> classify -> verdict) + +--- +### Methodology + +Run this sequence exactly. Do not skip steps. + +1. **Define analysis scope and taxonomy first** + - Before any API call, declare domain scope with positive and negative examples. For example: include "data engineer", "ml engineer", "ai platform engineer"; exclude generic "software engineer" unless explicitly in-scope. + - Define segmentation axes up front: geography, seniority, role family, industry, and date window. + - If taxonomy is ambiguous, your run quality is capped at `low` confidence regardless of data volume. + +2. **Collect hiring-demand evidence from at least two independent hiring sources when available** + - Pull source A (broad jobs coverage) and source B (company/technographic enriched coverage). + - Keep raw counts, deduplicated counts (if available), and query filters used. + - Treat posting count as demand-intent, not opening count. Never state literal vacancies unless a source explicitly measures vacancies. + +3. **Normalize for comparability before trend interpretation** + - Align date window, geography, and language. + - Prefer seasonally adjusted or matched-period YoY interpretation for hiring trend statements. + - Annotate method-revision windows and first-release/preliminary periods to avoid false trend breaks. + - If comparability is weak, add limitation and apply confidence penalty. + +4. **Collect ecosystem momentum evidence** + - Pull GitHub repository and issue activity for domain-representative projects. + - Pull Stack Exchange tag/question activity for domain tags and adjacent tags. + - Optionally pull Wikimedia pageviews as broad attention proxy. + - Label all ecosystem metrics as public-surface evidence unless private coverage is explicitly available. + +5. **Triangulate direction, not exact levels** + - Ask: do at least two independent sources agree on direction (growth/stable/decline)? + - Do not require numeric equality across sources with different collection logic. + - Record conflicts explicitly. Contradiction is valid output and should not be silently resolved. + +6. **Apply segmentation checks before final claims** + - Validate whether aggregate trend still holds by geography and role family. + - If aggregate and segment directions diverge, report segment divergence and lower overall confidence. + - Avoid global claims from one-country or one-role slices. + +7. **Classify signal strength and write verdict** + - `strong`: directional agreement across at least two independent sources, no critical quality-gate failure. + - `moderate`: plausible direction with one significant unresolved caveat. + - `weak`: single-source signal, unresolved contradiction, or severe comparability issue. + - If evidence is insufficient, set `result_state` to `insufficient_data` and stop. + +Decision rules: +- If hiring sources grow but ecosystem momentum is flat/declining, classify as demand-intent growth with ecosystem caution. +- If ecosystem momentum grows but hiring is flat, classify as early adoption watch signal. +- If hiring and ecosystem both decline, classify as decline signal unless methodology breakpoints explain most movement. +- If major source contradiction remains unexplained, set `result_state` to `ok_with_conflicts` and include concrete `next_checks`. + +Do NOT: +- Infer structural trend from one short window. +- Treat keyword mentions as proof of skill depth without context check. +- Treat public OSS activity as full enterprise adoption. +--- + +### Draft: Interpretation quality gates and confidence policy + +--- +### Interpretation Quality Gates + +You must pass these gates before assigning any `strong` signal: + +1. **Freshness and revision gate** + - Confirm whether source period is preliminary/revision-prone. + - If yes, either exclude newest unstable slice or apply confidence penalty. + +2. **Comparability gate** + - Confirm aligned geography, language, and time window across core sources. + - If mismatch exists, include it in `limitations` and downgrade confidence. + +3. **Coverage gate** + - Check representativeness by segment (role, region, sector). + - If coverage is known weak for a key segment, cap confidence. + +4. **Cross-source direction gate** + - Require at least two independent signals for `strong`. + - Single-source runs cannot exceed `moderate`. + +5. **Contradiction gate** + - If high-value sources disagree, write contradiction object and confidence penalty reason. + - Contradiction without explanation cannot be `strong`. + +6. **Anti-manipulation gate** + - Popularity metrics (stars, mentions, pageviews) must be paired with quality/maintenance indicators. + - If popularity and maintenance diverge, avoid positive overstatement. + +Confidence scoring policy: +- Start at `0.50`. +- Add `+0.10` for each passed gate (max `+0.50`). +- Subtract `-0.10` for each unresolved major contradiction. +- Subtract `-0.10` if taxonomy ambiguity remains unresolved. +- Subtract `-0.10` if only one evidence class is present. +- Cap confidence at `0.60` when only one provider family is used. +- Cap confidence at `0.70` when all ecosystem signals are public-only with no hiring corroboration. + +Confidence grade mapping: +- `0.80-1.00`: `high` +- `0.60-0.79`: `medium` +- `0.40-0.59`: `low` +- `<0.40`: `insufficient` +--- + +### Draft: Signal taxonomy and interpretation rules + +--- +Use this signal taxonomy to keep outputs consistent: + +- `hiring_growth`: hiring demand-intent rising after seasonality/revision-aware checks. +- `hiring_decline`: hiring demand-intent falling with corroboration. +- `hiring_stable`: no meaningful directional movement within declared window. +- `adoption_early`: ecosystem momentum rising before broad hiring confirmation. +- `adoption_mainstream`: both hiring and ecosystem layers show sustained strength. +- `adoption_declining`: broad decline across hiring and ecosystem signals. +- `ecosystem_active`: repository/issues/Q&A evidence shows active technical discourse and maintenance. +- `ecosystem_stagnant`: ecosystem activity weak or declining with no compensating evidence. +- `compensation_pressure`: hiring context suggests demand pressure (for example salary bands widening upward where available). +- `coverage_risk`: representativeness or data-coverage weakness materially affects interpretability. + +Interpretation rules: +- Demand and adoption are related but not identical. You may output demand growth with adoption uncertainty. +- Ecosystem activity should not be converted directly into labor demand without hiring corroboration. +- Compensation pressure requires explicit unit and source context; never infer salary trend from mention trend alone. +- `coverage_risk` can coexist with any positive signal and should reduce confidence rather than suppress evidence. +--- + +### Draft: Available Tools section (endpoint-safe, no invented method IDs) + +--- +## Available Tools + +You must avoid invented method IDs. Before making any call, inspect runtime methods first: + +```python +adzuna(op="help") +theirstack(op="help") +coresignal(op="help") +github(op="help") +stackexchange(op="help") +wikimedia(op="help") +``` + +Use only methods that exist in runtime help output. Map chosen runtime methods to these canonical real API endpoints: + +- Adzuna Jobs API: + - `GET /v1/api/jobs/{country}/search/{page}` + - `GET /v1/api/jobs/{country}/categories` +- TheirStack API: + - `POST /v1/jobs/search` + - `POST /v1/companies/search` + - `POST /v1/companies/technologies` +- CoreSignal: + - `POST /v2/job_base/search/filter` + - `POST /v2/job_base/search/es_dsl` + - `GET /v2/job_base/collect/{job_id}` + - `POST /v2/company_multi_source/search/es_dsl` + - `GET /v2/company_multi_source/collect/{company_id}` +- GitHub: + - `GET /repos/{owner}/{repo}` + - `GET /repos/{owner}/{repo}/issues` + - `GET /repos/{owner}/{repo}/issues/events` + - `POST https://api.github.com/graphql` +- Stack Exchange: + - `GET /2.3/questions` + - `GET /2.3/tags` + - `GET /2.3/tags/{tags}/info` + - `GET /2.3/tags/{tags}/related` +- Wikimedia: + - `GET /api/rest_v1/metrics/pageviews/per-article/{project}/{access}/{agent}/{article}/{granularity}/{start}/{end}` + +Call-sequencing guidance: +1. Pull hiring-demand base first (`adzuna` + one of `theirstack`/`coresignal`). +2. Pull ecosystem evidence (`github` + `stackexchange`). +3. Pull attention proxy (`wikimedia`) only as secondary context. +4. If any provider fails or throttles, continue with remaining sources and downgrade confidence. + +Example runtime-safe pattern: + +```python +# 1) Inspect available methods at runtime +adzuna(op="help") + +# 2) Choose only a method ID shown in help that maps to: +# GET /v1/api/jobs/{country}/search/{page} +adzuna( + op="call", + args={ + "method_id": "", + "country": "us", + "page": 1, + "what": "machine learning engineer", + "where": "new york", + "results_per_page": 50, + }, +) +``` + +API behavior rules: +- Respect provider throttling and `429` responses with exponential backoff. +- Complete pagination before making trend claims. +- Record when a query was truncated, timed out, or partially unavailable. +- Never fabricate missing fields; emit limitation instead. +--- + +### Draft: Anti-pattern warning blocks + +--- +### WARNING: Posting Count Literalism +**What it looks like:** You report job posting totals as exact open vacancies. +**Detection signal:** Output states exact vacancy counts without caveat. +**Consequence if missed:** False precision and incorrect demand sizing decisions. +**Mitigation steps:** +1. Label posting-based metrics as demand intent. +2. Add limitation text when vacancy truth is unavailable. +3. Use direction-focused language unless validated against vacancy/hiring sources. + +### WARNING: Dedup Blindness +**What it looks like:** You trust source-side dedup as complete and do no local QA. +**Detection signal:** Sudden count spikes with repeated posting signatures across sources. +**Consequence if missed:** Artificial growth calls and noisy alerts. +**Mitigation steps:** +1. Track dedup ratio (raw vs unique where possible). +2. Flag abnormal dedup-ratio shifts in limitations. +3. Downgrade confidence when duplication cannot be assessed. + +### WARNING: Popularity-as-Truth +**What it looks like:** You rank technology strength using stars or mentions only. +**Detection signal:** No maintenance, issue-response, or release-quality metrics in output. +**Consequence if missed:** Susceptibility to hype/manipulation and brittle decisions. +**Mitigation steps:** +1. Pair popularity metrics with maintenance-health evidence. +2. Add contradiction if popularity rises while maintenance weakens. +3. Cap confidence when quality indicators are missing. + +### WARNING: Segment Collapse +**What it looks like:** You publish one global verdict without role/geography segmentation checks. +**Detection signal:** Aggregate trend claim with no segment table or caveat. +**Consequence if missed:** Local misallocation and false "universal" conclusions. +**Mitigation steps:** +1. Validate trend on core segments before final verdict. +2. If segment divergence is material, emit `coverage_risk` signal. +3. Keep global claim at moderate/low confidence. + +### WARNING: Endpoint Invention +**What it looks like:** You use method IDs or endpoints not verifiable in docs/runtime help. +**Detection signal:** Method syntax cannot be mapped to official endpoint docs. +**Consequence if missed:** Broken runs and unmaintainable skill behavior. +**Mitigation steps:** +1. Start each provider with `op="help"`. +2. Use canonical endpoint map as truth source. +3. If mapping is unclear, stop and record uncertainty instead of guessing. +--- + +### Draft: Result-state policy + +--- +Set `result_state` using this policy: + +- `ok`: Evidence supports a defensible verdict with no unresolved major contradiction. +- `ok_with_conflicts`: Evidence supports a tentative verdict but major contradictions remain. +- `zero_results`: Query returned valid-empty results across core providers. +- `insufficient_data`: Data exists but quality/comparability is too weak for a defensible verdict. +- `technical_failure`: Run failed due to provider/network/auth/tool errors that prevented minimum evidence collection. + +Selection rules: +- Prefer `insufficient_data` over overconfident `ok` when quality gates fail. +- Use `ok_with_conflicts` instead of forcing a single narrative when sources disagree. +- Use `technical_failure` only when collection failed, not when evidence is contradictory. +--- + +### Draft: Output checklist before write_artifact + +--- +Before calling `write_artifact`, verify all checks: + +1. At least two provider families used unless unavailable. +2. Every signal has provider + evidence class + key metric value + observed timestamp. +3. Demand-intent and ecosystem momentum are both assessed (or explicit limitation explains why not). +4. Segment checks are either performed or explicitly listed as missing with confidence penalty. +5. Contradictions are recorded, not hidden. +6. Confidence score and grade are consistent with quality gates. +7. Result state matches actual evidence quality. +8. Next checks are concrete and operational (not generic). + +If any check fails, downgrade confidence and/or set `insufficient_data`. +--- + +### Draft: Schema additions + +```json +{ + "signal_talent_tech": { + "type": "object", + "required": [ + "domain", + "time_window", + "geo_scope", + "result_state", + "signals", + "confidence", + "confidence_grade", + "limitations", + "next_checks" + ], + "additionalProperties": false, + "properties": { + "domain": { + "type": "string", + "description": "Technology, role family, or domain analyzed in this run." + }, + "time_window": { + "type": "object", + "required": [ + "start_date", + "end_date" + ], + "additionalProperties": false, + "properties": { + "start_date": { + "type": "string", + "description": "ISO date (YYYY-MM-DD) for analysis start." + }, + "end_date": { + "type": "string", + "description": "ISO date (YYYY-MM-DD) for analysis end." + } + } + }, + "geo_scope": { + "type": "object", + "required": [ + "primary_country" + ], + "additionalProperties": false, + "properties": { + "primary_country": { + "type": "string", + "description": "Primary country/market code used in the run." + }, + "subregions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional subregions covered by this run." + } + } + }, + "result_state": { + "type": "string", + "enum": [ + "ok", + "ok_with_conflicts", + "zero_results", + "insufficient_data", + "technical_failure" + ], + "description": "Overall run outcome based on evidence quality and execution success." + }, + "signals": { + "type": "array", + "items": { + "type": "object", + "required": [ + "signal_type", + "description", + "strength", + "provider", + "evidence_class", + "metric_name", + "metric_value", + "metric_unit", + "observed_at" + ], + "additionalProperties": false, + "properties": { + "signal_type": { + "type": "string", + "enum": [ + "hiring_growth", + "hiring_decline", + "hiring_stable", + "adoption_early", + "adoption_mainstream", + "adoption_declining", + "ecosystem_active", + "ecosystem_stagnant", + "compensation_pressure", + "coverage_risk" + ], + "description": "Type of signal emitted by the run." + }, + "description": { + "type": "string", + "description": "Human-readable explanation of this signal and why it matters." + }, + "strength": { + "type": "string", + "enum": [ + "strong", + "moderate", + "weak" + ], + "description": "Signal strength after quality-gate checks." + }, + "provider": { + "type": "string", + "description": "Provider/tool namespace that produced this signal." + }, + "endpoint": { + "type": "string", + "description": "Canonical endpoint path or method reference used to collect this signal." + }, + "evidence_class": { + "type": "string", + "enum": [ + "direct", + "sampled", + "modeled" + ], + "description": "Provenance class for this metric." + }, + "metric_name": { + "type": "string", + "description": "Metric identifier used in interpretation (for example posting_count, stars_growth_30d)." + }, + "metric_value": { + "type": "string", + "description": "Observed metric value serialized as text to preserve source precision." + }, + "metric_unit": { + "type": "string", + "description": "Metric unit or format (count, percent, index, ratio, etc.)." + }, + "observed_at": { + "type": "string", + "description": "ISO-8601 timestamp when this value was captured." + }, + "segment": { + "type": "string", + "description": "Optional segment label (for example us/software-engineering/senior)." + } + } + } + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Overall confidence after quality-gate pass/fail and contradiction penalties." + }, + "confidence_grade": { + "type": "string", + "enum": [ + "high", + "medium", + "low", + "insufficient" + ], + "description": "Bucketed label derived from numeric confidence." + }, + "contradictions": { + "type": "array", + "description": "Explicit unresolved source disagreements affecting confidence.", + "items": { + "type": "object", + "required": [ + "topic", + "source_a", + "source_b", + "impact" + ], + "additionalProperties": false, + "properties": { + "topic": { + "type": "string", + "description": "Contradiction topic label." + }, + "source_a": { + "type": "string", + "description": "First conflicting source identifier." + }, + "source_b": { + "type": "string", + "description": "Second conflicting source identifier." + }, + "impact": { + "type": "string", + "enum": [ + "minor", + "moderate", + "major" + ], + "description": "Estimated impact on final confidence." + } + } + } + }, + "limitations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Known caveats that limit interpretation quality." + }, + "next_checks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Concrete follow-up checks that would reduce uncertainty." + } + } + } +} +``` + +### Draft: Recording guidance block + +--- +### Recording + +After collecting and interpreting evidence, call: + +```python +write_artifact( + artifact_type="signal_talent_tech", + path="/signals/talent-tech-{YYYY-MM-DD}", + data={...} +) +``` + +One artifact per domain per run. Do not dump raw JSON in chat. + +Before writing: +1. Confirm `result_state` matches evidence quality. +2. Confirm every signal has provenance (`evidence_class`) and metric context. +3. Confirm contradiction entries exist when sources disagree materially. +4. Confirm confidence penalties were applied for unresolved quality-gate failures. +5. Confirm `next_checks` are specific enough to execute in the next run. +--- + +## Gaps & Uncertainties + +- TheirStack and CoreSignal plan features and limits can change by contract tier; endpoint surfaces are stable but exact entitlements are tenant-specific. +- Wikimedia rate-limit behavior has conflicting public guidance across official pages; implementation should assume conservative pacing and robust retry. +- There is no single universal threshold set for "strong/moderate/weak" talent-tech signals across all industries; thresholds should remain context-aware and calibrated over time. +- Some foundational representativeness references are older than 2024 but still operationally relevant; these are marked evergreen. +- Public GitHub and Stack Exchange signals do not capture private enterprise workstreams; confidence should remain capped when private corroboration is unavailable. diff --git a/flexus_simple_bots/researcher/skills/_signal-talent-tech/SKILL.md b/flexus_simple_bots/researcher/skills/_signal-talent-tech/SKILL.md new file mode 100644 index 00000000..515b5111 --- /dev/null +++ b/flexus_simple_bots/researcher/skills/_signal-talent-tech/SKILL.md @@ -0,0 +1,77 @@ +--- +name: signal-talent-tech +description: Hiring demand and tech ecosystem signal detection — job posting trends, technology adoption, developer activity +--- + +You are detecting talent and technology adoption signals for one domain or technology per run. + +Core mode: evidence-first. Job posting counts are a lagging indicator — they confirm adoption already underway. Developer activity (GitHub, StackExchange) is a leading indicator of technology relevance. Combine both for high-confidence signals. + +## Methodology + +### Hiring demand +Job posting volume is a proxy for technology adoption and market growth. + +Key questions: +- How many companies are hiring for this technology/role in the past 30/90 days? +- Is posting volume growing, stable, or declining? +- What company types are hiring (early-stage, enterprise, specific industries)? +- What compensation levels indicate demand pressure? + +Use `adzuna` for broad job market coverage. Use `theirstack` for tech-stack-specific hiring signals (find companies hiring for specific tools). Use `coresignal` for company-level tech hiring profiles. + +### Technology adoption +Use `github` to measure open-source ecosystem health: +- Repository count for a technology or library +- Star growth velocity on key repos (new stars/week indicates interest) +- Issue volume: high issue count on active repos = active community +- Fork count: forks indicate developers are building on top of the technology + +Use `stackexchange` (Stack Overflow) for developer interest: +- Question volume: how often developers ask about this technology +- Question growth trend: is question count rising or falling? +- Accepted answer rate: high = mature community, low = early/difficult technology + +### Compensation signals +Use `wikimedia` page views on technology pages as an interest proxy. + +### Pattern interpretation +Strong adoption signal: high job postings + growing GitHub stars + rising StackOverflow questions +Declining signal: falling job postings + stable/declining GitHub activity + decreasing questions +Niche/early signal: low job postings + high GitHub star velocity + increasing questions (pre-mainstream adoption) + +## Recording + +``` +write_artifact( + artifact_type="signal_talent_tech", + path="/signals/talent-tech-{YYYY-MM-DD}", + data={...} +) +``` + +## Available Tools + +``` +adzuna(op="call", args={"method_id": "adzuna.jobs.search_ads.v1", "what": "your technology or role", "where": "us", "results_per_page": 50}) + +adzuna(op="call", args={"method_id": "adzuna.jobs.regional_data.v1", "what": "your technology", "where": "us"}) + +theirstack(op="call", args={"method_id": "theirstack.jobs.search.v1", "job_title_pattern": "role name", "technologies": ["technology"]}) + +theirstack(op="call", args={"method_id": "theirstack.companies.hiring.v1", "technologies": ["technology"], "min_employee_count": 50}) + +coresignal(op="call", args={"method_id": "coresignal.jobs.posts.v1", "query": "technology name", "date_from": "2024-01-01"}) + +coresignal(op="call", args={"method_id": "coresignal.companies.profile.v1", "company_url": "company.com"}) + +github(op="call", args={"method_id": "github.search.repositories.v1", "q": "topic:your-technology language:python", "sort": "stars", "order": "desc"}) + +github(op="call", args={"method_id": "github.search.issues.v1", "q": "your technology is:open label:bug", "sort": "updated"}) + +stackexchange(op="call", args={"method_id": "stackexchange.tags.info.v1", "tags": "your-tag", "site": "stackoverflow"}) + +stackexchange(op="call", args={"method_id": "stackexchange.questions.list.v1", "tagged": "your-tag", "site": "stackoverflow", "sort": "activity"}) + +wikimedia(op="call", args={"method_id": "wikimedia.pageviews.per_article.v1", "article": "Technology_Name", "project": "en.wikipedia.org", "granularity": "monthly", "start": "2024010100", "end": "2024120100"}) +``` diff --git a/flexus_simple_bots/researcher/skills/discovery-recruitment/SKILL.md b/flexus_simple_bots/researcher/skills/discovery-recruitment/SKILL.md index 26e9bdcc..cd89996d 100644 --- a/flexus_simple_bots/researcher/skills/discovery-recruitment/SKILL.md +++ b/flexus_simple_bots/researcher/skills/discovery-recruitment/SKILL.md @@ -18,9 +18,10 @@ Run these stages in order every time: - If key criteria are ambiguous, pause and resolve before fielding. **2. Study type → provider routing** -- **Survey (quantitative, N≥100):** Prolific (explicit filter/quota control, transparent participant-pay), Cint (larger/global volume, stricter setup), MTurk (budget-sensitive, requires stronger requester-managed QA). -- **Interview recruiting (qualitative, B2B):** Prolific screened candidates, then hand off to `discovery-scheduling` skill once validated. For B2B seniority targeting, Cint with job-title filters. -- **Usability testing:** UserTesting (built-in workflow mechanics, plan-tier limits apply). +- **Survey (quantitative, N≥100):** Prolific (self-serve + explicit filters), Cint (enterprise global volume), PureSpectrum (enterprise sample buying), Dynata Demand (enterprise sample), Lucid Marketplace (enterprise marketplace when consultant onboarding is complete), MTurk or Toloka only when cost sensitivity outweighs panel purity. +- **Interview recruiting (qualitative, B2B):** Respondent first for expert / professional recruiting, User Interviews when the target audience already lives in Research Hub, Prolific for screened candidates, Cint or Dynata for harder-to-fill professional cells. +- **Usability testing:** UserTesting first when the account tier is approved; Prolific or Respondent can be fallback recruiting sources if usability execution happens elsewhere. +- **Bring-your-own panel / synced audience:** User Interviews for Hub participant sync and profile management. - Provider switching rule: if feasibility remains poor after one controlled relaxation pass, switch provider instead of repeatedly weakening screening criteria. **3. Feasibility check before launch** @@ -99,6 +100,19 @@ Set compensation aligned to expected burden and time. Underpayment increases low **Consequence:** Unstable reproducibility and hidden source bias. **Mitigation:** Source-level dashboards, caps, and stop-loss rules. +## Provider Notes + +- `Prolific`: strongest self-serve option for transparent participant pay, reusable participant groups, and webhook-friendly study operations. +- `Cint`: enterprise demand-side marketplace with target groups, quota distribution, and async fielding jobs. +- `MTurk`: cheapest broad crowd option; requires the strictest QA, qualification, and notification discipline from the requester. +- `UserTesting`: reviewed-access usability platform; best for built-in UX session workflows and result retrieval after tests are live. +- `User Interviews`: best for Research Hub participant profile sync and managed panel operations, but current public API surface is narrower than the enterprise marketplaces. +- `Respondent`: strongest option for B2B expert recruiting and moderated interview attendance workflow. +- `PureSpectrum`: enterprise buyer API for survey procurement with qualifications, quotas, suppliers, and traffic channels. +- `Dynata`: enterprise option spanning sample demand and respondent exchange; may require separate credential sets for different flows. +- `Lucid`: consultant-led marketplace onboarding; treat as a provisioning-dependent provider until the exact Postman collection is available to the workspace. +- `Toloka`: strong for fast, budget-sensitive validation tasks and crowd-based screening when strict panel provenance is not mandatory. + ## Recording ``` @@ -119,28 +133,42 @@ Before writing any artifact, verify all checks: ## Available Tools ``` -prolific(op="help") -cint(op="help") -mturk(op="help") -usertesting(op="help") +prolific(op="help", args={}) +cint(op="help", args={}) +mturk(op="help", args={}) +usertesting(op="help", args={}) +userinterviews(op="help", args={}) +respondent(op="help", args={}) +purespectrum(op="help", args={}) +dynata(op="help", args={}) +lucid(op="help", args={}) +toloka(op="help", args={}) prolific(op="call", args={"method_id": "prolific.studies.create.v1", "name": "Study Name", "internal_name": "study_id", "description": "...", "external_study_url": "https://...", "prolific_id_option": "url_parameters", "completion_code": "COMPLETE123", "completion_option": "url", "total_available_places": 50, "estimated_completion_time": 15, "reward": 225}) -prolific(op="call", args={"method_id": "prolific.submissions.list.v1", "study_id": "study_id"}) +prolific(op="call", args={"method_id": "prolific.participant_groups.create.v1", "name": "P0 buyers allowlist", "description": "Returning qualified participants"}) -prolific(op="call", args={"method_id": "prolific.submissions.approve.v1", "study_id": "study_id", "submission_ids": ["sub_id1"]}) +cint(op="call", args={"method_id": "cint.projects.feasibility.get.v1", "account_id": "acct_123", "country_code": "US", "language_code": "en", "target_completes": 100}) -cint(op="call", args={"method_id": "cint.projects.feasibility.get.v1", "countryIsoCode": "US", "targetGroupId": "xxx", "quota": 100}) +cint(op="call", args={"method_id": "cint.target_groups.create.v1", "account_id": "acct_123", "project_id": "proj_123", "name": "VP RevOps US", "country_code": "US", "language_code": "en", "target_completes": 25}) -cint(op="call", args={"method_id": "cint.projects.create.v1", "name": "Study Name", "countryIsoCode": "US", "targetGroupId": "xxx", "numberOfCompletes": 100}) +mturk(op="call", args={"method_id": "mturk.hits.create.v1", "title": "Screener survey", "description": "10-minute B2B screener", "reward": "1.50", "max_assignments": 100, "lifetime_in_seconds": 86400, "assignment_duration_in_seconds": 1800, "question": "..."}) -cint(op="call", args={"method_id": "cint.projects.launch.v1", "projectId": "proj_id"}) +mturk(op="call", args={"method_id": "mturk.qualifications.create.v1", "name": "passed_b2b_screener", "description": "Workers who passed the current screener"}) -mturk(op="call", args={"method_id": "mturk.hits.create.v1", "Title": "Task Name", "Description": "...", "Keywords": "survey", "Reward": "0.50", "MaxAssignments": 100, "LifetimeInSeconds": 86400, "AssignmentDurationInSeconds": 1800}) +usertesting(op="call", args={"method_id": "usertesting.tests.sessions.list.v1", "test_id": "test_id"}) -mturk(op="call", args={"method_id": "mturk.assignments.list.v1", "HITId": "hit_id", "AssignmentStatuses": ["Submitted"]}) +userinterviews(op="call", args={"method_id": "userinterviews.participants.create.v1", "email": "buyer@example.com", "name": "Target Buyer", "metadata": {"segment": "revops_midmarket"}}) -usertesting(op="call", args={"method_id": "usertesting.tests.sessions.list.v1", "test_id": "test_id"}) +respondent(op="call", args={"method_id": "respondent.projects.create.v1", "publicTitle": "Revenue operations interviews", "publicDescription": "45-minute moderated interview", "targetNumberOfParticipants": 12, "typeOfResearch": "remote"}) + +respondent(op="call", args={"method_id": "respondent.screener_responses.invite.v1", "project_id": "proj_123", "screener_response_id": "resp_456", "bookingLink": "https://calendar.example/slot"}) + +purespectrum(op="call", args={"method_id": "purespectrum.surveys.create.v1", "survey_title": "Pricing validation", "survey_category_code": "TECH", "survey_localization": "en_US", "completes_required": 200, "expected_ir": 35, "expected_loi": 12, "live_url": "https://survey.example/start", "field_time": 7}) + +dynata(op="call", args={"method_id": "dynata.demand.projects.create.v1", "name": "Mid-market SaaS survey", "country_code": "US", "language_code": "en"}) + +toloka(op="call", args={"method_id": "toloka.projects.create.v1", "public_name": "B2B screener", "public_description": "Short validation survey", "task_spec": {"input_spec": {}, "output_spec": {}, "view_spec": {}}}) ``` Tool-call policy: if runtime help and official mapping disagree, stop and resolve before launch. Do not invent fallback endpoint syntax. Record any unresolved method uncertainty in artifact `limitations`. @@ -177,7 +205,7 @@ Tool-call policy: if runtime help and official mapping disagree, stop and resolv }, "channels": { "type": "array", - "items": {"type": "string", "enum": ["prolific", "cint", "mturk", "usertesting", "internal_panel", "other"]} + "items": {"type": "string", "enum": ["prolific", "cint", "mturk", "usertesting", "userinterviews", "respondent", "purespectrum", "dynata", "lucid", "toloka", "internal_panel", "other"]} }, "inclusion_criteria": {"type": "array", "items": {"type": "string"}}, "exclusion_criteria": {"type": "array", "items": {"type": "string"}}, diff --git a/flexus_simple_bots/strategist/skills/_experiment-analysis/RESEARCH.md b/flexus_simple_bots/strategist/skills/_experiment-analysis/RESEARCH.md new file mode 100644 index 00000000..fa8ddb0e --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_experiment-analysis/RESEARCH.md @@ -0,0 +1,1477 @@ +# Research: experiment-analysis + +**Skill path:** `strategist/skills/experiment-analysis/` +**Bot:** strategist (researcher | strategist | executor) +**Research date:** 2026-03-04 +**Status:** complete + +--- + +## Context + +`experiment-analysis` is the strategist skill for post-run experiment decisioning: read completed results, validate data quality, run statistical interpretation, and output a clear verdict (`ship`, `do_not_ship`, `inconclusive`, `blocked_by_guardrail`). + +The current `SKILL.md` already enforces core discipline (pre-registered sample/date checks, no HARKing, significance + guardrails, structured artifact). This research expands it with 2024-2026 practitioner patterns: decision frameworks (not metric-only readouts), sequential-valid inference for continuous monitoring, Bayesian risk-aware interpretation, SRM-first trust gates, and cross-platform schema grounding from real APIs/docs. + +For sources without explicit publication year, this document treats the referenced vendor docs as "current documentation accessed on 2026-03-04"; older non-current references are explicitly marked as evergreen. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024–2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- [x] No generic filler without concrete backing +- [x] No invented tool names, method IDs, or API endpoints — only verified real ones +- [x] Contradictions between sources are explicitly noted, not silently resolved +- [x] Volume: findings sections are within 800–4000 words combined +- [x] Volume: `Draft Content for SKILL.md` is longer than all Findings sections combined + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +1. **Mature teams now use explicit decision frameworks, not ad-hoc interpretation.** + Modern workflows encode outcomes such as `ship / rollback / review` with predeclared logic that combines primary metrics, guardrails, and quality checks. GrowthBook Decision Framework and Statsig ship decision guidance both reflect this operational style. + Sources: https://docs.growthbook.io/app/experiment-decisions, https://www.statsig.com/updates/update/ship-decision-framework + +2. **A health/trust gate is typically run before any statistical interpretation.** + SRM, assignment integrity, multiple-exposure anomalies, and missing-data checks are treated as blocking conditions in several platforms. This means "analyze first, debug later" is no longer acceptable in robust experimentation programs. + Sources: https://docs.growthbook.io/app/experiment-results, https://docs.geteppo.com/quick-starts/analysis-integration/creating-experiment-analysis/ + +3. **Frequentist practice has shifted from p-value-only to effect-plus-uncertainty reporting.** + LaunchDarkly and Statsig documentation emphasize interval interpretation and effect direction; significance without practical magnitude is treated as insufficient for a product call. + Sources: https://docs.launchdarkly.com/home/experimentation/frequentist-results, https://docs.statsig.com/experiments/interpreting-results/read-results + +4. **Power/MDE readiness is now an operational decision gate.** + Teams increasingly classify outcomes as "underpowered/inconclusive" if precision targets are not met, even when directional trends exist. This directly affects whether a winner call is allowed. + Sources: https://docs.geteppo.com/statistics/sample-size-calculator/mde, https://docs.growthbook.io/app/experiment-decisions + +5. **Multiple testing control is first-class in frequentist production workflows.** + Optimizely describes tiered Benjamini-Hochberg FDR control and warns segmented reads are exploratory; Statsig provides BH methodology docs. This makes correction scope a required interpretation input, not an optional afterthought. + Sources: https://support.optimizely.com/hc/en-us/articles/4410283967245-False-discovery-rate-control, https://docs.statsig.com/stats-engine/methodologies/benjamini-hochberg-procedure + +6. **Sequential monitoring is accepted only with always-valid/corrected inference.** + Vendor guidance converges on: continuous peeking is acceptable only when using sequential methods (e.g., mSPRT/GAVI-style) rather than fixed-horizon p-values. + Sources: https://docs.statsig.com/experiments/advanced-setup/sequential-testing, https://launchdarkly.com/docs/guides/statistical-methodology/methodology-frequentist + +7. **Bayesian analysis in product tools is thresholded and increasingly risk-aware.** + Probability-to-win or probability-to-be-best is commonly shown, but leading practice also surfaces expected loss/downside risk to avoid over-shipping on probability alone. + Sources: https://docs.launchdarkly.com/home/experimentation/bayesian-results, https://docs.growthbook.io/app/experiment-results + +8. **Guardrails are formalized as do-no-harm/non-inferiority constraints.** + Spotify's risk-aware framework and GrowthBook do-no-harm framing both support the same operational pattern: positive primary effect does not overrule materially degraded guardrails. + Sources: https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics, https://arxiv.org/abs/2402.11609, https://docs.growthbook.io/app/experiment-decisions + +9. **Variance reduction (CUPED-family) is now mainstream and changes interpretation quality.** + GrowthBook and Statsig both document and evolve CUPED behavior; this materially impacts uncertainty width and time-to-decision, so analysis output should explicitly record whether VR was used. + Sources: https://blog.growthbook.io/bayesian-model-updates-in-growthbook-3-0/, https://docs.statsig.com/stats-engine/methodologies/cuped/, https://www.statsig.com/updates/update/cuped-for-ratio-metrics + +**Sources:** +- https://docs.growthbook.io/app/experiment-decisions +- https://docs.growthbook.io/app/experiment-results +- https://www.statsig.com/updates/update/ship-decision-framework +- https://docs.statsig.com/experiments/interpreting-results/read-results +- https://docs.statsig.com/experiments/advanced-setup/sequential-testing +- https://docs.statsig.com/stats-engine/methodologies/benjamini-hochberg-procedure +- https://docs.statsig.com/stats-engine/methodologies/cuped/ +- https://www.statsig.com/updates/update/cuped-for-ratio-metrics +- https://docs.launchdarkly.com/home/experimentation/frequentist-results +- https://docs.launchdarkly.com/home/experimentation/bayesian-results +- https://launchdarkly.com/docs/guides/statistical-methodology/methodology-frequentist +- https://support.optimizely.com/hc/en-us/articles/4410283967245-False-discovery-rate-control +- https://docs.geteppo.com/statistics/sample-size-calculator/mde +- https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics +- https://arxiv.org/abs/2402.11609 +- https://blog.growthbook.io/bayesian-model-updates-in-growthbook-3-0/ + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +1. **The de-facto stack is multi-vendor, with no single universal result model.** + Statsig, Optimizely, LaunchDarkly, Amplitude Experiment, GrowthBook, Eppo, PostHog, and VWO all expose experiment analysis, but with different statistical defaults and API surface depth. + +2. **Statsig is strong on sequential + warehouse-native integration.** + Public docs show SPRT/sequential methodology, SRM checks, and API/warehouse export surfaces; some capabilities are plan-gated (notably advanced export paths). + Sources: https://docs.statsig.com/experiments/advanced-setup/sprt, https://docs.statsig.com/experiments/monitoring/srm, https://docs.statsig.com/console-api/experiments, https://docs.statsig.com/experiments/interpreting-results/access-whn + +3. **Optimizely remains strong on mature frequentist engine and FDR controls, with caveats.** + Stats Engine and FDR controls are well documented, but segmented views are explicitly described as outside strict FDR guarantees; event export also has environment/region constraints. + Sources: https://support.optimizely.com/hc/en-us/articles/4410284008461-Stats-Engine, https://support.optimizely.com/hc/en-us/articles/4410283967245-False-discovery-rate-control, https://docs.developers.optimizely.com/experimentation-data/docs/experimentation-events-export + +4. **LaunchDarkly supports both frequentist and Bayesian modes with SRM and CUPED, but API details are version-sensitive.** + Experimentation docs cover methodological options and health checks; experimentation API is documented with beta/versioning constraints in public references. + Sources: https://launchdarkly.com/docs/guides/statistical-methodology/methodology-frequentist, https://launchdarkly.com/docs/guides/statistical-methodology/methodology-bayesian, https://launchdarkly.com/docs/guides/statistical-methodology/sample-ratios, https://launchdarkly.com/docs/api/experiments + +5. **Amplitude Experiment emphasizes sequential inference and broad API family, but result extraction may require composition.** + Docs provide management/evaluation/export APIs and SRM guidance, but users often need to join experiment management metadata with analytics export outputs for full reporting. + Sources: https://amplitude.com/docs/feature-experiment/under-the-hood/experiment-sequential-testing, https://www.docs.developers.amplitude.com/docs/apis/experiment/experiment-management-api, https://amplitude.com/docs/apis/analytics/export, https://amplitude.com/docs/feature-experiment/troubleshooting/sample-ratio-mismatch + +6. **GrowthBook and Eppo are notably explicit in diagnostics and analysis controls.** + GrowthBook documents results, decision criteria, SRM checks, and results API. Eppo documents frequentist/always-valid/Bayesian options, diagnostics, and webhooks/API integration. + Sources: https://docs.growthbook.io/app/experiment-results, https://docs.growthbook.io/api/, https://docs.geteppo.com/statistics/, https://docs.geteppo.com/experiment-analysis/diagnostics/, https://docs.geteppo.com/reference/api + +7. **PostHog and VWO are important alternatives with different strengths.** + PostHog leans heavily Bayesian in documentation and offers experiment API endpoints; VWO SmartStats positioning highlights Bayesian-powered sequential workflows and strong operational guardrails in product messaging/docs. + Sources: https://posthog.com/docs/experiments/statistics, https://posthog.com/docs/api/experiments, https://vwo.com/product-updates/enhanced-vwo-smartstats/, https://vwo.com/blog/new-stats-engine-and-enhanced-vwo-reports/ + +8. **Cross-platform reality: capabilities are often plan-gated and tenant-specific.** + API access breadth, export features, and monitoring depth can vary by plan/region/server setup; this must be represented explicitly in any skill-level tool recommendation. + +**Comparative matrix (high-level):** + +| Tool | Analysis paradigm | Guardrails | Monitoring/alerts | API/export | Noted limitations | +|---|---|---|---|---|---| +| Statsig | Frequentist + sequential (SPRT), CUPED | Supported | SRM, experiment health monitoring | Console API + warehouse-native exports | Some exports/features are plan-gated | +| Optimizely | Frequentist Stats Engine + FDR | Primary/secondary/monitoring structure | SRM/quality checks | APIs + events export | Segmented views not under strict FDR; export constraints by environment | +| LaunchDarkly | Frequentist + Bayesian + CUPED | Release guardrails/metric groups docs | SRM + health checks | Experiments API (versioned/beta nuances) | Endpoint behavior may vary by API version/tenant | +| Amplitude | Sequential-centric experimentation | Secondary/guarding metric usage | SRM troubleshooting and analysis tools | Mgmt API + eval API + analytics export | Full result reconstruction may require joins | +| GrowthBook | Frequentist + Bayesian + CUPEDps | Explicit guardrail handling | SRM and health checks | Results API documented | Some advanced methods/features plan dependent | +| Eppo | Frequentist + always-valid + Bayesian + CUPED++ | Supported | Diagnostics + alerting | REST API + webhooks | Warehouse integration workflow complexity | +| PostHog | Bayesian-first docs + practical result analysis | Secondary metrics pattern | Result interpretation views | Experiment CRUD APIs | Public docs less explicit on dedicated computed-result endpoint | +| VWO | Bayesian-powered sequential SmartStats (+ fixed horizon options) | Guardrail threshold workflows | Experiment vitals style checks | REST + storage integrations | Enterprise gating for some data export paths | + +**Sources:** +- https://docs.statsig.com/experiments/advanced-setup/sprt +- https://docs.statsig.com/experiments/monitoring/srm +- https://docs.statsig.com/console-api/experiments +- https://docs.statsig.com/experiments/interpreting-results/access-whn +- https://support.optimizely.com/hc/en-us/articles/4410284008461-Stats-Engine +- https://support.optimizely.com/hc/en-us/articles/4410283967245-False-discovery-rate-control +- https://docs.developers.optimizely.com/experimentation-data/docs/experimentation-events-export +- https://launchdarkly.com/docs/guides/statistical-methodology/methodology-frequentist +- https://launchdarkly.com/docs/guides/statistical-methodology/methodology-bayesian +- https://launchdarkly.com/docs/guides/statistical-methodology/sample-ratios +- https://launchdarkly.com/docs/api/experiments +- https://amplitude.com/docs/feature-experiment/under-the-hood/experiment-sequential-testing +- https://amplitude.com/docs/feature-experiment/troubleshooting/sample-ratio-mismatch +- https://www.docs.developers.amplitude.com/docs/apis/experiment/experiment-management-api +- https://amplitude.com/docs/apis/analytics/export +- https://docs.growthbook.io/app/experiment-results +- https://docs.growthbook.io/api/ +- https://docs.geteppo.com/statistics/ +- https://docs.geteppo.com/experiment-analysis/diagnostics/ +- https://docs.geteppo.com/reference/api +- https://posthog.com/docs/experiments/statistics +- https://posthog.com/docs/api/experiments +- https://vwo.com/product-updates/enhanced-vwo-smartstats/ +- https://vwo.com/blog/new-stats-engine-and-enhanced-vwo-reports/ + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +1. **p-value is an incompatibility measure, not "probability the hypothesis is true."** + The ASA statement remains foundational (evergreen), and modern product docs reinforce combining significance with effect size and uncertainty. + Sources: https://amstat.org/docs/default-source/amstat-documents/p-valuestatement.pdf (evergreen), https://docs.launchdarkly.com/home/experimentation/frequentist-results + +2. **CI interpretation is operationally central in frequentist decisioning.** + In common product UIs, whether interval spans zero is more decision-useful than p-value alone; practical significance still must be checked separately. + Sources: https://docs.launchdarkly.com/home/experimentation/frequentist-results, https://www.itl.nist.gov/div898/handbook/prc/section2/prc221.htm (evergreen) + +3. **Bayesian interpretation should include probability plus downside risk.** + Probability-to-be-best/beat-control is not enough by itself in multi-variant settings; expected loss/risk improves decision quality. + Sources: https://docs.launchdarkly.com/home/experimentation/bayesian-results, https://docs.launchdarkly.com/home/experimentation/analyze/ + +4. **Power and MDE planning define whether non-significance is interpretable.** + Without planned power, "no significance" often means insufficient information rather than true null effect. + Sources: https://www.optimizely.com/insights/blog/power-analysis-in-fixed-horizon-frequentist-ab-tests/, https://launchdarkly.com/docs/home/experimentation/size + +5. **Practical significance is a separate gate from statistical significance.** + Teams should compare observed effect against business materiality thresholds, not merely against alpha. + Sources: https://www.optimizely.com/insights/blog/power-analysis-in-fixed-horizon-frequentist-ab-tests/, https://amstat.org/docs/default-source/amstat-documents/p-valuestatement.pdf (evergreen) + +6. **Guardrails are interpreted as explicit risk constraints in mature frameworks.** + Spotify/Confidence formulations show that passing primary uplift does not justify shipping if guardrails violate non-inferiority or deterioration thresholds. + Sources: https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics/, https://confidence.spotify.com/blog/better-decisions-with-guardrails, https://arxiv.org/abs/2402.11609 + +7. **Multiple comparisons and segmentation strongly inflate false discoveries without correction.** + Practical docs provide concrete inflation examples; deep slicing should be treated as exploratory unless pre-registered/corrected. + Sources: https://docs.growthbook.io/using/experimentation-problems, https://docs.launchdarkly.com/guides/statistical-methodology/mcc, https://docs.growthbook.io/statistics/multiple-corrections + +8. **SRM is a validity gate, not just another metric.** + Platform guidance converges on halting interpretation until mismatch causes are diagnosed; thresholding differs across ecosystems and should be standardized per org policy. + Sources: https://launchdarkly.com/docs/home/experimentation/health-checks, https://docs.launchdarkly.com/guides/statistical-methodology/sample-ratios, https://developer.harness.io/docs/feature-management-experimentation/experimentation/experiment-results/analyzing-experiment-results/sample-ratio-check, https://www.lukasvermeer.nl/srm/docs/faq/ + +**Practical thresholds & decision rules (source-backed, context-sensitive):** + +| Topic | Common threshold/range | Caveat | +|---|---|---| +| Alpha (`alpha`) | 0.05 is common default | Not universal; set by risk tolerance | +| Power target | 80% commonly recommended | Requires realistic MDE and traffic estimates | +| CI significance heuristic | Interval excludes 0 | Must align with test mode and correction scope | +| Bayesian winner threshold | Often policy-defined (e.g., 90-95%) | No universal value; needs explicit risk policy | +| SRM alerting | Commonly strict (e.g., p<0.01 to p<0.001 ranges across docs) | Thresholds vary by platform/policy | +| Segment readouts | Exploratory unless pre-registered/corrected | High false-positive risk in post-hoc slicing | + +**Sources:** +- https://amstat.org/docs/default-source/amstat-documents/p-valuestatement.pdf (evergreen) +- https://www.itl.nist.gov/div898/handbook/prc/section2/prc221.htm (evergreen) +- https://docs.launchdarkly.com/home/experimentation/frequentist-results +- https://docs.launchdarkly.com/home/experimentation/bayesian-results +- https://docs.launchdarkly.com/home/experimentation/analyze/ +- https://launchdarkly.com/docs/home/experimentation/size +- https://docs.launchdarkly.com/guides/statistical-methodology/mcc +- https://docs.launchdarkly.com/guides/statistical-methodology/sample-ratios +- https://launchdarkly.com/docs/home/experimentation/health-checks +- https://www.optimizely.com/insights/blog/power-analysis-in-fixed-horizon-frequentist-ab-tests/ +- https://docs.growthbook.io/statistics/multiple-corrections +- https://docs.growthbook.io/using/experimentation-problems +- https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics/ +- https://confidence.spotify.com/blog/better-decisions-with-guardrails +- https://arxiv.org/abs/2402.11609 +- https://developer.harness.io/docs/feature-management-experimentation/experimentation/experiment-results/analyzing-experiment-results/sample-ratio-check +- https://www.lukasvermeer.nl/srm/docs/faq/ + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +1. **Peeking/optional stopping remains a top false-positive driver under fixed-horizon methods.** + Continuous checking without sequential-valid inference inflates Type I error and creates fake winners. + Sources: https://optimizely.com/insights/blog/statistics-for-the-internet-age-the-story-behind-optimizelys-new-stats-engine (evergreen), https://www.statsig.com/perspectives/sequential-testing-ab-peek + +2. **Longitudinal "peeking problem 2.0" is distinct from classic optional stopping.** + Open-ended metrics can still break nominal error control unless covariance/information accrual is modeled correctly. + Sources: https://engineering.atspotify.com/2023/07/bringing-sequential-testing-to-experiments-with-longitudinal-data-part-1-the-peeking-problem-2-0 (evergreen), https://engineering.atspotify.com/2023/07/bringing-sequential-testing-to-experiments-with-longitudinal-data-part-2-sequential-testing (evergreen) + +3. **P-hacking/HARKing are governance failures, not just statistical accidents.** + Pre-analysis plans and strict confirmatory vs exploratory separation are practical controls; recent evidence on prevalence is mixed by context. + Sources: https://econpapers.repec.org/paper/zbwi4rdps/101.htm, https://doi.org/10.1287/isre.2024.0872, https://pubmed.ncbi.nlm.nih.gov/15647155/ (evergreen) + +4. **SRM often indicates deep validity issues (assignment, execution, logging, interference).** + Industry case studies show SRM can completely reverse conclusions after root-cause fixes. + Sources: https://exp-platform.com/Documents/2019_KDDFabijanGupchupFuptaOmhoverVermeerDmitriev.pdf (evergreen), https://docs.statsig.com/guides/srm + +5. **Metric definition drift causes contradictory decisions across teams/tools.** + When metric semantics diverge (filters, denominator definitions, stale transformations), statistically "correct" analyses can still be decision-wrong. + Sources: https://www.uber.com/en-GB/blog/umetric/, https://docs.geteppo.com/experiment-analysis/configuration/protocols/ + +6. **Instrumentation/telemetry issues can mimic treatment effects.** + Logging reliability asymmetry can create pseudo-lift or pseudo-regression; A/A monitoring remains an important guardrail. + Sources: https://arxiv.org/abs/1903.12470 (evergreen), https://docs.statsig.com/experiments/types/aa-test, https://www.exp-platform.com/Documents/puzzlingOutcomesInControlledExperiments.pdf (evergreen) + +7. **Novelty/primacy and survivorship can distort early and long-run interpretations.** + Early swings often regress; long-term attrition can bias cohort-level reads if not modeled/diagnosed. + Sources: https://support.optimizely.com/hc/en-us/articles/4410289544589-How-and-why-statistical-significance-changes-over-time-in-Optimizely-Experimentation, https://www.exp-platform.com/Documents/2016%20IEEEBigDataLongRunningControlledExperiments.pdf (evergreen) + +8. **Simpson's paradox and allocation drift can invert aggregate conclusions.** + Segment-weight imbalance over time may reverse observed direction; this requires weighted or epoch-aware interpretation. + Sources: https://support.optimizely.com/hc/en-us/articles/5326213705101-History-of-how-Optimizely-Experimentation-controls-Simpson-s-Paradox-in-experiments-with-Stats-Accelerator-enabled, https://medium.com/homeaway-tech-blog/simpsons-paradox-in-a-b-testing-93af7a2f3307 (evergreen) + +9. **Bad output format is itself an anti-pattern.** + Reports that omit trust gates, predeclared criteria, uncertainty context, and exploratory labeling invite post-hoc cherry-picking. + +**Anti-pattern -> detection -> mitigation:** + +| Anti-pattern | Detection | Mitigation | +|---|---|---| +| Peeking / optional stopping | Frequent interim checks with unstable p-values | Sequential-valid tests + preregistered stopping rules | +| P-hacking | Post-hoc metric switching and threshold chasing | Locked analysis plan + correction policy + audits | +| HARKing | Hypothesis changes after results | Timestamped hypothesis artifacts + explicit exploratory labels | +| SRM ignored | Split imbalance alerts dismissed | Mandatory SRM gate before verdict | +| Metric drift | Conflicting metric values across systems | Versioned metric catalog + owner/governance | +| Instrumentation bugs | Variant-specific logging anomalies | A/A canaries + telemetry parity checks | +| Segment cherry-picking | Post-hoc slices used for release calls | Exploratory label + confirmatory rerun requirement | + +**Sources:** +- https://www.statsig.com/perspectives/sequential-testing-ab-peek +- https://optimizely.com/insights/blog/statistics-for-the-internet-age-the-story-behind-optimizelys-new-stats-engine (evergreen) +- https://engineering.atspotify.com/2023/07/bringing-sequential-testing-to-experiments-with-longitudinal-data-part-1-the-peeking-problem-2-0 (evergreen) +- https://engineering.atspotify.com/2023/07/bringing-sequential-testing-to-experiments-with-longitudinal-data-part-2-sequential-testing (evergreen) +- https://docs.statsig.com/guides/srm +- https://exp-platform.com/Documents/2019_KDDFabijanGupchupFuptaOmhoverVermeerDmitriev.pdf (evergreen) +- https://arxiv.org/abs/1903.12470 (evergreen) +- https://www.exp-platform.com/Documents/puzzlingOutcomesInControlledExperiments.pdf (evergreen) +- https://www.exp-platform.com/Documents/2016%20IEEEBigDataLongRunningControlledExperiments.pdf (evergreen) +- https://www.uber.com/en-GB/blog/umetric/ +- https://docs.geteppo.com/experiment-analysis/configuration/protocols/ +- https://support.optimizely.com/hc/en-us/articles/4410289544589-How-and-why-statistical-significance-changes-over-time-in-Optimizely-Experimentation +- https://support.optimizely.com/hc/en-us/articles/5326213705101-History-of-how-Optimizely-Experimentation-controls-Simpson-s-Paradox-in-experiments-with-Stats-Accelerator-enabled +- https://medium.com/homeaway-tech-blog/simpsons-paradox-in-a-b-testing-93af7a2f3307 (evergreen) +- https://econpapers.repec.org/paper/zbwi4rdps/101.htm +- https://doi.org/10.1287/isre.2024.0872 +- https://pubmed.ncbi.nlm.nih.gov/15647155/ (evergreen) + +--- + +### Angle 5+: Output Schema & Interoperability (domain-specific) +> Canonical experiment result schema grounded in real API/data shapes across tools. + +**Findings:** + +1. **Real result APIs are nested (`experiment -> metrics -> per-variant results`), not flat.** + Optimizely result docs show metric objects containing per-variation maps with sample and variance-related fields. + Source: https://docs.developers.optimizely.com/feature-experimentation/reference/get_experiment_results + +2. **Primary/secondary/monitoring metric roles are often implicit in vendor payloads.** + A canonical schema should make role explicit (e.g., `metric_role`) rather than relying on index position. + Source: https://docs.developers.optimizely.com/feature-experimentation/reference/get_experiment_results + +3. **Frequentist and Bayesian outputs must be represented conditionally.** + Frequentist requires p-value/significance/CI; Bayesian requires posterior probability and risk/loss style fields where available. + Sources: https://docs.statsig.com/experiments/statistical-methods/p-value, https://docs.growthbook.io/app/experiment-results, https://docs.geteppo.com/experiment-analysis/ + +4. **Guardrail and SRM should be first-class structured blocks.** + Both are decision gates in modern workflows and should not be buried in free-text recommendation fields. + Sources: https://docs.growthbook.io/app/experiment-decisions, https://docs.statsig.com/guides/srm/, https://developer.harness.io/docs/feature-management-experimentation/experimentation/experiment-results/analyzing-experiment-results/sample-ratio-check + +5. **Decision metadata must be stored separately from metric calculations.** + Real systems expose explicit outcomes/states; schema should preserve decision traceability (`outcome`, `reason_codes`, `decided_at`, policy context). + Sources: https://docs.developers.optimizely.com/web-experimentation/reference/get_experiment_report, https://docs.growthbook.io/app/experiment-decisions + +6. **Time and freshness fields are necessary for reproducibility.** + Analysis window plus `analyzed_at`/staleness semantics are needed to prevent stale-result misinterpretation. + Sources: https://docs.developers.optimizely.com/feature-experimentation/reference/get_experiment_results, https://docs.developers.optimizely.com/web-experimentation/reference/get_experiment_report + +7. **Interoperable schema standards should be explicit (JSON Schema/OpenAPI + RFC3339).** + This reduces ambiguity in required fields, enums, and date-time formatting for downstream integrations. + Sources: https://spec.openapis.org/oas/v3.1.2.html, https://json-schema.org/draft/2020-12, https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01.html, https://www.rfc-editor.org/rfc/rfc3339 + +**Sources:** +- https://docs.developers.optimizely.com/feature-experimentation/reference/get_experiment_results +- https://docs.developers.optimizely.com/web-experimentation/reference/get_experiment_report +- https://docs.statsig.com/experiments/interpreting-results/read-results +- https://docs.statsig.com/experiments/statistical-methods/p-value +- https://docs.statsig.com/guides/srm/ +- https://docs.growthbook.io/app/experiment-results +- https://docs.growthbook.io/app/experiment-decisions +- https://docs.geteppo.com/experiment-analysis/ +- https://developer.harness.io/docs/feature-management-experimentation/experimentation/experiment-results/analyzing-experiment-results/sample-ratio-check +- https://spec.openapis.org/oas/v3.1.2.html +- https://json-schema.org/draft/2020-12 +- https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01.html +- https://www.rfc-editor.org/rfc/rfc3339 + +--- + +## Synthesis + +The strongest cross-source signal remains a shift from "statistical readout" to "explicit decision system." The 2024-2026 material strengthens this pattern: experiment quality now depends on hard trust gates (SRM and instrumentation integrity), policy-explicit multiplicity handling, and codified guardrail doctrine, not only on p-values or posterior probability. This confirms that `experiment-analysis` should orchestrate a strict pipeline (validity -> inference -> risk -> decision) with machine-checkable blockers. + +Methodologically, there is convergence on three valid paths with different operating assumptions: fixed-horizon frequentist (best efficiency when you do not need continuous looks), sequential-valid frequentist (safe for continuous monitoring with wider intervals at equal sample), and Bayesian risk-aware decisioning (probability + expected loss, not probability alone). The right implementation is not one "best method"; it is an explicit method branch with branch-specific required outputs and prohibition of mixed semantics in one verdict. + +A key contradiction still exists around guardrail multiplicity doctrine. Some ecosystems emphasize broad BH/FDR controls over many decision surfaces; risk-aware decision-rule work (for non-inferiority guardrails) emphasizes not simply alpha-correcting every guardrail while still preserving overall decision power. This must be made explicit as policy in the skill output (`guardrail_multiplicity_policy`) instead of being silently implied. + +Tool/API findings from this pass add practical interoperability constraints: data comes from heterogeneous surfaces (true result endpoints, report-download endpoints, and event-import-only APIs), with strong provider-specific limits and lifecycle semantics (`202`/`204`, rate headers, tenant/plan gates). Therefore schema normalization and provenance capture are mandatory for reproducible analysis in multi-platform stacks. + +Recent uncertainty guidance also reinforces language discipline: "non-significant" is not "no effect," and "high posterior probability" is not automatically "safe to ship." CI/CrI bounds, practical effect thresholds, expected loss, and guardrail veto must be present in every decision packet. This aligns with the anti-pattern evidence: peeking, post-hoc segmentation, HARKing, and ignored SRM remain common failure sources unless explicitly encoded as risk flags and blockers. + +--- + +## Recommendations for SKILL.md + +> Concrete, actionable list of what should change / be added in the SKILL.md based on research. + +- [x] Add a mandatory **Decision Quality Gate** before any winner call: SRM status, assignment integrity, instrumentation parity, metric-definition integrity. +- [x] Split methodology into explicit branches: `frequentist_fixed`, `frequentist_sequential`, `bayesian`; require branch-specific required outputs. +- [x] Require reporting of correction policy: `multiple_testing_method`, `correction_scope`, `alpha_global`, and segmentation doctrine (`confirmatory` vs `exploratory`). +- [x] Add method-aware uncertainty fields: confidence interval or credible interval with explicit `interval_kind`, interval level, and practical threshold relation. +- [x] Add Bayesian risk fields where available: `prob_beats_control`/`prob_best`, `expected_loss`, and `credible_interval`. +- [x] Expand guardrail policy from "check all guardrails" to explicit blocking logic (`non_inferiority_margin`, `degradation_threshold`, `guardrail_block_reason`). +- [x] Upgrade inconclusive guidance: exploratory slices cannot directly justify shipping; they can only generate follow-up confirmatory hypotheses. +- [x] Extend artifact schema with reproducibility metadata: `analysis_method`, `analysis_window`, `analyzed_at`, `result_freshness`, `hypothesis_ref`, `source_provenance`. +- [x] Add anti-pattern risk flags in artifact output: peeking, p-hacking, HARKing, SRM ignored, metric drift, instrumentation mismatch, trigger bias, novelty misread. +- [x] Add explicit `guardrail_multiplicity_policy` to resolve framework contradiction via declared policy (not implied behavior). +- [x] Add a provider-aware `## Available Tools` section with real syntax and rate-limit/error-handling guidance for ingestion surfaces. +- [x] Add standardized decision text template to enforce consistent output language and avoid overclaiming from uncertain results. + +--- + +## Draft Content for SKILL.md + +> Paste-ready SKILL content for each recommendation. This section is intentionally verbose so the editor can cut down without inventing missing logic. + +### Draft: Decision Quality Gate (Mandatory Pre-Verdict Block) + +Before you compute or interpret any winner signal, you must run a Decision Quality Gate. You are not allowed to produce `ship` or `do_not_ship` from raw metric deltas until this gate passes. The purpose is simple: statistical methods can only protect you when the data-generation process is trustworthy. If assignment is broken, exposure logging is inconsistent, or metric definitions drifted during runtime, your p-values and posterior probabilities are formally precise but operationally wrong. + +You must evaluate these checks in order: + +1. **Pre-registration integrity** + - Confirm pre-registered sample-size and decision-date constraints. + - Confirm hypothesis and metric roles were not changed post-launch. + - If scope changed materially (traffic split, variant set, bucketing behavior), mark analysis as contaminated and require restart/new experiment key. + +2. **Traffic/assignment integrity** + - Confirm observed allocation is consistent with designed split. + - Run SRM check (or provider SRM health equivalent). + - If SRM fails and root cause is unresolved, stop and output `blocked_by_guardrail` (or `invalid_for_inference` if your policy supports that state). + +3. **Instrumentation integrity** + - Confirm assignment->exposure funnel parity across variants and key segments (platform/app version/region when available). + - Confirm no variant-specific event loss, delayed ingestion, or schema mismatch. + - If parity fails, block verdict and request instrumentation fix + rerun window. + +4. **Metric-definition integrity** + - Confirm metric filters, denominators, time windows, and inclusion rules match pre-registered definitions. + - Confirm no post-hoc metric substitution for confirmatory verdicts. + +5. **Guardrail readiness** + - Confirm guardrail thresholds are present and measurable before reading primary success metrics. + - If any critical guardrail is missing or stale, block final verdict as `inconclusive`. + +Decision rule for this block: + +- If **any blocker** check fails -> `checklist_passed=false`, do not run winner logic, and output blocker reasons. +- If checks pass with warnings -> continue with explicit warning annotations in final recommendation. +- If all checks pass cleanly -> run method-specific inference branch. + +Source basis: https://docs.statsig.com/experiments/monitoring/srm, https://docs.statsig.com/stats-engine/methodologies/srm-checks, https://www.microsoft.com/en-us/research/group/experimentation-platform-exp/articles/diagnosing-sample-ratio-mismatch-in-a-b-testing/, https://amplitude.com/docs/feature-experiment/troubleshooting/sample-ratio-mismatch + +### Draft: Method Selection and Branching Logic + +You must choose one analysis method branch before statistical interpretation: + +- `frequentist_fixed` +- `frequentist_sequential` +- `bayesian` + +You must not mix branch semantics in one verdict sentence. For example, do not report a frequentist p-value and then justify shipping with a Bayesian probability threshold unless your policy explicitly defines how to combine them. Each branch has its own guarantees, stopping logic, and required fields. + +Use this method selector: + +1. If your decision time is fixed and interim peeking is not allowed -> choose `frequentist_fixed`. +2. If you need continuous monitoring or potential early stop -> choose `frequentist_sequential`. +3. If policy requires probability-of-win plus risk framing -> choose `bayesian`. + +Branch-specific minimum reporting: + +- **frequentist_fixed** + - test family and p-value + - confidence interval + - practical-threshold comparison (MEI/MDE) + - correction method/scope + +- **frequentist_sequential** + - sequential method identifier used by provider (or confidence-sequence semantics) + - anytime-valid p-value or sequential confidence interval + - stopping condition reached + - correction method/scope + +- **bayesian** + - posterior probability metric (`prob_beats_control` or `prob_best`) + - expected loss/downside metric + - credible interval + - prior policy note if informative priors were used + +If branch requirements are incomplete, output `inconclusive` and explain exactly which required fields were missing. This avoids fake certainty from partially observed pipelines. + +Source basis: https://docs.launchdarkly.com/guides/statistical-methodology/methodology-frequentist, https://docs.launchdarkly.com/home/experimentation/bayesian-results, https://docs.statsig.com/experiments/advanced-setup/sequential-testing, https://experienceleague.adobe.com/en/docs/journey-optimizer/using/content-management/content-experiment/technotes/experiment-calculations + +### Draft: Frequentist Fixed-Horizon Analysis Instructions + +When you run `frequentist_fixed`, you must behave as if the analysis endpoint is fixed at design time. You do not repeatedly evaluate significance and stop when numbers look favorable. You run your pre-registered sample/date gate, then compute and interpret once for the decision. + +For binary metrics: +- Use two-proportion z-test (or provider-equivalent frequentist binary test). + +For continuous metrics: +- Use Welch's t-test (or provider-equivalent unequal-variance frequentist test). + +Interpretation requirements: + +1. Report p-value and confidence interval together. +2. State whether interval excludes zero (or another null threshold), but also compare against practical threshold (`MEI`/`MDE`). +3. Never write "`p > alpha` therefore there is no effect." Correct wording: evidence is insufficient for the pre-registered claim at current precision. +4. For multi-metric or multi-variant analysis, apply declared correction and state scope. + +Frequentist fixed decision rule: + +- If primary metric is significant **and** practical threshold is met **and** no guardrail blocker -> `ship`. +- If significance is absent but interval still includes practically meaningful gains and losses -> `inconclusive`. +- If significant harm on primary or guardrails -> `do_not_ship` or `blocked_by_guardrail` depending on role. + +Recommended final sentence pattern: + +`Variant A produced % vs control on (p=, % : [, ]). Under at scope , and with guardrails passing, recommendation is .` + +Source basis: https://docs.launchdarkly.com/home/experimentation/frequentist-results, https://www.optimizely.com/insights/blog/power-analysis-in-fixed-horizon-frequentist-ab-tests/, https://amstat.org/docs/default-source/amstat-documents/p-valuestatement.pdf + +### Draft: Frequentist Sequential Analysis Instructions + +When you run `frequentist_sequential`, you must use a sequential-valid method from the underlying platform or methodology. You cannot simulate sequential safety by repeatedly checking fixed-horizon p-values. If your monitoring cadence is daily or continuous, this branch is preferred for validity, but you must disclose the precision trade-off (intervals are often wider at equal sample vs fixed horizon). + +Execution steps: + +1. Confirm sequential mode was configured before launch (or at least before interpretation). +2. Use provider sequential outputs (for example, sequential p-values or confidence-sequence style intervals). +3. Record stopping reason: + - planned stop reached + - early efficacy stop + - early harm stop + - maximum duration reached without decision +4. Apply multiplicity doctrine consistently with sequential branch. +5. Keep guardrail veto active exactly as in fixed branch. + +Interpretation rule: + +- If sequential evidence threshold reached with practical effect and guardrails pass -> `ship`. +- If sequential harm threshold reached on primary or guardrail -> `do_not_ship` or `blocked_by_guardrail`. +- If maximum monitoring window reached without stable conclusion -> `inconclusive` with explicit uncertainty statement. + +You must include this warning line in the final report: +`Sequential monitoring was used; interval width and stopping behavior reflect anytime-valid inference rather than fixed-horizon precision.` + +Source basis: https://docs.statsig.com/experiments/advanced-setup/sequential-testing, https://docs.growthbook.io/statistics/sequential, https://confidence.spotify.com/blog/smaller-sample-experiments, https://projecteuclid.org/journals/annals-of-statistics/volume-52/issue-6/Time-uniform-central-limit-theory-and-asymptotic-confidence-sequences/10.1214/24-AOS2408.short + +### Draft: Bayesian Analysis with Risk Controls + +When you run `bayesian`, you must report probability and risk together. A high probability-to-win alone is not enough for shipping decisions. You require the combined gate: posterior win signal, downside risk (expected loss), and guardrail safety. + +Required Bayesian outputs: + +- `prob_beats_control` (or `prob_best` for multi-variant) +- `expected_loss` (or closest provider downside proxy) +- `credible_interval` at declared level +- prior policy note: + - default/weakly-informative priors, or + - informative prior with governance record + +Bayesian decision rule: + +- If `prob_beats_control >= ship_probability_threshold` + **and** `expected_loss <= max_expected_loss` + **and** credible interval is consistent with acceptable downside + **and** guardrails pass -> `ship`. + +- If posterior probability is high but expected loss is above threshold -> `inconclusive` or limited rollout with explicit risk note. + +- If guardrails fail non-inferiority/degradation threshold -> `blocked_by_guardrail` even when posterior win signal is strong. + +Informative prior governance: + +- You must pre-register prior source and rationale. +- You must report raw estimate and prior-adjusted estimate side-by-side when possible. +- If prior provenance is missing, downgrade to default prior mode and mark output with governance warning. + +Source basis: https://docs.launchdarkly.com/home/experimentation/bayesian-results, https://posthog.com/docs/experiments/statistics, https://www.statsig.com/blog/informed-bayesian-ab-testing, https://docs.statsig.com/experiments-plus/bayesian + +### Draft: Multiple Testing and Segmentation Policy + +You must define the hypothesis family and correction scope before analysis output. "We corrected p-values" is insufficient unless you specify what family was corrected. + +Required fields: + +- `multiple_testing_method` (for example: `none`, `bonferroni`, `benjamini_hochberg`, `bayesian_fdr`) +- `correction_scope` (`primary_only`, `decision_family`, `all_reported`) +- `alpha_global` (or Bayesian equivalent risk target) +- `segmentation_policy` (`confirmatory_only`, `exploratory_labeled`, `hybrid`) + +Operational policy: + +1. Confirmatory claims must come only from pre-registered scopes. +2. Exploratory slices can generate hypotheses, but cannot directly authorize shipping. +3. If exploratory result is business-critical, require a dedicated confirmatory rerun. +4. Explicitly annotate uncorrected or weakly corrected outputs as exploratory. + +Recommended language: +`Segment-level uplift is exploratory and not used for shipping authorization under current correction scope.` + +Source basis: https://docs.statsig.com/stats-engine/methodologies/benjamini-hochberg-procedure, https://support.optimizely.com/hc/en-us/articles/4410283967245-False-discovery-rate-control, https://docs.launchdarkly.com/guides/statistical-methodology/mcc, https://docs.growthbook.io/statistics/multiple-corrections + +### Draft: Guardrail Doctrine and Blocking Logic + +You must formalize guardrails as hard risk constraints, not as informational side metrics. A positive primary effect does not override a failed critical guardrail. + +Guardrail requirement set: + +- each guardrail has: + - `metric_name` + - `direction` (`higher_is_worse` or `lower_is_worse`) + - `degradation_threshold` + - optional `non_inferiority_margin` + - significance or posterior risk check + +Blocking rule: + +- If any critical guardrail breaches declared threshold under declared uncertainty policy -> `blocked_by_guardrail`. +- If guardrail signal is noisy but risk cannot be ruled out -> `inconclusive` unless policy allows limited, monitored rollout. + +Multiplicity doctrine field: + +- `guardrail_multiplicity_policy` enum: + - `alpha_corrected` + - `beta_corrected_decision_rule` + - `policy_exception_documented` + +You must choose one doctrine and state it in the report. Do not silently mix frameworks. + +Source basis: https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics, https://arxiv.org/abs/2402.11609, https://docs.growthbook.io/app/experiment-decisions, https://support.optimizely.com/hc/en-us/articles/4410283160205-Primary-metrics-secondary-metrics-and-monitoring-goals + +### Draft: Inconclusive Outcome Handling + +When the outcome is inconclusive, you must avoid binary over-interpretation. "Not significant" is not proof of "no effect." Your next step should depend on uncertainty geometry and business context. + +Inconclusive decision tree: + +1. If interval still includes both meaningful gain and meaningful harm: + - keep verdict `inconclusive` + - choose between larger sample, longer runtime, or cleaner targeting/triggering. + +2. If interval excludes meaningful harm but does not show strong gain: + - classify as "safe but unproven" + - consider limited rollout only if business policy allows. + +3. If interval excludes meaningful gain: + - classify as low-priority hypothesis + - archive or redesign based on opportunity cost. + +4. If exploratory segment signal appears: + - do not ship from that signal alone + - register a follow-up confirmatory experiment. + +Required final language: +`Result is inconclusive at current precision and policy thresholds; follow-up path is with required sample/time estimate .` + +Source basis: https://pmc.ncbi.nlm.nih.gov/articles/PMC11049675/, https://pmc.ncbi.nlm.nih.gov/articles/PMC11995452/, https://www.optimizely.com/insights/blog/power-analysis-in-fixed-horizon-frequentist-ab-tests/, https://launchdarkly.com/docs/home/experimentation/size + +### Draft: Available Tools (Real Syntax, Provider-Aware) + +Use internal Flexus tools for policy retrieval and artifact recording. Use provider APIs only for ingestion/reference where upstream data collection is needed and permitted. + +#### Internal tools (primary in-skill interface) + +```python +flexus_policy_document(op="activate", args={"p": "/experiments/{experiment_id}/spec"}) +flexus_policy_document(op="list", args={"p": "/experiments/"}) +write_artifact( + artifact_type="experiment_results", + path="/experiments/{experiment_id}/results", + data={...}, +) +``` + +Internal tool usage guidance: + +- Always activate experiment spec before analysis to lock hypothesis, metric roles, and thresholds. +- If spec is missing or incomplete, return `inconclusive` and request policy completion. +- Write artifact once per final decision packet; include method branch and quality-gate status. + +#### External provider API syntax references (for ingestion/orchestration layers) + +Statsig report URL retrieval: + +```bash +curl -H "STATSIG-API-KEY: $STATSIG_API_KEY" \ + "https://statsigapi.net/console/v1/reports?type=pulse_daily&date=2024-09-01" +``` + +Statsig experiment metadata: + +```bash +curl -H "STATSIG-API-KEY: $STATSIG_API_KEY" \ + "https://statsigapi.net/console/v1/experiments/{experiment_id}" +``` + +GrowthBook experiment results: + +```bash +curl "https://api.growthbook.io/api/v1/experiments/{experiment_id}/results" \ + -u "secret_xxx:" +``` + +GrowthBook snapshot refresh before pull: + +```bash +curl -X POST "https://api.growthbook.io/api/v1/experiments/{experiment_id}/snapshot" \ + -u "secret_xxx:" +``` + +Optimizely Web Experimentation results: + +```bash +curl -H "Authorization: Bearer $OPTIMIZELY_TOKEN" \ + "https://api.optimizely.com/v2/experiments/{experiment_id}/results" +``` + +LaunchDarkly event-data import (for metric event ingestion): + +```bash +curl -X POST \ + "https://events.launchdarkly.com/v2/event-data-import/{projectKey}/{environmentKey}" \ + -H "Authorization: $LD_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '[{"kind":"custom","key":"checkout_completed","contextKeys":{"user":"u-123"},"creationDate":1735689600000}]' +``` + +Amplitude Experiment Evaluation API: + +```bash +curl --request GET \ + "https://api.lab.amplitude.com/v1/vardata?user_id=u-123" \ + --header "Authorization: Api-Key $AMPLITUDE_DEPLOYMENT_KEY" +``` + +Provider reliability notes you must encode in orchestration: + +- GrowthBook public limit: 60 req/minute; handle `429` with backoff. +- Optimizely results endpoint can return `202` (processing) and `204` (no data yet); this is a lifecycle state, not necessarily an error. +- LaunchDarkly import has strict payload and timing constraints; honor `Retry-After` on rate limits. +- Statsig and other providers may have plan/tenant/version constraints; capture API version and provenance in artifact metadata. + +Source basis: https://docs.statsig.com/api-reference/reports/get-reports, https://docs.statsig.com/api-reference/experiments/get-experiment, https://api.growthbook.io/api/v1/openapi.yaml, https://docs.developers.optimizely.com/web-experimentation/reference/get_experiment_results, https://launchdarkly.com/docs/home/metrics/import-events, https://amplitude.com/docs/apis/experiment/experiment-evaluation-api, https://developers.vwo.com/reference/api-rate-limits-1 + +### Draft: Anti-Pattern Warning Blocks + +#### Warning: Peeking / Optional Stopping +- **Signal:** frequent dashboard checks in fixed-horizon mode, with decision made at first favorable point. +- **Consequence:** inflated false positives and unstable winner calls. +- **Mitigation:** either enforce fixed analysis date/sample or move to sequential-valid branch; log stopping rule in artifact. + +#### Warning: Mid-Run Design Changes +- **Signal:** traffic split or variant set changed after launch; sticky bucketing policy changed. +- **Consequence:** contamination/carryover bias; invalid cross-variant comparability. +- **Mitigation:** restart experiment with clean randomization; mark old run as non-confirmatory. + +#### Warning: P-Hacking / Metric Shopping +- **Signal:** post-hoc swapping of primary metric, filters, or windows to obtain significance. +- **Consequence:** high chance of non-replicable false discoveries. +- **Mitigation:** lock analysis contract pre-launch; separate confirmatory and exploratory outputs. + +#### Warning: HARKing +- **Signal:** final narrative hypothesis differs from pre-registered hypothesis without explicit label. +- **Consequence:** misleading confidence and broken causal interpretation discipline. +- **Mitigation:** force "exploratory hypothesis" label and require follow-up confirmatory experiment. + +#### Warning: SRM Ignored +- **Signal:** SRM warning/fail present but result still interpreted as trustworthy. +- **Consequence:** assignment mismatch can invert or magnify observed effects. +- **Mitigation:** SRM failure is a blocker until root cause is resolved and data regenerated if needed. + +#### Warning: Trigger Selection Bias +- **Signal:** trigger condition is influenced by treatment or trigger-rate diverges by variant. +- **Consequence:** post-randomization selection bias. +- **Mitigation:** use pre-treatment trigger criteria; verify trigger parity across variants. + +#### Warning: Overtracking Dilution +- **Signal:** large share of analyzed users cannot be exposed to treatment effect. +- **Consequence:** diluted effect size and large uncertainty; slower decisions. +- **Mitigation:** tighten eligibility/triggering and analyze exposure-relevant population. + +#### Warning: Guardrail Blindness +- **Signal:** primary uplift used to justify shipping despite guardrail degradation. +- **Consequence:** local gain with systemic product risk (latency/churn/cost/reliability). +- **Mitigation:** guardrail veto policy with explicit thresholds and blocking reasons. + +#### Warning: Novelty Misread +- **Signal:** early uplift spike decays rapidly across days-since-exposure. +- **Consequence:** shipping transient novelty instead of durable impact. +- **Mitigation:** inspect temporal stability and, when possible, validate with holdout windows. + +#### Warning: Instrumentation Mismatch +- **Signal:** assignment-to-exposure funnel differs across variants or key segments. +- **Consequence:** pseudo-treatment effect from logging asymmetry. +- **Mitigation:** parity checks, native exposure events where possible, and unresolved mismatch as blocker. + +#### Warning: CUPED Leakage +- **Signal:** covariate likely influenced by treatment or pre-period contamination detected. +- **Consequence:** biased adjusted estimates and false confidence. +- **Mitigation:** only pre-treatment covariates; fallback to non-CUPED estimate when integrity is uncertain. + +Source basis: https://docs.statsig.com/experiments/advanced-setup/sequential-testing, https://docs.growthbook.io/using/experimentation-problems, https://www.microsoft.com/en-us/research/group/experimentation-platform-exp/articles/diagnosing-sample-ratio-mismatch-in-a-b-testing/, https://confidence.spotify.com/blog/trigger-analysis, https://booking.ai/overtracking-and-trigger-analysis-how-to-reduce-sample-sizes-and-increase-the-sensitivity-of-71755bad0e5f, https://www.statsig.com/blog/novelty-effects, https://launchdarkly.com/docs/guides/statistical-methodology/cuped + +### Draft: Artifact Schema Additions (JSON Schema Fragments) + +Use the following schema fragment to replace or extend current `experiment_results`. Every field has explicit descriptions, and all nested objects are closed with `additionalProperties: false` to prevent silent drift. + +```json +{ + "experiment_results": { + "type": "object", + "description": "Canonical decision packet for post-run experiment analysis. Includes quality gates, method-specific inference fields, risk controls, and final recommendation.", + "required": [ + "experiment_id", + "hypothesis_ref", + "analyzed_at", + "analysis_method", + "analysis_window", + "quality_gate", + "decision_policy", + "primary_metric_result", + "guardrail_results", + "multiple_testing", + "anti_pattern_risks", + "verdict", + "recommendation", + "hypothesis_verdict", + "source_provenance", + "result_freshness" + ], + "additionalProperties": false, + "properties": { + "experiment_id": { + "type": "string", + "description": "Stable experiment identifier from the policy document and provider system." + }, + "hypothesis_ref": { + "type": "string", + "description": "Reference to pre-registered hypothesis/version used for this analysis." + }, + "analyzed_at": { + "type": "string", + "format": "date-time", + "description": "RFC3339 timestamp when the decision packet was produced." + }, + "analysis_method": { + "type": "string", + "enum": [ + "frequentist_fixed", + "frequentist_sequential", + "bayesian" + ], + "description": "Inference branch chosen before interpretation." + }, + "analysis_window": { + "type": "object", + "description": "Time window of data included in this analysis.", + "required": [ + "start_at", + "end_at" + ], + "additionalProperties": false, + "properties": { + "start_at": { + "type": "string", + "format": "date-time", + "description": "Inclusive start of analysis data window." + }, + "end_at": { + "type": "string", + "format": "date-time", + "description": "Inclusive end of analysis data window." + } + } + }, + "result_freshness": { + "type": "string", + "enum": [ + "fresh", + "stale", + "unknown" + ], + "description": "Freshness status of source data at analysis time." + }, + "quality_gate": { + "type": "object", + "description": "Mandatory trust-gate checks executed before any winner logic.", + "required": [ + "checklist_passed", + "preregistered_sample_reached", + "preregistered_decision_date_reached", + "metric_definition_integrity", + "srm_status", + "assignment_integrity_status", + "instrumentation_integrity_status", + "blocker_reasons" + ], + "additionalProperties": false, + "properties": { + "checklist_passed": { + "type": "boolean", + "description": "True only if all blocking checks pass." + }, + "preregistered_sample_reached": { + "type": "boolean", + "description": "Whether pre-registered sample requirement was reached." + }, + "preregistered_decision_date_reached": { + "type": "boolean", + "description": "Whether pre-registered decision date/time requirement was reached." + }, + "metric_definition_integrity": { + "type": "boolean", + "description": "Whether metric definitions and filters match the pre-registered contract." + }, + "srm_status": { + "type": "string", + "enum": [ + "pass", + "warning", + "fail", + "not_run" + ], + "description": "Sample Ratio Mismatch check status." + }, + "srm_p_value": { + "type": [ + "number", + "null" + ], + "description": "SRM test p-value when available; null when provider does not expose it." + }, + "assignment_integrity_status": { + "type": "string", + "enum": [ + "pass", + "warning", + "fail" + ], + "description": "Status of assignment and split integrity checks." + }, + "instrumentation_integrity_status": { + "type": "string", + "enum": [ + "pass", + "warning", + "fail" + ], + "description": "Status of event logging and exposure parity checks." + }, + "blocker_reasons": { + "type": "array", + "description": "Human-readable reasons that blocked or nearly blocked interpretation.", + "items": { + "type": "string" + } + } + } + }, + "decision_policy": { + "type": "object", + "description": "Declared statistical and risk policy used for this verdict.", + "required": [ + "alpha_global", + "multiple_testing_method", + "correction_scope", + "segmentation_policy", + "guardrail_multiplicity_policy", + "ship_probability_threshold", + "max_expected_loss", + "decision_rule_version" + ], + "additionalProperties": false, + "properties": { + "alpha_global": { + "type": "number", + "description": "Global frequentist alpha target for confirmatory decisions." + }, + "multiple_testing_method": { + "type": "string", + "enum": [ + "none", + "bonferroni", + "benjamini_hochberg", + "bayesian_fdr" + ], + "description": "Multiplicity method applied to the declared hypothesis family." + }, + "correction_scope": { + "type": "string", + "enum": [ + "primary_only", + "decision_family", + "all_reported" + ], + "description": "Family scope over which multiplicity correction was applied." + }, + "segmentation_policy": { + "type": "string", + "enum": [ + "confirmatory_only", + "exploratory_labeled", + "hybrid" + ], + "description": "Policy for using segment-level results in decisioning." + }, + "guardrail_multiplicity_policy": { + "type": "string", + "enum": [ + "alpha_corrected", + "beta_corrected_decision_rule", + "policy_exception_documented" + ], + "description": "Declared doctrine for guardrail multiplicity handling." + }, + "ship_probability_threshold": { + "type": "number", + "description": "Bayesian minimum posterior probability threshold for shipping when Bayesian branch is used." + }, + "max_expected_loss": { + "type": "number", + "description": "Maximum tolerated expected downside for Bayesian shipping decisions." + }, + "decision_rule_version": { + "type": "string", + "description": "Version identifier of the policy rule set used to produce this verdict." + } + } + }, + "primary_metric_result": { + "type": "object", + "description": "Primary metric effect and uncertainty for decisioning.", + "required": [ + "metric_name", + "control_value", + "variant_values", + "relative_lift", + "interval_kind", + "interval_level", + "interval_low", + "interval_high", + "practical_threshold", + "practical_threshold_met", + "p_value", + "is_significant" + ], + "additionalProperties": false, + "properties": { + "metric_name": { + "type": "string", + "description": "Canonical primary metric identifier." + }, + "control_value": { + "type": "number", + "description": "Observed control value in analysis window." + }, + "variant_values": { + "type": "object", + "description": "Map from variant key to observed metric value.", + "additionalProperties": { + "type": "number" + } + }, + "relative_lift": { + "type": "number", + "description": "Relative effect size of selected variant vs control (fractional units)." + }, + "interval_kind": { + "type": "string", + "enum": [ + "confidence_interval", + "credible_interval", + "confidence_sequence" + ], + "description": "Type of uncertainty interval reported for this metric." + }, + "interval_level": { + "type": "number", + "description": "Interval confidence/credibility level in [0,1], for example 0.95." + }, + "interval_low": { + "type": "number", + "description": "Lower bound of uncertainty interval on effect scale." + }, + "interval_high": { + "type": "number", + "description": "Upper bound of uncertainty interval on effect scale." + }, + "practical_threshold": { + "type": "number", + "description": "Minimum practical effect threshold (MEI/MDE-aligned) used in decisioning." + }, + "practical_threshold_met": { + "type": "boolean", + "description": "Whether observed effect and uncertainty satisfy practical threshold requirement." + }, + "p_value": { + "type": [ + "number", + "null" + ], + "description": "Frequentist p-value when applicable; null for pure Bayesian outputs." + }, + "is_significant": { + "type": [ + "boolean", + "null" + ], + "description": "Frequentist significance flag under declared correction policy; null when not applicable." + } + } + }, + "guardrail_results": { + "type": "array", + "description": "Per-guardrail risk outcomes and blocking status.", + "items": { + "type": "object", + "required": [ + "metric_name", + "direction", + "degradation_threshold", + "is_degraded", + "p_value", + "non_inferiority_margin", + "blocked" + ], + "additionalProperties": false, + "properties": { + "metric_name": { + "type": "string", + "description": "Guardrail metric identifier." + }, + "direction": { + "type": "string", + "enum": [ + "higher_is_worse", + "lower_is_worse" + ], + "description": "Direction that constitutes degradation for this guardrail." + }, + "degradation_threshold": { + "type": "number", + "description": "Absolute or relative threshold beyond which degradation is considered unacceptable." + }, + "is_degraded": { + "type": "boolean", + "description": "Whether this guardrail currently indicates unacceptable deterioration." + }, + "p_value": { + "type": [ + "number", + "null" + ], + "description": "Frequentist p-value for degradation test when available." + }, + "non_inferiority_margin": { + "type": [ + "number", + "null" + ], + "description": "Non-inferiority margin used for this guardrail when policy applies." + }, + "blocked": { + "type": "boolean", + "description": "True when this guardrail alone is sufficient to block shipping." + } + } + } + }, + "multiple_testing": { + "type": "object", + "description": "Correction metadata applied to family-wise interpretation.", + "required": [ + "method", + "scope", + "hypothesis_count", + "adjusted_alpha" + ], + "additionalProperties": false, + "properties": { + "method": { + "type": "string", + "description": "Applied multiplicity correction method." + }, + "scope": { + "type": "string", + "description": "Declared family scope used for correction." + }, + "hypothesis_count": { + "type": "integer", + "minimum": 1, + "description": "Number of hypotheses included in correction family." + }, + "adjusted_alpha": { + "type": [ + "number", + "null" + ], + "description": "Adjusted alpha used for frequentist interpretation; null if not applicable." + } + } + }, + "bayesian_summary": { + "type": [ + "object", + "null" + ], + "description": "Bayesian uncertainty and risk summary. Required when analysis_method is bayesian.", + "required": [ + "prob_beats_control", + "prob_best", + "expected_loss", + "credible_interval_low", + "credible_interval_high", + "prior_policy" + ], + "additionalProperties": false, + "properties": { + "prob_beats_control": { + "type": "number", + "description": "Posterior probability that selected variant outperforms control." + }, + "prob_best": { + "type": [ + "number", + "null" + ], + "description": "Posterior probability that selected variant is best among all variants." + }, + "expected_loss": { + "type": "number", + "description": "Expected downside from shipping selected variant." + }, + "credible_interval_low": { + "type": "number", + "description": "Lower bound of Bayesian credible interval on effect scale." + }, + "credible_interval_high": { + "type": "number", + "description": "Upper bound of Bayesian credible interval on effect scale." + }, + "prior_policy": { + "type": "string", + "enum": [ + "default_prior", + "weakly_informative", + "informative_pre_registered" + ], + "description": "Prior policy used for Bayesian estimation." + } + } + }, + "anti_pattern_risks": { + "type": "object", + "description": "Structured anti-pattern risk flags observed during analysis.", + "required": [ + "peeking", + "p_hacking", + "harking", + "srm_ignored", + "metric_drift", + "instrumentation_mismatch", + "trigger_selection_bias", + "novelty_misread" + ], + "additionalProperties": false, + "properties": { + "peeking": { "$ref": "#/$defs/riskFlag" }, + "p_hacking": { "$ref": "#/$defs/riskFlag" }, + "harking": { "$ref": "#/$defs/riskFlag" }, + "srm_ignored": { "$ref": "#/$defs/riskFlag" }, + "metric_drift": { "$ref": "#/$defs/riskFlag" }, + "instrumentation_mismatch": { "$ref": "#/$defs/riskFlag" }, + "trigger_selection_bias": { "$ref": "#/$defs/riskFlag" }, + "novelty_misread": { "$ref": "#/$defs/riskFlag" } + } + }, + "verdict": { + "type": "string", + "enum": [ + "ship", + "do_not_ship", + "inconclusive", + "blocked_by_guardrail" + ], + "description": "Final decision outcome under declared policy." + }, + "recommendation": { + "type": "string", + "description": "Human-readable recommendation sentence suitable for stakeholders." + }, + "hypothesis_verdict": { + "type": "string", + "enum": [ + "validated", + "rejected", + "inconclusive" + ], + "description": "Hypothesis-level interpretation under declared method and policy." + }, + "next_action": { + "type": "object", + "description": "Structured follow-up plan for inconclusive or blocked outcomes.", + "required": [ + "action_type", + "rationale" + ], + "additionalProperties": false, + "properties": { + "action_type": { + "type": "string", + "enum": [ + "ship", + "rollback", + "rerun_larger_sample", + "confirmatory_followup", + "instrumentation_fix_then_rerun", + "archive_hypothesis" + ], + "description": "Operational next step after this analysis." + }, + "rationale": { + "type": "string", + "description": "Concise reason this next action is required." + } + } + }, + "source_provenance": { + "type": "object", + "description": "Provider and endpoint lineage for reproducibility.", + "required": [ + "platform", + "endpoint", + "retrieved_at" + ], + "additionalProperties": false, + "properties": { + "platform": { + "type": "string", + "description": "Source platform identifier (for example statsig, optimizely, growthbook, launchdarkly)." + }, + "endpoint": { + "type": "string", + "description": "API endpoint or report source used to fetch analysis inputs." + }, + "retrieved_at": { + "type": "string", + "format": "date-time", + "description": "RFC3339 timestamp when source data was retrieved." + }, + "api_version": { + "type": [ + "string", + "null" + ], + "description": "Version header or API revision used when available." + } + } + } + }, + "$defs": { + "riskFlag": { + "type": "object", + "description": "Risk-flag object for a specific anti-pattern.", + "required": [ + "level", + "signal", + "consequence", + "mitigation" + ], + "additionalProperties": false, + "properties": { + "level": { + "type": "string", + "enum": [ + "none", + "watch", + "high", + "blocker" + ], + "description": "Severity level for this anti-pattern in current run." + }, + "signal": { + "type": "string", + "description": "Observed signal indicating this anti-pattern may be present." + }, + "consequence": { + "type": "string", + "description": "Decision-quality consequence if the anti-pattern is ignored." + }, + "mitigation": { + "type": "string", + "description": "Immediate mitigation action required before shipping." + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "analysis_method": { "const": "bayesian" } + } + }, + "then": { + "required": [ "bayesian_summary" ] + } + }, + { + "if": { + "properties": { + "analysis_method": { + "enum": [ + "frequentist_fixed", + "frequentist_sequential" + ] + } + } + }, + "then": { + "properties": { + "bayesian_summary": { "type": "null" } + } + } + } + ] + } +} +``` + +Schema usage guidance: + +- You should keep `additionalProperties: false` to force explicit schema evolution. +- You should require `source_provenance` so future re-analysis can reproduce exact input lineage. +- You should keep anti-pattern risk fields as structured objects, not plain booleans, so remediation logic is auditable. +- You should use `allOf` method conditionals to prevent partially mixed branch payloads. + +Source basis: https://json-schema.org/draft/2020-12, https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01.html, https://spec.openapis.org/oas/v3.1.2.html, https://www.rfc-editor.org/rfc/rfc3339 + +### Draft: Final Decision Output Text Template + +Use this exact narrative pattern in your recommendation block to avoid overclaiming: + +1. **Validity statement** + - `Decision quality gate: ; blockers: .` + +2. **Method statement** + - `Analysis method: ; multiplicity: .` + +3. **Primary effect statement** + - Frequentist example: + - `Primary metric : lift % vs control (p=

, % [, ]).` + - Bayesian example: + - `Primary metric : P(variant>control)=

, expected loss=, % credible interval [, ].` + +4. **Guardrail statement** + - `Guardrails: ; blocking metric(s): .` + +5. **Verdict and next action** + - `Verdict: .` + - `Next action: because .` + +If outcome is inconclusive, you must include concrete next-step sizing guidance (sample increase or follow-up design) and explicitly mark any segment insight as exploratory unless pre-registered. + +Source basis: https://pmc.ncbi.nlm.nih.gov/articles/PMC11996237/, https://pmc.ncbi.nlm.nih.gov/articles/PMC11995452/, https://docs.launchdarkly.com/home/experimentation/analyze/ + +--- + +## Gaps & Uncertainties + +- Pricing and plan entitlements are highly variable by contract/region/tenant; this research captures capability presence and known gating patterns, not definitive commercial matrices. +- Public docs for some vendors are versioned/beta/fragmented; endpoint availability can differ from tenant reality and requires environment-level validation. +- Guardrail multiplicity doctrine is not standardized across ecosystems; teams must select and codify one policy rather than mixing assumptions. +- SRM threshold recommendations vary (`p<0.01` vs `p<0.001` style strictness). A single global threshold cannot be justified without organization-specific false-alarm tolerance. +- Evidence on p-hacking prevalence is mixed in newer studies; risk controls remain necessary even when a specific platform study reports low observed manipulation. +- Several high-value methodological papers are preprints (2024-2025) and should be treated as directional until peer-reviewed or adopted in vendor production docs. +- Some provider docs expose endpoint behavior but not complete hard numeric rate limits; integration layers should treat `429`/retry headers as source of truth at runtime. diff --git a/flexus_simple_bots/strategist/skills/_experiment-analysis/SKILL.md b/flexus_simple_bots/strategist/skills/_experiment-analysis/SKILL.md new file mode 100644 index 00000000..a8ff6b4d --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_experiment-analysis/SKILL.md @@ -0,0 +1,61 @@ +--- +name: experiment-analysis +description: Experiment results analysis — statistical significance testing, metric impact assessment, and winner/loser determination +--- + +You analyze completed experiment results and render a statistically sound verdict. Never call a winner before reaching pre-registered sample size and significance. Never HARKing (Hypothesizing After Results are Known) — the success criteria were set before launch and cannot change now. + +Core mode: discipline over speed. The most common experiment analysis error is peeking at results early and stopping when you see a trend. Check your pre-registered decision date, not the live dashboard. + +## Methodology + +### Pre-analysis checklist +Before analysis: +- [ ] Have we reached the pre-registered sample size? +- [ ] Have we reached the pre-registered decision date? +- [ ] Are the metrics being measured the same as those in the experiment spec? +- [ ] Is the traffic split as designed (no imbalance)? + +If any item is "no," do not analyze — wait or document why you're deviating. + +### Statistical significance testing +For binary metrics (conversion rate): +- Use two-proportion z-test +- Calculate p-value for primary metric +- Result is significant if p < α (from experiment spec, typically 0.05) + +For continuous metrics (revenue, time-on-site): +- Use Welch's t-test + +For Bayesian experiments: +- Report posterior probability that variant beats control + +### Guardrail checks +Even if primary metric is significant positive, check ALL guardrail metrics: +- If any guardrail metric shows significant degradation → do not ship variant +- Document which guardrail triggered the block + +### Reporting format +- State the result clearly: "Variant A produced X% lift in [metric] vs. control (p=Y, 95% CI: [Z, W])" +- State the recommendation: Ship / Don't ship / Run follow-up +- State the hypothesis verdict: Validated / Rejected / Inconclusive + +### What to do with inconclusive results +Inconclusive (p > 0.05 with full sample): hypothesis is neither confirmed nor rejected. +Next action options: +1. Archive the hypothesis (move on — inconclusive at this scale means the effect, if real, is too small to matter) +2. Run a larger test (only if MDE was set too high and you have reason to expect a smaller true effect) +3. Segment the results (did any sub-segment show a signal?) + +## Recording + +``` +write_artifact(path="/experiments/{experiment_id}/results", data={...}) +``` + +## Available Tools + +``` +flexus_policy_document(op="activate", args={"p": "/experiments/{experiment_id}/spec"}) +flexus_policy_document(op="list", args={"p": "/experiments/"}) +``` diff --git a/flexus_simple_bots/strategist/skills/_experiment-design/RESEARCH.md b/flexus_simple_bots/strategist/skills/_experiment-design/RESEARCH.md new file mode 100644 index 00000000..74d77417 --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_experiment-design/RESEARCH.md @@ -0,0 +1,1083 @@ +# Research: experiment-design + +**Skill path:** `strategist/skills/experiment-design/` +**Bot:** strategist (researcher | strategist | executor) +**Research date:** 2026-03-04 +**Status:** complete + +--- + +## Context + +`experiment-design` defines the pre-launch contract for a single experiment: control and variants, success and guardrail metrics, sample size, instrumentation, and decision timeline. The current `SKILL.md` already emphasizes pre-registration and warns against post-hoc decisions. + +2024-2026 practice confirms that this is the right core principle, but it also shows that strong experiment specs now need explicit decision-rule semantics, quality gates (for example SRM and assignment/exposure integrity), and explicit statistical regime choice (fixed-horizon vs sequential frequentist vs Bayesian). Tooling has also shifted toward hybrid feature-flag + warehouse-native workflows, which creates stronger requirements for identity contracts and event schema correctness. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024–2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- Concrete source-backed findings are used; no generic filler statements. +- Tool names, endpoints, and methods are only included when verified in source docs. +- Contradictions are documented explicitly (methodology and interpretation sections). +- Findings sections are within target depth (approx. 2,000+ words combined; inside 800-4000 range). + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +Modern teams treat experiment design as a decision-system design problem, not just a hypothesis document. A 2024 Spotify methodology formalizes a single ship/no-ship decision rule across metric roles (success metrics, guardrails, and quality checks), which reduces ad-hoc interpretation at readout time. This is directly aligned with the skill's pre-registration principle and suggests the skill should require explicit decision-rule syntax, not only metric lists. + +Pre-registration has become operationally specific: teams are expected to lock hypotheses, metric definitions, exclusion rules, and stop/decision criteria before outcome inspection. OSF guidance remains a practical checklist source for this lock process, even outside academic domains, because it forces explicit if/then contingencies and analysis choices ahead of data. + +Power planning practice in product experimentation has become more scenario-based: instead of one MDE guess, practitioners model multiple MDE/runtime scenarios, then pick a business-feasible operating point. Statsig, GrowthBook, and Eppo guidance all reinforce this operational framing and emphasize population-matched assumptions (variance and baseline must match the actual target population). + +Metric hierarchy is now treated as a design constraint. Optimizely and Statsig documentation make clear that too many secondary/monitoring metrics increase multiplicity burden and slow useful decisioning. Practical best practice is one primary decision metric (or a very small set), explicit guardrails with predeclared thresholds, and limited exploratory metrics. + +Randomization design has become first-class in specs: randomization unit selection (user/device/account/company), stable assignment policy, and no mid-test reassignment without reset logic. This is consistently emphasized in platform best-practice docs because invalid assignment policy can break causal claims even if significance appears strong. + +SRM is not just a "nice to check" diagnostic anymore. Platform and industry references position SRM as a hard quality gate that can fully invalidate readout interpretation if triggered, requiring diagnosis before any shipping decision. + +A practical 2024 update is pre-registered interim analysis design (PRIAD), which aims to keep confirmatory rigor while reducing data collection costs in suitable settings. While not universal for all product experiments, it indicates a broader trend: teams are moving from rigid single-look workflows toward preplanned adaptive workflows. + +**Sources:** +- [Spotify engineering: risk-aware multi-metric decisions (2024)](https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics) +- [Risk-aware decision rules paper (2024)](https://arxiv.org/abs/2402.11609) +- [OSF pre-registration templates (updated 2026)](https://help.osf.io/article/229-select-a-registration-template) +- [Eppo analysis plans](https://docs.geteppo.com/experiment-analysis/configuration/analysis-plans/) (Evergreen) +- [Optimizely metric roles](https://support.optimizely.com/hc/en-us/articles/4410283160205-Primary-metrics-secondary-metrics-and-monitoring-goals) (Evergreen) +- [Statsig experiment setup docs](https://docs.statsig.com/experiments/create-new) (Evergreen) +- [Statsig power analysis docs](https://docs.statsig.com/experiments/power-analysis) (Evergreen) +- [GrowthBook power guidance](https://docs.growthbook.io/statistics/power) (Evergreen) +- [Eppo SRM guidance](https://docs.geteppo.com/statistics/sample-ratio-mismatch) (Evergreen) +- [PRIAD article metadata (2024)](https://ideas.repec.org/a/oup/jconrs/v51y2024i4p845-865..html) + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +The 2024-2026 tooling landscape converges around two architecture families: (1) integrated feature-flag + experimentation suites (LaunchDarkly, Statsig, Optimizely FE, PostHog, Amplitude) and (2) warehouse-native experimentation layers (GrowthBook, Eppo, Statsig Warehouse Native). Both require a strong event/identity contract to produce valid results. + +LaunchDarkly provides experimentation APIs and published statistical methodology docs, but important API details are plan-gated and some experiment API surfaces are marked beta. It publishes 429 handling guidance and retry headers, but does not publish numeric API limits publicly; orchestration logic should therefore assume conservative backoff. + +Statsig exposes a concrete Console API surface for experiments (`/console/v1/experiments` family), with explicit auth/version headers and documented mutation throttles. It also provides rich warehouse-native modes and explicit Segment/warehouse integration docs, making it strong for teams needing both product-native and warehouse-native patterns. + +GrowthBook provides explicit API base/auth conventions and publishes a concrete 429 limit in docs (`60 requests/min` for the management API docs). It is strongly warehouse-centric and has very concrete assignment/exposure data-shape expectations (`user_id`, `timestamp`, `experiment_id`, `variation_id` style fields). This is valuable for schema design in `experiment_spec`. + +Amplitude Experiment has a clear split between management APIs and evaluation APIs, with explicit management API rate limits and region endpoint conventions. It also documents key separation between deployment/evaluation keys and management keys. This supports robust governance if encoded in skill guidance. + +PostHog documentation is unusually explicit about API classes and experimentation statistics docs (frequentist and Bayesian), including exposure-event expectations. It is operationally attractive for teams wanting one integrated product analytics + experimentation stack, but still requires strict exposure instrumentation discipline. + +Optimizely FE has mature experimentation/stats support, but API usability includes caveats (for example FE "List Experiments" behavior differences and beta holdout surfaces). This means generated plans should include endpoint-level validation steps, not assume uniform API parity across product areas. + +Eppo emphasizes warehouse-native governance and local-evaluation SDK patterns, with strong warehouse setup docs (BigQuery/Snowflake) and API key separation guidance. Public pricing detail is less explicit than some competitors in reviewed docs. + +Cross-tool integration pattern: CDPs/warehouses (Segment, BigQuery, Snowflake) are now common and require strict identity mapping (anonymous/user/custom IDs). Missing or inconsistent identity contracts are one of the most common hidden failure sources. + +**Sources:** +- [LaunchDarkly experiments API](https://launchdarkly.com/docs/api/experiments) +- [LaunchDarkly REST API guide](https://docs.launchdarkly.com/guides/api/rest-api/) +- [LaunchDarkly 429 guidance](https://support.launchdarkly.com/hc/en-us/articles/22328238491803-Error-429-Too-Many-Requests-API-Rate-Limit) +- [LaunchDarkly Bayesian methodology](https://launchdarkly.com/docs/guides/statistical-methodology/methodology-bayesian) +- [Statsig Console API overview](https://docs.statsig.com/console-api/experiments) +- [Statsig experiments API reference](https://docs.statsig.com/api-reference/experiments/list-experiments) +- [Statsig warehouse native experiment config](https://docs.statsig.com/statsig-warehouse-native/features/configure-an-experiment/) +- [Statsig Segment integration](https://docs.statsig.com/integrations/data-connectors/segment) +- [GrowthBook API docs](https://docs.growthbook.io/api/) +- [GrowthBook event forwarder contract](https://docs.growthbook.io/app/event-forwarder) +- [GrowthBook data sources schema expectations](https://docs.growthbook.io/app/datasources/) +- [GrowthBook pricing](https://www.growthbook.io/pricing) +- [Amplitude Experiment management API (updated 2026)](https://amplitude.com/docs/apis/experiment/experiment-management-api) +- [Amplitude evaluation API](https://amplitude.com/docs/apis/experiment/experiment-evaluation-api) +- [PostHog API docs](https://posthog.com/docs/api) +- [PostHog experiments exposure docs](https://posthog.com/docs/experiments/exposures) +- [PostHog frequentist methodology](https://posthog.com/docs/experiments/statistics-frequentist) +- [Optimizely FE API reference example](https://docs.developers.optimizely.com/feature-experimentation/reference/list_experiments) +- [Optimizely FE holdout endpoint](https://docs.developers.optimizely.com/feature-experimentation/reference/create_holdout) +- [Eppo warehouse connectors](https://docs.geteppo.com/data-management/connecting-dwh/bigquery) +- [Eppo REST API reference](https://docs.geteppo.com/reference/api/) + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +The largest interpretation error source remains regime confusion: teams mix fixed-horizon rules, sequential frequentist logic, and Bayesian interpretation in the same decision flow. Current vendor methodology docs are clear that these regimes have different guarantees and stopping semantics. The skill should require explicit regime selection before launch. + +Fixed-horizon frequentist tests require predeclared stopping points for valid nominal error control. If teams continuously peek and stop on first significance without correction, false positives can increase materially. Sequential frequentist methods are designed for continuous monitoring but may trade off precision or power under some scenarios. + +Baseline defaults in current practice remain stable (alpha near 0.05, power near 0.80, confidence near 95%), but 2024-2026 docs stress that these are defaults, not universal truth. High-stakes experiments may justify stricter settings with explicit runtime cost acceptance. + +MDE selection is a business-statistics tradeoff, not a pure statistical preference. In practice, halving target effect size usually requires roughly 4x data/time. Teams that ignore this relationship either run indefinitely or interpret underpowered negative results incorrectly. + +SRM and assignment-quality checks are core signal-validity gates. Platform docs expose concrete threshold policies (for example, strict chi-square thresholds or alert-tier logic) and recommend stopping interpretation until diagnosis if SRM/crossover anomalies appear. + +Multiplicity from many metrics or many segments is still a frequent source of false discovery. Current docs and support content recommend explicit correction policies (FDR/FWER families) and predeclared segment analyses. Segment "wins" without multiplicity control should be treated as exploratory. + +CUPED/CUPAC-style variance reduction remains high-impact but has eligibility assumptions: enough pre-period data, valid pre-treatment covariates, and no treatment leakage into predictors. Teams that treat CUPED/CUPAC as universal can introduce hidden bias. + +Temporal dynamics matter: novelty effects and seasonality can create early uplift that decays. Practical guidance repeatedly recommends running at least one full business cycle and inspecting time-based stability before final ship/no-ship calls. + +**Sources:** +- [LaunchDarkly choosing methodology](https://docs.launchdarkly.com/guides/statistical-methodology/choosing) +- [LaunchDarkly frequentist methodology](https://docs.launchdarkly.com/guides/statistical-methodology/methodology-frequentist) +- [LaunchDarkly sample size calculator guidance](https://docs.launchdarkly.com/guides/experimentation/sample-size-calc) +- [Eppo analysis methods](https://docs.geteppo.com/statistics/confidence-intervals/analysis-methods/) +- [Eppo analysis plans](https://docs.geteppo.com/experiment-analysis/configuration/analysis-plans/) +- [Statsig power analysis](https://docs.statsig.com/experiments/power-analysis) +- [Statsig differential impact detection](https://docs.statsig.com/experiments-plus/differential-impact-detection) +- [Statsig CUPED methodology](https://docs.statsig.com/stats-engine/methodologies/cuped/) +- [Eppo multiple testing](https://docs.geteppo.com/statistics/multiple-testing/) +- [Eppo SRM docs](https://docs.geteppo.com/statistics/sample-ratio-mismatch/) +- [PostHog Bayesian methodology docs](https://posthog.com/docs/experiments/statistics-bayesian) +- [Optimizely significance and time behavior](https://support.optimizely.com/hc/en-us/articles/4410289544589-How-and-why-statistical-significance-changes-over-time) +- [Always-valid inference (Evergreen, foundational)](https://arxiv.org/abs/1512.04922) +- [Statsig novelty effects (2024)](https://www.statsig.com/blog/novelty-effects) + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +**1) SRM ignored at readout time** +- Detection signal: realized split diverges from expected allocation, often with segment/time asymmetry. +- Prevention control: hard-stop rule for interpretation; mandatory SRM root-cause analysis first. +- Bad output: "Variant is significant, ship." +- Good output: "SRM failed; inference invalid pending assignment diagnostics." + +**2) Assignment-exposure telemetry mismatch** +- Detection signal: assignment logs indicate one variant but exposure/render events do not align. +- Prevention control: assignment-vs-exposure reconciliation and A/A telemetry validation before launch. +- Bad output: "Cookie assignment is enough evidence." +- Good output: "Exposure pipeline mismatch detected; pause decision." + +**3) Optional stopping without compatible statistics** +- Detection signal: frequent peeking and stop-on-first-significance behavior. +- Prevention control: either strict fixed-horizon stop or true sequential method chosen in advance. +- Bad output: "p<0.05 on day 2, done." +- Good output: "Decision follows predeclared stopping framework." + +**4) Post-hoc hypothesis switching / metric cherry-picking** +- Detection signal: primary metric changes after seeing data; only favorable slices reported. +- Prevention control: immutable preregistration log and separation of confirmatory vs exploratory claims. +- Bad output: "Primary flat, but one secondary positive, so winner." +- Good output: "Primary decision failed; exploratory leads are follow-up hypotheses." + +**5) Underpowered design interpreted as negative evidence** +- Detection signal: wide intervals, low exposure, and binary no-effect conclusions. +- Prevention control: prospective power/MDE checks and explicit "inconclusive" state in readout schema. +- Bad output: "No significance means no effect." +- Good output: "Result inconclusive at planned sensitivity." + +**6) Interference and contamination ignored (marketplaces/networks)** +- Detection signal: estimates shift substantially between individual vs cluster/two-sided analyses. +- Prevention control: interference risk screening and alternative randomization strategies where needed. +- Bad output: "User-level randomization always works." +- Good output: "Design adjusted due to spillover risk." + +**7) Novelty spikes mistaken for durable impact** +- Detection signal: large early uplifts decay over days since exposure. +- Prevention control: minimum runtime and stability checks by time slice before final decision. +- Bad output: "Day-2 spike proves long-term gain." +- Good output: "Decision deferred until stabilization window." + +**8) Allocation-history blindness (dynamic ramp artifacts, Simpson-like reversals)** +- Detection signal: aggregate effect diverges from epoch-stratified effect after allocation changes. +- Prevention control: readout with allocation-aware/epoch-aware framing and restart rules after major allocation changes. +- Bad output: "Aggregate number is enough." +- Good output: "Allocation shifts accounted for before causal interpretation." + +**Sources:** +- [LinkedIn variant assignment and SSRM causes](https://www.linkedin.com/blog/engineering/ab-testing-experimentation/a-b-testing-variant-assignment) +- [Lukas Vermeer SRM FAQ](https://www.lukasvermeer.nl/srm/docs/faq/) +- [CandyJapan case: invalidated A/B due to cookie issue](https://www.candyjapan.com/behind-the-scenes/previous-ab-test-results-invalidated) (Evergreen case study) +- [Statsig sequential testing](https://www.statsig.com/blog/sequential-testing-on-statsig) +- [Early stopping and repeated significance (2024)](https://arxiv.org/abs/2408.00908) +- [Automatic detection of biased online experiments (Evergreen)](https://arxiv.org/abs/1808.00114) +- [Spotify risk-aware decisions (2024)](https://arxiv.org/abs/2402.11609) +- [Airbnb cluster randomization interference evidence (2024)](https://business.columbia.edu/sites/default/files-efs/citation_file_upload/holtz-et-al-2024-reducing-interference-bias-in-online-marketplace-experiments-using-cluster-randomization-evidence-from%20(2).pdf) +- [Ranking interference bias evidence (2024)](https://ideas.repec.org/a/inm/ormksc/v43y2024i3p590-614.html) +- [Optimizely on Simpson's paradox controls](https://support.optimizely.com/hc/en-us/articles/5326213705101-History-of-how-Optimizely-Experimentation-controls-Simpson-s-Paradox-in-experiments-with-Stats-Accelerator-enabled) +- [Statsig novelty effects (2024)](https://www.statsig.com/blog/novelty-effects) + +--- + +### Angle 5+: Program-Level Governance & Portfolio Impact +> Beyond single-test correctness, what improves long-run experimentation program outcomes? + +**Findings:** + +2024-2026 research and industry writing increasingly argues that local test significance is insufficient for program-level value management. Teams need a portfolio view that tracks cumulative impact, winner's-curse exposure, replication outcomes, and decision-rule quality over time. + +A practical implication is to separate two questions in reporting: "Was this specific test statistically valid?" and "How well does our decision policy perform across many tests?" Spotify's 2024 work and newer portfolio-focused commentary both point to this distinction. + +Replication and confirmation discipline is still necessary. External replication literature and experimentation-platform commentary both suggest one-off wins can overstate true long-run effect; staged rollout with confirmation checkpoints reduces false confidence. + +Program governance also needs explicit language controls: causal claims should only appear when design assumptions hold, and exploratory segment findings should be labeled clearly to avoid accidental institutionalization of noise. + +For the skill, this means adding metadata that supports portfolio learning: confidence tags, validity-gate outcomes, and follow-up requirements (replication, holdout, or extended monitoring) rather than single binary winner labels. + +**Sources:** +- [Spotify risk-aware decisions (2024)](https://arxiv.org/abs/2402.11609) +- [Spotify engineering summary (2024)](https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics) +- [Eppo on cumulative impact pitfalls (2025)](https://www.geteppo.com/blog/rethinking-measuring-experimental-impact) +- [Nature Human Behaviour replication project (2024)](https://www.nature.com/articles/s41562-024-02062-9) + +--- + +## Synthesis + +The strongest cross-source agreement is that modern experiment design must be precommitted and operationally explicit. "Pre-registration" now means more than hypothesis text: it must include metric role definitions, stop/decision logic, multiplicity handling policy, and quality-gate checks. This aligns tightly with the current skill direction, but the existing skill should be expanded from a checklist to a stricter decision contract. + +There is a real contradiction in stopping guidance across methods, but it is a methodological contradiction, not a source-quality problem. Fixed-horizon frequentist, sequential frequentist, and Bayesian approaches are each internally coherent but produce different guarantees and decision semantics. The skill should resolve this by forcing the author to pick exactly one statistical regime per experiment spec and blocking regime mixing. + +A second important nuance is multiplicity and guardrails. Some modern decision-rule frameworks argue against blanket alpha correction for specific guardrail semantics, while platform defaults often apply broader correction controls. This should be surfaced as an explicit design choice in the skill: declare whether guardrails are strict non-inferiority constraints, exploratory monitors, or part of corrected family-wise inference. + +Tooling findings show that API capability alone is not enough: identity schema, exposure logging, and governance modes (approval workflows, plan-gated APIs, undocumented limits) often decide whether a design can be executed safely. Therefore `experiment_spec` should carry integration-quality fields (assignment unit, exposure event schema, identity keys, API constraints/unknowns), not just statistical inputs. + +The failure-mode evidence is consistent: the biggest practical risks are not advanced statistics errors first; they are validity failures (SRM, telemetry mismatch, contamination, optional stopping misuse). Strong quality gates and "inconclusive" outputs should be treated as first-class outcomes, not edge cases. + +--- + +## Recommendations for SKILL.md + +> Concrete, actionable list of what should change / be added in the SKILL.md based on research. +> Be specific: "Add X to methodology", "Replace Y tool with Z", "Add anti-pattern: ...", "Schema field X should be enum [...]". + +- [x] Add a mandatory `statistical_regime` field with enum: `fixed_horizon_frequentist | sequential_frequentist | bayesian`; require matching stopping/interpretation policy. (See `Draft: Statistical Regime Selection and Stop Rules` + `Draft: Schema additions`) +- [x] Add a required `decision_rule` object that explicitly encodes primary-metric success, guardrail non-inferiority thresholds, and quality-gate pass criteria. (See `Draft: Decision Rule Contract and Readout States` + `Draft: Schema additions`) +- [x] Add required `validity_checks` fields in artifact schema: `srm_check`, `assignment_exposure_parity_check`, `contamination_check`, each with status and notes. (See `Draft: Quality-Gate Order` + `Draft: Schema additions`) +- [x] Expand sample-size planning from single MDE to scenario array (`conservative/base/aggressive`) and require runtime implications per scenario. (See `Draft: Power and MDE Scenario Planning` + `Draft: Schema additions`) +- [x] Add explicit multiplicity policy field (`none_with_justification | fdr | fwer | custom`) plus rationale text. (See `Draft: Multiplicity and Segment Discipline` + `Draft: Schema additions`) +- [x] Add instrumentation contract fields: randomization unit, exposure event name, identity keys, and warehouse join keys. (See `Draft: Instrumentation Contract and Tool Syntax`) +- [x] Add readout-state semantics beyond win/lose: `win | loss | inconclusive | invalid_due_to_quality`. (See `Draft: Decision Rule Contract and Readout States` + `Draft: Schema additions`) +- [x] Add anti-pattern guardrails section with hard failures (SRM fail, post-hoc metric switch, non-predeclared stop rule). (See `Draft: Anti-Pattern Guardrails`) +- [x] Add optional portfolio metadata fields: `replication_required`, `confirmation_window`, `confidence_tag`. (See `Draft: Portfolio Metadata and Follow-up Discipline`) + +--- + +## Draft Content for SKILL.md + +> Paste-ready content below is intentionally verbose and sectioned for direct insertion into `SKILL.md`. Each section is written in second person and maps to at least one recommendation item above. + +### Draft: Quality-Gate Order + +Before you interpret any outcome metric, you must run quality gates in a fixed order. You do not have statistical evidence until quality gates pass, because broken assignment, missing exposure logging, or contamination can produce confident-looking but invalid readouts. + +You must execute gates in this sequence: +1. **Assignment integrity gate**: verify that randomization configuration, targeting, and traffic split match the pre-registered spec. +2. **SRM gate**: verify observed split vs expected split with a formal SRM test. +3. **Assignment-exposure parity gate**: verify that assigned users are actually exposed and exposure events are logged with correct variant identity. +4. **Contamination gate**: verify no major cross-variant contamination, spillover, or switching artifacts. +5. **Metric-join integrity gate**: verify primary/guardrail metrics can be joined with assignment and exposure keys for the full analysis window. + +You must treat this as a hard dependency chain: if any blocking gate fails, you must output `invalid_due_to_quality` and stop interpretation until root cause is fixed. You must never override this gate with a significant p-value or high posterior probability. This rule is supported by current SRM and diagnostics guidance in Eppo, GrowthBook, Statsig, and Harness docs. + +Source basis: +- https://docs.geteppo.com/statistics/sample-ratio-mismatch/ +- https://docs.geteppo.com/experiment-analysis/diagnostics/ +- https://docs.growthbook.io/app/experiment-results +- https://docs.statsig.com/experiments/monitoring/srm +- https://developer.harness.io/docs/feature-management-experimentation/experimentation/experiment-results/analyzing-experiment-results/sample-ratio-check + +### Draft: Statistical Regime Selection and Stop Rules + +You must declare exactly one statistical regime before launch: `fixed_horizon_frequentist`, `sequential_frequentist`, or `bayesian`. You must not mix regime semantics in one decision. If your readout uses frequentist p-values and Bayesian probabilities interchangeably, the decision is methodologically invalid. + +Choose regime with this decision logic: +- Choose **fixed-horizon frequentist** when you can commit to one planned decision horizon and you need stable effect-size estimation across a metric set. +- Choose **sequential frequentist** when you need valid interim monitoring and potential early decisions under continuous looks. +- Choose **bayesian** when your team has explicit prior and risk-threshold policy and can govern posterior probability plus downside risk jointly. + +You must pre-register stop rules aligned to the chosen regime: +- **Fixed-horizon**: no directional verdict before planned horizon. +- **Sequential frequentist**: allow interim verdicts only with sequentially adjusted inference. +- **Bayesian**: define probability threshold and downside-loss threshold together; probability-only criteria are insufficient. + +If you switch regimes after seeing data, the result becomes exploratory and cannot be treated as confirmatory. This follows current methodology guidance across LaunchDarkly, Statsig, and Spotify risk-aware decision frameworks. + +Source basis: +- https://docs.launchdarkly.com/guides/statistical-methodology/choosing +- https://docs.launchdarkly.com/guides/statistical-methodology/methodology-frequentist +- https://docs.launchdarkly.com/guides/statistical-methodology/methodology-bayesian +- https://docs.statsig.com/experiments-plus/sequential-testing/ +- https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics +- https://arxiv.org/abs/2402.11609 + +### Draft: Power and MDE Scenario Planning + +You must not plan sample size from a single MDE guess. You must create at least three pre-registered MDE scenarios (`conservative`, `base`, `aggressive`) and estimate runtime for each. The selected launch scenario must be justified by business materiality, not convenience. + +For each MDE scenario, you must register: +- Expected absolute lift and relative lift. +- Baseline metric value and expected variance assumptions. +- Target power and alpha (or Bayesian equivalent risk threshold). +- Estimated sample size per variant. +- Estimated runtime under realistic traffic. +- Business interpretation of what that MDE means (for example, conversion points, revenue per user, retention points). + +You must explicitly state tradeoff logic: smaller MDE requires materially more traffic/time; larger MDE speeds runtime but risks missing economically meaningful effects. If runtime assumptions are weak or traffic is unstable, you must mark the plan as uncertainty-sensitive and pre-register what triggers redesign. + +You must treat underpowered outcomes as `inconclusive`, not as evidence of no effect. This is consistent with current platform documentation and recent interpretation guidance. + +Source basis: +- https://docs.statsig.com/experiments/power-analysis +- https://docs.growthbook.io/statistics/power +- https://posthog.com/docs/experiments/sample-size-running-time +- https://docs.launchdarkly.com/guides/experimentation/sample-size-calc +- https://blog.analytics-toolkit.com/2024/comprehensive-guide-to-observed-power-post-hoc-power/ + +### Draft: Multiplicity and Segment Discipline + +You must declare multiplicity policy before launch because metric families and segment slicing can inflate false discoveries. You cannot correct this retroactively with narrative judgment. + +Use this policy: +- If you have one primary metric and no confirmatory segment family, you may use `none_with_justification` only when explicitly justified. +- If you have multiple decision-driving metrics or segment families, use `fdr` by default. +- Use `fwer` for high-risk decisions where any false positive is costly. +- Use `custom` only with explicit formula and reviewer sign-off. + +You must pre-register confirmatory segments. Post-hoc segment wins are exploratory unless they are re-tested in a confirmatory follow-up design with correction applied. + +You must separate metric roles: +- **Primary metric(s)**: defines success. +- **Guardrails**: safety and non-inferiority constraints. +- **Exploratory metrics**: hypothesis generation only. + +Source basis: +- https://docs.geteppo.com/statistics/multiple-testing/ +- https://docs.growthbook.io/statistics/multiple-corrections +- https://docs.statsig.com/stats-engine/methodologies/benjamini-hochberg-procedure +- https://developer.harness.io/docs/feature-management-experimentation/experimentation/key-concepts/multiple-comparison-correction +- https://support.optimizely.com/hc/en-us/articles/4410283967245-False-discovery-rate-control + +### Draft: Decision Rule Contract and Readout States + +You must pre-register one `decision_rule` object that binds statistical evidence, guardrails, and quality gates into one deterministic verdict. You must not allow free-text interpretation at readout time. + +Decision contract requirements: +1. Define **primary success condition** (for example, effect threshold and confidence/probability rule). +2. Define **guardrail non-inferiority condition** (for example, maximum tolerated degradation and confidence rule). +3. Define **quality pass condition** (all blocking validity checks pass). +4. Define **materiality condition** (effect must exceed minimum business-relevant threshold, not only statistical detectability). +5. Define **readout state mapping** into exactly one state. + +You must use these readout states: +- `win`: quality passes, guardrails pass, success + materiality criteria pass. +- `loss`: quality passes, but primary or guardrail indicates meaningful harm/failure. +- `inconclusive`: quality passes but uncertainty remains too high for decision. +- `invalid_due_to_quality`: one or more blocking quality checks fail. + +You must include the `triggered_by` explanation in every readout state to show which rule decided the outcome. This allows auditability and reduces post-hoc interpretation drift. + +Source basis: +- https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics +- https://arxiv.org/abs/2402.11609 +- https://docs.geteppo.com/experiment-analysis/diagnostics/ +- https://docs.statsig.com/experiments/interpreting-results/read-results + +### Draft: Instrumentation Contract and Tool Syntax + +You must pre-register instrumentation as an explicit contract. Assignment and exposure data are not interchangeable; both must be observable and joinable to outcome metrics. + +Your instrumentation contract must include: +- Randomization unit (`user`, `device`, `account`, `cluster`, or `session`). +- Assignment event source and field names. +- Exposure event name and payload requirements. +- Identity keys (`subject_id`, optional `device_id`, optional `account_id`). +- Warehouse join keys and timestamp policy. +- De-duplication key policy (for replay safety). + +Use real, verifiable syntax when integrating with external experimentation platforms. + +**Flexus-native recording syntax** +```text +write_artifact( + artifact_type="experiment_spec", + path="/experiments/{experiment_id}/spec", + data={...} +) +``` + +**Flexus policy context activation** +```text +flexus_policy_document(op="activate", args={"p": "/strategy/hypothesis-stack"}) +flexus_policy_document(op="list", args={"p": "/experiments/"}) +``` + +**Statsig evaluation + event syntax** +```bash +curl -X POST 'https://api.statsig.com/v1/check_gate' \ + -H 'statsig-api-key: client-xyz' \ + -H 'Content-Type: application/json' \ + -d '{ "gateName": "new_user_onboarding", "user": { "userID": "user-123" } }' +``` + +```bash +curl -X POST 'https://events.statsigapi.net/v1/log_event' \ + -H 'statsig-api-key: client-xyz' \ + -H 'Content-Type: application/json' \ + -d '{ "events": [ { "eventName": "add_to_cart", "user": { "userID": "user-123" }, "time": 1730000000000 } ] }' +``` + +**GrowthBook JavaScript tracking callback syntax** +```javascript +import { GrowthBook } from "@growthbook/growthbook"; + +const gb = new GrowthBook({ + apiHost: "https://cdn.growthbook.io", + clientKey: "sdk-abc123", + attributes: { id: "123", country: "US" }, + trackingCallback: (experiment, result) => { + console.log("Experiment Viewed", { experimentId: experiment.key, variationId: result.key }); + }, +}); + +await gb.init(); +const enabled = gb.isOn("my-feature"); +``` + +**LaunchDarkly evaluation and event syntax** +```javascript +const value = await client.boolVariation("example-flag-key", context, false); +client.track("example-event-key", context); +``` + +**Amplitude Experiment assignment + manual exposure syntax** +```bash +curl --request GET \ + --url 'https://api.lab.amplitude.com/v1/vardata?user_id=user123&flag_keys=my-flag' \ + --header 'Authorization: Api-Key YOUR_DEPLOYMENT_KEY' +``` + +```bash +curl --request POST \ + --url 'https://api2.amplitude.com/2/httpapi' \ + --header 'Content-Type: application/json' \ + --data '{"api_key":"YOUR_API_KEY","events":[{"event_type":"$exposure","user_id":"u1","event_properties":{"flag_key":"checkout_redesign","variant":"treatment"}}]}' +``` + +**PostHog flags and exposure event syntax** +```bash +curl -H "Content-Type: application/json" \ + -d '{"api_key":"","distinct_id":"user-123"}' \ + "https://us.i.posthog.com/flags?v=2" +``` + +```bash +curl -H "Content-Type: application/json" \ + -d '{"api_key":"","event":"$feature_flag_called","distinct_id":"user-123","properties":{"$feature_flag":"checkout_test","$feature_flag_response":"variant_a"}}' \ + "https://us.i.posthog.com/i/v0/e/" +``` + +**Optimizely JS decision + conversion syntax** +```javascript +const user = optimizely.createUserContext("user123", { logged_in: true }); +const decision = user.decide("checkout_redesign"); +user.trackEvent("purchase", { + revenue: 10000, + value: 100.0, +}); +``` + +**Eppo Python assignment syntax** +```python +import eppo_client + +client = eppo_client.get_instance() +variant = client.get_string_assignment( + "flag-key-123", + "user-123", + {"country": "US"}, + "version-a", +) +``` + +You must declare rate-limit uncertainty when vendor docs do not publish hard numeric limits. In those cases, rely on `429` handling, retry headers, and conservative backoff rather than assumptions. + +Source basis: +- https://docs.statsig.com/api-reference/feature-gates/check-feature-gates +- https://docs.statsig.com/api-reference/events/log-custom-events +- https://docs.statsig.com/api-reference/experiments/get-experiment +- https://docs.growthbook.io/api +- https://docs.growthbook.io/lib/js +- https://launchdarkly.com/docs/sdk/features/evaluating +- https://docs.launchdarkly.com/sdk/features/events +- https://launchdarkly.com/docs/api +- https://amplitude.com/docs/apis/experiment/experiment-evaluation-api +- https://amplitude.com/docs/apis/analytics/http-v2 +- https://amplitude.com/docs/feature-experiment/track-exposure +- https://posthog.com/docs/api/flags +- https://posthog.com/docs/api/capture +- https://docs.developers.optimizely.com/feature-experimentation/docs/optimizelyusercontext-for-the-javascript-sdk-v6 +- https://docs.developers.optimizely.com/feature-experimentation/docs/track-event-for-the-javascript-sdk +- https://docs.geteppo.com/sdks/server-sdks/python/assignments + +### Draft: Anti-Pattern Guardrails + +Use the following blocks as mandatory warnings in `SKILL.md`. + +**Anti-pattern: SRM ignored** +- Signal: SRM diagnostic fails or split deviates materially from expected allocation. +- Consequence: treatment effect is not interpretable; ship/no-ship decision can reverse after fix. +- Mitigation: stop interpretation immediately, diagnose assignment/logging root cause, relaunch only after SRM passes. + +**Anti-pattern: Assignment-exposure mismatch** +- Signal: users appear in assignment logs but not in exposure logs (or vice versa) beyond allowed threshold. +- Consequence: effect dilution and biased denominator; false negatives and false positives both possible. +- Mitigation: enforce assignment-exposure parity check, fix exposure emission point, rerun integrity test before readout. + +**Anti-pattern: Optional stopping abuse** +- Signal: repeated peeking with fixed-horizon inference and early stop on first significant result. +- Consequence: inflated Type I error and unstable effect sizes. +- Mitigation: either commit to fixed horizon with no early verdicts, or switch to pre-registered sequential frequentist design before launch. + +**Anti-pattern: Post-hoc metric switching** +- Signal: primary metric is changed after data observation or deck emphasizes non-preregistered winners. +- Consequence: selection bias and non-reproducible wins. +- Mitigation: freeze primary metrics pre-launch, label post-hoc findings as exploratory, require confirmatory rerun. + +**Anti-pattern: Underpowered null overclaim** +- Signal: non-significant result with wide intervals and unmet sample/runtime assumptions. +- Consequence: meaningful effects incorrectly classified as absent. +- Mitigation: classify as `inconclusive`, extend runtime or redesign MDE/power assumptions. + +**Anti-pattern: Interference and contamination ignored** +- Signal: treatment can plausibly affect control via network, marketplace, or ranking interactions. +- Consequence: naive A/B estimates are biased and may have wrong sign. +- Mitigation: screen for interference at design time, choose cluster/switchback/two-sided alternatives where needed. + +**Anti-pattern: Novelty spike treated as durable** +- Signal: large early uplift that decays by days-since-exposure or calendar time. +- Consequence: short-term adoption effect misread as durable product improvement. +- Mitigation: enforce minimum runtime over at least one business cycle and inspect temporal stability before decision. + +**Anti-pattern: Allocation-history blindness** +- Signal: traffic ramps, rollbacks, or reallocation changes occur during run but analysis treats all periods as exchangeable. +- Consequence: pooled estimate is confounded by rollout epoch effects. +- Mitigation: pre-register ramp policy, stratify analysis by epoch after major allocation changes, rerun if continuity breaks. + +Source basis: +- https://docs.geteppo.com/statistics/sample-ratio-mismatch/ +- https://docs.geteppo.com/experiment-analysis/diagnostics/ +- https://docs.statsig.com/feature-flags/multiple-rollout-stages +- https://docs.statsig.com/experiments-plus/sequential-testing/ +- https://www.statsig.com/blog/novelty-effects +- https://docs.growthbook.io/using/experimentation-problems +- https://www.linkedin.com/blog/engineering/ab-testing-experimentation/a-b-testing-variant-assignment +- https://business.columbia.edu/sites/default/files-efs/citation_file_upload/holtz-et-al-2024-reducing-interference-bias-in-online-marketplace-experiments-using-cluster-randomization-evidence-from%20(2).pdf + +### Draft: Schema additions + +```json +{ + "experiment_spec": { + "type": "object", + "description": "Pre-registered contract for one experiment design. This schema encodes statistical regime, quality gates, instrumentation contract, and deterministic decision logic.", + "required": [ + "experiment_id", + "hypothesis_ref", + "experiment_type", + "control", + "variants", + "primary_metric", + "guardrail_metrics", + "statistical_regime", + "sample_size_per_variant", + "mde_scenarios", + "significance_threshold", + "power", + "decision_rule", + "multiplicity_policy", + "validity_checks", + "instrumentation_contract", + "readout_semantics", + "launch_date", + "decision_date" + ], + "additionalProperties": false, + "properties": { + "experiment_id": { + "type": "string", + "description": "Unique experiment identifier used in storage paths and dashboards." + }, + "hypothesis_ref": { + "type": "string", + "description": "Pointer to the hypothesis artifact that this experiment operationalizes." + }, + "experiment_type": { + "type": "string", + "description": "Execution topology for assignment and analysis planning.", + "enum": ["ab_test", "holdout", "pre_post", "bayesian"] + }, + "control": { + "type": "object", + "description": "Current baseline experience and baseline assumptions.", + "required": ["description", "current_baseline_rate"], + "additionalProperties": false, + "properties": { + "description": { + "type": "string", + "description": "Human-readable description of the control experience." + }, + "current_baseline_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Expected baseline value for the primary metric before treatment." + } + } + }, + "variants": { + "type": "array", + "description": "Treatment variants with explicit change documentation and traffic allocation.", + "minItems": 1, + "items": { + "type": "object", + "required": ["variant_id", "description", "change_description", "traffic_split"], + "additionalProperties": false, + "properties": { + "variant_id": { + "type": "string", + "description": "Stable variant key used in assignment and readout." + }, + "description": { + "type": "string", + "description": "Short summary of the variant." + }, + "change_description": { + "type": "string", + "description": "Exact change relative to control; must isolate causal change intent." + }, + "traffic_split": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Planned allocation share for this variant." + } + } + } + }, + "primary_metric": { + "type": "string", + "description": "Single decision-driving metric identifier." + }, + "guardrail_metrics": { + "type": "array", + "description": "Safety/non-inferiority metrics that can block launch despite primary gain.", + "items": { + "type": "string" + } + }, + "statistical_regime": { + "type": "string", + "description": "One inference regime selected before launch. Regime mixing is not allowed in confirmatory decisioning.", + "enum": [ + "fixed_horizon_frequentist", + "sequential_frequentist", + "bayesian" + ] + }, + "sample_size_per_variant": { + "type": "integer", + "minimum": 1, + "description": "Target sample per variant for the selected base planning scenario." + }, + "minimum_detectable_effect": { + "type": "number", + "description": "Legacy single-MDE field for backward compatibility. Prefer mde_scenarios for planning." + }, + "mde_scenarios": { + "type": "array", + "description": "Scenario-based planning for runtime and detectability tradeoffs.", + "minItems": 3, + "items": { + "type": "object", + "required": [ + "scenario_id", + "absolute_mde", + "relative_mde", + "estimated_sample_per_variant", + "estimated_runtime_days", + "business_materiality_note" + ], + "additionalProperties": false, + "properties": { + "scenario_id": { + "type": "string", + "enum": ["conservative", "base", "aggressive"], + "description": "Planning profile label." + }, + "absolute_mde": { + "type": "number", + "description": "Absolute effect-size threshold in metric units." + }, + "relative_mde": { + "type": "number", + "description": "Relative effect-size threshold as fraction (for example 0.03 for 3%)." + }, + "estimated_sample_per_variant": { + "type": "integer", + "minimum": 1, + "description": "Projected sample needed per variant for this scenario." + }, + "estimated_runtime_days": { + "type": "number", + "minimum": 0, + "description": "Projected runtime under expected traffic and eligibility." + }, + "business_materiality_note": { + "type": "string", + "description": "Business interpretation of why this MDE is decision-relevant." + } + } + } + }, + "significance_threshold": { + "type": "number", + "description": "Alpha-like threshold used in frequentist planning, if applicable." + }, + "power": { + "type": "number", + "description": "Target statistical power for frequentist planning." + }, + "decision_rule": { + "type": "object", + "description": "Deterministic launch decision logic with explicit success, guardrail, and quality pass criteria.", + "required": [ + "primary_success_rule", + "guardrail_rules", + "quality_gate_policy", + "materiality_rule", + "state_mapping" + ], + "additionalProperties": false, + "properties": { + "primary_success_rule": { + "type": "string", + "description": "Machine- and human-readable statement for primary success logic." + }, + "guardrail_rules": { + "type": "array", + "description": "Blocking and warning rules for each guardrail metric.", + "items": { + "type": "object", + "required": ["metric", "rule", "severity"], + "additionalProperties": false, + "properties": { + "metric": { + "type": "string", + "description": "Guardrail metric identifier." + }, + "rule": { + "type": "string", + "description": "Non-inferiority or degradation constraint." + }, + "severity": { + "type": "string", + "enum": ["blocking", "warning"], + "description": "Whether this guardrail blocks launch." + } + } + } + }, + "quality_gate_policy": { + "type": "string", + "description": "Rule text specifying that all blocking validity checks must pass before interpretation." + }, + "materiality_rule": { + "type": "string", + "description": "Business threshold that prevents shipping statistically tiny but irrelevant gains." + }, + "state_mapping": { + "type": "object", + "required": ["win", "loss", "inconclusive", "invalid_due_to_quality"], + "additionalProperties": false, + "properties": { + "win": { + "type": "string", + "description": "Condition expression that maps to win." + }, + "loss": { + "type": "string", + "description": "Condition expression that maps to loss." + }, + "inconclusive": { + "type": "string", + "description": "Condition expression that maps to inconclusive." + }, + "invalid_due_to_quality": { + "type": "string", + "description": "Condition expression that maps to invalid due to failed quality gates." + } + } + } + } + }, + "multiplicity_policy": { + "type": "object", + "description": "Multiple testing policy used for confirmatory claims.", + "required": ["mode", "rationale"], + "additionalProperties": false, + "properties": { + "mode": { + "type": "string", + "enum": ["none_with_justification", "fdr", "fwer", "custom"], + "description": "Correction mode for confirmatory metric and segment families." + }, + "rationale": { + "type": "string", + "description": "Reason this correction mode matches experiment risk profile." + }, + "custom_definition": { + "type": "string", + "description": "Required when mode is custom; include exact formula and scope." + } + } + }, + "validity_checks": { + "type": "object", + "description": "Quality gates that determine whether readout is valid for interpretation.", + "required": [ + "srm_check", + "assignment_exposure_parity_check", + "contamination_check" + ], + "additionalProperties": false, + "properties": { + "srm_check": { + "type": "object", + "required": ["status", "threshold", "notes"], + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "enum": ["pass", "warn", "fail"], + "description": "SRM gate outcome." + }, + "threshold": { + "type": "string", + "description": "Configured SRM decision threshold (for example p<0.001)." + }, + "notes": { + "type": "string", + "description": "Root-cause notes or validation comments." + } + } + }, + "assignment_exposure_parity_check": { + "type": "object", + "required": ["status", "threshold", "notes"], + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "enum": ["pass", "warn", "fail"], + "description": "Parity gate outcome for assignment and exposure records." + }, + "threshold": { + "type": "string", + "description": "Configured mismatch tolerance and comparison window." + }, + "notes": { + "type": "string", + "description": "Investigation details if mismatch appears." + } + } + }, + "contamination_check": { + "type": "object", + "required": ["status", "threshold", "notes"], + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "enum": ["pass", "warn", "fail"], + "description": "Contamination/interference gate outcome." + }, + "threshold": { + "type": "string", + "description": "Configured contamination tolerance." + }, + "notes": { + "type": "string", + "description": "Diagnostics, assumptions, and remediation notes." + } + } + } + } + }, + "instrumentation_contract": { + "type": "object", + "description": "Pre-registered telemetry contract for assignment, exposure, and outcome joins.", + "required": [ + "randomization_unit", + "assignment_event_name", + "exposure_event_name", + "identity_keys", + "warehouse_join_keys" + ], + "additionalProperties": false, + "properties": { + "randomization_unit": { + "type": "string", + "enum": ["user", "device", "account", "cluster", "session"], + "description": "Unit used for randomization and analysis." + }, + "assignment_event_name": { + "type": "string", + "description": "Event or log stream name that records variant assignment." + }, + "exposure_event_name": { + "type": "string", + "description": "Event emitted when user is truly exposed to variant logic." + }, + "identity_keys": { + "type": "array", + "description": "Ordered identifiers used for event joins (for example subject_id, account_id, device_id).", + "minItems": 1, + "items": { + "type": "string" + } + }, + "warehouse_join_keys": { + "type": "array", + "description": "Warehouse fields used to join assignment/exposure/outcome records.", + "minItems": 1, + "items": { + "type": "string" + } + }, + "dedupe_key_field": { + "type": "string", + "description": "Event-level unique key used to remove duplicates in replay or retry scenarios." + }, + "api_constraints_note": { + "type": "string", + "description": "Rate-limit and endpoint caveats, including unknown numeric limits." + } + } + }, + "readout_semantics": { + "type": "object", + "description": "Allowed readout states and blocking behavior.", + "required": ["allowed_states", "blocking_states"], + "additionalProperties": false, + "properties": { + "allowed_states": { + "type": "array", + "description": "Canonical state set used in all experiment readouts.", + "items": { + "type": "string", + "enum": ["win", "loss", "inconclusive", "invalid_due_to_quality"] + } + }, + "blocking_states": { + "type": "array", + "description": "States that block ship decisions and require remediation.", + "items": { + "type": "string", + "enum": ["loss", "invalid_due_to_quality"] + } + }, + "inconclusive_policy": { + "type": "string", + "description": "Follow-up action required when state is inconclusive." + } + } + }, + "portfolio_metadata": { + "type": "object", + "description": "Optional program-level governance fields for replication and confidence tracking.", + "required": ["replication_required", "confirmation_window", "confidence_tag"], + "additionalProperties": false, + "properties": { + "replication_required": { + "type": "boolean", + "description": "Whether a follow-up confirmatory run is required before broad rollout." + }, + "confirmation_window": { + "type": "string", + "description": "Time window for confirmation or post-launch holdout monitoring." + }, + "confidence_tag": { + "type": "string", + "enum": ["high", "medium", "low"], + "description": "Human-readable confidence label based on quality and uncertainty profile." + } + } + }, + "launch_date": { + "type": "string", + "description": "Planned launch date in ISO-8601 format." + }, + "decision_date": { + "type": "string", + "description": "Planned first confirmatory decision date in ISO-8601 format." + }, + "instrumentation_plan": { + "type": "array", + "description": "Legacy textual checklist retained for backward compatibility.", + "items": { + "type": "string" + } + } + } + } +} +``` + +### Draft: Portfolio Metadata and Follow-up Discipline + +You should track portfolio-level metadata even for single experiments because local significance does not guarantee durable portfolio value. You should require replication or holdout confirmation for high-impact launches, especially when novelty effects, interference risk, or regime complexity is high. + +You should set: +- `replication_required = true` when decision relies on borderline evidence, unstable temporal profile, or high business risk. +- `confirmation_window` to one full business cycle at minimum for behavior-sensitive outcomes. +- `confidence_tag` from a documented rule combining quality-gate outcomes and uncertainty profile. + +You should treat this metadata as an execution control, not a retrospective annotation. If the experiment is marked high impact and confidence is not high, do not allow unconditional full rollout in one step. + +Source basis: +- https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics +- https://www.geteppo.com/blog/rethinking-measuring-experimental-impact +- https://www.nature.com/articles/s41562-024-02062-9 + +--- + +## Gaps & Uncertainties + +- Several vendor docs are "living documentation" without clear publication date; these were marked evergreen where date precision is missing. +- Numeric API rate limits are not publicly disclosed for some platforms (for example, some LaunchDarkly and Split/Harness limits). Specs should include conservative pacing when limits are unknown. +- Cross-platform methodology labels are not perfectly comparable (for example, "Bayesian" implementations differ in priors and reporting semantics), so one-to-one policy mapping remains approximate. +- Interference-aware design guidance is strong in recent literature, but production-ready decision thresholds are still context-dependent and not standardized across vendors. +- Portfolio-level optimization methods are emerging quickly (2024-2026); some evidence is still preprint-stage and should be treated as promising rather than settled default practice. diff --git a/flexus_simple_bots/strategist/skills/_experiment-design/SKILL.md b/flexus_simple_bots/strategist/skills/_experiment-design/SKILL.md new file mode 100644 index 00000000..3249ed9d --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_experiment-design/SKILL.md @@ -0,0 +1,55 @@ +--- +name: experiment-design +description: Experiment design — variant definition, control conditions, sample size calculation, instrumentation plan, and statistical validity +--- + +You design the execution details of a specific experiment from the hypothesis stack. Each experiment needs a control, variants, success metrics, sample size, instrumentation plan, and timeline before launch. + +Core mode: pre-register everything before running. Any decision made after seeing data is post-hoc and produces false signals. Success criteria, sample size, and analysis method must be locked before the experiment starts. + +## Methodology + +### Experiment type selection +- **A/B test**: split traffic between control and variant. Use for: landing pages, email subject lines, onboarding flows, pricing page. +- **Holdout test**: give a segment the old experience, another the new. Use for: feature launches, algorithm changes. +- **Pre/post test**: measure before and after an intervention. Low validity (confounds with time), use only when A/B is impossible. +- **Bayesian test**: update beliefs continuously from evidence. Use for: low-traffic, high-stakes decisions where sequential testing is preferable. + +### Sample size calculation +Before starting: calculate minimum sample size using: +- Baseline conversion rate (current state) +- Minimum detectable effect (smallest change worth detecting) +- Statistical power (standard: 80%) +- Significance threshold (standard: α=0.05) + +Rule of thumb: 5% baseline, 20% relative lift wanted → need ~4,000 per variant for 80% power. + +Never launch an experiment without verifying you have enough sample to reach significance before making a decision. + +### Variant design +Control: exact current state, no changes. +Variant: exactly one change from control. Multiple changes = can't isolate cause. + +Document for each variant: +- What is different vs. control? +- How is it different? (screenshot, copy, logic) +- Why this specific change? (hypothesis link) + +### Instrumentation plan +What events need to be tracked to measure the primary metric and secondary metrics? +- Primary metric: the one number that determines win/lose +- Secondary (guardrail) metrics: metrics that must not get worse (e.g., retention while testing signup flow) +- Data pipeline: where does event data go? Is it instrumented before launch? + +## Recording + +``` +write_artifact(path="/experiments/{experiment_id}/spec", data={...}) +``` + +## Available Tools + +``` +flexus_policy_document(op="activate", args={"p": "/strategy/hypothesis-stack"}) +flexus_policy_document(op="list", args={"p": "/experiments/"}) +``` diff --git a/flexus_simple_bots/strategist/skills/_experiment-learning/RESEARCH.md b/flexus_simple_bots/strategist/skills/_experiment-learning/RESEARCH.md new file mode 100644 index 00000000..bc7923c7 --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_experiment-learning/RESEARCH.md @@ -0,0 +1,1391 @@ +# Research: experiment-learning + +**Skill path:** `strategist/skills/experiment-learning/` +**Bot:** strategist (researcher | strategist | executor) +**Research date:** 2026-03-04 +**Status:** complete + +--- + +## Context + +`experiment-learning` converts completed experiment outcomes into durable organizational knowledge, then updates the hypothesis stack. The skill's core problem is not "did variant B win," but "what mechanism did we learn, how certain are we, and what hypotheses should move next?" This matters because teams that document only winners lose rejected and inconclusive evidence, causing repeated mistakes, weak prioritization, and slow strategic convergence. + +In practice, this skill is used after experiment readout and before strategy reprioritization. The user (typically PM, growth lead, data scientist, or strategist) needs a structured codification step that captures: decision logic, evidence quality, mechanism explanation, implications, and resulting hypothesis transitions. Research focus for this pass: robust methodology, real tool/API landscape, signal-quality interpretation, anti-pattern prevention, and schema-level recommendations that can be translated directly into `SKILL.md`. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024-2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- [x] No generic filler without concrete backing +- [x] No invented tool names, method IDs, or API endpoints; only verified references +- [x] Contradictions between sources are explicitly called out +- [x] Findings volume is within target range and synthesized (not shallow, not dump-only) + +--- + +## Research Angles + +Each angle was researched by a separate sub-agent, then synthesized. + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +1) **Codify a decision rule before interpreting outcomes.** Mature programs map metric outcomes to a deterministic ship/no-ship recommendation using metric classes (success, guardrail, deterioration, quality), not ad-hoc interpretation after the fact. This is directly applicable to `experiment-learning`: codification should include rule evaluation output, not only narrative explanation. Source: https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics + +2) **Guardrails need different treatment than "multiple chances to win" metrics.** Recent risk-aware guidance shows alpha correction logic differs for guardrail non-inferiority; power/beta handling becomes central at decision-rule level. For this skill, storing only p-values is insufficient; it should preserve rule-level risk assumptions. Sources: https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics, https://arxiv.org/abs/2402.11609 + +3) **Pre-register interim analysis and allowed decision actions.** PRIAD-style planning reduces hindsight rationalization and improves cost-effectiveness of experimentation decisions. For this skill, include links to analysis plan and explicit deviation records. Source: https://ideas.repec.org/a/oup/jconrs/v51y2024i4p845-865..html + +4) **When variance is uncertain, use precision-based stopping discipline.** Fixed-power designs (2024) provide a practical option between strict fixed-horizon and naive peeking. For codification, record stop design and stop reason so future analysts can assess validity. Sources: https://engineering.atspotify.com/2024/05/fixed-power-designs-its-not-if-you-peek-its-what-you-peek-at, https://arxiv.org/abs/2405.03487 + +5) **Separate monitoring events from inferential decisions.** Operational alerts (instrumentation, SRM, regressions) must be logged as quality blockers, not mixed into effect interpretation. This improves reproducibility and "why we trusted this readout" traceability. Source: https://www.statsig.com/blog/product-experimentation-best-practices + +6) **Upgrade hypothesis updates from binary status to confidence transitions.** Vendor practice increasingly supports Bayesian priors/posteriors; practically, this enables richer handling of inconclusive outcomes than a strict validated/rejected split. Sources: https://blog.growthbook.io/bayesian-model-updates-in-growthbook-3-0/, https://www.statsig.com/updates/update/bayesian-priors + +7) **Institutional learning requires a searchable experiment memory.** Meta-analysis and knowledge-base workflows show value compounding with experiment volume, including better metric sensitivity judgments and faster hypothesis generation. Source: https://www.statsig.com/blog/experimental-meta-analysis-and-knowledge-base + +8) **Synthesize across batches, not only per experiment.** Once teams accumulate enough experiments, aggregate pattern synthesis (by lever, metric family, segment) becomes a first-class strategic process. Source: https://kameleoon.com/blog/how-to-use-meta-analysis-in-ab-testing + +9) **Code-to-metric context improves mechanism quality.** Emerging practice links experiment outcomes with implementation context (flags, variants, code changes), reducing shallow "it moved" conclusions and strengthening causal explanations. Source: https://www.statsig.com/blog/knowledge-graph + +**Contradictions noted in this angle:** +- **Sequential flexibility vs fixed-power discipline:** sequential approaches allow earlier looks, while fixed-power emphasizes stronger effect estimation discipline in many product contexts. +- **Bayesian "monitor anytime" messaging vs stopping-policy rigor:** practical implementations still need explicit stopping governance to avoid decision drift. +- **Strict centralized schema vs documentation burden:** richer templates improve reuse but can reduce compliance without automation/defaults. + +**Sources:** +- https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics (2024) +- https://arxiv.org/abs/2402.11609 (2024) +- https://engineering.atspotify.com/2024/05/fixed-power-designs-its-not-if-you-peek-its-what-you-peek-at (2024) +- https://arxiv.org/abs/2405.03487 (2024) +- https://ideas.repec.org/a/oup/jconrs/v51y2024i4p845-865..html (2024) +- https://www.statsig.com/blog/product-experimentation-best-practices (2024) +- https://blog.growthbook.io/bayesian-model-updates-in-growthbook-3-0/ (2024) +- https://www.statsig.com/updates/update/bayesian-priors (2025) +- https://www.statsig.com/blog/experimental-meta-analysis-and-knowledge-base (2024) +- https://kameleoon.com/blog/how-to-use-meta-analysis-in-ab-testing (2025) +- https://www.statsig.com/blog/knowledge-graph (2026) + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +1) **The market splits into two planes:** experimentation control plane (assignment, metrics, analysis) and knowledge/decision plane (decision logs, hypothesis workflow, documentation). This split is now the de-facto architecture in 2024-2026 programs. + +2) **Experimentation control plane options are concrete and API-accessible:** LaunchDarkly, Statsig, Optimizely Feature Experimentation, GrowthBook, PostHog, Eppo, and Amplitude all expose APIs for experiment/flag workflows, with different governance and data-model tradeoffs. Sources: https://docs.launchdarkly.com/home/experimentation/, https://docs.statsig.com/console-api/all-endpoints-generated, https://docs.developers.optimizely.com/feature-experimentation/reference/feature-experimentation-api-overview, https://docs.growthbook.io/api/, https://posthog.com/docs/api/flags, https://docs.geteppo.com/reference/api/, https://amplitude.com/docs/apis/experiment/experiment-management-api + +3) **Knowledge/decision systems are usually external systems of record:** Notion, Jira, Confluence, GitHub Projects, Airtable are commonly used for codified learnings, rationale, and ownership transitions. Sources: https://developers.notion.com/reference/post-page, https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/, https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/, https://docs.github.com/en/rest/projects/items, https://support.airtable.com/docs/public-rest-api + +4) **API-level operational constraints are real and must shape skill behavior.** Rate limits and key-scoping patterns differ by vendor; codification pipelines need idempotent writes, retry backoff, and write-audit traces. Sources: https://support.launchdarkly.com/hc/en-us/articles/22328238491803-Error-429-Too-Many-Requests-API-Rate-Limit, https://developers.notion.com/reference/request-limits, https://developer.atlassian.com/cloud/jira/platform/rate-limiting, https://docs.github.com/rest/using-the-rest-api/rate-limits-for-the-rest-api, https://support.airtable.com/docs/managing-api-call-limits-in-airtable + +5) **Warehouse-native experimentation is now mainstream, not niche.** Statsig warehouse-native and Eppo patterns show direct warehouse integration becoming a default for data-mature organizations, improving reproducibility but increasing data engineering dependency. Sources: https://docs.statsig.com/statsig-warehouse-native/introduction, https://docs.geteppo.com/ + +6) **Evaluation and management APIs are often separated.** Several platforms separate runtime evaluation from administrative operations; skill design should avoid mixing these credentials/surfaces. Source: https://amplitude.com/docs/apis/experiment/experiment-evaluation-api + +7) **Entity semantics are inconsistent across tools.** `flag`, `experiment`, `rule`, and `layer` can differ by platform; a canonical internal schema is required to avoid mapping errors during codification. + +8) **De-facto integration standard:** always persist a cross-system join key set (`experiment_id`, `flag_key`, `hypothesis_id`, `metric_set_id`) in every record. This is the practical backbone of durable learning retrieval. + +**Tool matrix (concise):** + +| Tool | Core capability | Relevant API/integration surface | Key limitation | Source | +|---|---|---|---|---| +| LaunchDarkly | Feature flags + experimentation | REST API + experimentation guides | Rate limiting and enterprise complexity | https://docs.launchdarkly.com/guides/api/rest-api | +| Statsig | Experimentation and analytics platform | Console API, warehouse-native stack | Strong governance needed for metrics/keys | https://docs.statsig.com/console-api/all-endpoints-generated | +| Optimizely FE | Feature experimentation lifecycle | FE API for flags/rules/variations | API coverage can differ by object type | https://docs.developers.optimizely.com/feature-experimentation/reference/feature-experimentation-api-overview | +| GrowthBook | Open-source/cloud experimentation | REST API (`/api/v1`) + warehouse model | Self-hosting/ops overhead tradeoff | https://docs.growthbook.io/api/ | +| PostHog | Unified product analytics + experiments | Flags API + event ingestion APIs | Event volume/cost coupling | https://posthog.com/docs/api/flags | +| Eppo | Warehouse-native experimentation | REST API + warehouse connectors | Depends on warehouse maturity | https://docs.geteppo.com/reference/api/ | +| Amplitude Experiment | Management/evaluation split | Management API + evaluation API | Surface fragmentation risk | https://amplitude.com/docs/apis/experiment/experiment-management-api | + +**Contradictions / fragmentation in this angle:** +- Some platforms present "all-in-one" workflows, while others require explicit multi-system architecture. +- API maturity differs by object type and plan tier; "has API" does not mean "full lifecycle API parity." +- Pricing/rate limits are often plan-dependent and not fully public, requiring implementation-time confirmation. + +**Sources:** +- https://docs.launchdarkly.com/home/experimentation/ (2026) +- https://docs.launchdarkly.com/guides/api/rest-api (2026) +- https://docs.statsig.com/console-api/all-endpoints-generated (2026) +- https://docs.statsig.com/statsig-warehouse-native/introduction (2026) +- https://docs.developers.optimizely.com/feature-experimentation/reference/feature-experimentation-api-overview (2026) +- https://docs.growthbook.io/api/ (2026) +- https://posthog.com/docs/api/flags (2026) +- https://docs.geteppo.com/reference/api/ (2026) +- https://amplitude.com/docs/apis/experiment/experiment-management-api (2026) +- https://amplitude.com/docs/apis/experiment/experiment-evaluation-api (2026) +- https://developers.notion.com/reference/post-page (2026) +- https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/ (evergreen, accessed 2026) +- https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/ (evergreen, accessed 2026) +- https://docs.github.com/en/rest/projects/items (2026) +- https://support.airtable.com/docs/public-rest-api (evergreen, accessed 2026) + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +1) **SRM is a hard quality gate, not a soft warning.** If allocation mismatch is detected, causal interpretation is unsafe until assignment/exposure root cause is investigated. Platforms document SRM with formal testing (for example, Amplitude uses sequential chi-square with alpha 0.01). Sources: https://amplitude.com/docs/feature-experiment/troubleshooting/sample-ratio-mismatch, https://docs.geteppo.com/statistics/sample-ratio-mismatch/ + +2) **Threshold mismatch exists for SRM policies (`0.01` vs `0.001`).** Vendor and team standards differ; the key is policy consistency, explicitness, and logging threshold rationale in the learning artifact. + +3) **Fixed-horizon and sequential inference cannot be mixed casually.** Peeking with fixed-horizon p-values inflates false positives; sequentially valid engines address this differently. Source: https://docs.statsig.com/experiments-plus/sequential-testing/ + +4) **Seasonality and exposure window effects are practical validity risks.** Teams should avoid early conclusions before sufficient cycle coverage (often at least one full week in product contexts). Source: https://www.statsig.com/blog/product-experimentation-best-practices + +5) **Inconclusive is not one state; it needs subclassification.** Distinguish insufficient sensitivity, near-zero practical effect, and quality-invalidated runs. This distinction is essential for correct hypothesis-stack updates. + +6) **Do not use p>0.05 as "safe/no harm."** For guardrails, non-inferiority/equivalence framing is stronger than "not significant." Source: https://www.optimizely.com/insights/blog/understanding-and-implementing-guardrail-metrics + +7) **Multiplicity must be handled at the right layer.** Metric/segment/variant expansion increases false discovery risk; teams should classify segment findings as exploratory unless preplanned and corrected. Source: https://www.optimizely.com/hc/en-us/articles/4410283967245-False-discovery-rate-control + +8) **Novelty and temporal drift can invert conclusions post-launch.** Time-profiled interpretation (for example, days-since-exposure) helps separate transient excitement from durable effect. Sources: https://www.statsig.com/blog/novelty-effects, https://research.atspotify.com/2024/05/estimating-long-term-outcome-of-algorithms + +9) **CUPED improves precision but does not cure bad instrumentation.** Covariate adjustment helps variance, but data quality failures still invalidate conclusions. Sources: https://docs.statsig.com/stats-engine/methodologies/cuped/, https://www.statsig.com/blog/cuped + +10) **Interpretation should be codified as a state machine.** Recommended transition logic: `quality gate -> inference mode gate -> sensitivity gate -> effect/guardrail gate -> learning verdict -> next hypotheses`. This makes output audit-ready and teachable. + +**Contradictions noted in this angle:** +- SRM policy strictness differs by organization and platform. +- "Peeking is fine" and "never peek" are both oversimplifications; validity depends on chosen inferential design. +- Some practice guides still mention post-hoc power; statistical guidance increasingly favors CI + practical bound interpretation. + +**Sources:** +- https://amplitude.com/docs/feature-experiment/troubleshooting/sample-ratio-mismatch (2024) +- https://docs.geteppo.com/statistics/sample-ratio-mismatch/ (2026, accessed) +- https://docs.statsig.com/experiments-plus/sequential-testing/ (2026, accessed) +- https://www.statsig.com/blog/product-experimentation-best-practices (2024) +- https://www.optimizely.com/insights/blog/understanding-and-implementing-guardrail-metrics (2025) +- https://www.optimizely.com/hc/en-us/articles/4410283967245-False-discovery-rate-control (evergreen, accessed 2026) +- https://www.statsig.com/blog/novelty-effects (2024) +- https://research.atspotify.com/2024/05/estimating-long-term-outcome-of-algorithms (2024) +- https://docs.statsig.com/stats-engine/methodologies/cuped/ (2026, accessed) +- https://www.statsig.com/blog/cuped (2024) +- https://arxiv.org/abs/1512.04922 (2016/2019, evergreen foundational optional-stopping reference) + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +1) **Survivorship bias:** teams archive wins and lose null/negative outcomes, creating false confidence and repeated mistakes. Mitigation: mandatory all-outcomes registry with denominator metrics. Sources: https://pmc.ncbi.nlm.nih.gov/articles/PMC11962440/, https://www.statsig.com/blog/experimental-meta-analysis-and-knowledge-base + +2) **Untracked rejected hypotheses (required):** if rejection reasons are not stored, teams rerun failed ideas under new names. Mitigation: preregistered hypothesis IDs and required rejection rationale fields. Source: https://www.socialscienceregistry.org/site/instructions + +3) **Local overfitting / winner's curse (required):** selecting top uplift estimates overstates expected impact in production. Mitigation: shrinkage or Bayesian adjustment plus confirmatory follow-up. Sources: https://www.amazon.science/publications/overcoming-the-winners-curse-leveraging-bayesian-inference-to-improve-estimates-of-the-impact-of-features-launched-via-a-b-tests, https://arxiv.org/abs/2411.18569 + +4) **Proxy ratio trap:** ratio improvements can hide denominator collapse (for example, sessions down). Mitigation: enforce numerator/denominator decomposition before verdict. Source: https://www.statsig.com/blog/product-experimentation-best-practices + +5) **Premature stopping on significance:** early "wins" often regress with full horizon. Mitigation: precommitted stopping design, with explicit prohibition of ad-hoc stop-on-green. Source: https://engineering.atspotify.com/2024/05/fixed-power-designs-its-not-if-you-peek-its-what-you-peek-at + +6) **Multiple comparisons without correction:** large test families inflate false positives and create fragile roadmaps. Mitigation: family-level correction policy and primary-metric discipline. Source: https://www.statsig.com/perspectives/multiple-comparisons-abtests-care + +7) **Ignoring SRM and identity contamination:** variant jumping, changed allocations, or exposure logging defects can invalidate inference. Mitigation: "no ship under SRM" and assignment/exposure diagnostics as hard blockers. Sources: https://amplitude.com/docs/feature-experiment/troubleshooting/sample-ratio-mismatch, https://launchdarkly.com/docs/guides/statistical-methodology/sample-ratios + +8) **Incident-to-policy debt:** emergency protections can outlive intent and degrade normal users if lifecycle governance is weak. Mitigation: temporary-by-default controls, owner, expiry, and cleanup SLA. Source: https://github.blog/engineering/infrastructure/when-protections-outlive-their-purpose-a-lesson-on-managing-defense-systems-at-scale/ + +9) **Rollout false positives in safety systems:** dry runs can miss production false positive classes. Mitigation: staged rollout plus explicit false-positive corpus and kill-switch criteria. Source: https://blog.railway.com/p/incident-report-february-11-2026 + +10) **Retry amplification and control-plane fragility:** infrastructure instability in flag systems can distort experiment operations and interpretation cadence. Mitigation: bounded retries, right-sizing, and dependency isolation. Source: https://posthog.com/handbook/company/post-mortems/2025-10-21-feature-flags-recurring-outages + +**Bad vs good output patterns:** +- **Bad:** "Win rate is high" without denominator including null/negative experiments. **Good:** win/loss/inconclusive denominator and reasons. +- **Bad:** "Rejected, moving on" with no causal explanation. **Good:** rejected hypothesis includes wrong prediction, evidence, and next hypothesis. +- **Bad:** "Significant at day 2, ship." **Good:** decision references planned stopping design and quality checks. +- **Bad:** "Guardrail not significant, so safe." **Good:** explicit non-inferiority criterion and margin handling. +- **Bad:** "One strong segment proves effect." **Good:** exploratory segment flagged for confirmatory follow-up. + +**Sources:** +- https://pmc.ncbi.nlm.nih.gov/articles/PMC11962440/ (2025) +- https://www.statsig.com/blog/experimental-meta-analysis-and-knowledge-base (2024) +- https://www.socialscienceregistry.org/site/instructions (evergreen registry standard) +- https://www.amazon.science/publications/overcoming-the-winners-curse-leveraging-bayesian-inference-to-improve-estimates-of-the-impact-of-features-launched-via-a-b-tests (2024) +- https://arxiv.org/abs/2411.18569 (2024) +- https://www.statsig.com/blog/product-experimentation-best-practices (2024) +- https://engineering.atspotify.com/2024/05/fixed-power-designs-its-not-if-you-peek-its-what-you-peek-at (2024) +- https://www.statsig.com/perspectives/multiple-comparisons-abtests-care (2025) +- https://amplitude.com/docs/feature-experiment/troubleshooting/sample-ratio-mismatch (2024) +- https://launchdarkly.com/docs/guides/statistical-methodology/sample-ratios (evergreen, accessed 2026) +- https://blog.railway.com/p/incident-report-february-11-2026 (2026) +- https://github.blog/engineering/infrastructure/when-protections-outlive-their-purpose-a-lesson-on-managing-defense-systems-at-scale/ (2026) +- https://posthog.com/handbook/company/post-mortems/2025-10-21-feature-flags-recurring-outages (2025) + +--- + +### Angle 5+: Schema Design, Provenance, and Interoperability +> Domain-specific angle: which data shapes make experiment learnings durable, queryable, and auditable across systems? + +**Findings:** + +1) **Use a 3-entity chain:** `experiment_result -> learning_artifact -> hypothesis_update/decision_log`. This prevents conflating statistical output with strategic decision and enables traceable updates. + +2) **Separate assignment and exposure fields explicitly.** Real platforms distinguish assignment and exposure events; codification should preserve both timestamps and policy. Sources: https://posthog.com/docs/experiments/exposures, https://amplitude.com/docs/feature-experiment/under-the-hood/event-tracking + +3) **Add integrity block fields:** `srm_detected`, `srm_p_value`, `multi_variant_exposure`, `attribution_policy`. These fields determine whether inference is trusted or blocked. Source: https://amplitude.com/docs/feature-experiment/troubleshooting/sample-ratio-mismatch + +4) **Store feature-evaluation provenance:** `flag_key`, `variant`, `provider`, `ruleset_version`, and context identifier; otherwise mechanism explanations are hard to reproduce. Sources: https://openfeature.dev/specification/sections/flag-evaluation/, https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-events/ + +5) **Promote confidence to structured object, not free text.** Include method, effect size, interval/posterior, guardrail failures, and confidence level so downstream systems can query quality and uncertainty. + +6) **Decision log should be first-class schema object.** Include `decision`, `decided_by`, `decided_at`, `rationale`, and blockers; this bridges analytics and product governance. + +7) **Evidence links should be typed and verifiable.** Instead of plain URLs, store `type`, `uri`, `relation`, `captured_at`, optional checksum for integrity. + +8) **Version schema aggressively.** Add `schema_version` and `record_version`; use modern JSON Schema features for controlled evolution (`if/then/else`, `unevaluatedProperties`, composition). Source: https://json-schema.org/draft/2020-12 + +9) **Capture lineage/provenance metadata.** OpenLineage-style concepts (parent run, dataset version, source code ref) increase auditability of learning claims. Source: https://openlineage.io/docs/spec/ + +10) **For event-driven interoperability, support CloudEvents envelope (evergreen).** This reduces integration friction for publishing/consuming learning events across systems. Sources: https://raw.githubusercontent.com/cloudevents/spec/v1.0/spec.md, https://learn.microsoft.com/en-us/azure/event-grid/cloud-event-schema + +**Proposed schema deltas for `experiment-learning`:** +- Rename `hypothesis_ref` -> `source_hypothesis_id` +- Expand `new_hypotheses_generated` into `hypothesis_updates[]` with operation type (`create`, `reprioritize`, `invalidate`, `merge`) +- Add `source_result_ref` (`artifact_path`, `analysis_run_id`, `analyzed_at`, `result_hash`) +- Add `assignment_integrity` object +- Add `decision_log` object +- Add `confidence` object (method + uncertainty + confidence level) +- Add typed `evidence_links[]` +- Add `lineage` object (`parent_learning_ids`, `dataset_versions`, `source_code_ref`) +- Replace rigid `implications.for_*` with extensible `implications[]` entries (`domain`, `summary`, `confidence`) +- Add `schema_version` and `record_version` + +**Sources:** +- https://posthog.com/docs/experiments/exposures (2026) +- https://amplitude.com/docs/feature-experiment/under-the-hood/event-tracking (2024) +- https://amplitude.com/docs/feature-experiment/troubleshooting/sample-ratio-mismatch (2024) +- https://openfeature.dev/specification/sections/flag-evaluation/ (evergreen, accessed 2026) +- https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-events/ (evergreen, accessed 2026) +- https://openlineage.io/docs/spec/ (evergreen, accessed 2026) +- https://json-schema.org/draft/2020-12 (2022, evergreen standard) +- https://raw.githubusercontent.com/cloudevents/spec/v1.0/spec.md (evergreen standard) +- https://learn.microsoft.com/en-us/azure/event-grid/cloud-event-schema (2026) + +--- + +## Synthesis + +Across all angles, the central pattern is clear: teams no longer treat experiment readout as a one-off statistical report; they treat it as a decision system with explicit risk controls, quality gates, and durable knowledge capture. The most actionable takeaway for this skill is to convert "learning extraction" into a structured state machine with explicit checkpoints: quality validity, inferential validity, decision-rule evaluation, mechanism codification, and hypothesis transitions. + +A key cross-angle connection is that methodology quality and schema quality are inseparable. If the artifact schema does not represent assignment integrity, stopping design, confidence structure, and decision rationale, then even good analysis is not reusable. Conversely, over-rich schemas can fail in practice if tool integrations are brittle. This creates a practical requirement: a strict minimum required core, plus optional enrichments where automation exists. + +There are also explicit contradictions that should remain visible in the skill instead of being hidden: sequential flexibility vs fixed-power discipline, SRM strictness thresholds, and varying interpretations of guardrail safety. The correct response is not to pick one universal rule; it is to encode policy parameters explicitly in artifacts so future readers can reconstruct why a conclusion was trusted. + +Failure-mode research reinforces that organizational learning breaks more often from process debt than from pure statistical ignorance: survivorship bias, missing rejected hypotheses, and incident-era shortcuts that outlive their purpose are recurring causes of poor strategic memory. Therefore, anti-pattern checks must be first-class outputs of this skill, not optional commentary. + +Finally, tool landscape findings suggest that this skill should remain tool-agnostic but schema-strict: integrations will vary (LaunchDarkly/Statsig/GrowthBook/PostHog/Eppo/etc.), while durable learning requires stable canonical IDs, typed evidence links, and explicit decision logs regardless of platform choice. + +--- + +## Recommendations for SKILL.md + +> Concrete, actionable list of what should change / be added in the SKILL.md based on research. + +- [ ] Add a mandatory `decision_rule_evaluation` step (success/guardrail/deterioration/quality -> recommendation) before hypothesis-stack update. +- [ ] Add `quality_gate_status` with explicit blockers (`srm_detected`, instrumentation issues, variant jumping) and rule "no final verdict if quality gate fails." +- [ ] Split `inconclusive` into operational subclasses (`insufficient_sensitivity`, `practically_null`, `quality_invalid`). +- [ ] Extend schema with `source_result_ref`, `assignment_integrity`, `decision_log`, and structured `confidence`. +- [ ] Replace status-only hypothesis updates with `hypothesis_updates[]` operations and confidence delta. +- [ ] Require `rejection_reason` and `next_hypothesis` for rejected outcomes to prevent untracked rejected hypotheses. +- [ ] Add anti-pattern checklist section: survivorship bias, local overfitting/winner's curse, ratio trap, multiplicity, and stop-on-significance. +- [ ] Add pattern-synthesis cadence requirement (for example, every 5+ experiments or monthly portfolio review) with cross-experiment metrics. +- [ ] Require canonical join keys in every artifact: `experiment_id`, `source_hypothesis_id`, `flag_key` (if applicable), `metric_set_id`. +- [ ] Add schema versioning (`schema_version`, `record_version`) and evidence typing (`evidence_links[]`) for long-term interoperability. + +--- + +## Draft Content for SKILL.md + +> Paste-ready content fragments for `SKILL.md`. This section is intentionally verbose so the editor can cut down without inventing missing logic. +> Coverage: every recommendation in the previous section has a matching draft block here. + +### Draft: Core Operating Mode and Decision State Machine + +You run this skill in learning-first mode. You do not optimize for fast shipping if confidence, validity, or interpretation quality is weak. Your job is to convert one experiment result into durable organizational memory that updates the hypothesis stack with traceable logic. + +You always follow this state machine and you do not skip states: + +1. **Input activation** -> activate result, spec, and current hypothesis stack documents. +2. **Quality gate** -> validate assignment/exposure integrity before any effect interpretation. +3. **Inference gate** -> identify the inference mode used in analysis (sequential, fixed-horizon, Bayesian) and confirm decisions are consistent with that mode. +4. **Decision-rule evaluation** -> evaluate success metrics, guardrails, deterioration metrics, and quality status against explicit policy. +5. **Outcome classification** -> classify as `validated`, `rejected`, or `inconclusive` with an explicit subclass if inconclusive. +6. **Mechanism extraction** -> document why the observed outcome happened, not only what moved. +7. **Hypothesis stack update** -> apply explicit operations (`create`, `reprioritize`, `invalidate`, `merge`) with confidence deltas. +8. **Artifact persistence** -> write both experiment-level learnings and updated stack artifacts with canonical join keys and evidence links. +9. **Portfolio synthesis trigger** -> mark whether this artifact must be included in the next batch synthesis review. + +You must block final strategic conclusions if quality checks fail. In this case, you still write a learning artifact, but it must be marked as `inconclusive` with reason `quality_invalid` and include corrective actions before rerun. + +Source basis: +- https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics +- https://engineering.atspotify.com/2024/05/fixed-power-designs-its-not-if-you-peek-its-what-you-peek-at +- https://amplitude.com/docs/feature-experiment/troubleshooting/sample-ratio-mismatch +- https://docs.geteppo.com/statistics/sample-ratio-mismatch/ +- https://docs.statsig.com/experiments-plus/sequential-testing/ + +### Draft: Mandatory Workflow (Second-Person Procedure) + +Before you codify any conclusion, you activate required context and verify that the experiment result is decision-ready. If any required context is missing, you stop and write a blocked record instead of inventing assumptions. + +**Step 1 - Activate required artifacts** + +You activate: +- experiment results document, +- experiment specification document, +- current hypothesis stack document. + +If any activation fails or returns missing content, you do not continue with verdict logic. + +**Step 2 - Run quality gate as a hard blocker** + +You check for: +- sample ratio mismatch and allocation anomalies, +- instrumentation gaps and missing event coverage, +- exposure/assignment inconsistencies, +- identity contamination or variant jumping. + +If any blocker is present, set: +- `quality_gate_status.is_valid = false`, +- `experiment_verdict = "inconclusive"`, +- `inconclusive_reason = "quality_invalid"`. + +You then record mitigation actions and rerun preconditions. + +**Step 3 - Validate inference mode before interpretation** + +You identify whether the underlying readout used: +- frequentist sequential testing, +- frequentist fixed-horizon design, +- Bayesian decision outputs. + +You do not mix interpretation rules across modes. For example, you do not treat Bayesian posterior outputs as if they were fixed-horizon p-values. + +**Step 4 - Evaluate explicit decision rule** + +You evaluate: +- success metrics against predeclared success criteria, +- guardrails with non-inferiority/no-harm logic, +- deterioration metrics for explicit regression risks, +- quality status from Step 2. + +You produce one recommendation: +- `rollout`, +- `do_not_rollout`, +- `discuss`. + +You record recommendation rationale in plain language with references to metric evidence. + +**Step 5 - Classify outcome with required inconclusive subclass** + +If recommendation is positive and guardrails pass, classify `validated`. +If recommendation is negative or harm is detected, classify `rejected`. +If decision is unresolved, classify `inconclusive` and force one reason: +- `insufficient_sensitivity`, +- `practically_null`, +- `quality_invalid`. + +You never leave an experiment in a generic untyped inconclusive state. + +**Step 6 - Extract mechanism and generalizable learning** + +You write: +- mechanism explanation (what causal behavior likely drove outcome), +- generalizable learning claims with confidence levels, +- scope boundaries (where this learning probably does or does not transfer). + +If mechanism is weak or speculative, you say so explicitly and reduce confidence. + +**Step 7 - Update hypothesis stack with explicit operations** + +You create `hypothesis_updates[]` entries with one of: +- `create`, +- `reprioritize`, +- `invalidate`, +- `merge`. + +Each entry includes rationale, confidence delta, and evidence links. + +If verdict is `rejected`, you must include: +- `rejection_reason`, +- at least one `next_hypothesis` direction (embedded in `hypothesis_updates`). + +**Step 8 - Persist artifacts and set synthesis hint** + +You write: +- experiment-level learnings artifact, +- updated hypothesis stack artifact. + +You include canonical join keys: `experiment_id`, `source_hypothesis_id`, `flag_key` (if available), `metric_set_id`. + +You set `pattern_synthesis_hint.include_in_next_portfolio_review` based on learning significance and novelty. + +Source basis: +- https://docs.statsig.com/exp-templates/decision-framework +- https://docs.geteppo.com/experiment-analysis/configuration/protocols/ +- https://www.statsig.com/blog/product-experimentation-best-practices +- https://launchdarkly.com/docs/guides/statistical-methodology/sample-ratios +- https://www.statsig.com/updates/update/related-experiments + +### Draft: Tool Usage Syntax (Real Methods Only) + +Use only documented runtime methods shown in existing strategist skills. + +#### Activate required context + +```python +flexus_policy_document(op="activate", args={"p": "/experiments/{experiment_id}/results"}) +flexus_policy_document(op="activate", args={"p": "/experiments/{experiment_id}/spec"}) +flexus_policy_document(op="activate", args={"p": "/strategy/hypothesis-stack"}) +``` + +You call these before any verdict. If one call fails or returns missing content, you record a blocked codification outcome instead of continuing. + +#### Discover related experiments for synthesis context + +```python +flexus_policy_document(op="list", args={"p": "/experiments/"}) +``` + +You use this to support portfolio pattern synthesis and to avoid duplicate hypothesis restarts that ignore previously rejected evidence. + +#### Write experiment-level learning artifact + +```python +write_artifact( + artifact_type="experiment_learnings", + path="/experiments/{experiment_id}/learnings", + data={...} +) +``` + +You write exactly one canonical learning artifact per codification pass. If you rerun codification, increment `record_version` and preserve lineage in the artifact payload. + +#### Write updated hypothesis stack artifact + +```python +write_artifact( + artifact_type="experiment_hypothesis_stack", + path="/strategy/hypothesis-stack", + data={...} +) +``` + +You write this after learning codification and only after `hypothesis_updates[]` are computed from evidence. + +#### Tool usage discipline + +You do not invent methods, endpoints, or hidden operations. You only use: +- `flexus_policy_document(op="activate", ...)`, +- `flexus_policy_document(op="list", ...)`, +- `write_artifact(...)`. + +If persistence fails transiently, you retry according to platform policy; if it fails deterministically, you emit a blocked state and stop instead of silently dropping updates. + +Source basis: +- Existing skill runtime usage in strategist and executor skill files +- https://developers.notion.com/reference/request-limits +- https://developer.atlassian.com/cloud/jira/platform/rate-limiting/ +- https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api +- https://support.airtable.com/docs/managing-api-call-limits-in-airtable + +### Draft: Decision Rule and Outcome Taxonomy + +You evaluate decision readiness with explicit policy logic. You do not reduce outcomes to win/loss only. + +#### Decision rule skeleton + +- If quality is invalid -> recommendation `do_not_rollout`, verdict `inconclusive`, reason `quality_invalid`. +- Else if success criteria pass and all guardrails pass no-harm criteria -> recommendation `rollout`, verdict `validated`. +- Else if deterioration is detected or guardrail harm fails policy -> recommendation `do_not_rollout`, verdict `rejected`. +- Else -> recommendation `discuss`, verdict `inconclusive`, reason either `insufficient_sensitivity` or `practically_null`. + +#### Inconclusive subclass policy + +You must assign one inconclusive reason: +- `insufficient_sensitivity`: sample or runtime is not enough to resolve planned effect. +- `practically_null`: estimate and interval indicate negligible business impact. +- `quality_invalid`: integrity failure blocks interpretation regardless of effect size. + +You then define the next action: +- increase power / rerun, +- close as low-priority neutral, +- fix instrumentation and rerun. + +This keeps inconclusive outcomes actionable instead of becoming dead-end records. + +Source basis: +- https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics +- https://www.optimizely.com/insights/blog/understanding-and-implementing-guardrail-metrics +- https://docs.statsig.com/experiments-plus/sequential-testing/ +- https://www.statsig.com/blog/product-experimentation-best-practices + +### Draft: Organizational Learning and Portfolio Synthesis + +You run this skill as part of a recurring operating system, not one-off reporting. + +You execute two cadences: +- **weekly or bi-weekly closure cadence**: close open experiment learnings and apply hypothesis stack updates, +- **monthly portfolio synthesis cadence**: aggregate cross-experiment patterns, decide strategic reallocations, and prune stale hypotheses. + +For monthly synthesis, you include: +- denominator metrics (`validated/rejected/inconclusive` counts), +- quality-invalid rate, +- repeated mechanism patterns by segment/channel/product area, +- recurring anti-pattern incidence, +- backlog recycle rate (how often rejected ideas reappear), +- median planned-vs-actual experiment duration. + +You mark any experiment with high strategic novelty for mandatory inclusion in next monthly synthesis via `pattern_synthesis_hint`. + +You preserve rejected outcomes as first-class evidence. A rejected hypothesis with clear mechanism evidence is a valid strategic asset and must be searchable. + +Source basis: +- https://handbook.gitlab.com/handbook/product/groups/growth/#how-growth-launches-experiments +- https://docs.statsig.com/experimentation/meta-analysis +- https://www.statsig.com/updates/update/meta-analysisv0 +- https://www.statsig.com/blog/experimental-meta-analysis-and-knowledge-base +- https://docs.geteppo.com/experiment-analysis/reporting/knowledge-base/ + +### Draft: Anti-Pattern Warning Blocks + +#### Warning: Survivorship bias (file-drawer pattern) +- **Signal:** Archive mostly contains winners; null and rejected outcomes are missing or weakly documented. +- **Consequence:** You overestimate expected win rate and keep repeating previously disproven ideas. +- **Mitigation:** Require all-outcomes registry with denominator metrics before any strategic summary. + +#### Warning: Missing rejected hypotheses +- **Signal:** `rejection_reason` is absent and rejected items later return under new labels. +- **Consequence:** Organizational memory erodes and experimentation velocity is wasted. +- **Mitigation:** Make `rejection_reason` and next hypothesis direction mandatory for all rejected outcomes. + +#### Warning: Winner's curse (local overfitting) +- **Signal:** Largest observed uplifts systematically shrink during rollout or replication. +- **Consequence:** You over-allocate roadmap capacity to noisy winners. +- **Mitigation:** Apply shrinkage-aware interpretation and require confirmatory follow-up for top-impact claims. + +#### Warning: Optional stopping / stop-on-significance +- **Signal:** Teams stop early after temporary significance in fixed-horizon designs. +- **Consequence:** False positives rise and effect sizes are inflated. +- **Mitigation:** Enforce predeclared stopping policy and record stop reason (`precision_reached`, `horizon_completed`, or policy-valid equivalent). + +#### Warning: Multiplicity and segment fishing +- **Signal:** Many metric/segment comparisons produce isolated significant hits without correction. +- **Consequence:** Discovery false positive rate increases and roadmap quality drops. +- **Mitigation:** Treat non-preplanned segment findings as exploratory and require confirmatory reruns. + +#### Warning: Ratio metric denominator trap +- **Signal:** Ratio looks improved while numerator or denominator dynamics hide deterioration. +- **Consequence:** Harmful changes can be shipped under misleading KPI movement. +- **Mitigation:** Always decompose ratio metrics into numerator and denominator behavior before verdict. + +#### Warning: Guardrail misinterpretation +- **Signal:** "Guardrail not significant" is interpreted as "safe." +- **Consequence:** Silent harm slips into rollout decisions. +- **Mitigation:** Use explicit non-inferiority/no-harm criteria and require guardrail pass in decision rule. + +#### Warning: SRM and assignment integrity ignored +- **Signal:** Sample ratio mismatch alerts or exposure anomalies are present but treated as minor. +- **Consequence:** Causal interpretation is invalid regardless of apparent uplift. +- **Mitigation:** Treat integrity failures as hard blockers and rerun only after root-cause fix. + +#### Warning: HARKing and post-hoc hypothesis rewriting +- **Signal:** Hypothesis statement is modified after results are known without explicit annotation. +- **Consequence:** Learning appears stronger than evidence and cannot be audited. +- **Mitigation:** Preserve original hypothesis reference and log all post-hoc reinterpretations as exploratory. + +#### Warning: Novelty effect and temporal drift +- **Signal:** Early uplift decays across days-since-exposure or time windows. +- **Consequence:** Short-term excitement is misread as durable impact. +- **Mitigation:** Record time-profile interpretation and include long-term risk notes for rollout decisions. + +Source basis: +- https://www.statsig.com/blog/experimental-meta-analysis-and-knowledge-base +- https://www.amazon.science/publications/overcoming-the-winners-curse-leveraging-bayesian-inference-to-improve-estimates-of-the-impact-of-features-launched-via-a-b-tests +- https://arxiv.org/abs/2411.18569 +- https://engineering.atspotify.com/2024/05/fixed-power-designs-its-not-if-you-peek-its-what-you-peek-at +- https://www.statsig.com/perspectives/multiple-comparisons-abtests-care +- https://www.optimizely.com/insights/blog/understanding-and-implementing-guardrail-metrics +- https://amplitude.com/docs/feature-experiment/troubleshooting/sample-ratio-mismatch +- https://www.statsig.com/blog/novelty-effects + +### Draft: Decision Override Governance + +You allow recommendation overrides only with explicit governance metadata. If your final decision differs from decision-rule recommendation, you must capture: +- who approved the override, +- why the override was necessary, +- which risk was accepted, +- what follow-up validation is required. + +You never allow silent overrides. Silent overrides destroy comparability across experiments and make portfolio learning unreliable. + +You mark override decisions for mandatory follow-up review in the next portfolio synthesis cycle. + +Source basis: +- https://docs.statsig.com/exp-templates/decision-framework +- https://docs.geteppo.com/administration/recommended-decisions +- https://www.statsig.com/blog/organization-experiment-policy + +### Draft: JSON Schema Fragment (Experiment Learnings v2) + +```json +{ + "experiment_learnings": { + "type": "object", + "description": "Canonical codified learning artifact created after experiment readout and used for strategic memory.", + "required": [ + "schema_version", + "record_version", + "experiment_id", + "codified_at", + "source_hypothesis_id", + "source_result_ref", + "decision_rule_evaluation", + "quality_gate_status", + "experiment_verdict", + "mechanism_explanation", + "generalizable_learnings", + "hypothesis_updates", + "decision_log", + "confidence", + "implications", + "evidence_links", + "lineage" + ], + "additionalProperties": false, + "properties": { + "schema_version": { + "type": "string", + "description": "Schema contract version for compatibility and migration policy." + }, + "record_version": { + "type": "integer", + "minimum": 1, + "description": "Monotonic version number of this artifact record for reruns and corrections." + }, + "experiment_id": { + "type": "string", + "description": "Stable experiment identifier from the experimentation control plane." + }, + "codified_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when learning codification was completed." + }, + "source_hypothesis_id": { + "type": "string", + "description": "Identifier of the hypothesis that originated this experiment." + }, + "flag_key": { + "type": ["string", "null"], + "description": "Feature flag key when experiment is tied to a flag; null when not applicable." + }, + "metric_set_id": { + "type": ["string", "null"], + "description": "Reference to the metric set/policy used for decision evaluation." + }, + "source_result_ref": { + "type": "object", + "description": "Pointer to analysis outputs used as evidence for codification.", + "required": [ + "artifact_path", + "analysis_run_id", + "analyzed_at" + ], + "additionalProperties": false, + "properties": { + "artifact_path": { + "type": "string", + "description": "Path to the result artifact consumed during codification." + }, + "analysis_run_id": { + "type": "string", + "description": "Identifier of the specific analysis execution." + }, + "analyzed_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the source analysis was generated." + }, + "result_hash": { + "type": ["string", "null"], + "description": "Optional checksum to detect source-result mutation." + } + } + }, + "decision_rule_evaluation": { + "type": "object", + "description": "Structured outcome of decision policy evaluation across metric classes.", + "required": [ + "inference_mode", + "recommendation", + "recommendation_rationale" + ], + "additionalProperties": false, + "properties": { + "inference_mode": { + "type": "string", + "enum": [ + "frequentist_sequential", + "frequentist_fixed_horizon", + "bayesian", + "mixed" + ], + "description": "Inference framework used to interpret this result; prevents cross-mode misreads." + }, + "recommendation": { + "type": "string", + "enum": [ + "rollout", + "do_not_rollout", + "discuss" + ], + "description": "Decision-rule recommendation before human override." + }, + "recommendation_rationale": { + "type": "string", + "description": "Human-readable rationale grounded in metric and quality evidence." + }, + "success_metrics_summary": { + "type": "array", + "description": "Per-success-metric summary needed for downstream audit and synthesis.", + "items": { + "type": "object", + "required": [ + "metric_id", + "direction", + "effect_estimate" + ], + "additionalProperties": false, + "properties": { + "metric_id": { + "type": "string", + "description": "Metric identifier from experiment configuration." + }, + "direction": { + "type": "string", + "enum": [ + "improved", + "neutral", + "regressed" + ], + "description": "Observed directional movement under policy interpretation." + }, + "effect_estimate": { + "type": "number", + "description": "Estimated relative effect used for recommendation." + }, + "interval_lower": { + "type": ["number", "null"], + "description": "Lower confidence/credible interval bound when available." + }, + "interval_upper": { + "type": ["number", "null"], + "description": "Upper confidence/credible interval bound when available." + }, + "p_value": { + "type": ["number", "null"], + "description": "Adjusted p-value when frequentist outputs are available." + } + } + } + }, + "guardrail_metrics_summary": { + "type": "array", + "description": "Per-guardrail no-harm or non-inferiority status used in decision policy.", + "items": { + "type": "object", + "required": [ + "metric_id", + "status" + ], + "additionalProperties": false, + "properties": { + "metric_id": { + "type": "string", + "description": "Guardrail metric identifier." + }, + "status": { + "type": "string", + "enum": [ + "pass", + "fail", + "unknown" + ], + "description": "Policy status of guardrail under configured no-harm rule." + }, + "notes": { + "type": ["string", "null"], + "description": "Optional explanation of edge-case interpretation." + } + } + } + } + } + }, + "quality_gate_status": { + "type": "object", + "description": "Integrity checks that determine whether causal interpretation is valid.", + "required": [ + "is_valid", + "blockers", + "srm_detected" + ], + "additionalProperties": false, + "properties": { + "is_valid": { + "type": "boolean", + "description": "True only when all quality gates pass." + }, + "blockers": { + "type": "array", + "description": "List of integrity blockers discovered during codification.", + "items": { + "type": "string", + "enum": [ + "sample_ratio_mismatch", + "instrumentation_error", + "variant_jumping", + "exposure_mismatch", + "missing_tracking", + "identity_contamination", + "other" + ] + } + }, + "srm_detected": { + "type": "boolean", + "description": "Whether SRM was detected by platform checks or internal diagnostics." + }, + "srm_p_value": { + "type": ["number", "null"], + "description": "SRM test p-value when available; null when not computed." + }, + "notes": { + "type": ["string", "null"], + "description": "Optional details about quality diagnostics and remediation." + } + } + }, + "experiment_verdict": { + "type": "string", + "enum": [ + "validated", + "rejected", + "inconclusive" + ], + "description": "Final codified verdict after applying quality and decision rules." + }, + "inconclusive_reason": { + "type": ["string", "null"], + "enum": [ + "insufficient_sensitivity", + "practically_null", + "quality_invalid", + null + ], + "description": "Required subclass when verdict is inconclusive; null otherwise." + }, + "mechanism_explanation": { + "type": "string", + "description": "Explanation of likely causal mechanism behind the observed outcome." + }, + "generalizable_learnings": { + "type": "array", + "description": "Transferable learning claims derived from this experiment.", + "items": { + "type": "object", + "required": [ + "statement", + "scope", + "confidence" + ], + "additionalProperties": false, + "properties": { + "statement": { + "type": "string", + "description": "Concrete learning statement suitable for reuse." + }, + "scope": { + "type": "string", + "description": "Where the learning is expected to apply, including known boundaries." + }, + "confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low" + ], + "description": "Confidence level assigned to this learning claim." + } + } + } + }, + "rejection_reason": { + "type": ["string", "null"], + "description": "Required explanation when verdict is rejected; null otherwise." + }, + "hypothesis_updates": { + "type": "array", + "description": "Operations applied to the hypothesis stack as a result of codified learning.", + "items": { + "type": "object", + "required": [ + "operation", + "hypothesis_id", + "statement", + "category", + "priority_after", + "confidence_delta", + "rationale" + ], + "additionalProperties": false, + "properties": { + "operation": { + "type": "string", + "enum": [ + "create", + "reprioritize", + "invalidate", + "merge" + ], + "description": "Type of hypothesis stack transition." + }, + "hypothesis_id": { + "type": "string", + "description": "Identifier of affected hypothesis (new or existing)." + }, + "statement": { + "type": "string", + "description": "Hypothesis text after this operation." + }, + "category": { + "type": "string", + "enum": [ + "market", + "product", + "channel", + "pricing" + ], + "description": "Hypothesis domain category for stack organization." + }, + "priority_before": { + "type": ["string", "null"], + "enum": [ + "p0", + "p1", + "p2", + "p3", + null + ], + "description": "Priority before operation; null for newly created hypotheses." + }, + "priority_after": { + "type": "string", + "enum": [ + "p0", + "p1", + "p2", + "p3" + ], + "description": "Priority after operation." + }, + "confidence_delta": { + "type": "number", + "description": "Signed change in confidence resulting from evidence in this experiment." + }, + "rationale": { + "type": "string", + "description": "Reason for this update linked to experiment evidence." + } + } + } + }, + "decision_log": { + "type": "object", + "description": "First-class decision record bridging analysis output and execution governance.", + "required": [ + "decision", + "decided_by", + "decided_at", + "override_of_recommendation" + ], + "additionalProperties": false, + "properties": { + "decision": { + "type": "string", + "enum": [ + "rollout", + "hold", + "do_not_rollout", + "rerun" + ], + "description": "Final human decision after considering recommendation and constraints." + }, + "decided_by": { + "type": "array", + "description": "Identifiers of decision owners/approvers.", + "items": { + "type": "string" + } + }, + "decided_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp of final decision." + }, + "override_of_recommendation": { + "type": "boolean", + "description": "True when final decision differs from policy recommendation." + }, + "override_reason": { + "type": ["string", "null"], + "description": "Mandatory explanation when override_of_recommendation is true." + } + } + }, + "confidence": { + "type": "object", + "description": "Structured uncertainty representation to support downstream filtering and governance.", + "required": [ + "level", + "method", + "interval_summary" + ], + "additionalProperties": false, + "properties": { + "level": { + "type": "string", + "enum": [ + "high", + "medium", + "low" + ], + "description": "Final confidence label used for prioritization and synthesis." + }, + "method": { + "type": "string", + "enum": [ + "frequentist", + "bayesian", + "mixed" + ], + "description": "Uncertainty framework used to derive confidence." + }, + "interval_summary": { + "type": "string", + "description": "Compact interval interpretation supporting audit and human review." + }, + "chance_to_beat_control": { + "type": ["number", "null"], + "description": "Bayesian posterior probability of outperforming control when available." + }, + "expected_loss": { + "type": ["number", "null"], + "description": "Bayesian expected loss under rollout when available." + }, + "notes": { + "type": ["string", "null"], + "description": "Optional context on confidence caveats." + } + } + }, + "implications": { + "type": "array", + "description": "Actionable implications split by domain for strategic planning.", + "items": { + "type": "object", + "required": [ + "domain", + "summary", + "confidence" + ], + "additionalProperties": false, + "properties": { + "domain": { + "type": "string", + "enum": [ + "strategy", + "product", + "go_to_market", + "measurement", + "operations" + ], + "description": "Business domain affected by this learning." + }, + "summary": { + "type": "string", + "description": "Concrete implication statement for this domain." + }, + "confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low" + ], + "description": "Confidence in this implication." + } + } + } + }, + "evidence_links": { + "type": "array", + "description": "Typed evidence references used to support and audit codified learning.", + "items": { + "type": "object", + "required": [ + "type", + "uri", + "relation", + "captured_at" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "result_dashboard", + "spec_doc", + "analysis_notebook", + "decision_record", + "postmortem", + "external_reference" + ], + "description": "Evidence artifact class." + }, + "uri": { + "type": "string", + "format": "uri", + "description": "Location of evidence artifact." + }, + "relation": { + "type": "string", + "enum": [ + "supports", + "contradicts", + "context" + ], + "description": "How this evidence relates to the codified claim." + }, + "captured_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when evidence reference was captured." + }, + "checksum_sha256": { + "type": ["string", "null"], + "description": "Optional checksum for immutable evidence validation." + } + } + } + }, + "lineage": { + "type": "object", + "description": "Lineage metadata connecting this learning artifact to upstream and sibling records.", + "required": [ + "parent_learning_ids", + "dataset_versions" + ], + "additionalProperties": false, + "properties": { + "parent_learning_ids": { + "type": "array", + "description": "Identifiers of prior learning artifacts directly referenced in synthesis.", + "items": { + "type": "string" + } + }, + "dataset_versions": { + "type": "array", + "description": "Version identifiers for datasets used in upstream analysis.", + "items": { + "type": "string" + } + }, + "source_code_ref": { + "type": ["string", "null"], + "description": "Optional code revision reference for reproducibility." + } + } + }, + "pattern_synthesis_hint": { + "type": "object", + "description": "Metadata that helps schedulers include this artifact in batch learning reviews.", + "required": [ + "include_in_next_portfolio_review" + ], + "additionalProperties": false, + "properties": { + "include_in_next_portfolio_review": { + "type": "boolean", + "description": "True when this learning should be reviewed in the immediate next synthesis cycle." + }, + "reason": { + "type": ["string", "null"], + "description": "Optional reason for forced inclusion." + } + } + } + } + } +} +``` + +### Draft: JSON Schema Fragment (Hypothesis Stack v2) + +```json +{ + "experiment_hypothesis_stack": { + "type": "object", + "description": "Current strategic hypothesis stack updated by experiment learning operations.", + "required": [ + "schema_version", + "updated_at", + "hypotheses" + ], + "additionalProperties": false, + "properties": { + "schema_version": { + "type": "string", + "description": "Schema version for hypothesis stack artifact." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp of most recent stack update." + }, + "hypotheses": { + "type": "array", + "description": "Active and archived hypotheses with evidence-backed status and priority.", + "items": { + "type": "object", + "required": [ + "hypothesis_id", + "statement", + "category", + "status", + "priority", + "last_updated_at", + "evidence_refs" + ], + "additionalProperties": false, + "properties": { + "hypothesis_id": { + "type": "string", + "description": "Stable identifier for hypothesis traceability." + }, + "statement": { + "type": "string", + "description": "Current hypothesis statement used for planning." + }, + "category": { + "type": "string", + "enum": [ + "market", + "product", + "channel", + "pricing" + ], + "description": "Hypothesis domain category." + }, + "status": { + "type": "string", + "enum": [ + "active", + "validated", + "rejected", + "archived" + ], + "description": "Current lifecycle status of the hypothesis." + }, + "priority": { + "type": "string", + "enum": [ + "p0", + "p1", + "p2", + "p3" + ], + "description": "Current execution priority in strategic backlog." + }, + "last_updated_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp of last evidence-backed update." + }, + "evidence_refs": { + "type": "array", + "description": "List of learning artifact IDs supporting this hypothesis status.", + "items": { + "type": "string" + } + } + } + } + } + } + } +} +``` + +Source basis: +- https://json-schema.org/draft/2020-12 +- https://openlineage.io/docs/spec/ +- https://raw.githubusercontent.com/cloudevents/spec/v1.0/spec.md +- https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-events/ +- https://openfeature.dev/specification/sections/flag-evaluation/ + +### Draft: "Do Not Do This" Rules (Paste-Ready) + +You do not do any of the following: + +- You do not publish a final verdict when `quality_gate_status.is_valid` is false. +- You do not treat `p > 0.05` on guardrails as equivalent to "safe." +- You do not collapse all unresolved outcomes into one generic `inconclusive` label. +- You do not drop rejected hypotheses without storing explicit rejection mechanism and follow-up direction. +- You do not update hypothesis priorities without evidence links and rationale. +- You do not archive only wins; all valid outcomes must be retained for portfolio memory. +- You do not introduce undocumented methods or hidden tool calls in this skill. + +If any of these violations occur, you write the learning artifact as policy-invalid and require remediation before strategic use. + +--- + +## Gaps & Uncertainties + +- No single public industry standard defines the exact minimum number of experiments required before meta-analysis is reliable; guidance varies by context and metric volatility. +- Vendor documentation quality is high for platform usage but uneven for deep statistical internals; some implementation details remain black-box. +- Public benchmarks for non-inferiority margin selection in product guardrails are sparse; margin policy is usually domain-specific. +- Cross-platform entity semantics remain inconsistent (`experiment`, `flag`, `rule`, `layer`), so internal canonical mapping design still requires local decisions. +- Open-source/public case studies are biased toward notable incidents and may overrepresent failure extremes relative to normal operations. +- Several standards used in schema recommendations (CloudEvents, JSON Schema 2020-12) are older than 2024; they are intentionally included as evergreen interoperability foundations. +- There is no universal policy mapping for Bayesian outputs (for example, expected loss thresholds) into a single cross-industry ship/no-ship rule; local policy is still required. +- Several source-backed operational constraints (rate limits, write behavior) vary by plan tier and require implementation-time confirmation in the target workspace. diff --git a/flexus_simple_bots/strategist/skills/_experiment-learning/SKILL.md b/flexus_simple_bots/strategist/skills/_experiment-learning/SKILL.md new file mode 100644 index 00000000..0f07b30a --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_experiment-learning/SKILL.md @@ -0,0 +1,53 @@ +--- +name: experiment-learning +description: Experiment learning codification — convert experiment results into durable strategic learnings and update hypothesis stack +--- + +You convert completed experiment results into durable strategic learnings and update the hypothesis stack accordingly. Experiments that don't produce documented learnings are organizational waste — the insight dies with the experiment. + +Core mode: learning > shipping. A rejected hypothesis that updates your model of the world is more valuable than a "winner" that nobody understands why it worked. Document the mechanism, not just the outcome. + +## Methodology + +### Learning extraction +For validated hypotheses: +- What mechanism explains the result? (Why did it work, not just that it worked) +- Does this learning generalize to other experiments or product areas? +- What does this imply about the user's mental model or behavior? + +For rejected hypotheses: +- What was the specific prediction that was wrong? +- What does the rejection tell us about the underlying assumption? +- What new hypothesis does this rejection generate? + +For inconclusive results: +- What does this tell us about the size of the effect we were looking for? +- Did we learn anything about measurement or instrumentation that changes future experiment design? + +### Hypothesis stack update +After each experiment: +1. Update hypothesis status (validated → `validated`, rejected → `rejected`) +2. Generate new hypotheses from learnings (a validated hypothesis often generates product hypotheses; a rejected one often generates new market hypotheses) +3. Reprioritize the stack based on new information + +### Pattern synthesis +After 5+ experiments: look for patterns across the stack: +- Are market hypotheses consistently validating while product hypotheses fail? → Product-market fit gap +- Are certain user segments consistently responding better? → ICP refinement +- Are certain channels consistently efficient? → Channel focus recommendation + +## Recording + +``` +write_artifact(path="/experiments/{experiment_id}/learnings", data={...}) +write_artifact(path="/strategy/hypothesis-stack", data={...}) +``` + +## Available Tools + +``` +flexus_policy_document(op="activate", args={"p": "/experiments/{experiment_id}/results"}) +flexus_policy_document(op="activate", args={"p": "/experiments/{experiment_id}/spec"}) +flexus_policy_document(op="activate", args={"p": "/strategy/hypothesis-stack"}) +flexus_policy_document(op="list", args={"p": "/experiments/"}) +``` diff --git a/flexus_simple_bots/strategist/skills/_gtm-launch-plan/RESEARCH.md b/flexus_simple_bots/strategist/skills/_gtm-launch-plan/RESEARCH.md new file mode 100644 index 00000000..28283bd8 --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_gtm-launch-plan/RESEARCH.md @@ -0,0 +1,1044 @@ +# Research: gtm-launch-plan + +**Skill path:** `strategist/skills/gtm-launch-plan/` +**Bot:** strategist (researcher | strategist | executor) +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`gtm-launch-plan` turns strategy artifacts into operational execution for the first 90 days: owners, dates, milestones, and measurable outcomes. The current `SKILL.md` already enforces an important principle ("one owner per milestone"), but it still leaves major quality risks under-specified: launch readiness gates, modern cross-channel sequencing, signal interpretation in noisy early windows, and compliance/deliverability checks that can quietly invalidate outreach. + +Research from 2024-2026 shows that launch execution now has to account for multi-channel buyer behavior, larger and more complex buying ecosystems, AI-mediated discovery, stricter outreach platform controls, and persistent attribution disagreement. This research package is designed so a future editor can upgrade the skill from a good checklist into a robust operating system for launch execution. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024-2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- No generic filler ("it is important to...", "best practices suggest...") without concrete backing: **passed** +- No invented tool names, method IDs, or API endpoints - only verified real ones: **passed** +- Contradictions between sources are explicitly noted, not silently resolved: **passed** +- Volume: findings section should be 800-4000 words (too short = shallow, too long = unsynthesized): **passed** + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +Modern launch methodology should be designed as omnichannel orchestration, not a one-channel campaign. McKinsey reports B2B buyers commonly use around 10 channels and expect seamless switching; fragmented journeys can trigger supplier switching. Practically, launch plans should include cross-channel handoff readiness as a pre-launch gate, not a postmortem topic [A1]. + +Practical sequencing starts earlier than many startup launch plans assume. HubSpot and Asana both emphasize a 6-8 week preparation window, and HubSpot includes early customer/prospect interviews (12-15) before finalizing messaging. This implies "messaging locked" should be evidence-backed, not a subjective status [A4][A5]. + +Task-level accountability discipline is still a hard requirement. Asana's RACI guidance reinforces one Responsible and one Accountable per deliverable. In launch operations, this maps directly to the skill's existing "one owner per milestone" rule and supports keeping that constraint as non-negotiable [A6]. + +Launch readiness should include commercial-speed gates, not only content/asset completion. LeanData reports substantial delays in first lead activity and opportunity creation among many teams; teams with better automation/reporting discipline perform materially better. This supports adding SLA gates for response/handoff speed before broad launch expansion [A3]. + +Buying environments now combine broad stakeholder influence with pressure for fast decision progress. Forrester reports high multi-department participation and frequent buying stalls, while G2 reports shifts toward smaller active decision cores in software. The practical resolution: map wide stakeholder influence, but run fast owner-led execution cells [A2][A7]. + +AI-mediated discovery behavior is a meaningful methodology change versus older playbooks. G2 reports stronger preference for later-stage seller engagement and more AI-assisted research initiation. This suggests launch-week operations should include proof assets (comparisons, FAQs, customer evidence) before assuming outbound can carry demand alone [A7][A8]. + +A strict 30/60/90 cadence is now table stakes for operational launches, not an optional reporting format. Sources converge on structured pre-launch preparation, launch-week execution, and recurring post-launch iteration windows. Treat day 90 as the first full learning horizon, not the finish line [A4][A5]. + +Another major shift is from lead-centric optimization to opportunity- and buying-group-centric execution. LeanData and McKinsey evidence points to higher complexity and stronger need for account-level orchestration. Launch plans should therefore include opportunity progression and buying-group coverage metrics, not only top-of-funnel volume [A1][A3]. + +**Sources:** +- [A1] McKinsey, "Five fundamental truths: How B2B winners keep growing" - https://www.mckinsey.com/capabilities/growth-marketing-and-sales/our-insights/five-fundamental-truths-how-b2b-winners-keep-growing - 2024-09 +- [A2] Forrester press release, "The State of Business Buying, 2024" - https://www.forrester.com/press-newsroom/forrester-the-state-of-business-buying-2024/ - 2024-12 +- [A3] LeanData, "The 2024 State of Go-to-Market Efficiency Report" - https://www.leandata.com/wp-content/uploads/2024/06/LeanData-The-2024-State-GTM-Efficiency-Report.pdf - 2024-06 +- [A4] HubSpot, "Product Launch Checklist" - https://blog.hubspot.com/marketing/product-launch-checklist - 2024-09 +- [A5] Asana, "5 steps to successfully managing product launches" - https://asana.com/resources/successful-product-launch-marketing - 2025-10 +- [A6] Asana, "RACI Charts: The Ultimate Guide" - https://asana.com/resources/raci-chart - 2025-12 +- [A7] G2, "Buyer Behavior Report 2025" - https://learn.g2.com/hubfs/G2-2025-Buyer-Behavior-Report-AI-Always-Included.pdf?hsLang=en - 2025-04 +- [A8] G2, "How AI is Redefining the Buyer Journey in 2025" - https://company.g2.com/news/buyer-behavior-in-2025 - 2025-05 + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +For launch planning execution, tool quality should be judged by operational reliability under deadline pressure, not feature count. CRM, analytics, communication, and calendar systems all have quota/rate/latency behavior that can distort launch metrics or break automations if not considered up front. + +HubSpot and Salesforce remain common CRM anchors for launch operations. HubSpot provides structured CRM object APIs and explicit rate-limit headers; Salesforce exposes API limit visibility and can enforce protection behavior when sustained limits are exceeded. Launch plans should include explicit API budget and retry strategy where workflow automation depends on CRM writes [T1][T2][T3]. + +Marketing automation and campaign channels require versioning and concurrency governance. Mailchimp enforces connection/time constraints; LinkedIn Marketing APIs require explicit version headers and sunset awareness. A launch plan should include a technical "API version freeze" decision in pre-launch readiness [T4][T19][T20]. + +Analytics systems differ materially in ingestion and quality failure modes. Amplitude documents EPS and payload constraints; Mixpanel documents hot-shard behavior tied to identifier design; PostHog applies endpoint and organization-level limits with separate public/private API expectations. These are not edge cases during launches; they are common causes of silent data quality degradation [T7][T8][T9]. + +GA4 should be interpreted with freshness and thresholding caveats. Intraday reporting and attribution updates can shift after processing windows, and privacy thresholding is system-defined. Launch dashboards should explicitly label "directional intraday" versus "decision-grade daily" views [T10][T11][T12]. + +Attribution recovery tooling (for example enhanced conversions for leads) is increasingly relevant in first-party data environments, but requires exact implementation details (terms acceptance, hashing normalization, partial-failure handling, delayed visibility expectations). Launch plans that assume immediate perfect matching will overreact to early noise [T13][T14]. + +Project/calendar orchestration tools (Asana and Google Calendar APIs) are useful, but quota and retry discipline are mandatory under high event mutation patterns. Push-based sync and controlled backoff patterns are safer than high-frequency polling during launch week [T5][T6]. + +Slack and LinkedIn community/social integration is often constrained by method-specific or app-status-specific limits. Teams should treat community data ingestion as sampled operational input, not a guaranteed full-fidelity telemetry stream [T15][T16][T17][T18]. + +De-facto standards across sources: +1) One operational source of truth for owners/tasks (CRM or PM system), +2) explicit API rate-limit handling with retries, +3) clear distinction between directional and decision-grade metrics, +4) version/change management for APIs used in launch workflows. + +**Practical Tooling Matrix** + +| category | common options | what data you get | major caveat | +|---|---|---|---| +| CRM | HubSpot, Salesforce | Contacts/accounts/deals, ownership, lifecycle progression | Rate and daily API usage constraints can break high-volume automation [T1][T3] | +| Marketing automation / outreach | Mailchimp, HubSpot workflows, LinkedIn lead flows | Campaign events, audience state, lead sync data | Concurrency/timeouts and API version sunsets can interrupt launch [T4][T19] | +| Product analytics | Amplitude, Mixpanel, PostHog | Event streams, user behavior, feature usage | Identifier design and throttling rules can distort early metrics [T7][T8][T9] | +| Web analytics | GA4 | Session/source/conversion trend data | Freshness and thresholding caveats affect daily interpretation [T10][T12] | +| Attribution / conversion recovery | GA4 attribution settings, Google Ads enhanced conversions | Attribution model outputs, click-to-conversion matching | Model/lookback changes alter comparability over time [T11][T13][T14] | +| PM/calendar orchestration | Asana, Google Calendar API | Owners, due dates, launch event schedules | Quota and retry constraints matter under frequent updates [T5][T6] | +| Community/social operations | Slack, LinkedIn | Community activity and paid social outcomes | Method-specific throttling and app-policy changes [T15][T16][T18] | + +**Sources:** +- [T1] HubSpot Developers, "API usage guidelines and limits" - https://developers.hubspot.com/docs/api/usage-details - 2026-03 +- [T2] HubSpot Developers, "CRM API | Contacts" - https://developers.hubspot.com/docs/api-reference/crm-contacts-v3/guide - 2026-03 +- [T3] Salesforce Developers Blog, "API Limits and Monitoring Your API Usage" - https://developer.salesforce.com/blogs/2024/11/api-limits-and-monitoring-your-api-usage - 2024-11 +- [T4] Mailchimp Developer, "Fundamentals" - https://mailchimp.com/developer/marketing/docs/fundamentals/ - 2026-03 +- [T5] Asana Developers, "Rate limits" - https://developers.asana.com/docs/rate-limits - 2026-03 +- [T6] Google for Developers, "Manage quotas (Calendar API)" - https://developers.google.com/calendar/api/guides/quota - 2026-03 +- [T7] Amplitude Docs, "HTTP V2 API" - https://amplitude.com/docs/apis/analytics/http-v2 - 2024-05 +- [T8] Mixpanel Docs, "Hot Shard Limits" - https://docs.mixpanel.com/docs/tracking-best-practices/hot-shard-limits - 2026-03 +- [T9] PostHog Docs, "API overview" - https://posthog.com/docs/api/overview - 2026-03 +- [T10] Google Analytics Help, "Data freshness" - https://support.google.com/analytics/answer/11198161 - 2026-03 +- [T11] Google Analytics Help, "Select attribution settings" - https://support.google.com/analytics/answer/10597962 - 2026-03 +- [T12] Google Analytics Help, "About data thresholds" - https://support.google.com/analytics/answer/9383630 - 2026-03 +- [T13] Google for Developers, "Enhanced conversions for leads overview" - https://developers.google.com/google-ads/api/docs/conversions/enhanced-conversions/overview - 2026-03 +- [T14] Google for Developers, "ConversionUploadService.UploadClickConversions" - https://developers.google.com/google-ads/api/reference/rpc/v23/ConversionUploadService/UploadClickConversions - 2026-03 +- [T15] Slack Developer Docs, "Web API rate limits" - https://docs.slack.dev/apis/web-api/rate-limits/ - 2025-05 +- [T16] Slack Developer Docs changelog, "Rate limit changes for non-Marketplace apps" - https://docs.slack.dev/changelog/2025/05/29/rate-limit-changes-for-non-marketplace-apps - 2025-05 +- [T17] Slack Developer Docs, "`conversations.history`" - https://docs.slack.dev/reference/methods/conversations.history/ - 2025-05 +- [T18] Microsoft Learn (LinkedIn), "API Rate Limiting" - https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/rate-limits - 2025-08 +- [T19] Microsoft Learn (LinkedIn), "Marketing API Versioning" - https://learn.microsoft.com/en-us/linkedin/marketing/versioning?view=li-lms-2025-10 - 2026-02 +- [T20] Microsoft Learn (LinkedIn), "Recent Marketing API Changes" - https://learn.microsoft.com/en-us/linkedin/marketing/integrations/recent-changes?view=li-lms-2026-02 - 2026-02 + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +Early launch interpretation should prioritize click/reply/progression quality over open-rate optics. 2024-2025 benchmark reports show opens can move opposite to clicks and CTOR due privacy and platform behavior changes. A launch plan that treats open-rate drop as immediate channel failure will often make the wrong decision [D1][D2]. + +Benchmarks must be segmented by industry, region, and motion. Public averages hide large spreads; for example landing page conversion differs materially by channel and vertical, and cold outreach productivity is heavily skewed between average and top performers. Use segmented references and include confidence notes in recommendations [D3][D4]. + +Copy and sequence structure can be the dominant lever in early outbound performance. If reply rates are weak, fixing email structure and message framing can produce larger gains than prematurely abandoning ICP/channel hypotheses [D3]. + +Channel mix changes can mimic execution changes. When traffic mix shifts toward lower-converting channels, blended CVR can fall even if each channel remains stable. Launch scorecards should include both blended and channel-normalized views [D4]. + +Stage-level funnel diagnosis is higher signal than single end-to-end conversion numbers. Where benchmark ranges are available, use them to identify leak stage before recommending broad strategy pivots. Without stage decomposition, interventions tend to be expensive and unfocused [D5]. + +CAC/payback targets are useful but should be interpreted as context-dependent guardrails, not universal constants. Segment differences are large; early-stage decisions should use directional thresholding plus trend stability, not one static number [D6]. + +Early product activation and time-to-value (TTV) can be stronger leading indicators than user-count growth in the first 60 days. If activation is weak, scaling acquisition often increases waste and masks root-cause onboarding/product issues [D7][D8]. + +Attribution disagreement is common and is itself a warning signal. If tools disagree materially, launch plans should reduce decision aggressiveness and require triangulation before major budget reallocation [D9][D10]. + +Opportunity-creation lag means closed-won outcomes are too delayed to be the only decision basis in first-90-day windows. Use leading indicators for near-term decisions, then validate with lagging outcomes as cohorts mature [D11]. + +**Decision Rules for 30/60/90** + +- **30-day rule:** If opens move but clicks/CTOR and qualified responses do not confirm, treat as signal conflict and fix creative/targeting before channel reallocation [D1][D2]. +- **30-day rule:** If outbound reply is weak and emails are long/pitch-heavy, rewrite to concise, value-first sequence before changing ICP assumptions [D3]. +- **60-day rule:** If one funnel stage underperforms benchmark bands while others are stable, target that stage (handoff, qualification, offer) instead of rewriting whole launch plan [D5]. +- **60-day rule:** If activation/TTV underperforms while top-funnel is healthy, pause scale and focus on onboarding/product value delivery [D7][D8]. +- **90-day rule:** If attribution systems disagree, require at least two independent methods before major budget shifts [D9][D10]. +- **90-day rule:** If opportunity creation is lagging but leading indicators are improving and incubation windows are still open, extend observation rather than forcing full GTM reset [D11]. + +**Contradictions / Context Dependence** + +- Opens can decline while clicks/replies improve; interpretation changes by metric layer [D1][D2]. +- Email can look highly efficient for certain LP cohorts while cold outbound productivity remains harsh at average-rep level [D3][D4]. +- Agency-derived benchmark sources are useful directional references but should be marked medium confidence for universalization [D5][D6]. +- Last-touch attribution can over-credit short-cycle channels versus broader incrementality outcomes [D10]. + +**Sources:** +- [D1] Zeta Global, "Q2 2024 Email Marketing Benchmark Report" - https://zetaglobal.com/wp-content/uploads/2024/09/24Q2-Email-Benchmark-Report.pdf - 2024-09 +- [D2] MailerLite, "Email benchmarks by industry and region for 2026" - https://www.mailerlite.com/blog/compare-your-email-performance-metrics-industry-benchmarks - 2025-12 +- [D3] Gong Labs, "Does cold email even work any more?" - https://www.gong.io/resources/labs/does-cold-email-even-work-any-more-heres-what-the-data-says/ - 2025-07 +- [D4] Unbounce, "Average landing page conversion rates (Q4 2024)" - https://unbounce.com/average-conversion-rates-landing-pages - 2024-12 +- [D5] First Page Sage, "B2B SaaS Funnel Conversion Benchmarks" - https://firstpagesage.com/seo-blog/b2b-saas-funnel-conversion-benchmarks-fc/ - 2025-06 +- [D6] First Page Sage, "SaaS CAC Payback Benchmarks: 2025 Report" - https://firstpagesage.com/reports/saas-cac-payback-benchmarks/ - 2024-06 +- [D7] Mind the Product, "Introducing our product benchmarking report" - https://www.mindtheproduct.com/introducing-our-product-benchmarking-report - 2024-06 +- [D8] Pendo, "Product Benchmarks for Top Teams" - https://pendo.io/product-benchmarks - 2024-06 +- [D9] MMA Global, "State of Attribution: 8th Annual Marketer Benchmark Report" - https://www.mmaglobal.com/matt/state-of-attribution - 2024-06 +- [D10] MarketingCharts, "Attribution models biased to short-term effects" - https://www.marketingcharts.com/customer-centric/analytics-automated-and-martech-234337 - 2024-11 +- [D11] Gradient Works, "The sales incubation period" - https://www.gradient.works/blog/the-sales-incubation-period-outbounds-overlooked-metric - 2025-05 + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +1) **Pattern:** Budget-agnostic launch planning. +**Detection signal:** Plan lists channels and tasks, but no spend guardrails, efficiency thresholds, or stop rules. +**Consequence:** Mid-cycle cuts and reactive pivots that break launch sequencing. +**Mitigation:** Add channel-level efficiency guardrails and pre-agreed pause/scale triggers [F1][F2]. + +2) **Pattern:** Awareness-first with no proof-of-value milestones. +**Detection signal:** 30/60/90 plan lacks time-to-first-value or measurable customer outcome targets. +**Consequence:** Early churn, buyer skepticism, and stalled opportunities. +**Mitigation:** Require concrete value proof milestones in weeks 2-8 [F2][F3]. + +3) **Pattern:** Late legal/security/procurement alignment. +**Detection signal:** Opportunities advance commercially but stall in compliance/contracting. +**Consequence:** Forecast misses and avoidable late-stage losses. +**Mitigation:** Front-load legal/security artifacts in pre-launch checklist [F3][F1]. + +4) **Pattern:** Scale motions on weak data quality. +**Detection signal:** Low trust in CRM and high manual reporting burden. +**Consequence:** Mis-targeting, unreliable forecasting, and misread channel performance. +**Mitigation:** Add data readiness sprint and hygiene SLAs before launch [F4][F1]. + +5) **Pattern:** Build-first, PMF-later launch. +**Detection signal:** Product shipped, but usage/value signals remain weak or non-repeatable. +**Consequence:** Burn without durable revenue progression. +**Mitigation:** Gate scale by repeatable validation milestones (problem proof -> paid pilot -> repeatable conversion) [F5][F6]. + +6) **Pattern:** Pricing disconnected from pain intensity. +**Detection signal:** Demo interest but repeated drop-off at pricing/procurement stages. +**Consequence:** Active-looking pipeline with low monetization. +**Mitigation:** Tie price to quantified pain and run paid design-partner tests [F6][F5]. + +7) **Pattern:** Deliverability treated as optional technical detail. +**Detection signal:** Missing SPF/DKIM/DMARC, poor unsubscribe handling, rising complaint risk. +**Consequence:** Outreach enters spam pathways, causing false "messaging failed" diagnosis. +**Mitigation:** Include deliverability as hard launch blocker with ongoing monitoring [F7][F8]. + +8) **Pattern:** Transactional/promotional policy mixing. +**Detection signal:** Promotional language in transactional streams or deceptive headers/subjects. +**Consequence:** Legal exposure and deliverability deterioration. +**Mitigation:** Separate governance for transactional vs marketing streams [F7][F13]. + +9) **Pattern:** "Consent theater." +**Detection signal:** Front-end opt-out accepted but backend trackers/data sharing continue. +**Consequence:** Regulatory enforcement and remediation overhead. +**Mitigation:** Test consent propagation end-to-end and run recurring tracker audits [F9][F10][F11]. + +10) **Pattern:** One-time compliance checklist with no monitoring cadence. +**Detection signal:** No periodic review despite active regulatory changes and enforcement activity. +**Consequence:** Plan drifts out of compliance during first 90 days. +**Mitigation:** Add monthly regulatory delta review tied to GTM backlog updates [F15][F10]. + +**Bad vs Good Output Signatures** + +- **Bad:** task list with dates but no owner accountability model beyond team labels; **Good:** one named owner per deliverable plus escalation owner. +- **Bad:** launch plan with activity metrics only; **Good:** includes value, conversion, and efficiency guardrails tied to decisions. +- **Bad:** "consent banner installed" as privacy proof; **Good:** consent propagation and tracker audit evidence. +- **Bad:** outbound volume targets without deliverability gates; **Good:** SPF/DKIM/DMARC + complaint controls before scale. +- **Bad:** single blended paid search KPI; **Good:** explicit segmentation and caveat notes for attribution confidence. + +**Sources:** +- [F1] Gartner, "CMO Survey 2024" - https://www.gartner.com/en/newsroom/press-releases/2024-05-13-gartner-cmo-survey-reveals-marketing-budgets-have-dropped-to-7-7-percent-of-overall-company-revenue-in-2024 - 2024-05 +- [F2] G2, "Buyer Behavior in 2024" - https://company.g2.com/news/buyer-behavior-in-2024 - 2024-06 +- [F3] G2, "2024 Buyer Behavior Report" - https://research.g2.com/hubfs/2024-buyer-behavior-report.pdf - 2024-06 +- [F4] Salesforce, "Sales Teams Using AI" - https://www.salesforce.com/news/stories/sales-ai-statistics-2024/?bc=HA - 2024-07 +- [F5] Basis Robotics postmortem - https://basisrobotics.tech/2025/01/08/postmortem/ - 2025-01 +- [F6] Niko Noll, "Lessons from a 1.5 year SaaS journey" - https://www.nikonoll.com/p/post-mortem-lessons-from-a-15-year - 2024-11 +- [F7] Google Workspace Help, "Email sender guidelines" - https://support.google.com/mail/answer/81126 - 2024-02 +- [F8] Yahoo Sender Hub, "Sender Best Practices" - https://senders.yahooinc.com/best-practices - 2024-02 +- [F9] California DOJ, "Largest CCPA settlement (Healthline)" - https://oag.ca.gov/news/press-releases/attorney-general-bonta-announces-largest-ccpa-settlement-date-secures-155 - 2025-07 +- [F10] CPPA, "Joint investigative privacy sweep (GPC)" - https://cppa.ca.gov/announcements/2025/20250909.html - 2025-09 +- [F11] IAPP, "Opt-out functionality enforcement analysis" - https://iapp.org/news/a/gaps-in-website-opt-out-functionality-under-the-microscope-in-privacy-enforcement - 2025-12 +- [F12] EDPB, "Opinion 08/2024 on consent or pay models" - https://www.edpb.europa.eu/system/files/2024-04/edpb_opinion_202408_consentorpay_en.pdf - 2024-04 +- [F13] FTC, "CAN-SPAM Compliance Guide" - https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business - 2023-08 **[Evergreen]** +- [F14] EUR-Lex, "C-252/21 Meta v Bundeskartellamt" - https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:62021CA0252 - 2023-07 **[Evergreen]** +- [F15] IAPP, "Retrospective: 2025 in state privacy law" - https://iapp.org/news/a/retrospective-2025-in-state-data-privacy-law/ - 2025-11 + +--- + +### Angle 5+: Regulatory, Compliance, and Deliverability Constraints +> Domain-specific angle: launch plans fail operationally when outreach and analytics controls are treated as legal afterthoughts rather than execution prerequisites. + +**Findings:** + +Deliverability controls became stricter in 2024 and should be encoded as launch gates. Gmail/Yahoo sender requirements raised the floor for authentication and complaint management. Without these controls, outbound metrics become invalid because delivery quality is unstable [C1][C2]. + +Consent enforcement has shifted toward implementation verification, not policy text. 2025 California enforcement signals focus on whether user choices propagate to actual tracking/data-sharing behavior. Launch plans that only track "banner present" are no longer sufficient [C3][C4]. + +"Consent or pay" and related platform consent patterns remain contested and context-dependent in EU guidance. If the launch includes behaviorally targeted acquisition in regulated regions, explicit legal review should be built into pre-launch readiness and weekly post-launch checks [C5][C6]. + +Email compliance remains an evergreen requirement, but practical risk management must be operationalized with owner/date/status fields, just like product milestones. Compliance controls should be represented as tracked tasks, not static policy notes [C7]. + +Regulatory drift is now normal. A fixed compliance review at T-30 is insufficient for a 90-day launch window; teams need recurring review cadence and change-triggered backlog updates [C4][C8]. + +**Sources:** +- [C1] Google Workspace Help, "Email sender guidelines" - https://support.google.com/mail/answer/81126 - 2024-02 +- [C2] Yahoo Sender Hub, "Sender Best Practices" - https://senders.yahooinc.com/best-practices - 2024-02 +- [C3] California DOJ, CCPA settlement announcement - https://oag.ca.gov/news/press-releases/attorney-general-bonta-announces-largest-ccpa-settlement-date-secures-155 - 2025-07 +- [C4] CPPA announcement, GPC privacy sweep - https://cppa.ca.gov/announcements/2025/20250909.html - 2025-09 +- [C5] EDPB Opinion 08/2024 - https://www.edpb.europa.eu/system/files/2024-04/edpb_opinion_202408_consentorpay_en.pdf - 2024-04 +- [C6] EUR-Lex C-252/21 ruling text - https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:62021CA0252 - 2023-07 **[Evergreen]** +- [C7] FTC CAN-SPAM guide - https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business - 2023-08 **[Evergreen]** +- [C8] IAPP, state privacy law retrospective - https://iapp.org/news/a/retrospective-2025-in-state-data-privacy-law/ - 2025-11 + +--- + +## Synthesis + +The strongest consensus across angles is that a GTM launch plan in 2026 must behave like an execution operating system, not a campaign checklist. Sources align on complexity growth (more channels, larger stakeholder influence, AI-mediated discovery) and on the need for explicit owner-and-gate discipline [A1][A2][A7]. + +A second conclusion is that tool availability is not the hard part; interpretation quality is. Organizations can assemble CRM, automation, and analytics stacks quickly, but first-90-day decisions still fail when teams over-trust single metrics, ignore latency/attribution caveats, or do not segment by channel context [T10][D1][D4][D9]. + +The major contradiction in research is apparent team-size and process tension: broad buying influence remains real, but active decision cells can be smaller and faster. The practical reconciliation is to keep wide stakeholder mapping while assigning single-thread execution ownership per milestone and phase [A2][A6][A7]. + +Most actionable for `SKILL.md`: force explicit decision logic into the artifact itself. The skill should require launch gates, interpretation guardrails, anti-pattern monitoring, and compliance/deliverability checks as first-class fields, so weak plans become structurally difficult to produce. + +--- + +## Recommendations for SKILL.md + +> Concrete, actionable list of what should change / be added in the SKILL.md based on research. + +- [ ] Expand `## Methodology` from phase checklist to decision-gated operating sequence (pre-launch, launch-week, 30/60/90) with explicit scale/hold/stop rules. +- [ ] Add a mandatory launch readiness gate section with commercial-speed, data-readiness, and compliance/deliverability criteria, not only product/payment checks. +- [ ] Add `## Metrics Interpretation Rules` with channel-normalized and stage-level diagnosis logic, including explicit handling for attribution disagreement. +- [ ] Add named anti-pattern warning blocks (pattern, detection signal, consequence, mitigation) so outputs include failure prevention, not just activity planning. +- [ ] Update `## Available Tools` guidance to enforce activation order, artifact-write timing, and conflict-handling behavior. +- [ ] Expand artifact schema with decision evidence, risk register, compliance checks, measurement plan, and decision log fields. +- [ ] Add explicit "no-go" conditions where launch should be delayed despite calendar pressure. + +--- + +## Draft Content for SKILL.md + +> This section is paste-ready draft language for the future `SKILL.md` editor. + +### Draft: Core Operating Mandate + +Use this near the top of `SKILL.md` after the short description. + +--- +You produce the operational launch plan for the first 90 days after launch: who does what, by when, with what evidence threshold, and what decision happens if outcomes miss target. + +A launch plan is invalid if it lacks: +- one named owner per milestone, +- a due date, +- a measurable success signal, +- and a predeclared decision rule (scale, hold, or stop). + +You are not writing a wish list. You are writing an execution system under uncertainty. When evidence quality is low, you must lower confidence and tighten scope rather than inflating certainty. +--- + +### Draft: Methodology + +Use this to replace the current methodology section. + +--- +### Methodology + +Before drafting the launch plan, activate upstream strategy documents and extract constraints from each: +- channel priorities and exclusions, +- pilot/pricing constraints, +- MVP validation criteria. + +If those inputs conflict, do not silently pick one. Record the conflict in assumptions and continue with reduced confidence. + +#### Step 1: Define launch objective and no-go conditions + +Write one objective sentence with a measurable 90-day outcome (for example: "Reach 3 paid pilots with onboarding complete by day 90"). +Then define no-go conditions. You must delay launch if any no-go condition is true, regardless of timeline pressure. + +Minimum no-go examples: +1. Core job-to-be-done not validated with at least one live user. +2. Payment/invoicing path not operational. +3. No accountable owner for support escalations. +4. Required privacy/compliance controls not active. +5. Outreach deliverability controls (SPF/DKIM/DMARC + unsubscribe handling) not verified. + +#### Step 2: Build pre-launch readiness plan (T-30 to T-0) + +Create a pre-launch checklist where every item includes owner, due date, status, and proof evidence. + +Required readiness categories: +1. **Market and message readiness:** target account/prospect list finalized, positioning approved, objection handling prepared. +2. **Execution readiness:** outreach sequence loaded, calendar blocks scheduled, meeting routing tested, escalation owner defined. +3. **Data readiness:** event tracking validated, dashboard ownership assigned, source-of-truth documented. +4. **Compliance and deliverability readiness:** consent and opt-out flows tested end-to-end; sender authentication and policy checks complete. + +Do not mark "ready" based on verbal confirmation. Require objective proof (artifact link, screenshot, QA note, or test record). + +#### Step 3: Run launch week as a controlled execution window (T-0 to T+7) + +Launch week is not "do everything fast." It is a controlled test of the operating plan. + +For each day, define: +- activities by owner, +- expected leading indicators, +- escalation condition if metrics degrade. + +Required launch-week motions: +1. first outreach wave to highest-fit accounts, +2. direct network/community announcement (if relevant), +3. daily signal review with one decision owner. + +If deliverability or response quality fails hard thresholds, execute mitigation before increasing volume. + +#### Step 4: Manage day 0-30 for evidence quality, not vanity velocity + +Focus on leading indicators and stage progression: +- qualified responses, +- meetings booked, +- proposal progression, +- onboarding starts. + +Do not overreact to opens alone. Prefer click, reply, and downstream progression signals for early decisions. + +At day 30, choose one decision: +1. **Scale:** if leading indicators and economics pass thresholds. +2. **Hold and iterate:** if engagement quality is mixed but improving. +3. **Pause and redesign:** if both engagement quality and progression fail with no positive trend. + +#### Step 5: Manage day 30-60 for conversion reliability + +Shift focus from activity counts to conversion quality: +- discovery-to-proposal rate, +- proposal-to-pilot rate, +- pilot onboarding completion. + +Diagnose by stage. If one stage leaks, fix that stage first (handoff, offer, qualification, onboarding) before changing the full launch strategy. + +#### Step 6: Manage day 60-90 for repeatability and economics + +By day 90, evaluate whether the launch motion is repeatable: +- multiple pilots running in parallel, +- onboarding reliability, +- early payback or efficiency signal trend, +- clear evidence on which channels and messages produce durable progression. + +Only scale budget/volume when repeatability and evidence quality are both acceptable. + +#### Step 7: Weekly operating cadence (mandatory) + +Run a weekly 30-minute launch review with fixed agenda: +1. status by phase and owner, +2. top risks and blocker status, +3. metric trend and interpretation caveats, +4. decision log updates, +5. next-week changes. + +If a decision is made, log rationale and evidence refs in the artifact. + +Do NOT: +- assign milestones to teams without one named person, +- treat launch-day completion as success, +- report only blended metrics without stage/channel breakdown, +- continue outreach scale if deliverability or compliance gates fail. +--- + +### Draft: Launch Day Blockers (Pre-flight Checklist) + +Use this to replace and expand the existing blocker checklist. + +--- +### Launch day blockers (hard gates) +Must be complete before any scaled outreach: + +- [ ] Core product delivers primary user job in live use +- [ ] Payment/invoicing flow tested end-to-end +- [ ] Support SLA and escalation owner documented +- [ ] Privacy/data handling controls reviewed and signed off +- [ ] Consent/opt-out flow tested from UI to downstream systems +- [ ] Sender authentication configured (SPF/DKIM/DMARC) and verified +- [ ] Unsubscribe and complaint-handling flow tested +- [ ] Launch dashboard owner assigned with source-of-truth documented +- [ ] Meeting routing and scheduling path tested +- [ ] Legal/security/procurement packet prepared for likely objections + +If any blocker is unresolved, launch status is `delayed` and outreach scale must not proceed. +--- + +### Draft: Metrics Interpretation Rules + +Add this as a standalone section before Recording. + +--- +### Metrics interpretation rules (30/60/90) + +You must interpret launch metrics by signal quality tier: + +1. **Directional leading indicators:** opens, clicks, replies, early traffic shifts. +2. **Operational progression indicators:** meetings booked, proposals sent, pilots signed, onboarding starts. +3. **Economic indicators:** CAC trend, payback trend, channel efficiency by cohort. + +Rules: +- Do not use a single metric as launch verdict. +- If open-rate and click/reply trends conflict, prioritize click/reply and downstream progression. +- If blended conversion drops, inspect channel mix before declaring strategy failure. +- If attribution tools disagree, reduce decision confidence and require triangulation before large reallocation. +- If lagging outcomes are delayed but leading indicators improve within expected incubation windows, extend observation before full reset. + +Decision rubric: +- **Scale** when indicator tiers 2 and 3 trend positive with acceptable confidence. +- **Hold** when tier-1 is positive but tier-2/3 evidence is not yet stable. +- **Stop/Pivot** when tier-2/3 remain negative for consecutive windows and mitigation fails. +--- + +### Draft: Anti-Pattern Warnings + +Use these warning blocks as mandatory output components. + +--- +#### Warning: Budget-Agnostic Launch Plan +**What it looks like:** Channel tasks and spend exist, but no efficiency guardrails or stop rules. +**Detection signal:** Missing CAC/payback thresholds and no weekly budget decision logic. +**Consequence:** Reactive cuts and plan instability mid-cycle. +**Mitigation:** Add channel-level guardrails and explicit stop/scale triggers before execution. + +#### Warning: Activity Theater +**What it looks like:** Plan optimizes outreach volume and impressions only. +**Detection signal:** No linkage from activity to qualified progression milestones. +**Consequence:** High activity, weak conversion, false success narratives. +**Mitigation:** Re-anchor to stage progression and value outcomes. + +#### Warning: Deliverability Blindness +**What it looks like:** Outreach scale starts without sender/authentication checks. +**Detection signal:** Missing SPF/DKIM/DMARC verification and unsubscribe QA. +**Consequence:** Spam placement and incorrect messaging-quality diagnosis. +**Mitigation:** Treat deliverability as hard launch gate; block scale until complete. + +#### Warning: Consent Theater +**What it looks like:** Consent UI present, but backend tracking/sharing unchanged. +**Detection signal:** Network/telemetry shows continued data flow after opt-out. +**Consequence:** Regulatory risk and forced remediation during launch window. +**Mitigation:** Add recurring consent propagation audits and owner accountability. + +#### Warning: Single-Threaded Execution +**What it looks like:** Milestones assigned to groups or no accountable person. +**Detection signal:** Blockers persist with no clear escalation path. +**Consequence:** Delays and unresolved dependencies. +**Mitigation:** One accountable person per milestone plus escalation owner. + +#### Warning: Premature Strategy Reset +**What it looks like:** Full GTM pivot after short noisy windows. +**Detection signal:** No incubation/lag consideration in decision rationale. +**Consequence:** Abandons potentially viable motion before signal matures. +**Mitigation:** Use tiered metric rules and minimum observation windows. +--- + +### Draft: Available Tools + +Use this to update the tools guidance text. + +--- +### Available Tools + +Before planning execution, activate prerequisite strategy documents: + +```python +flexus_policy_document(op="activate", args={"p": "/strategy/gtm-channel-strategy"}) +flexus_policy_document(op="activate", args={"p": "/strategy/pricing-pilot-packaging"}) +flexus_policy_document(op="activate", args={"p": "/strategy/mvp-validation-criteria"}) +``` + +Use calendar scheduling for launch milestones and named-owner reminders: + +```python +google_calendar( + op="call", + args={ + "method_id": "google_calendar.events.insert.v1", + "calendarId": "primary", + "summary": "GTM Launch Milestone", + "start": {"date": "2026-03-15"} + } +) +``` + +Persist the final plan only after all required schema fields are complete: + +```python +write_artifact( + artifact_type="gtm_launch_plan", + path="/strategy/gtm-launch-plan", + data={...} +) +``` + +Tool usage rules: +- Do not write artifact before blockers and metrics sections are complete. +- If source documents conflict, record assumptions and lower confidence. +- Never omit owner/date fields to "move faster." +--- + +### Draft: Recording + +Use this to replace the current short recording section. + +--- +### Recording + +Your artifact must be executable by another operator without hidden context. Record: + +1. launch objective and no-go conditions, +2. pre-launch checklist with proof evidence, +3. phase plan with milestones, owners, dependencies, and exit criteria, +4. success metrics with leading/lagging types and decision thresholds, +5. measurement caveats and interpretation confidence, +6. risk register with mitigations and owner, +7. compliance/deliverability checks and status, +8. decision log entries for every scale/hold/stop choice. + +A launch plan is incomplete if it only lists activities and dates without decision logic. +--- + +### Draft: Schema additions + +Use this JSON Schema fragment to replace the current artifact schema. + +```json +{ + "gtm_launch_plan": { + "type": "object", + "description": "Operational GTM launch plan for first 90 days with owners, milestones, evidence, risks, and decision logic.", + "required": [ + "created_at", + "launch_date", + "launch_objective", + "assumptions", + "no_go_conditions", + "pre_launch_checklist", + "phases", + "success_metrics", + "measurement_plan", + "risk_register", + "compliance_checks", + "communication_plan", + "decision_log" + ], + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string", + "format": "date-time", + "description": "ISO-8601 UTC timestamp when the launch plan was generated." + }, + "launch_date": { + "type": "string", + "format": "date", + "description": "Planned launch date in YYYY-MM-DD." + }, + "launch_objective": { + "type": "string", + "description": "Single measurable 90-day objective statement." + }, + "assumptions": { + "type": "array", + "description": "Critical assumptions used to build this plan.", + "items": { + "type": "object", + "required": ["assumption", "confidence", "evidence_refs"], + "additionalProperties": false, + "properties": { + "assumption": { + "type": "string", + "description": "Declarative assumption affecting scope, timeline, or economics." + }, + "confidence": { + "type": "string", + "enum": ["low", "medium", "high"], + "description": "Confidence level for this assumption." + }, + "evidence_refs": { + "type": "array", + "description": "References to source docs, dashboards, or research IDs.", + "items": { + "type": "string" + } + } + } + } + }, + "no_go_conditions": { + "type": "array", + "description": "Conditions that force launch delay if unresolved.", + "items": { + "type": "object", + "required": ["condition", "status", "owner"], + "additionalProperties": false, + "properties": { + "condition": { + "type": "string", + "description": "Hard gate condition (for example deliverability ready, compliance check complete)." + }, + "status": { + "type": "string", + "enum": ["pass", "fail", "unknown"], + "description": "Current state of this no-go condition." + }, + "owner": { + "type": "string", + "description": "Person accountable for resolving this condition." + } + } + } + }, + "pre_launch_checklist": { + "type": "array", + "description": "T-30 to T-0 readiness checklist.", + "items": { + "type": "object", + "required": ["item", "owner", "due_date", "status", "evidence"], + "additionalProperties": false, + "properties": { + "item": { + "type": "string", + "description": "Specific readiness task." + }, + "owner": { + "type": "string", + "description": "Single accountable person." + }, + "due_date": { + "type": "string", + "format": "date", + "description": "Due date in YYYY-MM-DD." + }, + "status": { + "type": "string", + "enum": ["done", "in_progress", "blocked", "not_started"], + "description": "Current status of the checklist item." + }, + "evidence": { + "type": "string", + "description": "Proof artifact reference (doc link, test note, QA record, or screenshot path)." + } + } + } + }, + "phases": { + "type": "array", + "description": "Execution phases from pre-launch through day 90.", + "items": { + "type": "object", + "required": ["phase_name", "start_day", "end_day", "owner", "milestones", "exit_criteria"], + "additionalProperties": false, + "properties": { + "phase_name": { + "type": "string", + "enum": ["pre_launch", "launch_week", "day_0_30", "day_30_60", "day_60_90"], + "description": "Canonical phase identifier." + }, + "start_day": { + "type": "integer", + "description": "Relative day offset from launch date." + }, + "end_day": { + "type": "integer", + "description": "Relative day offset from launch date." + }, + "owner": { + "type": "string", + "description": "Phase owner accountable for delivery and reporting." + }, + "milestones": { + "type": "array", + "description": "Milestones for this phase.", + "items": { + "type": "object", + "required": ["milestone_id", "milestone", "owner", "due_day", "status", "success_metric", "target"], + "additionalProperties": false, + "properties": { + "milestone_id": { + "type": "string", + "description": "Unique milestone identifier used for dependency references." + }, + "milestone": { + "type": "string", + "description": "Milestone description." + }, + "owner": { + "type": "string", + "description": "Single accountable owner." + }, + "due_day": { + "type": "integer", + "description": "Relative day offset when milestone is due." + }, + "status": { + "type": "string", + "enum": ["done", "in_progress", "blocked", "not_started"], + "description": "Milestone status." + }, + "success_metric": { + "type": "string", + "description": "Metric used to evaluate milestone completion quality." + }, + "target": { + "type": "string", + "description": "Target value/range for success metric." + }, + "dependency_ids": { + "type": "array", + "description": "IDs of milestones that must complete first.", + "items": { + "type": "string" + } + } + } + } + }, + "exit_criteria": { + "type": "array", + "description": "Conditions required to move to next phase.", + "items": { + "type": "string" + } + } + } + } + }, + "success_metrics": { + "type": "array", + "description": "Core launch metrics with targets by horizon and decision threshold.", + "items": { + "type": "object", + "required": [ + "metric", + "metric_type", + "data_source", + "baseline", + "target_30d", + "target_60d", + "target_90d", + "decision_threshold" + ], + "additionalProperties": false, + "properties": { + "metric": { + "type": "string", + "description": "Metric name." + }, + "metric_type": { + "type": "string", + "enum": ["leading", "lagging", "economic", "quality"], + "description": "Signal tier for interpretation." + }, + "data_source": { + "type": "string", + "description": "System of record for this metric." + }, + "baseline": { + "type": "string", + "description": "Baseline value before launch execution." + }, + "target_30d": { + "type": "string", + "description": "Target by day 30." + }, + "target_60d": { + "type": "string", + "description": "Target by day 60." + }, + "target_90d": { + "type": "string", + "description": "Target by day 90." + }, + "decision_threshold": { + "type": "string", + "description": "Threshold that triggers scale/hold/stop action." + } + } + } + }, + "measurement_plan": { + "type": "object", + "description": "How launch data is collected, interpreted, and caveated.", + "required": ["source_of_truth", "dashboard_cadence", "interpretation_rules", "attribution_note", "data_latency_expectation"], + "additionalProperties": false, + "properties": { + "source_of_truth": { + "type": "string", + "description": "Primary reporting system for decision-making." + }, + "dashboard_cadence": { + "type": "string", + "enum": ["daily", "weekly"], + "description": "Update cadence for launch dashboard reviews." + }, + "interpretation_rules": { + "type": "array", + "description": "Explicit metric interpretation rules used in reviews.", + "items": { + "type": "string" + } + }, + "attribution_note": { + "type": "string", + "description": "Known attribution caveats and reconciliation rules." + }, + "data_latency_expectation": { + "type": "string", + "description": "Expected freshness/latency limits for key systems." + } + } + }, + "risk_register": { + "type": "array", + "description": "Launch risks with owner and mitigation.", + "items": { + "type": "object", + "required": ["risk", "signal", "impact", "mitigation", "owner", "status"], + "additionalProperties": false, + "properties": { + "risk": { + "type": "string", + "description": "Named risk." + }, + "signal": { + "type": "string", + "description": "Detection signal that risk is materializing." + }, + "impact": { + "type": "string", + "description": "Business consequence if unresolved." + }, + "mitigation": { + "type": "string", + "description": "Concrete mitigation plan." + }, + "owner": { + "type": "string", + "description": "Person accountable for mitigation." + }, + "status": { + "type": "string", + "enum": ["open", "watching", "mitigating", "resolved"], + "description": "Current risk status." + } + } + } + }, + "compliance_checks": { + "type": "object", + "description": "Operational compliance and deliverability checks tied to launch readiness.", + "required": ["privacy_opt_out_flow_verified", "email_sender_auth_verified", "policy_owner", "last_review_date"], + "additionalProperties": false, + "properties": { + "privacy_opt_out_flow_verified": { + "type": "boolean", + "description": "Whether opt-out/consent preferences are verified to propagate to downstream systems." + }, + "email_sender_auth_verified": { + "type": "boolean", + "description": "Whether sender authentication setup has been verified." + }, + "policy_owner": { + "type": "string", + "description": "Accountable owner for compliance checks." + }, + "last_review_date": { + "type": "string", + "format": "date", + "description": "Most recent compliance review date." + } + } + }, + "communication_plan": { + "type": "object", + "description": "Cadence and audience for launch communication.", + "required": ["internal_cadence", "stakeholders", "update_format"], + "additionalProperties": false, + "properties": { + "internal_cadence": { + "type": "string", + "enum": ["daily_launch_week", "weekly"], + "description": "Primary internal update cadence." + }, + "stakeholders": { + "type": "array", + "description": "Stakeholders who receive updates.", + "items": { + "type": "string" + } + }, + "update_format": { + "type": "string", + "description": "Format for recurring launch updates." + } + } + }, + "decision_log": { + "type": "array", + "description": "Chronological record of major launch decisions.", + "items": { + "type": "object", + "required": ["date", "decision", "rationale", "confidence_after", "next_review_date"], + "additionalProperties": false, + "properties": { + "date": { + "type": "string", + "format": "date", + "description": "Decision date." + }, + "decision": { + "type": "string", + "enum": ["scale", "hold", "pause", "pivot", "delay_launch"], + "description": "Decision type." + }, + "rationale": { + "type": "string", + "description": "Evidence-backed reason for the decision." + }, + "confidence_after": { + "type": "string", + "enum": ["low", "medium", "high"], + "description": "Confidence level after decision." + }, + "evidence_refs": { + "type": "array", + "description": "References to metrics, docs, or notes supporting the decision.", + "items": { + "type": "string" + } + }, + "next_review_date": { + "type": "string", + "format": "date", + "description": "Date for decision re-evaluation." + } + } + } + } + } + } +} +``` + +--- + +## Gaps & Uncertainties + +- Public benchmark ranges for launch metrics vary heavily by industry, ACV, and channel mix; hard universal thresholds remain weak and should be treated as directional. +- Some evidence in failure-mode research comes from vendor reports or operator postmortems rather than controlled comparative studies; these are useful for risk patterning but should be marked medium confidence. +- Public API limit details are uneven across tools and sometimes plan/account dependent; implementation must verify account-specific limits before automation-heavy launch workflows. +- We did not find one cross-vendor standard taxonomy for "qualified launch success" that spans all B2B/B2C contexts; the skill should require explicit metric definitions in every artifact. +- Regulatory expectations continue to shift at jurisdiction level; monthly compliance review cadence is recommended, but exact scope must be tailored to launch geography and data flows. diff --git a/flexus_simple_bots/strategist/skills/_gtm-launch-plan/SKILL.md b/flexus_simple_bots/strategist/skills/_gtm-launch-plan/SKILL.md new file mode 100644 index 00000000..9653683a --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_gtm-launch-plan/SKILL.md @@ -0,0 +1,64 @@ +--- +name: gtm-launch-plan +description: Go-to-market launch plan — execution timeline, launch day checklist, first 90-day milestones, and owner assignments +--- + +You produce the operational launch plan: who does what, by when, and with what success metrics for the first 90 days post-launch. The launch plan converts channel strategy into an executable calendar. + +Core mode: dates and owners are required. A launch plan without assigned owners and dates is a wish list. Every milestone needs one name attached to it (not a team — one person). + +## Methodology + +### Launch phases +**Pre-launch (T-30 to T-0):** +- Prospect list finalized (from `pipeline-contact-enrichment`) +- Messaging approved (from `positioning-messaging`) +- Outreach sequences loaded (from `pipeline-outreach-sequencing`) +- Pilot packages prepared (from `pricing-pilot-packaging`) +- Website / landing page live +- Analytics instrumented (tracking events for success metrics) +- Internal enablement: everyone who touches customers knows the pitch + +**Launch week (T-0 to T+7):** +- Launch batch outreach: first wave of outreach to top ICP contacts +- Community announcements (Reddit, Slack, ProductHunt if relevant) +- Direct network outreach (founder personal network) +- Track: email open rates, reply rates, meeting requests + +**First 30 days (T+0 to T+30):** +- Milestone: first discovery call booked +- Milestone: first pilot proposal delivered +- Milestone: first pilot customer signed +- Weekly pipeline review + +**30-60 days:** +- Milestone: first pilot customer completes onboarding +- Milestone: first payment received (if applicable) +- Insight: what messaging is resonating / not resonating? + +**60-90 days:** +- Milestone: 3+ pilots running simultaneously +- Pilot feedback synthesized → iteration on MVP scope +- Channel efficiency measured vs. CAC targets + +### Launch day blockers (pre-flight checklist) +Must be complete before any outreach: +- [ ] Product delivers core job for at least 1 live test user +- [ ] Payments / invoicing working +- [ ] SLA / support process defined +- [ ] Data handling / privacy policy signed off + +## Recording + +``` +write_artifact(path="/strategy/gtm-launch-plan", data={...}) +``` + +## Available Tools + +``` +flexus_policy_document(op="activate", args={"p": "/strategy/gtm-channel-strategy"}) +flexus_policy_document(op="activate", args={"p": "/strategy/pricing-pilot-packaging"}) +flexus_policy_document(op="activate", args={"p": "/strategy/mvp-validation-criteria"}) +google_calendar(op="call", args={"method_id": "google_calendar.events.insert.v1", "calendarId": "primary", "summary": "GTM Launch Day", "start": {"date": "2024-03-01"}}) +``` diff --git a/flexus_simple_bots/strategist/skills/_mvp-feasibility/RESEARCH.md b/flexus_simple_bots/strategist/skills/_mvp-feasibility/RESEARCH.md new file mode 100644 index 00000000..5e7778d6 --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_mvp-feasibility/RESEARCH.md @@ -0,0 +1,1270 @@ +# Research: mvp-feasibility + +**Skill path:** `strategist/skills/mvp-feasibility/` +**Bot:** strategist (researcher | strategist | executor) +**Research date:** 2026-03-04 +**Status:** complete + +--- + +## Context + +`mvp-feasibility` is the strategist skill used before build commitment to determine whether the proposed MVP scope is realistically deliverable under technical, resource, timeline, dependency, and governance constraints. The skill is not a veto mechanism; it is a structured risk inventory that supports explicit go / no-go / scope-cut decisions. + +The current `SKILL.md` already establishes the core structure (`build | buy | partner | block`, capacity and timeline estimates, dependency risks, and a base artifact schema). This research extends that base with 2024-2026 evidence on: modern pre-build feasibility workflows, constraints and tooling limits, evidence quality interpretation, and recurring failure modes seen in major incidents and delivery postmortems. Older sources are explicitly labeled as evergreen when they remain operationally valid. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024-2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- [x] No generic filler without concrete backing +- [x] No invented tool names, method IDs, or API endpoints - only verified real ones +- [x] Contradictions between sources are explicitly noted, not silently resolved +- [x] Volume: findings sections are within 800-4000 words combined +- [x] Volume: `Draft Content for SKILL.md` is longer than all Findings sections combined + +--- + +## Research Angles + +### Angle 1: Domain Methodology and Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +1. **High-performing teams now run feasibility as a staged pre-build workflow, not one meeting.** + Practical sequence seen across Atlassian and Microsoft playbooks: problem framing (`Project Poster`), structured risk discovery (`Premortem`), dependency inventory (`Dependency Mapping`), uncertainty reduction (`Engineering Feasibility Spikes`), option comparison (`Trade Study`), decision record (`ADR/Decision Log`), then final go/no-go review. This materially reduces hidden assumptions before engineering commitment. + Sources: https://www.atlassian.com/team-playbook/plays/project-poster, https://www.atlassian.com/wac/team-playbook/plays/pre-mortem, https://www.atlassian.com/team-playbook/plays/dependency-mapping, https://microsoft.github.io/code-with-engineering-playbook/design/design-reviews/recipes/engineering-feasibility-spikes/, https://microsoft.github.io/code-with-engineering-playbook/design/design-reviews/trade-studies/, https://microsoft.github.io/code-with-engineering-playbook/design/design-reviews/decision-log/ + +2. **Premortem moved from "brainstorming exercise" to operational gate.** + Atlassian specifies concrete session design (3-11 participants, timeboxed, voting, owner assignment), making it a repeatable risk surfacing method rather than informal discussion. + Source: https://www.atlassian.com/wac/team-playbook/plays/pre-mortem (evergreen process, currently maintained) + +3. **Dependency mapping now includes ownership and review cadence, not just diagrams.** + Current dependency mapping guidance requires risks, mitigations, owners, and cadence (weekly/monthly/quarterly), aligning with feasibility needs for external blockers and cross-team sequencing. + Source: https://www.atlassian.com/team-playbook/plays/dependency-mapping + +4. **Feasibility spikes are increasingly formalized as pre-build de-risking artifacts.** + Microsoft guidance (updated 2024) positions spikes between design review and engineering sprints, with explicit pre-mortem and weekly feedback loops. + Source: https://microsoft.github.io/code-with-engineering-playbook/design/design-reviews/recipes/engineering-feasibility-spikes/ + +5. **Trade studies and decision logs are now treated as mandatory traceability in uncertain architecture choices.** + The combination prevents undocumented decision drift and supports post-hoc explanation of why a path was chosen under uncertainty. + Sources: https://microsoft.github.io/code-with-engineering-playbook/design/design-reviews/trade-studies/, https://microsoft.github.io/code-with-engineering-playbook/design/design-reviews/decision-log/ (evergreen methods, currently maintained) + +6. **Reliability feasibility shifted to continuously updated cloud checklists in 2024-2026.** + AWS, Azure, and GCP all shipped meaningful framework updates, including AI/ML perspectives and revised reliability practices. Feasibility methods therefore need periodic refresh; static checklists decay quickly. + Sources: https://aws.amazon.com/blogs/architecture/announcing-updates-to-the-aws-well-architected-framework-guidance-3/, https://aws.amazon.com/about-aws/whats-new/2025/11/new-aws-well-architected-lenses-ai-ml-workloads/, https://learn.microsoft.com/en-us/azure/well-architected/reliability/checklist, https://docs.cloud.google.com/architecture/framework/whats-new, https://docs.cloud.google.com/architecture/framework/reliability + +7. **AI-heavy MVPs need explicit risk framework triggers, not generic software feasibility only.** + NIST AI RMF GenAI Profile (2024) adds concrete risk categories and governance actions; EU AI Act timeline introduces phased legal dependencies that can become launch blockers. + Sources: https://www.nist.gov/publications/artificial-intelligence-risk-management-framework-generative-artificial-intelligence, https://tsapps.nist.gov/publication/get_pdf.cfm?pub_id=958388, https://airc.nist.gov/airmf-resources/playbook, https://ai-act-service-desk.ec.europa.eu/en/ai-act/eu-ai-act-implementation-timeline + +8. **Delivery feasibility must now separate local productivity from system-level delivery performance.** + DORA 2024 reports cases where AI adoption improves local coding/review activity while aggregate throughput/stability can decline, making "faster coding" an insufficient feasibility argument. + Sources: https://cloud.google.com/blog/products/devops-sre/announcing-the-2024-dora-report, https://services.google.com/fh/files/misc/2024_final_dora_report.pdf + +**Sources:** +- https://www.atlassian.com/team-playbook/plays/project-poster +- https://www.atlassian.com/wac/team-playbook/plays/pre-mortem +- https://www.atlassian.com/team-playbook/plays/dependency-mapping +- https://microsoft.github.io/code-with-engineering-playbook/design/design-reviews/recipes/engineering-feasibility-spikes/ +- https://microsoft.github.io/code-with-engineering-playbook/design/design-reviews/trade-studies/ +- https://microsoft.github.io/code-with-engineering-playbook/design/design-reviews/decision-log/ +- https://aws.amazon.com/blogs/architecture/announcing-updates-to-the-aws-well-architected-framework-guidance-3/ +- https://aws.amazon.com/about-aws/whats-new/2025/11/new-aws-well-architected-lenses-ai-ml-workloads/ +- https://learn.microsoft.com/en-us/azure/well-architected/reliability/checklist +- https://docs.cloud.google.com/architecture/framework/whats-new +- https://docs.cloud.google.com/architecture/framework/reliability +- https://www.nist.gov/publications/artificial-intelligence-risk-management-framework-generative-artificial-intelligence +- https://tsapps.nist.gov/publication/get_pdf.cfm?pub_id=958388 +- https://airc.nist.gov/airmf-resources/playbook +- https://ai-act-service-desk.ec.europa.eu/en/ai-act/eu-ai-act-implementation-timeline +- https://cloud.google.com/blog/products/devops-sre/announcing-the-2024-dora-report +- https://services.google.com/fh/files/misc/2024_final_dora_report.pdf + +--- + +### Angle 2: Tool and API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +1. **Cloud quota services are first-line feasibility controls for technical constraints.** + AWS Service Quotas, GCP Quotas, and Azure Quotas reveal hard limits and increase workflows that directly affect launch feasibility and lead time. + Sources: https://docs.aws.amazon.com/servicequotas/latest/userguide/intro.html, https://cloud.google.com/docs/quotas/overview, https://learn.microsoft.com/en-us/azure/quotas/quotas-overview + +2. **Quota increase pathways themselves are feasibility risks.** + Quotas differ by region/service, many are non-adjustable, and some increase workflows require support paths or preconditions. MVP plans without increase lead-time assumptions often understate timeline risk. + Sources: https://docs.aws.amazon.com/servicequotas/latest/userguide/intro.html, https://cloud.google.com/docs/quotas/view-manage, https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/azure-subscription-service-limits + +3. **Roadmap/capacity tools are useful but frequently plan-gated.** + Jira Advanced Roadmaps capacity planning is available only on specific cloud tiers, so feasibility methods must check tool access assumptions early. + Sources: https://support.atlassian.com/jira-software-cloud/docs/enable-capacity-planning-in-advanced-roadmaps/, https://www.atlassian.com/software/jira/pricing?tab=cloud + +4. **Data ingestion pipelines for feasibility telemetry are rate-limit-constrained by design.** + Jira Cloud, GitHub REST, and GitHub GraphQL enforce quotas and secondary/resource limits. Forecasting pipelines must be budget-aware and retry-aware to remain reliable. + Sources: https://developer.atlassian.com/cloud/jira/platform/rate-limiting/, https://docs.github.com/rest/using-the-rest-api/rate-limits-for-the-rest-api, https://docs.github.com/en/graphql/overview/rate-limits-and-query-limits-for-the-graphql-api, https://github.blog/changelog/2025-09-01-graphql-api-resource-limits/ + +5. **Dependency risk needs multi-source security evidence, not one feed.** + Dependabot, NVD, OSV, and CISA KEV each provide different signal quality and coverage. Single-source approaches miss either exploit prioritization or breadth. + Sources: https://docs.github.com/en/code-security/dependabot/dependabot-alerts/about-dependabot-alerts, https://nvd.nist.gov/developers/start-here, https://google.github.io/osv.dev/api/, https://www.cisa.gov/known-exploited-vulnerabilities-catalog + +6. **Compliance tooling introduces licensing and scope constraints that directly impact feasibility.** + AWS Audit Manager, Google Assured Workloads, and Microsoft Purview Compliance Manager expose practical evidence and policy workflows but have tier/licensing caveats and non-equivalence to legal sign-off. + Sources: https://aws.amazon.com/audit-manager/pricing/, https://cloud.google.com/assured-workloads/docs/restrictions-limitations, https://learn.microsoft.com/en-us/purview/compliance-manager-faq?view=o365-worldwide + +7. **Cost feasibility tooling is directional, not authoritative.** + AWS Pricing Calculator is useful for scenario comparisons but requires explicit assumption logging and sensitivity bands. + Source: https://aws.amazon.com/aws-cost-management/aws-pricing-calculator/pricing/ + +8. **Timeline confidence improves when flow-history forecasting is used.** + Monte Carlo style flow forecasting tools (for example ActionableAgile) support probabilistic schedule ranges rather than single-point commitments. + Sources: https://marketplace.atlassian.com/apps/1216661/actionableagile-analytics-kanban-agile-metrics-forecasts?hosting=cloud&tab=overview, https://www.55degrees.se/products/actionableagileanalytics/pricing + +**Sources:** +- https://docs.aws.amazon.com/servicequotas/latest/userguide/intro.html +- https://docs.aws.amazon.com/awssupport/latest/user/trusted-advisor.html +- https://cloud.google.com/docs/quotas/overview +- https://cloud.google.com/docs/quotas/view-manage +- https://learn.microsoft.com/en-us/azure/quotas/quotas-overview +- https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/azure-subscription-service-limits +- https://support.atlassian.com/jira-software-cloud/docs/enable-capacity-planning-in-advanced-roadmaps/ +- https://www.atlassian.com/software/jira/pricing?tab=cloud +- https://developer.atlassian.com/cloud/jira/platform/rate-limiting/ +- https://docs.github.com/rest/using-the-rest-api/rate-limits-for-the-rest-api +- https://docs.github.com/en/graphql/overview/rate-limits-and-query-limits-for-the-graphql-api +- https://github.blog/changelog/2025-09-01-graphql-api-resource-limits/ +- https://docs.github.com/en/code-security/dependabot/dependabot-alerts/about-dependabot-alerts +- https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference +- https://nvd.nist.gov/developers/start-here +- https://google.github.io/osv.dev/api/ +- https://osv.dev/docs/ +- https://www.cisa.gov/known-exploited-vulnerabilities-catalog +- https://github.com/cisagov/kev-data +- https://aws.amazon.com/audit-manager/pricing/ +- https://cloud.google.com/assured-workloads/docs/restrictions-limitations +- https://learn.microsoft.com/en-us/purview/compliance-manager-faq?view=o365-worldwide +- https://aws.amazon.com/aws-cost-management/aws-pricing-calculator/pricing/ +- https://marketplace.atlassian.com/apps/1216661/actionableagile-analytics-kanban-agile-metrics-forecasts?hosting=cloud&tab=overview +- https://www.55degrees.se/products/actionableagileanalytics/pricing + +--- + +### Angle 3: Data Interpretation and Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +1. **Data integrity checks should gate interpretation, not decorate it.** + Microsoft ExP and Statsig both treat SRM and health checks as blockers for trustworthy interpretation. Thresholds differ by implementation (`p < 0.0005` and `p < 0.01` are both used), but the core pattern is consistent: invalid randomization invalidates conclusions. + Sources: https://www.microsoft.com/en-us/research/group/experimentation-platform-exp/articles/diagnosing-sample-ratio-mismatch-in-a-b-testing/, https://docs.statsig.com/stats-engine/methodologies/srm-checks/, https://www.statsig.com/blog/sample-ratio-mismatch + +2. **Decision quality improves with explicit rule-based ship logic.** + Spotify's risk-aware decision framework demonstrates practical rules combining success metrics, guardrails, and validity checks, reducing ad-hoc interpretation drift. + Sources: https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics/, https://arxiv.org/abs/2402.11609, https://confidence.spotify.com/blog/better-decisions-with-guardrails + +3. **Uncertainty reporting is required for practical feasibility interpretation.** + NIST guidance emphasizes expanded uncertainty and transparent reporting, showing why point estimates alone are inadequate for decisions with delivery risk exposure. + Sources: https://www.nist.gov/pml/nist-technical-note-1297/nist-tn-1297-6-expanded-uncertainty, https://www.nist.gov/pml/nist-technical-note-1297/nist-tn-1297-7-reporting-uncertainty (evergreen, still operationally valid) + +4. **Continuous monitoring requires sequential-valid methods.** + If teams repeatedly "peek" and stop when significance appears, false positive risk inflates. Always-valid sequential methods are the accepted mitigation. + Source: https://arxiv.org/abs/1512.04922 (evergreen) + +5. **Operational feasibility should use error-budget policy as a hard decision signal.** + Google SRE recommends freeze-style policy responses when budget is exhausted; this converts reliability from narrative to enforceable gate. + Sources: https://sre.google/workbook/error-budget-policy/, https://sre.google/workbook/implementing-slos/ (evergreen) + +6. **Technical readiness should be level-based for critical capabilities.** + NASA TRL and GAO readiness guidance remain useful to avoid committing MVP timelines against immature core components. + Sources: https://www.nasa.gov/directorates/somd/space-communications-navigation-program/technology-readiness-levels/, https://www.gao.gov/products/gao-20-48g (evergreen) + +7. **Delivery signal must combine throughput and stability dimensions.** + DORA guidance warns against reading speed metrics in isolation; stability and rework characteristics change feasibility outcomes. + Sources: https://dora.dev/guides/dora-metrics/, https://dora.dev/guides/dora-metrics/history, https://cloud.google.com/blog/products/devops-sre/announcing-the-2024-dora-report + +8. **Business feasibility signals are stronger when retention/efficiency are included.** + Private SaaS surveys and benchmark analyses show retention and growth-quality metrics are better early feasibility signals than vanity growth alone. + Sources: https://sapphireventures.com/press/keybanc-capital-markets-and-sapphire-ventures-private-saas-company-survey-reveals-a-continued-focus-on-operational-efficiency-and-profitability/, https://www.saas-capital.com/blog-posts/growth-benchmarks-for-private-saas-companies/, https://www.saas-capital.com/blog-posts/growth-profitability-and-the-rule-of-40-for-private-saas-companies/ + +9. **Vanity metrics still drive false confidence when guardrails are absent.** + This remains a known failure mode from Lean Startup era to modern delivery metrics discourse (Goodhart effect in practice). + Sources: https://www.startuplessonslearned.com/2009/12/why-vanity-metrics-are-dangerous.html (evergreen), https://dora.dev/guides/dora-metrics/, https://pmc.ncbi.nlm.nih.gov/articles/PMC7901608/ (evergreen) + +**Sources:** +- https://www.microsoft.com/en-us/research/group/experimentation-platform-exp/articles/diagnosing-sample-ratio-mismatch-in-a-b-testing/ +- https://docs.statsig.com/stats-engine/methodologies/srm-checks/ +- https://www.statsig.com/blog/sample-ratio-mismatch +- https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics/ +- https://arxiv.org/abs/2402.11609 +- https://confidence.spotify.com/blog/better-decisions-with-guardrails +- https://www.nist.gov/pml/nist-technical-note-1297/nist-tn-1297-6-expanded-uncertainty +- https://www.nist.gov/pml/nist-technical-note-1297/nist-tn-1297-7-reporting-uncertainty +- https://arxiv.org/abs/1512.04922 +- https://sre.google/workbook/error-budget-policy/ +- https://sre.google/workbook/implementing-slos/ +- https://www.nasa.gov/directorates/somd/space-communications-navigation-program/technology-readiness-levels/ +- https://www.gao.gov/products/gao-20-48g +- https://dora.dev/guides/dora-metrics/ +- https://dora.dev/guides/dora-metrics/history +- https://cloud.google.com/blog/products/devops-sre/announcing-the-2024-dora-report +- https://sapphireventures.com/press/keybanc-capital-markets-and-sapphire-ventures-private-saas-company-survey-reveals-a-continued-focus-on-operational-efficiency-and-profitability/ +- https://www.saas-capital.com/blog-posts/growth-benchmarks-for-private-saas-companies/ +- https://www.saas-capital.com/blog-posts/growth-profitability-and-the-rule-of-40-for-private-saas-companies/ +- https://www.startuplessonslearned.com/2009/12/why-vanity-metrics-are-dangerous.html +- https://pmc.ncbi.nlm.nih.gov/articles/PMC7901608/ + +--- + +### Angle 4: Failure Modes and Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does bad output look like vs good output? + +**Findings:** + +1. **Single-point estimate theater** + Detection: one delivery date and one budget, no probabilistic range, no confidence rationale. + Consequence: false feasibility confidence and predictable slip. + Mitigation: require at least P50/P80 ranges and explicit optimism-bias adjustment. + Sources: https://www.bcg.com/publications/2024/most-large-scale-tech-programs-fail-how-to-succeed, https://www.gov.uk/government/publications/green-book-supplementary-guidance-optimism-bias, https://www.gao.gov/products/gao-09-3sp (evergreen) + +2. **Missing critical path and dependency ownership** + Detection: local team plans exist, but no integrated critical path with owners. + Consequence: late blocker discovery and cascade delays. + Mitigation: single master dependency plan with owner and review cadence. + Sources: https://www.bcg.com/publications/2024/most-large-scale-tech-programs-fail-how-to-succeed, https://www.nao.org.uk/insights/governments-approach-to-technology-suppliers-addressing-the-challenges/ + +3. **Unverified assumptions marked as closed** + Detection: assumption status says done, but no direct validation evidence. + Consequence: production impact from hidden false premise. + Mitigation: mandatory machine+human verification checkpoints before irreversible changes. + Source: https://blog.cloudflare.com/cloudflare-incident-on-september-17-2024 + +4. **Legacy impact blind spot** + Detection: risk register ignores legacy paths because they are "stable". + Consequence: regressions via poorly understood old logic. + Mitigation: legacy hotspot mapping and additional uncertainty buffer. + Source: https://blog.cloudflare.com/cloudflare-incident-on-september-17-2024 + +5. **Uneven quality gates for non-code updates** + Detection: config/content update pipelines have weaker testing than code release pipelines. + Consequence: high-blast-radius incidents from seemingly minor updates. + Mitigation: parity testing policy across code/config/content release artifacts. + Sources: https://www.techtarget.com/searchsecurity/news/366596579/CrowdStrike-Content-validation-bug-led-to-global-outage, https://www.techtarget.com/searchsecurity/news/366602392/CrowdStrike-details-errors-that-led-to-mass-IT-outage, https://www.crowdstrike.com/en-us/blog/channel-file-291-rca-available/ + +6. **Big-bang rollout default** + Detection: full-fleet deployment without progressive ring/canary rules. + Consequence: maximum blast radius on defect. + Mitigation: staged rollout + measurable promotion gates + automatic rollback thresholds. + Sources: https://sre.google/workbook/canarying-releases/ (evergreen), https://www.techtarget.com/searchsecurity/news/366602392/CrowdStrike-details-errors-that-led-to-mass-IT-outage + +7. **Fail-safe path exists on paper but is untested under load/misconfiguration** + Detection: "we have fallback" without overload validation evidence. + Consequence: cascade failures when fallback path itself breaks. + Mitigation: regular overload and misconfiguration drills as launch gate. + Source: https://blog.cloudflare.com/cloudflare-incident-on-november-14-2024-resulting-in-lost-logs/ + +8. **Late security discovery and remediation bottlenecks** + Detection: vulnerabilities discovered primarily after merge, with process friction blocking fixes. + Consequence: security debt converts directly into schedule debt. + Mitigation: shift-left controls plus pre-approved remediation escalation paths. + Source: https://about.gitlab.com/press/releases/2024-06-25-gitlab-survey-reveals-tension-around-ai-security-and-developer-productivity-within-organizations/ + +9. **Supply-chain visibility gap** + Detection: no SBOM or vendor criticality model in feasibility package. + Consequence: underestimating third-party incident likelihood and duration. + Mitigation: mandatory SBOM and vendor tiering in MVP readiness checks. + Sources: https://about.gitlab.com/press/releases/2024-06-25-gitlab-survey-reveals-tension-around-ai-security-and-developer-productivity-within-organizations/, https://www.verizon.com/about/news/2025-data-breach-investigations-report + +10. **Compliance treated as post-MVP concern** + Detection: no legal/regulatory milestones on delivery timeline. + Consequence: launch block or forced de-scope near release. + Mitigation: regulation-dependent milestones and evidence obligations in baseline feasibility plan. + Sources: https://commission.europa.eu/news/ai-act-enters-force-2024-08-01_en, https://ai-act-service-desk.ec.europa.eu/en/ai-act/eu-ai-act-implementation-timeline, https://www.federalregister.gov/documents/2023/08/04/2023-16194/cybersecurity-risk-management-strategy-governance-and-incident-disclosure (evergreen regulatory baseline) + +11. **Identity baseline missing for internet-facing systems** + Detection: critical external systems without MFA-by-default. + Consequence: preventable compromise with severe downstream business impact. + Mitigation: MFA-by-default and explicit external asset control attestation before go decision. + Sources: https://www.cnbc.com/2024/05/01/unitedhealth-ceo-says-company-paid-hackers-22-million-ransom.html, https://www.cisa.gov/securebydesign, https://www.cisa.gov/securebydesign/pledge + +12. **AI productivity illusion in feasibility narratives** + Detection: claims of faster coding without explicit controls on batch size, testing rigor, and stability impact. + Consequence: local speed gains with system-level delivery degradation. + Mitigation: treat throughput and stability as dual hard gates when AI tools are involved. + Sources: https://cloud.google.com/blog/products/devops-sre/announcing-the-2024-dora-report, https://dora.dev/research/2024/dora-report + +**Sources:** +- https://www.bcg.com/publications/2024/most-large-scale-tech-programs-fail-how-to-succeed +- https://www.bcg.com/publications/2024/software-projects-dont-have-to-be-late-costly-and-irrelevant +- https://www.gov.uk/government/publications/green-book-supplementary-guidance-optimism-bias +- https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/191507/Optimism_bias.pdf +- https://www.gao.gov/products/gao-09-3sp +- https://www.nao.org.uk/insights/governments-approach-to-technology-suppliers-addressing-the-challenges/ +- https://blog.cloudflare.com/cloudflare-incident-on-september-17-2024 +- https://blog.cloudflare.com/cloudflare-incident-on-november-14-2024-resulting-in-lost-logs/ +- https://www.techtarget.com/searchsecurity/news/366596579/CrowdStrike-Content-validation-bug-led-to-global-outage +- https://www.techtarget.com/searchsecurity/news/366602392/CrowdStrike-details-errors-that-led-to-mass-IT-outage +- https://www.crowdstrike.com/en-us/blog/channel-file-291-rca-available/ +- https://sre.google/workbook/canarying-releases/ +- https://about.gitlab.com/press/releases/2024-06-25-gitlab-survey-reveals-tension-around-ai-security-and-developer-productivity-within-organizations/ +- https://www.verizon.com/about/news/2025-data-breach-investigations-report +- https://commission.europa.eu/news/ai-act-enters-force-2024-08-01_en +- https://ai-act-service-desk.ec.europa.eu/en/ai-act/eu-ai-act-implementation-timeline +- https://www.federalregister.gov/documents/2023/08/04/2023-16194/cybersecurity-risk-management-strategy-governance-and-incident-disclosure +- https://www.cnbc.com/2024/05/01/unitedhealth-ceo-says-company-paid-hackers-22-million-ransom.html +- https://www.cisa.gov/securebydesign +- https://www.cisa.gov/securebydesign/pledge +- https://cloud.google.com/blog/products/devops-sre/announcing-the-2024-dora-report +- https://dora.dev/research/2024/dora-report + +--- + +### Angle 5: Regulatory and Governance Timing Constraints +> What legal/governance timing dependencies commonly shift feasibility outcomes? + +**Findings:** + +1. **EU AI Act creates phased obligations (2025-2027) that can turn into hard timeline blockers for AI MVPs.** + Feasibility assessment must explicitly map target-market obligations to launch window. + Sources: https://commission.europa.eu/news/ai-act-enters-force-2024-08-01_en, https://ai-act-service-desk.ec.europa.eu/en/ai-act/eu-ai-act-implementation-timeline + +2. **NIST AI RMF GenAI Profile is voluntary but operationally useful as readiness structure.** + It provides concrete risk categories and control planning language that can be converted into artifact-level checks. + Sources: https://www.nist.gov/publications/artificial-intelligence-risk-management-framework-generative-artificial-intelligence, https://airc.nist.gov/airmf-resources/playbook + +3. **Disclosure obligations (for example SEC cyber incident disclosure timing) affect operational feasibility assumptions.** + Even if not directly in MVP feature scope, incident reporting requirements influence readiness, detection, and governance process design. + Source: https://www.federalregister.gov/documents/2023/08/04/2023-16194/cybersecurity-risk-management-strategy-governance-and-incident-disclosure (evergreen legal baseline) + +4. **Compliance score tooling is not equivalent to legal compliance proof.** + Vendor compliance platforms provide evidence support, but legal/regulatory sufficiency remains contextual and often requires separate review. + Sources: https://learn.microsoft.com/en-us/purview/compliance-manager-faq?view=o365-worldwide, https://aws.amazon.com/audit-manager/pricing/ + +**Sources:** +- https://commission.europa.eu/news/ai-act-enters-force-2024-08-01_en +- https://ai-act-service-desk.ec.europa.eu/en/ai-act/eu-ai-act-implementation-timeline +- https://www.nist.gov/publications/artificial-intelligence-risk-management-framework-generative-artificial-intelligence +- https://airc.nist.gov/airmf-resources/playbook +- https://www.federalregister.gov/documents/2023/08/04/2023-16194/cybersecurity-risk-management-strategy-governance-and-incident-disclosure +- https://learn.microsoft.com/en-us/purview/compliance-manager-faq?view=o365-worldwide +- https://aws.amazon.com/audit-manager/pricing/ + +--- + +## Synthesis + +The evidence converges on one core shift: feasibility is now a workflow with hard gates, not a one-time estimate. The strongest 2024-2026 pattern is staged de-risking before build commitment, combining lightweight discovery methods (problem framing, premortem, dependency mapping, spikes) with explicit decision traceability (trade studies and decision logs). Teams that skip this sequence tend to carry implicit assumptions into implementation, where correction is more expensive. + +A second convergence is "constraints realism." Tooling for quotas, rate limits, compliance evidence, and dependency risk is mature, but each tool carries access tiers, scope limits, and operational caveats. This means the feasibility process must evaluate both business idea viability and instrumentation viability: can the team continuously gather reliable feasibility evidence without violating API budgets, quota ceilings, or governance boundaries? + +The biggest interpretation risk remains false confidence. Across experimentation, delivery, and operations literature, high-quality decisions require data-integrity checks first, uncertainty reporting, and explicit decision rules. Point estimates, vanity metrics, and isolated speed metrics consistently correlate with avoidable wrong-way commitments. + +The most actionable contradiction is speed vs rigor. Lightweight methods improve tempo; governance and reliability checks add overhead. The practical answer is not to choose one side globally, but to define escalation triggers: start lightweight, escalate when dependency criticality, external exposure, AI/regulatory risk, or reliability blast radius increases. This lets feasibility stay fast for low-risk MVPs while remaining strict for high-consequence launches. + +--- + +## Recommendations for SKILL.md + +- [x] Replace current methodology with a mandatory staged feasibility workflow (problem framing -> premortem -> dependency mapping -> spikes -> trade study -> decision record -> verdict). +- [x] Add explicit evidence-quality gates (`data_integrity`, `uncertainty`, `decision_rule_passed`) before allowing `overall_feasibility` verdict. +- [x] Add probabilistic timeline guidance (at least P50/P80) and prohibit single-point estimate decisions. +- [x] Add dual delivery signal rule (throughput + stability) and error-budget gate for operational feasibility. +- [x] Add escalation policy: lightweight default, heavyweight governance path for high-criticality or regulated contexts. +- [x] Expand `Available Tools` guidance with concrete usage order, including `flexus_policy_document(op="list", ...)` and artifact write discipline. +- [x] Add named anti-pattern warning blocks with detection, consequence, and mitigation procedures. +- [x] Extend artifact schema to include confidence, evidence links, dependency ownership, assumptions, and decision trace. +- [x] Add AI/regulatory trigger checks (NIST AI RMF profile and timeline mapping where applicable). + +--- + +## Draft Content for SKILL.md + +### Draft: Core stance rewrite + +You assess MVP feasibility to expose delivery failure points before commitment. Your job is to make hidden risk explicit early enough to support intelligent scope decisions. + +Feasibility is not optimism scoring and not a veto theater. You do not ask "can we maybe do this?" You ask "under what constraints can this scope be delivered with acceptable risk, and what must change if constraints fail?" + +Default mindset is risk-first realism: +- Assume estimates are optimistic until evidence proves otherwise. +- Treat unknown dependencies as schedule risk, not as neutral placeholders. +- Treat absent evidence as low confidence, never as implicit support. +- Separate local productivity signals (for example faster coding) from system-level delivery feasibility (throughput, stability, and rework). + +Mark older but still valid methods explicitly when you use them (for example premortem, TRL, error budget policy), and always attach current evidence where possible. + +**Source basis:** Atlassian Team Playbook, Microsoft engineering playbook updates, DORA 2024, SRE workbook. + +### Draft: Mandatory feasibility workflow + +Before writing any feasibility verdict, you must execute this workflow in order. If any stage is skipped, the assessment is incomplete and cannot be marked `feasible`. + +#### Stage 1: Scope grounding (problem and unknowns) + +1. Activate MVP scope context: + - Confirm in-scope features and explicit out-of-scope boundaries. + - Extract critical assumptions from scope language. +2. Convert assumptions into testable statements: + - Each assumption must be either evidence-backed now or scheduled for validation. +3. Write an "unknowns list": + - Unknown technical capability + - Unknown dependency lead time + - Unknown compliance impact + - Unknown reliability exposure + +If scope boundaries are ambiguous, pause verdicting and request clarification through recommendations. Do not infer scope silently. + +#### Stage 2: Structured risk discovery (premortem) + +Run a premortem mindset pass: +- Assume the MVP failed six months after launch. +- List plausible failure causes across technical, resource, timeline, dependency, and governance domains. +- Prioritize the highest-consequence and highest-likelihood risks. + +For each top risk, capture: +- Failure mechanism +- Early signal +- Mitigation owner +- Earliest decision checkpoint + +Do not accept generic risks such as "integration issues." Rewrite into concrete statements like "third-party API approval lead time exceeds release window by 4 weeks." + +#### Stage 3: Dependency mapping with ownership + +Build a dependency register with explicit owner and timing: +- Internal team dependency +- External vendor/API dependency +- Legal or compliance review dependency +- Infrastructure capacity dependency +- Customer/beta partner dependency + +For each dependency, record: +- Required milestone date +- Lead-time assumption +- Confidence (`high`, `medium`, `low`) +- Fallback path if missed + +If no owner is assigned, treat the dependency as unresolved and include it in blockers. + +#### Stage 4: Feasibility spikes and option comparison + +When critical uncertainty is present, run timeboxed feasibility spikes before final verdict: +- Spike output is evidence, not production code. +- Each spike must answer one decision-relevant uncertainty. + +Then run option comparison: +- Compare at least two plausible paths when uncertainty is material. +- Document trade-offs: delivery speed, reliability risk, operating cost, compliance burden, reversibility. +- Record chosen option and rejected option with reasons. + +If one option appears clearly dominant and evidence is strong, you may skip formal comparison table, but you must still record rationale in decision trace. + +#### Stage 5: Quantitative feasibility scoring + +Score feasibility on five axes: +1. Technical feasibility +2. Resource feasibility +3. Timeline feasibility +4. Dependency feasibility +5. Regulatory/operational feasibility (required when relevant) + +Use `0..5` score per axis: +- `5`: strong evidence, low uncertainty +- `4`: credible evidence, manageable uncertainty +- `3`: mixed evidence, requires scoped changes +- `2`: weak evidence, unresolved blockers likely +- `1`: unsupported, high risk +- `0`: impossible within MVP constraints + +For each score, include: +- Confidence (`high`, `medium`, `low`) +- Evidence references +- One-sentence rationale + +#### Stage 6: Hard gate checks + +Before final verdict, enforce hard gates: +- No unresolved critical dependency owner +- No single-point timeline estimate (must have range) +- No critical capability with unknown validation path +- No compliance-critical gap without mitigation plan +- No operational reliability plan for high-exposure launch + +If any hard gate fails, verdict cannot be `feasible`. + +#### Stage 7: Verdict and change path + +Use verdict rules: +- `feasible`: all hard gates pass, no axis score below `4` +- `feasible_with_changes`: hard gates pass after explicit scope changes, at least one axis is `3` +- `infeasible`: any hard gate fails or any axis is `0..2` without credible mitigation in MVP window + +Always provide: +- Scope-cut options +- Risk transfer options (`buy` / `partner`) +- Reassessment trigger date + +**Source basis:** Atlassian premortem/dependency/project-poster plays, Microsoft feasibility spikes + decision log, SAFe planning rigor, NIST AI RMF profile guidance. + +### Draft: Interpretation and confidence rules + +You must separate evidence quality from business desirability. A desirable outcome with low-quality evidence is still low confidence. + +Apply these interpretation rules: + +1. **Evidence integrity first** + - If upstream data integrity is known-bad, mark confidence `low` regardless of apparent upside. + - Example indicators: sampling mismatch, stale telemetry windows, unresolved metric-definition drift. + +2. **Uncertainty is mandatory** + - Avoid point-only interpretation. + - Report assumptions and uncertainty bounds (at least optimistic/realistic/pessimistic for schedule and capacity-sensitive estimates). + +3. **Decision-rule discipline** + - Do not conclude from a single favorable metric. + - Use at least one outcome signal and one guardrail signal for each critical claim. + +4. **Dual delivery lens** + - Never treat speed-only improvements as feasibility proof. + - Check both throughput and stability trends where data exists. + +5. **Operational gate** + - If reliability risk is material and error budget process is absent, downgrade operational confidence. + +6. **Readiness gating** + - Critical technical components with low maturity evidence should downgrade technical score and may force scope reduction. + +**Source basis:** Microsoft ExP SRM guidance, Statsig SRM docs, Spotify risk-aware decision framework, NIST uncertainty reporting, DORA metrics guidance, SRE error budget policy, NASA/GAO readiness framing. + +### Draft: Available tools section rewrite + +Use internal tools in this order: + +1. Activate scope source of truth. +2. Optionally list strategy documents when scope context is incomplete. +3. Write one consolidated feasibility artifact at the end of the run (do not scatter partial artifacts across multiple ad-hoc paths). + +```text +flexus_policy_document(op="activate", args={"p": "/strategy/mvp-scope"}) +flexus_policy_document(op="list", args={"p": "/strategy/"}) +write_artifact(artifact_type="mvp_feasibility_assessment", path="/strategy/mvp-feasibility", data={...}) +``` + +Tool-usage rules: +- Use `activate` on `/strategy/mvp-scope` before scoring any axis. +- Use `list` only when required context is missing or scope references are unclear. +- Call `write_artifact` only after all five feasibility axes and hard gates are complete. +- If evidence is incomplete, write explicit uncertainty and set lower confidence instead of inventing facts. + +External evidence handling rules: +- When using vendor or regulator documentation, store source URLs in artifact evidence fields. +- Prefer 2024-2026 sources. If using older source, label it as evergreen and explain why it still applies. +- Never claim tool limits or pricing without source reference. + +### Draft: Anti-pattern blocks + +#### Anti-pattern: Single-point estimate decision + +What it looks like in practice: +- One delivery date, one budget, no confidence range. + +Detection signal: +- Timeline field has one value only and no uncertainty narrative. + +Consequence if missed: +- Predictable timeline and budget overrun with delayed scope cuts. + +Mitigation steps: +1. Require optimistic/realistic/pessimistic values at minimum. +2. Require confidence annotation and assumption list. +3. Downgrade verdict to `feasible_with_changes` or `infeasible` if no ranges can be justified. + +#### Anti-pattern: No integrated dependency ownership + +What it looks like in practice: +- Dependency list exists, but owner or deadline is blank. + +Detection signal: +- Any critical dependency missing owner, lead time, or fallback. + +Consequence if missed: +- Hidden blockers appear late and force unplanned de-scope. + +Mitigation steps: +1. Require owner + date + fallback for each critical dependency. +2. Reclassify unresolved dependency as blocker. +3. Add reassessment date before final go recommendation. + +#### Anti-pattern: Assumption marked as validated without proof + +What it looks like in practice: +- Status says "done" but no direct evidence link. + +Detection signal: +- Assumption record lacks validation method and evidence reference. + +Consequence if missed: +- Irreversible decisions made on false premise. + +Mitigation steps: +1. Require validation type (`test`, `documented confirmation`, `measured telemetry`). +2. Require evidence URI or source citation. +3. Downgrade confidence to `low` until evidence exists. + +#### Anti-pattern: Legacy impact ignored + +What it looks like in practice: +- Risk analysis models only modern path and excludes legacy flow. + +Detection signal: +- No legacy-specific risks despite known legacy components in scope. + +Consequence if missed: +- Side effects appear after release with limited rollback options. + +Mitigation steps: +1. Add explicit legacy hotspot check in technical feasibility. +2. Add extra uncertainty buffer when legacy understanding is low. +3. Require fallback or phased exposure for legacy-touching changes. + +#### Anti-pattern: Big-bang rollout by default + +What it looks like in practice: +- Full rollout first, rollback strategy second. + +Detection signal: +- No ring/canary progression and no rollback threshold. + +Consequence if missed: +- Maximum blast radius from a single defect. + +Mitigation steps: +1. Define staged exposure progression. +2. Define objective promotion criteria. +3. Define automatic rollback trigger per stage. + +#### Anti-pattern: Safeguard exists but is untested + +What it looks like in practice: +- Team claims fallback exists but cannot show stress validation. + +Detection signal: +- No overload/misconfiguration test evidence for fail-safe path. + +Consequence if missed: +- Fallback path fails during incident conditions. + +Mitigation steps: +1. Add safeguard stress test as launch prerequisite. +2. Record expected behavior under degraded mode. +3. Include rollback command path in operational plan. + +#### Anti-pattern: Security discovered mostly after merge + +What it looks like in practice: +- Late vulnerability discovery repeatedly delays release. + +Detection signal: +- Majority of critical findings emerge post-merge. + +Consequence if missed: +- Security debt becomes timeline debt. + +Mitigation steps: +1. Add shift-left checks to feasibility constraints. +2. Define remediation SLA by severity. +3. Add pre-approved exception path for urgent mitigation. + +#### Anti-pattern: Supply-chain risk absent from feasibility + +What it looks like in practice: +- OSS and vendor dependencies are listed but not risk-ranked. + +Detection signal: +- No SBOM, no criticality tiering, no third-party fallback narrative. + +Consequence if missed: +- Third-party incidents produce unplanned outage window. + +Mitigation steps: +1. Require dependency inventory and criticality levels. +2. Require exploit-priority source in risk assessment. +3. Add vendor incident response assumptions to dependency axis. + +#### Anti-pattern: Compliance deferred to "after MVP" + +What it looks like in practice: +- Regulatory requirements acknowledged but not scheduled. + +Detection signal: +- No compliance milestones in timeline estimate. + +Consequence if missed: +- Late launch block or emergency de-scope. + +Mitigation steps: +1. Map applicable obligations to timeline checkpoints. +2. Record required evidence artifacts and responsible owner. +3. Block `feasible` verdict if obligations conflict with release window. + +#### Anti-pattern: AI productivity interpreted as automatic feasibility + +What it looks like in practice: +- Team cites coding acceleration but ignores stability and rework impact. + +Detection signal: +- Throughput claim present, stability signal absent. + +Consequence if missed: +- Delivery reliability degrades while local speed appears improved. + +Mitigation steps: +1. Require throughput and stability evidence together. +2. Require explicit small-batch and test-rigor controls when AI coding tools are used. +3. Downgrade confidence if system-level stability evidence is missing. + +### Draft: Decision output rules + +Your final recommendations must include both verdict and action path: + +- If `feasible`: provide critical assumptions that must remain true and monitoring triggers. +- If `feasible_with_changes`: provide explicit scope cuts and sequencing plan. +- If `infeasible`: provide minimum condition set required for reassessment. + +Recommendation quality requirements: +- Every recommendation must be linked to at least one evidence item. +- Every recommendation must include expected risk reduction. +- Every recommendation must include owner type (`engineering`, `product`, `ops`, `legal`, `security`, `vendor`). + +Do not provide recommendation lists that are not actionable. Replace vague text like "improve architecture" with concrete text like "replace custom auth module with managed identity provider to remove compliance and incident response burden from MVP scope." + +### Draft: Schema additions + +```json +{ + "mvp_feasibility_assessment": { + "type": "object", + "required": [ + "assessed_at", + "assessment_version", + "scope_ref", + "overall_feasibility", + "confidence", + "signal_scores", + "technical_risks", + "resource_estimate", + "timeline_estimate", + "dependency_register", + "critical_assumptions", + "hard_gate_results", + "blockers", + "recommendations", + "decision_trace", + "sources" + ], + "additionalProperties": false, + "properties": { + "assessed_at": { + "type": "string", + "description": "ISO-8601 timestamp when the assessment was finalized." + }, + "assessment_version": { + "type": "string", + "description": "Version label for the feasibility method used (for reproducibility across iterations)." + }, + "scope_ref": { + "type": "string", + "description": "Path or identifier of the MVP scope source used for this assessment." + }, + "overall_feasibility": { + "type": "string", + "enum": [ + "feasible", + "feasible_with_changes", + "infeasible" + ], + "description": "Final verdict after axis scoring and hard gate checks." + }, + "confidence": { + "type": "object", + "required": [ + "level", + "rationale" + ], + "additionalProperties": false, + "properties": { + "level": { + "type": "string", + "enum": [ + "high", + "medium", + "low" + ], + "description": "Overall confidence in the verdict given evidence quality and uncertainty." + }, + "rationale": { + "type": "string", + "description": "Plain-language explanation of why this confidence level is assigned." + } + } + }, + "signal_scores": { + "type": "object", + "required": [ + "technical", + "business", + "operational", + "dependency", + "regulatory" + ], + "additionalProperties": false, + "properties": { + "technical": { + "$ref": "#/$defs/axisScore" + }, + "business": { + "$ref": "#/$defs/axisScore" + }, + "operational": { + "$ref": "#/$defs/axisScore" + }, + "dependency": { + "$ref": "#/$defs/axisScore" + }, + "regulatory": { + "$ref": "#/$defs/axisScore" + } + }, + "description": "Normalized 0-5 feasibility evidence scores by axis." + }, + "technical_risks": { + "type": "array", + "description": "Technical risk items discovered during feasibility workflow.", + "items": { + "type": "object", + "required": [ + "risk", + "resolution_type", + "severity", + "owner", + "status" + ], + "additionalProperties": false, + "properties": { + "risk": { + "type": "string", + "description": "Concrete technical failure mode statement." + }, + "resolution_type": { + "type": "string", + "enum": [ + "build", + "buy", + "partner", + "block" + ], + "description": "Primary strategy to resolve this risk." + }, + "severity": { + "type": "string", + "enum": [ + "critical", + "high", + "medium", + "low" + ], + "description": "Impact severity if unresolved." + }, + "owner": { + "type": "string", + "description": "Role or team accountable for mitigation." + }, + "status": { + "type": "string", + "enum": [ + "open", + "mitigated", + "accepted", + "blocked" + ], + "description": "Current mitigation status." + }, + "mitigation": { + "type": "string", + "description": "Specific mitigation plan for the risk." + } + } + } + }, + "resource_estimate": { + "type": "object", + "required": [ + "engineering_weeks", + "design_weeks", + "qa_weeks", + "estimate_confidence" + ], + "additionalProperties": false, + "properties": { + "engineering_weeks": { + "type": "number", + "minimum": 0, + "description": "Estimated engineering effort in person-weeks." + }, + "design_weeks": { + "type": "number", + "minimum": 0, + "description": "Estimated design effort in person-weeks." + }, + "qa_weeks": { + "type": "number", + "minimum": 0, + "description": "Estimated quality/testing effort in person-weeks." + }, + "estimate_confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low" + ], + "description": "Confidence in resource estimate quality." + }, + "capacity_notes": { + "type": "string", + "description": "Constraints on available team capacity and competing work." + } + } + }, + "timeline_estimate": { + "type": "object", + "required": [ + "optimistic_weeks", + "realistic_weeks", + "pessimistic_weeks", + "critical_path" + ], + "additionalProperties": false, + "properties": { + "optimistic_weeks": { + "type": "number", + "minimum": 0, + "description": "Best-case estimate under favorable assumptions." + }, + "realistic_weeks": { + "type": "number", + "minimum": 0, + "description": "Most likely timeline estimate." + }, + "pessimistic_weeks": { + "type": "number", + "minimum": 0, + "description": "Conservative timeline estimate including known uncertainties." + }, + "p50_weeks": { + "type": "number", + "minimum": 0, + "description": "Optional probabilistic median schedule estimate if modeled." + }, + "p80_weeks": { + "type": "number", + "minimum": 0, + "description": "Optional probabilistic high-confidence schedule estimate if modeled." + }, + "critical_path": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Ordered list of tasks/dependencies that define minimum delivery duration." + } + } + }, + "dependency_register": { + "type": "array", + "description": "Dependencies with ownership and lead-time assumptions.", + "items": { + "type": "object", + "required": [ + "name", + "type", + "owner", + "required_by", + "lead_time_weeks", + "confidence", + "fallback" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Dependency identifier (team, service, vendor, regulator, or partner)." + }, + "type": { + "type": "string", + "enum": [ + "internal", + "external_api", + "vendor", + "legal_compliance", + "infrastructure", + "customer" + ], + "description": "Dependency category used for risk interpretation." + }, + "owner": { + "type": "string", + "description": "Accountable owner for dependency tracking." + }, + "required_by": { + "type": "string", + "description": "ISO-8601 date by which dependency must be satisfied." + }, + "lead_time_weeks": { + "type": "number", + "minimum": 0, + "description": "Estimated lead time to satisfy dependency." + }, + "confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low" + ], + "description": "Confidence in lead-time estimate quality." + }, + "fallback": { + "type": "string", + "description": "Fallback path if dependency misses required date." + } + } + } + }, + "critical_assumptions": { + "type": "array", + "description": "Assumptions that materially change feasibility outcome.", + "items": { + "type": "object", + "required": [ + "assumption", + "status", + "validation_method", + "evidence_ref" + ], + "additionalProperties": false, + "properties": { + "assumption": { + "type": "string", + "description": "Plain-language assumption statement." + }, + "status": { + "type": "string", + "enum": [ + "validated", + "partially_validated", + "unvalidated", + "invalidated" + ], + "description": "Current validation state." + }, + "validation_method": { + "type": "string", + "description": "How assumption was validated (test, metric, contract, legal review, or equivalent)." + }, + "evidence_ref": { + "type": "string", + "description": "URL or internal evidence identifier supporting status." + } + } + } + }, + "hard_gate_results": { + "type": "array", + "description": "Mandatory gate checks evaluated before final verdict.", + "items": { + "type": "object", + "required": [ + "gate", + "passed", + "reason" + ], + "additionalProperties": false, + "properties": { + "gate": { + "type": "string", + "description": "Name of hard gate rule." + }, + "passed": { + "type": "boolean", + "description": "Whether the gate passed." + }, + "reason": { + "type": "string", + "description": "Explanation for pass/fail outcome." + } + } + } + }, + "blockers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Critical unresolved items blocking feasible verdict." + }, + "recommendations": { + "type": "array", + "items": { + "$ref": "#/$defs/recommendation" + }, + "description": "Actionable recommendations tied to evidence and risk reduction." + }, + "decision_trace": { + "type": "string", + "description": "Concise explanation of how evidence and scores led to final verdict." + }, + "sources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Source URLs used in this assessment." + } + }, + "$defs": { + "axisScore": { + "type": "object", + "required": [ + "score", + "confidence", + "rationale", + "evidence" + ], + "additionalProperties": false, + "properties": { + "score": { + "type": "integer", + "minimum": 0, + "maximum": 5, + "description": "Feasibility score for this axis where 0 is impossible and 5 is strongly feasible." + }, + "confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low" + ], + "description": "Confidence in the score based on evidence quality." + }, + "rationale": { + "type": "string", + "description": "Reasoning for axis score." + }, + "evidence": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Source references supporting this score." + } + } + }, + "recommendation": { + "type": "object", + "required": [ + "action", + "owner", + "expected_risk_reduction", + "priority", + "evidence_refs" + ], + "additionalProperties": false, + "properties": { + "action": { + "type": "string", + "description": "Specific action statement." + }, + "owner": { + "type": "string", + "enum": [ + "engineering", + "product", + "design", + "qa", + "ops", + "security", + "legal", + "vendor" + ], + "description": "Primary owner role for implementation." + }, + "expected_risk_reduction": { + "type": "string", + "description": "What risk is reduced if this recommendation is executed." + }, + "priority": { + "type": "string", + "enum": [ + "p0", + "p1", + "p2", + "p3" + ], + "description": "Recommendation urgency." + }, + "evidence_refs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Evidence links that justify the recommendation." + } + } + } + } + } +} +``` + +### Draft: Quality checklist for skill execution + +Before finalizing the artifact, verify: +- You did not skip any mandatory workflow stage. +- Every axis score has confidence and evidence links. +- Every blocker is either mitigated or reflected in verdict downgrade. +- Every recommendation has owner and expected risk reduction. +- Sources are attached for all major claims. +- Older sources are explicitly marked as evergreen. + +If any item above is missing, do not output `feasible`. + +--- + +## Gaps and Uncertainties + +- Public sources do not provide universal numeric thresholds for many feasibility dimensions (for example "acceptable dependency approval lead time"). Teams still need local baselines. +- Several practical tool constraints are plan- and tenant-specific; published docs are directional but not always complete for every enterprise contract variant. +- Some valuable methods are evergreen rather than new; they remain included because current 2024-2026 operational guidance still depends on them. +- Cross-industry evidence linking specific feasibility checklists to quantified launch success rates remains limited; most available data is postmortem-oriented. +- Regulatory applicability is jurisdiction and product-context dependent; this research identifies timing frameworks but not legal advice. + diff --git a/flexus_simple_bots/strategist/skills/_mvp-feasibility/SKILL.md b/flexus_simple_bots/strategist/skills/_mvp-feasibility/SKILL.md new file mode 100644 index 00000000..a81a52c4 --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_mvp-feasibility/SKILL.md @@ -0,0 +1,57 @@ +--- +name: mvp-feasibility +description: MVP feasibility assessment — technical, resource, timeline, and dependency risk evaluation before committing to build +--- + +You assess the feasibility of a proposed MVP scope before resources are committed. Feasibility is not a veto — it is a risk inventory that allows the team to make go/no-go decisions with full awareness of constraints. + +Core mode: surface all blockers and risks explicitly. Optimism kills feasibility assessments. The purpose of this skill is to find the things that will go wrong, not to confirm the things that will go right. + +## Methodology + +### Technical feasibility +Review the in-scope features from `mvp_scope` against: +- Required technical capabilities (does the team have them or need to hire?) +- Required external dependencies (APIs, infrastructure, integrations) +- Data requirements (is the required data available, owned, or licensable?) +- Security / compliance implications (GDPR, SOC2, industry regulations) + +For each capability gap, classify: +- Build: can be built in-house within timeline +- Buy: needs licensed component or vendor +- Partner: requires a third-party to provide (adds timeline risk) +- Block: cannot be resolved within MVP timeline + +### Resource feasibility +For the proposed MVP scope: +- Engineering estimate (story points or t-shirt sizes per feature) +- Design estimate (screens, components, flow design required) +- QA estimate (testing scope) +- Total estimate vs. available capacity + +If capacity is insufficient: recommend which features to defer to keep timeline. + +### Timeline feasibility +Given resource estimate and available capacity: +- Can MVP be delivered within the window required for hypothesis testing? +- What are the critical path dependencies (what must be done before what)? +- What is the risk multiplier? (First time building this type of system = 1.5x-2x estimate) + +### Dependencies and risks +External dependencies that create timeline risk: +- Third-party API access and approval processes +- Legal/compliance reviews +- Hardware procurement (if relevant) +- Beta customer commitment timelines + +## Recording + +``` +write_artifact(path="/strategy/mvp-feasibility", data={...}) +``` + +## Available Tools + +``` +flexus_policy_document(op="activate", args={"p": "/strategy/mvp-scope"}) +``` diff --git a/flexus_simple_bots/strategist/skills/_mvp-roadmap/RESEARCH.md b/flexus_simple_bots/strategist/skills/_mvp-roadmap/RESEARCH.md new file mode 100644 index 00000000..d826d779 --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_mvp-roadmap/RESEARCH.md @@ -0,0 +1,1013 @@ +# Research: mvp-roadmap + +**Skill path:** `strategist/skills/mvp-roadmap/` +**Bot:** strategist +**Research date:** 2026-03-04 +**Status:** complete + +--- + +## Context + +`mvp-roadmap` is a strategist skill that turns MVP intent into a conditional, evidence-gated roadmap from pre-MVP to scale. The skill is used when a team already has a product hypothesis (or early MVP) and needs to sequence milestones, dependencies, and resource commitments without overcommitting to long-range plans before evidence exists. + +The core problem this skill solves is roadmap false precision: teams commit to 12-18 month feature plans before confirming core value, retention, or delivery stability. Research from 2024-2026 shows that high-performing teams now bias toward outcome-led roadmaps, explicit gate criteria, and short replanning cadences, while weaker teams still run feature-factory roadmaps with weak experimentation and unstable priorities. + +This research expands the current SKILL.md by grounding phase gates, sequencing logic, and risk interpretation in current evidence (2024-2026 priority, older sources explicitly marked evergreen). + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024-2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- No generic filler ("it is important to...", "best practices suggest...") without concrete backing +- No invented tool names, method IDs, or API endpoints - only verified real ones +- Contradictions between sources are explicitly noted, not silently resolved +- Volume: findings section is within 800-4000 words and synthesized (not copied source text) + +Verification notes: +- Findings include explicit contradictions (outcome rhetoric vs execution reality; benchmark thresholds vs context baselines; qualitative risk matrices vs quantitative simulation). +- Tool section references real products and documented capabilities/tier constraints. +- Draft section is longer than findings and provides paste-ready SKILL content. + +--- + +## Research Angles + +### Angle 1: Domain Methodology and Best Practices +> How practitioners actually do MVP-to-scale sequencing in 2024-2026. + +**Findings:** + +1. Outcome-led roadmapping is now mainstream language, but not consistently operationalized. ProductPlan 2024 reports broad movement toward outcome metrics in product planning, with strong investment intent in strategy and roadmapping workflows. Practical implication: this skill should require every phase to define outcome evidence before feature commitments. +Source: [ProductPlan 2024 annual report](https://www.productplan.com/2024-state-of-product-management-annual-report), [ProductPlan 2024 guide](https://productplan.com/the-busy-product-managers-guide-to-the-state-of-product-2024) + +2. Strategy and roadmap capacity is a bottleneck, not just methodology knowledge. Atlassian State of Product 2026 (published 2025) shows many teams lack time for strategic planning and roadmap development. Practical implication: cadence must be explicit in the skill, otherwise roadmap quality degrades into reactive backlog management. +Source: [Atlassian State of Product 2026 PDF](https://dam-cdn.atl.orangelogic.com/AssetLink/iv3u81ou0u70gsy04ia61oci00d0h0je.pdf) + +3. Experimentation discipline is uneven. Atlassian reports a split between teams that run rapid or structured experimentation and teams that run limited/no experimentation. Practical implication: phase transitions must be evidence-gated; "we shipped it" is not enough for GO decisions. +Source: [Atlassian State of Product 2026 PDF](https://dam-cdn.atl.orangelogic.com/AssetLink/iv3u81ou0u70gsy04ia61oci00d0h0je.pdf) + +4. Engineering is frequently involved too late. Atlassian indicates early engineering involvement at ideation is still low. Practical implication: dependency realism and capacity realism should be explicit preconditions for phase commitment in this skill. +Source: [Atlassian State of Product 2026 PDF](https://dam-cdn.atl.orangelogic.com/AssetLink/iv3u81ou0u70gsy04ia61oci00d0h0je.pdf) + +5. Product Ops is common, but maturity is uneven. Productboard 2025 reports high Product Ops penetration but low automation and inconsistent measurement. Practical implication: the skill should include a minimum instrumentation standard for roadmap evidence packs. +Source: [Productboard State of Product Ops 2025](https://www.productboard.com/blog/the-state-of-product-ops-in-2025/) + +6. Stage-gate thinking is not dead; it is hybridized with Agile and Lean approaches. A 2024 IEEE review highlights formal hybridization patterns and selection criteria. Practical implication: this skill should keep gate rigor while allowing iterative learning inside each phase. +Source: [IEEE TEM hybrid stage-gate review (2024)](https://ui.adsabs.harvard.edu/abs/2024ITEM...71.6435C/abstract) + +7. Cadence-based planning is used to balance predictability and adaptation. SAFe guidance (updated 2024-2025) defines PI cadence and multi-horizon roadmaps (committed near-term, forecast medium-term). Practical implication: roadmap output should separate committed next phase from conditional forecast phases. +Source: [SAFe PI Planning](https://scaledagileframework.com/PI-planning), [SAFe Roadmap](https://scaledagileframework.com/roadmap) + +8. There is a measurable gap between "outcome orientation" and actual digital outcome attainment. Gartner-reported 2024 survey results show less than half of digital initiatives meet/exceed targets, while top performers have stronger shared ownership models. Practical implication: gate ownership must be cross-functional, not product-only. +Source: [Gartner survey coverage (Business Wire, 2024)](https://www.businesswire.com/news/home/20241022615512/en/Gartner-Survey-Reveals-That-Only-48-of-Digital-Initiatives-Meet-or-Exceed-Their-Business-Outcome-Targets) + +9. Contradiction to preserve in SKILL text: teams claim outcome-led methods, but still underinvest in strategy time and experimental rigor. This contradiction should be represented as explicit anti-pattern checks, not ignored. + +**Sources:** +- [ProductPlan 2024 annual report](https://www.productplan.com/2024-state-of-product-management-annual-report) +- [ProductPlan 2024 guide](https://productplan.com/the-busy-product-managers-guide-to-the-state-of-product-2024) +- [Atlassian State of Product 2026 PDF](https://dam-cdn.atl.orangelogic.com/AssetLink/iv3u81ou0u70gsy04ia61oci00d0h0je.pdf) +- [Productboard State of Product Ops 2025](https://www.productboard.com/blog/the-state-of-product-ops-in-2025/) +- [IEEE TEM hybrid stage-gate review (2024)](https://ui.adsabs.harvard.edu/abs/2024ITEM...71.6435C/abstract) +- [SAFe PI Planning](https://scaledagileframework.com/PI-planning) +- [SAFe Roadmap](https://scaledagileframework.com/roadmap) +- [Gartner survey coverage (Business Wire, 2024)](https://www.businesswire.com/news/home/20241022615512/en/Gartner-Survey-Reveals-That-Only-48-of-Digital-Initiatives-Meet-or-Exceed-Their-Business-Outcome-Targets) + +--- + +### Angle 2: Tool and API Landscape +> Tools and data systems for sequencing, dependencies, and roadmap risk management. + +**Findings:** + +1. Jira Plans (Advanced Roadmaps) is one of the strongest practical backbones for roadmap sequencing because it supports capacity planning, cross-team dependencies, and scenario modeling in sandbox mode. This is directly relevant for conditional phase commitment workflows. +Source: [Atlassian plans overview](https://atlassian.com/software/jira/guides/advanced-roadmaps/overview), [What is Advanced Roadmaps](https://support.atlassian.com/jira-software-cloud/docs/what-is-advanced-roadmaps/), [Jira scenarios](https://support.atlassian.com/jira-software-cloud/docs/what-are-scenarios-in-advanced-roadmaps) + +2. Tier constraints are not a minor detail; they change what risk management is possible. Jira pricing indicates advanced planning and scenario modeling are tier-dependent. Practical implication: any roadmap method should include entitlement checks before promising dependency or what-if features. +Source: [Jira pricing](https://www.atlassian.com/software/jira/pricing) + +3. Jira dependency semantics are explicit enough for machine interpretation (incoming/outgoing links, conflict signals), supporting deterministic gate checks for blocked milestones. +Source: [Dependencies in Advanced Roadmaps](https://confluence.atlassian.com/jirasoftware/dependencies-in-advanced-roadmaps-1688898866.html) + +4. monday.com supports structured dependencies (FS/SS/FF/SF, lead/lag, strict/flexible modes), but capabilities vary by plan and some portfolio-risk features are enterprise-focused or rolling out gradually. Practical implication: dependency and risk workflows should degrade gracefully when enterprise features are absent. +Source: [monday dependencies](https://support.monday.com/hc/en-us/articles/360007402599-Dependencies-on-monday-com), [monday plans](https://support.monday.com/hc/en-us/articles/115005320209-Available-plan-types-on-Work-Management), [monday pricing details](https://support.monday.com/hc/en-us/articles/4405633151634-Plans-and-pricing-for-monday-com) + +5. Productboard gives strategic-layer strengths (objectives, prioritization context, delivery integrations) with practical constraints such as integration slot limits by tier. That matters when sequencing across many delivery streams. +Source: [Productboard pricing](https://www.productboard.com/pricing/productboard/), [Productboard integrations](https://www.productboard.com/product/integrations/) + +6. Aha! plus Jira integration can synchronize estimates and capacity context, but has setup and mode constraints (for example legacy mode and role requirements). Practical implication: "sync exists" does not mean "sync is production-safe" without governance rules. +Source: [Aha-Jira capacity tracking](https://aha.io/support/roadmaps/integrations/jira/track-capacity-between-aha-and-jira), [Aha capacity planning](https://aha.io/support/develop/develop/aha-roadmaps/capacity-planning) + +7. Asana and Smartsheet provide portfolio/capacity/baseline support with meaningful plan boundaries and governance controls (for example baseline ownership). These are viable alternatives, but each needs explicit limitations in planning playbooks. +Source: [Asana pricing](https://asana.com/pricing), [Asana advanced plan](https://asana.com/plan/advanced), [Smartsheet baselines and critical path](https://help.smartsheet.com/learning-track/project-fundamentals-part-2-project-settings/baselines-and-critical-path), [Smartsheet pricing](https://www.smartsheet.com/pricing) + +8. Roadmap risk quality improves when delivery tools are overlaid with release and experiment telemetry (feature flag states, experiment outcomes, behavior analytics). LaunchDarkly, Statsig, and Amplitude integrations with Jira/Productboard make this technically feasible. +Source: [LaunchDarkly Jira docs](https://docs.launchdarkly.com/integrations/jira), [Statsig Jira docs](https://docs.statsig.com/integrations/jira/), [Amplitude Jira integration](https://amplitude.com/docs/data/integrate-jira), [Amplitude Productboard integration](https://amplitude.com/integrations/productboard) + +9. Tool contradiction to preserve: documentation markets broad capability, but practical value is heavily entitlement-, setup-, and process-dependent. The SKILL draft should therefore avoid tool absolutism and encode fallback behavior. + +**Sources:** +- [Atlassian plans overview](https://atlassian.com/software/jira/guides/advanced-roadmaps/overview) +- [What is Advanced Roadmaps](https://support.atlassian.com/jira-software-cloud/docs/what-is-advanced-roadmaps/) +- [Jira scenarios](https://support.atlassian.com/jira-software-cloud/docs/what-are-scenarios-in-advanced-roadmaps) +- [Jira pricing](https://www.atlassian.com/software/jira/pricing) +- [Dependencies in Advanced Roadmaps](https://confluence.atlassian.com/jirasoftware/dependencies-in-advanced-roadmaps-1688898866.html) +- [monday dependencies](https://support.monday.com/hc/en-us/articles/360007402599-Dependencies-on-monday-com) +- [monday plans](https://support.monday.com/hc/en-us/articles/115005320209-Available-plan-types-on-Work-Management) +- [monday pricing details](https://support.monday.com/hc/en-us/articles/4405633151634-Plans-and-pricing-for-monday-com) +- [Productboard pricing](https://www.productboard.com/pricing/productboard/) +- [Productboard integrations](https://www.productboard.com/product/integrations/) +- [Aha-Jira capacity tracking](https://aha.io/support/roadmaps/integrations/jira/track-capacity-between-aha-and-jira) +- [Aha capacity planning](https://aha.io/support/develop/develop/aha-roadmaps/capacity-planning) +- [Asana pricing](https://asana.com/pricing) +- [Asana advanced plan](https://asana.com/plan/advanced) +- [Smartsheet baselines and critical path](https://help.smartsheet.com/learning-track/project-fundamentals-part-2-project-settings/baselines-and-critical-path) +- [Smartsheet pricing](https://www.smartsheet.com/pricing) +- [LaunchDarkly Jira docs](https://docs.launchdarkly.com/integrations/jira) +- [Statsig Jira docs](https://docs.statsig.com/integrations/jira/) +- [Amplitude Jira integration](https://amplitude.com/docs/data/integrate-jira) +- [Amplitude Productboard integration](https://amplitude.com/integrations/productboard) + +--- + +### Angle 3: Data Interpretation and Signal Quality +> How to decide whether milestones are valid, dependency risk is acceptable, and learning cadence is sufficient. + +**Findings:** + +1. Milestone validity is multi-signal, not single-metric. DORA guidance treats software delivery metrics as a system and warns against single metric interpretation. Practical implication: phase gates must require both progress and stability signals. +Source: [DORA metrics guide](https://dora.dev/guides/dora-metrics-four-keys/), [DORA metrics history](https://dora.dev/guides/dora-metrics/history/) + +2. Productivity proxy gains can hide stability regression. Google Cloud's 2024 DORA highlights show positive movement in some AI-related productivity/quality proxies while throughput and stability can decline. Practical implication: "faster output" should not be accepted as sufficient gate evidence. +Source: [Google Cloud 2024 DORA highlights](https://cloud.google.com/blog/products/devops-sre/announcing-the-2024-dora-report) + +3. Contradiction to preserve: teams ask for universal thresholds, but DORA framing emphasizes contextual baselines and reference points. Skill logic should encode trend-vs-baseline checks instead of hard universal gates. +Source: [DORA 2024 report page](https://dora.dev/research/2024/dora-report/) + +4. CVSS is severity, not exploit likelihood. CISA notes that only a small portion of CVEs are exploited, but exploited vulnerabilities can be weaponized rapidly. Practical implication: dependency risk decisions should combine severity with exploitation evidence. +Source: [CISA BOD 22-01 FAQ](https://www.cisa.gov/news-events/directives/bod-22-01-reducing-significant-risk-known-exploited-vulnerabilities) + +5. CVSS v4 bins are useful for severity normalization, but cannot substitute threat context. NVD and FIRST guidance support this distinction. +Source: [NVD CVSS metrics](https://nvd.nist.gov/cvss.cfm), [FIRST CVSS v4 spec](https://www.first.org/cvss/v4-0/specification-document) + +6. EPSS thresholds are operating points, not universal standards. FIRST explicitly warns against hard-coded universal cutoff assumptions (for example, 10%). Practical implication: thresholds should be calibrated to risk appetite and remediation capacity. +Source: [FIRST EPSS model](https://www.first.org/epss/model) + +7. NIST LEV (2025) provides a practical composite ranking approach (`max(EPSS, KEV, LEV)`) and highlights that LEV supports prioritization but does not replace confirmed-exploited catalogs. Practical implication: skill-level dependency risk scoring can include this composite logic. +Source: [NIST CSWP 41 LEV](https://nvlpubs.nist.gov/nistpubs/CSWP/NIST.CSWP.41.pdf) + +8. Time-window bias can distort risk interpretation. NIST LEV analysis shows expected exploitation prevalence changes significantly depending on cohort definitions. Practical implication: milestone risk comparisons must normalize observation windows. +Source: [NIST CSWP 41 LEV](https://nvlpubs.nist.gov/nistpubs/CSWP/NIST.CSWP.41.pdf) + +9. Risk scoring methods materially affect ranking outcomes. Oracle Primavera documentation shows how method choices (for example highest impact vs average impact) can shift practical prioritization. Practical implication: risk scoring method must be explicit and stable for cross-phase comparisons. +Source: [Oracle risk scoring](https://primavera.oraclecloud.com/help/en/user/186844.htm), [Oracle risk matrix template](https://docs.oracle.com/cd/E80480_01/English/user_guides/risk_management_user_guide/90763.htm) + +10. Qualitative probability-impact matrices and quantitative simulation can disagree; recent 2024 research recommends quantitative methods (for example Monte Carlo simulation) for high-stakes schedule/cost decisions. Practical implication: gates with large budget or schedule impact should use quantitative validation. +Source: [Nature portfolio risk paper (2024)](https://www.nature.com/articles/s41599-024-03180-5) + +11. Evergreen cadence references remain useful. Scrum Guide (2020) and SAFe Inspect-and-Adapt cadence guidance are older but still canonical for short-loop learning and periodic replanning. +Source (Evergreen): [Scrum Guide 2020 PDF](https://scrumguides.org/docs/scrumguide/v2020/2020-Scrum-Guide-US.pdf), [SAFe Inspect and Adapt](https://scaledagileframework.com/inspect-and-adapt/) + +**Sources:** +- [DORA metrics guide](https://dora.dev/guides/dora-metrics-four-keys/) +- [DORA metrics history](https://dora.dev/guides/dora-metrics/history/) +- [Google Cloud 2024 DORA highlights](https://cloud.google.com/blog/products/devops-sre/announcing-the-2024-dora-report) +- [DORA 2024 report page](https://dora.dev/research/2024/dora-report/) +- [CISA BOD 22-01 FAQ](https://www.cisa.gov/news-events/directives/bod-22-01-reducing-significant-risk-known-exploited-vulnerabilities) +- [NVD CVSS metrics](https://nvd.nist.gov/cvss.cfm) +- [FIRST CVSS v4 spec](https://www.first.org/cvss/v4-0/specification-document) +- [FIRST EPSS model](https://www.first.org/epss/model) +- [NIST CSWP 41 LEV](https://nvlpubs.nist.gov/nistpubs/CSWP/NIST.CSWP.41.pdf) +- [Oracle risk scoring](https://primavera.oraclecloud.com/help/en/user/186844.htm) +- [Oracle risk matrix template](https://docs.oracle.com/cd/E80480_01/English/user_guides/risk_management_user_guide/90763.htm) +- [Nature portfolio risk paper (2024)](https://www.nature.com/articles/s41599-024-03180-5) +- [Scrum Guide 2020 PDF](https://scrumguides.org/docs/scrumguide/v2020/2020-Scrum-Guide-US.pdf) (Evergreen) +- [SAFe Inspect and Adapt](https://scaledagileframework.com/inspect-and-adapt/) (Evergreen) + +--- + +### Angle 4: Failure Modes and Anti-Patterns +> What fails in real MVP roadmapping and how to detect/mitigate it early. + +**Findings:** + +1. Goal sprawl destroys roadmap focus. Atlassian State of Teams 2024 reports many teams feel pulled in too many directions. Practical detection signal: high parallel objective count with weak "mission-critical" concentration. Mitigation: force explicit top-priority outcome constraints per phase. +Source: [Atlassian State of Teams 2024](https://www.atlassian.com/blog/state-of-teams-2024) + +2. Feature-factory behavior is measurable. Pendo benchmarking shows usage concentration where a small fraction of features drives most clicks, meaning many shipped features add little product value. Detection signal: low concentration in core-feature adoption after release. +Source: [Pendo product benchmarks 2024](https://www.pendo.io/pendo-blog/product-benchmarks/) + +3. Discovery starvation creates late rework. ProductPlan 2024 data indicates many PM teams are delivery-heavy relative to discovery. Detection signal: high delivery effort with low pre-commit evidence quality. +Source: [ProductPlan 2024 report PDF](https://assets.productplan.com/content/The-2024-State-of-Product-Management-Report.pdf) + +4. Leadership-only prioritization weakens validation quality. ProductPlan data shows strong top-down influence patterns in many orgs. Detection signal: roadmap items lacking customer evidence attachments at gate review. +Source: [ProductPlan 2024 report PDF](https://assets.productplan.com/content/The-2024-State-of-Product-Management-Report.pdf) + +5. Weak triad research participation is a recurring feasibility trap. ProductPlan highlights low rates of PM + design + engineering joint research. Detection signal: milestone proposals without cross-functional feasibility notes. +Source: [ProductPlan 2024 report PDF](https://assets.productplan.com/content/The-2024-State-of-Product-Management-Report.pdf) + +6. Priority thrash correlates with productivity loss and burnout. DORA 2024 emphasizes instability costs. Detection signal: frequent re-prioritization outside planned windows and rising unplanned work. +Source: [DORA 2024 report PDF](https://services.google.com/fh/files/misc/2024_final_dora_report.pdf) + +7. Large-batch delivery increases instability and rework risk. DORA 2024 supports small-batch flow for better stability. Detection signal: larger change bundles with rising incident/churn rates. +Source: [DORA 2024 report PDF](https://services.google.com/fh/files/misc/2024_final_dora_report.pdf) + +8. Rollout gating can fail when risky paths are untested. Google Cloud's June 2025 incident describes broad disruption linked to rollout and validation gaps on specific execution paths. Detection signal: no canary evidence on risky code paths before full rollout. +Source: [Google Cloud incident report (June 2025)](https://status.cloud.google.com/incidents/ow5i3PPK96RduMcb1SsW) + +9. Validation-layer mismatch can bypass test pipelines. CrowdStrike's 2024 RCA documents parameter mismatch and testing blind spots with severe consequences. Detection signal: schema/validator/runtime assumptions are not asserted consistently in deployment checks. +Source: [CrowdStrike RCA PDF 2024](https://www.crowdstrike.com/wp-content/uploads/2024/08/Channel-File-291-Incident-Root-Cause-Analysis-08.06.2024.pdf) + +10. Unsafe configuration deployment sequencing causes avoidable outages and delays. GitHub 2024 availability reports include configuration/environment sequencing issues. Detection signal: config changes without strict environment guardrails or tested rollback paths. +Source: [GitHub availability report March 2024](https://github.blog/2024-04-10-github-availability-report-march-2024/), [GitHub availability report September 2024](https://github.blog/news-insights/company-news/github-availability-report-september-2024) + +11. GTM and integration readiness gaps can sink roadmap economics even with product progress. A 2026 founder postmortem describes feature expansion without viable distribution/unit economics. Detection signal: roadmap growth scope increases while channel economics remain unresolved. +Source: [Founder postmortem 2026](https://glassboxmedicine.com/2026/02/21/why-i-shut-down-my-bootstrapped-health-ai-startup-after-7-years-a-founders-postmortem/) + +**Sources:** +- [Atlassian State of Teams 2024](https://www.atlassian.com/blog/state-of-teams-2024) +- [Pendo product benchmarks 2024](https://www.pendo.io/pendo-blog/product-benchmarks/) +- [ProductPlan 2024 report PDF](https://assets.productplan.com/content/The-2024-State-of-Product-Management-Report.pdf) +- [DORA 2024 report PDF](https://services.google.com/fh/files/misc/2024_final_dora_report.pdf) +- [Google Cloud incident report (June 2025)](https://status.cloud.google.com/incidents/ow5i3PPK96RduMcb1SsW) +- [CrowdStrike RCA PDF 2024](https://www.crowdstrike.com/wp-content/uploads/2024/08/Channel-File-291-Incident-Root-Cause-Analysis-08.06.2024.pdf) +- [GitHub availability report March 2024](https://github.blog/2024-04-10-github-availability-report-march-2024/) +- [GitHub availability report September 2024](https://github.blog/news-insights/company-news/github-availability-report-september-2024) +- [Founder postmortem 2026](https://glassboxmedicine.com/2026/02/21/why-i-shut-down-my-bootstrapped-health-ai-startup-after-7-years-a-founders-postmortem/) + +--- + +### Angle 5+: Governance and Operating Model Signals +> Domain-specific add-on: governance design that keeps conditional roadmaps actionable. + +**Findings:** + +1. Cross-functional ownership consistently appears in stronger outcome performance narratives, while single-function ownership underperforms. +Source: [Gartner survey coverage (Business Wire, 2024)](https://www.businesswire.com/news/home/20241022615512/en/Gartner-Survey-Reveals-That-Only-48-of-Digital-Initiatives-Meet-or-Exceed-Their-Business-Outcome-Targets) + +2. Product Ops coverage without measurement rigor leaves blind spots in roadmap quality assurance. +Source: [Productboard State of Product Ops 2025](https://www.productboard.com/blog/the-state-of-product-ops-in-2025/) + +3. Stable cadence governance (short execution loops + periodic replanning) is repeatedly represented as a predictor of better adaptation quality in both agile and scaled frameworks. +Source: [SAFe PI Planning](https://scaledagileframework.com/PI-planning), [SAFe Inspect and Adapt](https://scaledagileframework.com/inspect-and-adapt/), [Scrum Guide 2020 PDF](https://scrumguides.org/docs/scrumguide/v2020/2020-Scrum-Guide-US.pdf) (Evergreen) + +4. Governance contradiction to preserve: heavy process can reduce responsiveness if not paired with explicit adaptation windows. Therefore, this skill should encode when re-planning is allowed and when scope changes are frozen. + +**Sources:** +- [Gartner survey coverage (Business Wire, 2024)](https://www.businesswire.com/news/home/20241022615512/en/Gartner-Survey-Reveals-That-Only-48-of-Digital-Initiatives-Meet-or-Exceed-Their-Business-Outcome-Targets) +- [Productboard State of Product Ops 2025](https://www.productboard.com/blog/the-state-of-product-ops-in-2025/) +- [SAFe PI Planning](https://scaledagileframework.com/PI-planning) +- [SAFe Inspect and Adapt](https://scaledagileframework.com/inspect-and-adapt/) +- [Scrum Guide 2020 PDF](https://scrumguides.org/docs/scrumguide/v2020/2020-Scrum-Guide-US.pdf) (Evergreen) + +--- + +## Synthesis + +The strongest conclusion is that modern MVP-to-scale roadmapping is less about producing a bigger plan and more about maintaining evidence quality under uncertainty. Sources across ProductPlan, Atlassian, and DORA align on the same practical pattern: teams are adopting outcome language, but many still struggle to allocate enough strategy/discovery time and maintain stable learning loops. + +Tooling is mature enough to support high-quality sequencing, but only when teams account for entitlement and governance constraints. Jira Plans, monday, Productboard, Aha, Asana, and Smartsheet all provide relevant capabilities, yet each has tier boundaries and setup caveats that materially affect risk visibility. The skill should therefore encode capability checks and fallback logic, not "tool X always solves Y." + +Risk interpretation quality is the most critical technical gap. Findings from DORA, CISA, FIRST, NIST LEV, Oracle, and 2024 quantitative risk research show that weak interpretation habits (single metric gates, CVSS-only decisions, unnormalized risk comparisons, qualitative-only matrices) create false confidence. This means gate decisions should explicitly require multi-signal evidence and method transparency. + +Failure-mode evidence from incident reports and benchmark datasets is unusually actionable. Anti-patterns like goal sprawl, discovery starvation, large-batch releases, and validation-layer mismatch have clear detection signals and repeatable mitigation steps. Embedding these warning blocks directly in SKILL.md will materially improve output quality and reduce naive "feature list roadmap" behavior. + +The biggest contradiction to keep visible in the final skill text is this: teams want predictability and adaptability at the same time. The practical answer in current evidence is a dual structure - committed near-term phases plus conditional forecast phases - with strict evidence gates and scheduled replanning windows. + +--- + +## Recommendations for SKILL.md + +> Concrete, actionable changes based on findings. + +- [x] Add a strict conditional commitment policy: only one phase is committed at a time; downstream phases remain forecast until gate pass. +- [x] Add a mandatory gate evidence pack schema (outcome, delivery health, dependency risk, learning cadence) and GO/HOLD/NO_GO decisions. +- [x] Add explicit cadence instructions (execution loop and strategy review loop) with priority-freeze/replan windows. +- [x] Upgrade dependency risk interpretation from qualitative-only to method-explicit scoring with optional quantitative validation for high-stakes gates. +- [x] Add entitlement-aware tool guidance so roadmap quality does not depend on unavailable premium features. +- [x] Add anti-pattern warning blocks with detection signal, consequence, and mitigation steps. +- [x] Expand artifact schema to include gate decisions, evidence bundles, normalized risk metadata, and source references. +- [x] Require cross-functional gate ownership (product, engineering, design/business) for phase transition validity. + +--- + +## Draft Content for SKILL.md + +### Draft: Core operating principle (replace intro and core mode) + +You build a conditional roadmap from MVP to scale-ready product. The roadmap is a sequence of bets, not a fixed feature list. A phase is committed only when the previous phase has passed gate criteria with evidence. If gate evidence is incomplete or contradictory, you do not commit the next phase. + +Use this default commitment rule: +1. Commit exactly one active phase (the one currently funded and executed). +2. Keep the next one to two phases as forecast only. +3. Re-evaluate forecast phases at each gate review; do not treat forecast as commitment. +4. If pressure demands long-range certainty, surface uncertainty explicitly instead of fabricating confidence. + +Roadmap quality is defined by evidence quality: +- Outcome signal quality (customer/business movement), +- Delivery health quality (stability + throughput trend), +- Dependency risk quality (method and assumptions explicit), +- Learning cadence quality (how often hypotheses are tested and decisions updated). + +If any of these quality dimensions is weak, the roadmap is high risk even when milestone dates look clean. + +### Draft: Methodology - Evidence-gated phase flow + +Before drafting phase details, activate policy context and identify the active hypothesis stack. You should not author milestones before confirming what hypothesis each milestone is intended to test. + +Method: +1. Define phase hypothesis. + - For every phase, write one clear hypothesis in testable form. + - Include expected customer or business movement and expected delivery constraints. + - If the hypothesis is not falsifiable, rewrite it before planning. +2. Define gate criteria by signal type. + - For each phase, include at least one leading indicator and one lagging indicator. + - Include at least one delivery health check and one dependency risk check. + - Do not accept a gate that only measures shipped output. +3. Define commitment and adaptation windows. + - Commitment window: scope is stable except for risk mitigation. + - Adaptation window: scope can be re-prioritized based on evidence. + - Declare both windows in the roadmap artifact so stakeholders know when changes are allowed. +4. Run gate review and assign decision. + - `go`: evidence meets threshold with no critical contradiction. + - `hold`: evidence incomplete or contradictory; run focused learning actions. + - `no_go`: evidence disproves hypothesis or risk is above accepted appetite. +5. Update downstream phases conditionally. + - On `go`, refine next phase and keep farther phases as forecast. + - On `hold`, preserve committed work and schedule targeted evidence collection. + - On `no_go`, de-scope or pivot before any new commitment. + +Decision rules you must apply: +- If you only have productivity/output gains but stability is worsening, do not call the milestone valid. +- If dependency risk is assessed with severity-only data (for example CVSS-only), mark risk evidence as insufficient and keep gate on hold. +- If risk scores across teams/phases use different methods, normalize or avoid direct comparison. +- If the team cannot explain why a threshold exists, treat it as provisional and request calibration from baseline data. + +### Draft: Updated phase structure text + +Use this phase model as default: + +**Phase 0: Pre-MVP (optional but recommended for high-uncertainty ideas)** +- Objective: validate core problem-value hypothesis with minimal build. +- Typical mode: concierge/manual/fake-door/research prototype. +- Gate requirement: explicit go/no-go memo tied to customer evidence. + +**Phase 1: MVP** +- Objective: prove that core job can be completed by target early users. +- Scope rule: must-have only, no growth optimizations unless required for validity. +- Gate requirement: initial validation criteria from `mvp_validation_criteria` plus minimum delivery-health checks. + +**Phase 2: Validated MVP** +- Objective: remove top friction points and verify repeatable value for early paying users. +- Scope rule: prioritize retention and activation fixes before expansion features. +- Gate requirement: retention trend and dependency-risk profile support controlled expansion. + +**Phase 3: Growth-ready** +- Objective: establish repeatable acquisition/onboarding and improve unit economics. +- Scope rule: expansion features only if they directly improve acquisition efficiency or retention depth. +- Gate requirement: stable delivery profile, controlled dependency risk, and positive evidence for growth mechanics. + +**Phase 4: Scale** +- Objective: scale reliability, compliance, and multi-segment execution without losing economics. +- Scope rule: enterprise/compliance/globalization features are justified by validated demand and operational readiness. +- Gate requirement: economics and delivery stability remain acceptable under increased complexity. + +Phase transition policy: +- You may define dates, but dates are conditional on gate pass. +- If stakeholders ask for unconditional date commitments beyond active phase, return a forecast range with uncertainty notes. + +### Draft: Cadence and governance instructions + +You must run two cadences in parallel: + +1. Execution cadence (short loop): + - Weekly to biweekly evidence updates for active phase. + - Focus: signal movement, blocked dependencies, and delivery health drift. +2. Strategy cadence (longer loop): + - 4-12 week roadmap review cycle. + - Focus: gate decision, re-prioritization window, and forecast phase updates. + +Governance rules: +- Gate decisions require cross-functional owners (at minimum product + engineering; include design/business as available). +- Re-prioritization outside adaptation windows is allowed only for critical risk mitigation. +- Every gate review must log assumptions that remain unvalidated. +- If assumptions accumulate without testing, reduce forward commitment horizon. + +### Draft: Tool usage section (paste-ready, real method syntax) + +Always load strategic inputs before writing the roadmap artifact: + +```python +flexus_policy_document(op="activate", args={"p": "/strategy/mvp-scope"}) +flexus_policy_document(op="activate", args={"p": "/strategy/mvp-validation-criteria"}) +flexus_policy_document(op="activate", args={"p": "/strategy/mvp-feasibility"}) +flexus_policy_document(op="activate", args={"p": "/strategy/hypothesis-stack"}) +``` + +Then write the roadmap artifact with gate evidence, not only phase names and features: + +```python +write_artifact( + artifact_type="product_roadmap", + path="/strategy/roadmap", + data={ + "created_at": "2026-03-04T00:00:00Z", + "product_name": "Example Product", + "roadmap_mode": "conditional_only", + "phases": [ + { + "phase_id": "mvp", + "phase_name": "MVP", + "goal": "Validate core job completion for early users", + "hypothesis": "If core flow is simplified, activation and repeat use improve", + "duration_estimate_weeks": 8, + "gate_decision": "hold", + "gate_criteria": [], + "evidence_bundle": {}, + "dependencies": [], + "features": [], + "resource_commitment": { + "budget_window_months": 3, + "team_fte": 4 + } + } + ] + }, +) +``` + +Tool usage guidance: +- Do not write `/strategy/roadmap` before all required policy docs are activated. +- If a required policy document is missing, explicitly mark related gate criteria as unknown and use `hold`. +- Re-write the artifact at each gate review; do not leave stale gate decisions in place. + +### Draft: Entitlement-aware tool guidance block + +If external roadmap systems are used as input signals (for example Jira Plans, monday, Productboard, Aha, Asana, Smartsheet), verify capability availability before interpreting missing data as "no risk." + +Entitlement checks to apply: +- If scenario modeling or cross-project dependency views are tier-locked and unavailable, mark dependency confidence as reduced. +- If integration slots are limited, declare which streams are not represented in evidence. +- If capacity sync is partially configured, treat effort estimates as provisional. + +When capability is missing: +1. Record the limitation in assumptions. +2. Reduce commitment horizon. +3. Increase gate review frequency until observability is restored. + +### Draft: Anti-pattern warning blocks + +> **WARNING: Goal Sprawl** +> - Detection signal: too many simultaneous objectives, weak concentration on mission-critical work. +> - Consequence: roadmap thrash and low milestone validity. +> - Mitigation: freeze net-new goals, re-rank by outcome impact, and resume only after explicit top-priority selection. + +> **WARNING: Feature Factory Drift** +> - Detection signal: low concentration of usage in recently shipped features, poor adoption of "must-have" items. +> - Consequence: capacity consumed by low-value output and rising maintenance debt. +> - Mitigation: stop adding net-new features, run adoption diagnosis, remove low-signal scope from next phase. + +> **WARNING: Discovery Starvation** +> - Detection signal: delivery work dominates while customer evidence is thin at gate review. +> - Consequence: late pivots, expensive rework, invalid phase commitments. +> - Mitigation: block phase exit until customer evidence and hypothesis test results are attached. + +> **WARNING: Leadership-Only Prioritization** +> - Detection signal: milestone selection is mainly top-down with weak customer signal. +> - Consequence: roadmap confidence is political, not empirical. +> - Mitigation: require customer-backed evidence for every committed milestone. + +> **WARNING: Priority Thrash** +> - Detection signal: frequent reprioritization outside declared adaptation windows. +> - Consequence: throughput collapse, burnout increase, unstable outcomes. +> - Mitigation: enforce freeze windows and allow exceptions only for critical risk containment. + +> **WARNING: Large-Batch Milestone** +> - Detection signal: broad scope bundles with rising unplanned fixes and change-failure symptoms. +> - Consequence: unstable delivery and false confidence in progress. +> - Mitigation: split into smaller increments and tighten quality gates before next transition. + +> **WARNING: CVSS-Only Dependency Risk** +> - Detection signal: dependency priority is based only on severity labels. +> - Consequence: real exploit risk is mis-ranked, urgent exposures can be missed. +> - Mitigation: combine severity with exploitation evidence and document scoring method. + +> **WARNING: Rollout Path Untested** +> - Detection signal: high-risk code paths or config paths are not validated under staged rollout conditions. +> - Consequence: incident blast radius at launch. +> - Mitigation: require staged rollout evidence and rollback criteria before GO. + +### Draft: Gate decision protocol block + +Use this protocol for every phase gate: + +1. Validate evidence completeness. + - All required signal groups are present: outcome, delivery health, dependency risk, learning cadence. + - Missing groups force `hold` unless explicit emergency override is justified. +2. Validate evidence quality. + - Metrics include measurement window and source reference. + - Risk method is declared (qualitative, quantitative, or hybrid). +3. Evaluate contradictions. + - If leading and lagging indicators conflict, classify as unresolved contradiction and set `hold`. +4. Assign gate decision. + - `go`: criteria met, contradictions resolved, no critical unresolved risk. + - `hold`: partial signal quality or unresolved contradictions. + - `no_go`: hypothesis disproven or risk above appetite. +5. Write decision rationale. + - Capture why this decision was made and what evidence would change it. + +### Draft: Schema additions + +Use the following schema fragment to upgrade the `product_roadmap` artifact for evidence-gated planning: + +```json +{ + "product_roadmap": { + "type": "object", + "required": [ + "created_at", + "product_name", + "roadmap_mode", + "cadences", + "phases" + ], + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string", + "description": "ISO-8601 timestamp when this roadmap snapshot was produced." + }, + "product_name": { + "type": "string", + "description": "Human-readable product name." + }, + "roadmap_mode": { + "type": "string", + "enum": [ + "conditional_only" + ], + "description": "Planning mode. conditional_only means only active phase is committed." + }, + "cadences": { + "type": "object", + "required": [ + "execution_review_weeks", + "strategy_review_weeks", + "replan_window_rule" + ], + "additionalProperties": false, + "properties": { + "execution_review_weeks": { + "type": "integer", + "minimum": 1, + "maximum": 4, + "description": "Frequency of evidence updates for active phase." + }, + "strategy_review_weeks": { + "type": "integer", + "minimum": 4, + "maximum": 12, + "description": "Frequency of phase gate and roadmap replan decisions." + }, + "replan_window_rule": { + "type": "string", + "description": "Text rule describing when reprioritization is allowed." + } + } + }, + "phases": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "phase_id", + "phase_name", + "goal", + "hypothesis", + "duration_estimate_weeks", + "gate_decision", + "gate_criteria", + "evidence_bundle", + "dependencies", + "features", + "resource_commitment" + ], + "additionalProperties": false, + "properties": { + "phase_id": { + "type": "string", + "description": "Stable phase identifier." + }, + "phase_name": { + "type": "string", + "description": "Readable phase label." + }, + "goal": { + "type": "string", + "description": "Outcome-oriented objective for this phase." + }, + "hypothesis": { + "type": "string", + "description": "Falsifiable statement tested by this phase." + }, + "duration_estimate_weeks": { + "type": "number", + "minimum": 1, + "description": "Estimated duration for phase execution." + }, + "gate_decision": { + "type": "string", + "enum": [ + "go", + "hold", + "no_go", + "not_reviewed" + ], + "description": "Most recent gate outcome for this phase." + }, + "gate_criteria": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "criterion_id", + "criterion_type", + "criterion", + "threshold", + "must_pass" + ], + "additionalProperties": false, + "properties": { + "criterion_id": { + "type": "string", + "description": "Stable ID for traceability." + }, + "criterion_type": { + "type": "string", + "enum": [ + "outcome", + "delivery_health", + "dependency_risk", + "learning_cadence", + "commercial", + "compliance" + ], + "description": "Signal family this criterion belongs to." + }, + "criterion": { + "type": "string", + "description": "Human-readable criterion definition." + }, + "threshold": { + "type": "string", + "description": "Expected threshold expression or condition." + }, + "must_pass": { + "type": "boolean", + "description": "Whether failing this criterion blocks GO." + } + } + } + }, + "evidence_bundle": { + "type": "object", + "required": [ + "outcome_signals", + "delivery_health", + "dependency_risk", + "learning_cadence" + ], + "additionalProperties": false, + "properties": { + "outcome_signals": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "metric_name", + "metric_type", + "current_value", + "target_value", + "measurement_window", + "source_ref" + ], + "additionalProperties": false, + "properties": { + "metric_name": { + "type": "string", + "description": "Name of outcome metric." + }, + "metric_type": { + "type": "string", + "enum": [ + "leading", + "lagging" + ], + "description": "Signal role for this metric." + }, + "current_value": { + "type": "string", + "description": "Current measured value as reported." + }, + "target_value": { + "type": "string", + "description": "Target required for gate." + }, + "measurement_window": { + "type": "string", + "description": "Time window used for this measurement." + }, + "source_ref": { + "type": "string", + "description": "Reference to source report or system." + } + } + } + }, + "delivery_health": { + "type": "object", + "required": [ + "throughput_trend", + "stability_trend", + "change_failure_signal" + ], + "additionalProperties": false, + "properties": { + "throughput_trend": { + "type": "string", + "enum": [ + "improving", + "flat", + "degrading" + ], + "description": "Direction of delivery throughput vs baseline." + }, + "stability_trend": { + "type": "string", + "enum": [ + "improving", + "flat", + "degrading" + ], + "description": "Direction of stability vs baseline." + }, + "change_failure_signal": { + "type": "string", + "description": "Narrative or metric summary for failure behavior." + } + } + }, + "dependency_risk": { + "type": "object", + "required": [ + "method", + "normalization_scope", + "risk_items" + ], + "additionalProperties": false, + "properties": { + "method": { + "type": "string", + "enum": [ + "qualitative_matrix", + "quantitative_simulation", + "hybrid" + ], + "description": "Method used to rank dependency risks." + }, + "normalization_scope": { + "type": "string", + "enum": [ + "phase", + "program" + ], + "description": "Scope used to normalize comparable risk scores." + }, + "risk_items": { + "type": "array", + "items": { + "type": "object", + "required": [ + "risk_id", + "title", + "probability", + "impact_schedule", + "impact_cost", + "score", + "status", + "mitigation" + ], + "additionalProperties": false, + "properties": { + "risk_id": { + "type": "string", + "description": "Stable risk identifier." + }, + "title": { + "type": "string", + "description": "Short risk title." + }, + "probability": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Estimated probability (0..1)." + }, + "impact_schedule": { + "type": "string", + "description": "Expected schedule impact if risk materializes." + }, + "impact_cost": { + "type": "string", + "description": "Expected cost impact if risk materializes." + }, + "score": { + "type": "number", + "description": "Calculated score based on declared method." + }, + "status": { + "type": "string", + "enum": [ + "open", + "watch", + "mitigating", + "closed" + ], + "description": "Current treatment status." + }, + "mitigation": { + "type": "string", + "description": "Current mitigation plan." + } + } + } + } + } + }, + "learning_cadence": { + "type": "object", + "required": [ + "review_frequency_days", + "experiments_completed", + "decision_log_ref" + ], + "additionalProperties": false, + "properties": { + "review_frequency_days": { + "type": "integer", + "minimum": 1, + "description": "How often evidence is reviewed." + }, + "experiments_completed": { + "type": "integer", + "minimum": 0, + "description": "Number of completed experiments in measurement window." + }, + "decision_log_ref": { + "type": "string", + "description": "Reference to decision log or gate memo." + } + } + } + } + }, + "dependencies": { + "type": "array", + "items": { + "type": "object", + "required": [ + "dependency_id", + "kind", + "owner", + "status" + ], + "additionalProperties": false, + "properties": { + "dependency_id": { + "type": "string", + "description": "Stable dependency identifier." + }, + "kind": { + "type": "string", + "enum": [ + "incoming", + "outgoing" + ], + "description": "Direction relative to this phase." + }, + "owner": { + "type": "string", + "description": "Owning team or function." + }, + "status": { + "type": "string", + "enum": [ + "clear", + "at_risk", + "blocked" + ], + "description": "Current dependency health state." + } + } + } + }, + "features": { + "type": "array", + "items": { + "type": "object", + "required": [ + "feature", + "priority", + "rationale" + ], + "additionalProperties": false, + "properties": { + "feature": { + "type": "string", + "description": "Feature or capability name." + }, + "priority": { + "type": "string", + "enum": [ + "must_have", + "should_have", + "nice_to_have" + ], + "description": "Priority class for phase execution." + }, + "rationale": { + "type": "string", + "description": "Reason this item belongs in this phase." + } + } + } + }, + "resource_commitment": { + "type": "object", + "required": [ + "budget_window_months", + "team_fte" + ], + "additionalProperties": false, + "properties": { + "budget_window_months": { + "type": "number", + "minimum": 1, + "description": "Funding commitment window for this phase." + }, + "team_fte": { + "type": "number", + "minimum": 0.5, + "description": "Planned full-time-equivalent team size." + } + } + } + } + } + }, + "source_references": { + "type": "array", + "description": "External or internal references used in gate decisions.", + "items": { + "type": "string" + } + } + } + } +} +``` + +### Draft: Short rationale block for the skill author (optional include) + +This skill enforces evidence-gated roadmap progression because current product and engineering evidence shows a consistent failure pattern: teams overcommit scope before evidence quality is sufficient. By separating committed phases from forecast phases, requiring mixed signal quality at gates, and forcing explicit risk method declarations, you reduce false confidence and improve decision quality without abandoning delivery cadence. + +--- + +## Gaps and Uncertainties + +- Public sources provide strong directional evidence, but many benchmark metrics are self-reported and should be treated as guidance rather than universal thresholds. +- Several tool capability pages are living docs without explicit publication dates; they were prioritized only when functionality was directly documented and cross-checked. +- Incident postmortems are high-value for failure patterns but can overrepresent extreme events; additional medium-severity case studies would improve calibration. +- There is no single universal numeric gate threshold set that is valid across all product contexts; this is why the draft recommends baseline-relative thresholds and explicit calibration. +- More direct 2026 peer-reviewed research on product roadmap governance (outside platform/vendor reports) appears limited at the time of writing. diff --git a/flexus_simple_bots/strategist/skills/_mvp-roadmap/SKILL.md b/flexus_simple_bots/strategist/skills/_mvp-roadmap/SKILL.md new file mode 100644 index 00000000..c46e716d --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_mvp-roadmap/SKILL.md @@ -0,0 +1,119 @@ +--- +name: mvp-roadmap +description: MVP to product roadmap — milestone planning, phase gates, and resource sequencing from MVP to scale-ready product +--- + +You build the roadmap from MVP to a scale-ready product, defining milestones, phase gates, and resource sequencing. The roadmap is not a feature list — it's a sequence of bets, each conditioned on the previous bet paying off. + +Core mode: conditional roadmap only. Phase 2 is planned but not committed until Phase 1 gates are passed. Building a committed 18-month roadmap before Phase 1 validation is resource burning, not planning. + +## Methodology + +### Phase structure +**Phase 0: Pre-MVP** (if applicable) +- Manual delivery, concierge MVP, or fake-door test +- Goal: validate core hypothesis before writing code +- Gate: go/no-go decision documented + +**Phase 1: MVP** +- Core feature set defined in `mvp_scope` +- 5-10 pilot customers +- Gate: PMF signals defined in `mvp_validation_criteria` + +**Phase 2: Validated MVP** +- Address top 3 friction points from pilot feedback +- Start scaling acquisition channel +- Gate: retention >40% at Month 1, ≥5 paying customers + +**Phase 3: Growth-ready** +- Self-serve onboarding +- Integrations that expand ICP or reduce CAC +- Gate: CAC < LTV/3, MoM growth >15% + +**Phase 4: Scale** +- Enterprise features, compliance, multi-region +- Gate: $Xk MRR, channel efficiency proven + +### Gate-based resource allocation +Gates prevent over-investment before evidence: +- Gate 1 (MVP → Validated): 3-6 months budget commitment +- Gate 2 (Validated → Growth-ready): requires PMF signal + positive unit economics +- Gate 3 (Growth-ready → Scale): requires proof of repeatable CAC + +### Feature sequencing logic +Sequence features by: +1. Core job enablement (Phase 1) +2. Retention improvement (Phase 2) +3. Acquisition enablement (Phase 3 — integrations, API, self-serve) +4. Monetization optimization (Phase 3-4 — upsell, enterprise) + +## Recording + +``` +write_artifact(path="/strategy/roadmap", data={...}) +``` + +## Available Tools + +``` +flexus_policy_document(op="activate", args={"p": "/strategy/mvp-scope"}) +flexus_policy_document(op="activate", args={"p": "/strategy/mvp-validation-criteria"}) +flexus_policy_document(op="activate", args={"p": "/strategy/mvp-feasibility"}) +flexus_policy_document(op="activate", args={"p": "/strategy/hypothesis-stack"}) +``` + +## Artifact Schema + +```json +{ + "product_roadmap": { + "type": "object", + "required": ["created_at", "product_name", "phases"], + "additionalProperties": false, + "properties": { + "created_at": {"type": "string"}, + "product_name": {"type": "string"}, + "phases": { + "type": "array", + "items": { + "type": "object", + "required": ["phase_id", "phase_name", "goal", "duration_estimate_weeks", "gate_criteria", "features", "resource_commitment"], + "additionalProperties": false, + "properties": { + "phase_id": {"type": "string"}, + "phase_name": {"type": "string"}, + "goal": {"type": "string"}, + "duration_estimate_weeks": {"type": "number", "minimum": 1}, + "gate_criteria": { + "type": "array", + "items": { + "type": "object", + "required": ["criterion", "threshold"], + "additionalProperties": false, + "properties": { + "criterion": {"type": "string"}, + "threshold": {"type": "string"} + } + } + }, + "features": { + "type": "array", + "items": { + "type": "object", + "required": ["feature", "priority", "rationale"], + "additionalProperties": false, + "properties": { + "feature": {"type": "string"}, + "priority": {"type": "string", "enum": ["must_have", "should_have", "nice_to_have"]}, + "rationale": {"type": "string"} + } + } + }, + "resource_commitment": {"type": "string"} + } + } + } + } + } +} +``` diff --git a/flexus_simple_bots/strategist/skills/_offer-validation/RESEARCH.md b/flexus_simple_bots/strategist/skills/_offer-validation/RESEARCH.md new file mode 100644 index 00000000..f2774a21 --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_offer-validation/RESEARCH.md @@ -0,0 +1,1942 @@ +# Research: offer-validation + +**Skill path:** `strategist/skills/offer-validation/` +**Bot:** strategist (researcher | strategist | executor) +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`offer-validation` is the strategist skill for testing whether an offer has real demand before full product build. The existing `SKILL.md` already centers the right principle: behavioral evidence beats stated intent, and stronger signals include payment, deposits, LOIs, and pilot commitments. + +Research context expansion: 2024-2026 practice has become more operational and risk-aware. Teams now treat offer validation as a staged evidence ladder with explicit quality gates, legal/compliance constraints for pre-sales, and a clearer split between weak intent signals (clicks, signups) and high-friction commitments (non-refundable deposits, paid pilots, signed commercial steps). The strongest upgrade opportunity for this skill is to move from "method list + one conversion target" to a full decision protocol with quality blockers and evidence-strength semantics. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024-2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- [x] No generic filler without concrete backing +- [x] No invented tool names, method IDs, or API endpoints - only verified real ones +- [x] Contradictions between sources are explicitly noted, not silently resolved +- [x] Volume: findings sections are within 800-4000 words combined +- [x] Volume: `Draft Content for SKILL.md` is longer than all Findings sections combined + +Verification notes: +- Endpoint syntax in this document is from vendor documentation links directly cited under each angle. +- Contradictions are explicitly called out in angles and reconciled in `Synthesis`. +- Undated docs are marked as **Evergreen** and used only where current vendor docs are the authoritative source. + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +1. **Behavior-first validation now uses an evidence ladder, not a binary pass/fail.** + Teams increasingly separate low-friction intent (views, clicks, signups) from medium-friction intent (confirmed signups, scheduled calls) and high-friction commitment (deposits, paid pilots, signed commercial docs). This directly addresses the "I would use it" bias by requiring progressive commitment for stronger claims. [A1][A2][A10][A11] + +2. **Fake-door and painted-door tests are treated as triage mechanisms, not final proof.** + Current guidance emphasizes hypothesis pre-definition, realistic framing, and immediate post-click transparency. Practitioners use these tests to decide whether to run deeper commitment tests, not to justify full build alone. [A1][A2] + +3. **Landing-page tests are run with stricter cadence and hygiene than older startup playbooks.** + Google Ads experiment guidance emphasizes split stability, controlled variable scope, and sufficient runtime for conversion cycles; this reduces false confidence from early noise or campaign churn. [A3][A4] + +4. **Pre-launch signup programs are now explicitly modeled as launch predictors with caveats.** + Kickstarter reports strong pre-launch signal patterns (for example, higher conversion among pre-launch followers than post-launch followers), but this still represents intent-stage evidence unless paired with commitment. [A5][A6] + +5. **Pre-sale/deposit methodology requires explicit payment-design and policy communication.** + Teams now define charge timing, refund/cancellation terms, and customer communication windows as part of experiment design itself. Payment mechanics are part of signal quality, not an implementation detail. [A7][A9] + +6. **Legal feasibility is part of validation design for pre-sales.** + FTC Mail/Internet/Telephone Order Rule requirements (reasonable shipping basis, delay notices, consent/refund paths) affect whether a "demand test" is operationally valid and ethically acceptable. [A8] + +7. **B2B offer validation is converging on paid pilot discipline.** + Recent SaaStr operator guidance consistently pushes paid pilots with narrow scopes, explicit success criteria, and named decision owners, because free pilots often inflate learning but under-predict conversion. [A10][A11] + +8. **Pilot/POC planning quality strongly predicts conversion utility.** + AWS and Microsoft pilot guidance both stress bounded scope, explicit outcomes, checkpoint governance, and post-pilot decision criteria. In practice, these are methodology controls for avoiding pilot purgatory. [A12][A13] + +9. **Decision quality is moving from single uplift metrics to risk-aware decision matrices.** + Spotify's risk-aware experimentation framework formalizes success, guardrails, deterioration, and quality checks. Offer validation guidance should mirror this structure to avoid over-weighting a single conversion metric. [A14][A15] + +10. **Sequential monitoring is accepted, but only with method discipline.** + Teams increasingly monitor continuously with sequential approaches, yet still separate early directional reads from final commitment decisions requiring stronger precision. [A16][A17] + +**Contradictions observed:** +- Signup momentum can be highly predictive for launches in creator ecosystems, but B2B commercialization sources treat unpaid intent as weak evidence. Practical resolution: stage conclusions by evidence tier instead of one global verdict. [A5][A6][A10][A11] +- Faster decisions via continuous monitoring conflict with fixed-horizon guidance for stable estimates. Practical resolution: define method upfront and use early calls for risk containment, not final pricing/roadmap commitment. [A3][A4][A16] + +**Sources:** +- [A1] Amplitude, "Fake door testing" (2025): https://amplitude.com/explore/experiment/fake-door-testing +- [A2] Amplitude, "Painted door testing" (2025): https://amplitude.com/explore/experiment/painted-door-testing +- [A3] Google Ads Help, "Set up an experiment" (current, **Evergreen**): https://support.google.com/google-ads/answer/7281575?hl=en +- [A4] Google Ads Help, experimentation timing guidance (current, **Evergreen**): https://support.google.com/google-ads/answer/13826584?hl=en +- [A5] Kickstarter, "The anatomy of a great pre-launch page" (2025): https://updates.kickstarter.com/the-anatomy-of-a-great-kickstarter-prelaunch-page/ +- [A6] Kickstarter, "How to maximize pre-launch sign ups" (2025): https://updates.kickstarter.com/how-to-maximize-pre-launch-sign-ups-for-your-kickstarter-campaign/ +- [A7] Shopify, "How pre-orders work" (2025): https://www.shopify.com/blog/pre-orders +- [A8] FTC, Mail/Internet/Telephone Order Rule (current, **Evergreen**): https://www.ftc.gov/legal-library/browse/rules/mail-internet-or-telephone-order-merchandise-rule +- [A9] Stripe, "Deposit invoices 101" (2025): https://stripe.com/us/resources/more/deposit-invoices-101-what-they-are-and-how-to-use-them +- [A10] SaaStr, paid pilot guidance (2025): https://www.saastr.com/we-are-a-b2b-saas-startup-and-want-to-develop-our-product-in-pilots-with-customers-should-we-charge-for-the-pilots-and-how-much/ +- [A11] SaaStr, pilot conversion and execution guidance (2025): https://www.saastr.com/dear-saastr-were-starting-a-big-paid-pilot-how-do-maximize-the-chances-of-success/ +- [A12] AWS, "Conduct a proof of concept in Amazon Redshift" (2024): https://aws.amazon.com/blogs/big-data/successfully-conduct-a-proof-of-concept-in-amazon-redshift +- [A13] Microsoft Learn, "Pilot essentials" (current, **Evergreen**): https://learn.microsoft.com/en-us/microsoftteams/pilot-essentials +- [A14] Spotify Engineering, "Risk-aware product decisions in A/B tests" (2024): https://engineering.atspotify.com/2024/03/risk-aware-product-decisions-in-a-b-tests-with-multiple-metrics/ +- [A15] Statsig, "What are guardrail metrics in A/B tests?" (2025): https://www.statsig.com/blog/what-are-guardrail-metrics-in-ab-tests +- [A16] Statsig docs, sequential testing (current, **Evergreen**): https://docs.statsig.com/experiments/advanced-setup/sequential-testing +- [A17] Optimizely support, native global holdouts (2025): https://support.optimizely.com/hc/en-us/articles/41924760675981-Native-global-holdouts-in-Feature-Experimentation + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +1. **Stripe is the primary "hard commitment" stack for offer tests.** + Verified endpoints include `POST /v1/payment_intents` and `POST /v1/checkout/sessions`. Useful constraints are explicit: minimum amount floors, line-item caps, and known rate limits (global and endpoint-level). This makes Stripe suitable for deposit and paid-intent evidence with predictable integration contracts. [B1][B2][B3] + +2. **PayPal Orders v2 supports deposit-style flows but requires adaptive traffic handling.** + Verified endpoints include `POST /v2/checkout/orders`, authorize, and capture operations. Rate-limit behavior is explicitly documented as dynamic rather than static quotas, so client design must include robust backoff, token reuse, and webhook-first updates. [B4][B5] + +3. **Square Payments API is useful when idempotency and delayed capture are required.** + Verified endpoint: `POST /v2/payments`; requires `idempotency_key` and documents delayed capture windows and SCA tooling. This maps cleanly to pre-sale commitment evidence with lower double-charge risk. [B6] + +4. **Waitlist and lead-capture tooling has strict anti-abuse quotas that can distort tests if ignored.** + HubSpot forms submission APIs enforce endpoint limits and short-window abuse protection. If traffic bursts are not handled, false "demand drop" can be self-inflicted by throttling rather than offer quality. [B7] + +5. **Landing-page platforms are operationally viable but require explicit API key security and quota planning.** + Unbounce API provides page and lead management with documented requests/minute caps. This is enough for medium-scale fake-door operations but requires throttled ingestion and secure key handling. [B8] + +6. **Scheduling APIs are part of validation stack quality when interviews/pilot calls are the commitment step.** + Cal.com v2 and Google Calendar APIs support booking orchestration with versioning/quota rules; missing version headers or quota handling can silently degrade downstream evidence volume. [B9][B10] + +7. **Analytics ingestion APIs have payload and throughput caps that materially affect signal integrity.** + GA4 Measurement Protocol and Amplitude HTTP v2/Batch APIs publish per-request and throughput limits. Offer-validation SKILL guidance should include ingestion-quality checks (drop rates, malformed events, late arrivals) to avoid false readouts. [B11][B12] + +8. **CRM/waitlist stores can bottleneck real-time tests despite generous monthly plans.** + Airtable's per-base throughput ceiling is fixed and can throttle bursty campaigns even when monthly call allowances remain. Integration architecture must include queueing and batch writes. [B13] + +9. **Email nurture and re-engagement tools have both connection and queue constraints.** + Mailchimp documents simultaneous connection caps and batch queue limits. Offer tests that rely on nurture and reminder loops need batch scheduling strategy, not ad hoc API burst calls. [B14] + +10. **Experimentation/flag platforms expose robust APIs but numeric limits can be opaque.** + LaunchDarkly provides route/global rate-limit headers and API version pinning but does not publish full static numeric limits. This creates a contradiction between "enterprise-grade API" and deterministic throughput planning. [B15] + +11. **API standards that matter in 2024-2026 stacks:** + webhook-first state handling, idempotent write primitives for money/booking actions, version pinning in headers, exponential backoff with jitter, and separation of control-plane vs runtime pipelines. These are now baseline requirements, not advanced best practices. [B3][B5][B6][B9][B15] + +**Contradictions observed:** +- Vendor docs often claim flexible scale while withholding fixed rate ceilings (for example PayPal, LaunchDarkly), forcing conservative integration assumptions. [B5][B15] +- "200 OK accepted" does not always imply valid analytics interpretation (notably GA4 payload acceptance behavior), so ingestion acknowledgement and analytical validity must be separated. [B11] + +**Sources:** +- [B1] Stripe API, create PaymentIntent (**Evergreen**): https://docs.stripe.com/api/payment_intents/create +- [B2] Stripe API, create Checkout Session (**Evergreen**): https://docs.stripe.com/api/checkout/sessions/create +- [B3] Stripe docs, rate limits (**Evergreen**): https://docs.stripe.com/rate-limits +- [B4] PayPal Orders v2 API (**Evergreen**): https://developer.paypal.com/docs/api/orders/v2/#orders_create +- [B5] PayPal API rate-limiting guide (current, **Evergreen**): https://docs.paypal.ai/developer/how-to/api/rate-limiting +- [B6] Square Payments API (2026 API version page): https://developer.squareup.com/reference/square/payments-api/create-payment +- [B7] HubSpot forms API limit updates (2021/2023, **Evergreen operational**): https://developers.hubspot.com/changelog/announcing-forms-submission-rate-limits and https://developers.hubspot.com/changelog/additional-rate-limit-protection-being-added-to-form-submissions-api +- [B8] Unbounce API docs (**Evergreen**): https://developer.unbounce.com/getting_started/ and https://developer.unbounce.com/api_reference/#tag/Pages +- [B9] Cal.com API v2 intro and bookings (2024-2026 active docs): https://cal.com/docs/api-reference/v2/introduction and https://cal.com/docs/api-reference/v2/bookings/get-all-bookings +- [B10] Google Calendar API quota and events insert (**Evergreen**): https://developers.google.com/workspace/calendar/api/guides/quota and https://developers.google.com/workspace/calendar/api/v3/reference/events/insert +- [B11] GA4 Measurement Protocol reference and limitations (**Evergreen**): https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference and https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events#limitations +- [B12] Amplitude HTTP v2 and batch ingest docs (**Evergreen**): https://amplitude.com/docs/apis/analytics/http-v2 and https://www.docs.developers.amplitude.com/analytics/apis/batch-event-upload-api/ +- [B13] Airtable API call limits (**Evergreen**): https://support.airtable.com/docs/managing-api-call-limits-in-airtable +- [B14] Mailchimp Marketing API fundamentals and batch guide (**Evergreen**): https://mailchimp.com/developer/marketing/docs/fundamentals/ and https://mailchimp.com/developer/marketing/guides/run-async-requests-batch-endpoint/ +- [B15] LaunchDarkly API docs and rate-limit support note (2025 support page): https://apidocs.launchdarkly.com/ and https://support.launchdarkly.com/hc/en-us/articles/22328238491803-Error-429-Too-Many-Requests-API-Rate-Limit + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +1. **Minimum data quality gates are now explicit before interpretation.** + Amplitude significance guidance requires minimum sample and event counts per variant for reliable significance outputs. Practically, this supports adding an explicit `invalid_test` state when sample/event floors are not met. [C1] + +2. **Conversion benchmarks are source-dependent and highly variable.** + Unbounce 2024 benchmark data shows broad channel/category spread, so universal fake-door threshold claims are weak. Offer validation should compare against source-normalized baselines rather than one global pass line. [C2] + +3. **CTR improvements alone are weak validation signals.** + WordStream 2024 benchmark reporting highlights how click behavior and downstream conversion can diverge. Interpretation should prioritize commitment funnel depth over top-funnel click rates. [C3] + +4. **Statistical significance and practical significance must be separate checks.** + Platform guidance from LaunchDarkly/Statsig-style methods supports predefining MDE/power decisions; "statistically significant but too small to matter" should be treated as inconclusive or fail depending on business threshold. [C4][C5][C6] + +5. **Peeking discipline remains a major quality separator.** + Sequential methods allow ongoing checks with valid inference if declared upfront. Fixed-horizon methods with ad hoc peeking should be downgraded because error rates inflate. [C5] + +6. **Observed power is not a valid rescue for weak tests.** + 2024 analyses show post-hoc observed-power extensions can distort Type I error and decision quality. Pre-launch power planning is the practical standard. [C7] + +7. **Commitment friction fundamentally changes evidence strength.** + Refundable reservation counts can look large but represent weaker commitment than non-refundable deposit or paid pilot conversion. Offer-validation results should explicitly classify commitment tier. [C9][C10][C11] + +8. **LOI evidence quality is highly dependent on drafting and process.** + Legal commentary shows non-binding labels alone do not fully determine enforceability or practical commitment. Stronger LOI evidence includes specific terms, authority, timeline, and follow-on path clarity. [C12] + +9. **Validated/failed/inconclusive classification works best as a rubric, not a single threshold.** + Practical classification includes: quality gates, method validity, practical threshold, and commitment depth. Without all four, "validated" is overclaimed. [C1][C4][C5][C8] + +**Common misinterpretations and corrections:** +- "p < 0.05 means validated demand" -> correction: require practical threshold and commitment-stage evidence. +- "One CTR threshold works everywhere" -> correction: normalize by channel and audience baseline. +- "Large reservation counts prove willingness to pay" -> correction: encode refundable vs non-refundable vs paid pilot as separate evidence tiers. +- "If observed power is low, just extend until it passes" -> correction: use predeclared power/sample rules only. + +**Contradictions observed:** +- Some playbooks push simple threshold heuristics (for speed), while benchmark datasets show large heterogeneity; this tension is solved by using heuristics as priors only, then calibrating to channel-specific baselines. [C2][C3] +- Real-time sequential monitoring enables speed, but precision for strategic commitments still often needs longer windows; this should be encoded as separate "early directional" vs "final validation" checkpoints. [C5][C6] + +**Sources:** +- [C1] Amplitude docs, statistical significance FAQ (**Evergreen**): https://amplitude.com/docs/faq/statistical-significance +- [C2] Unbounce, conversion benchmark methodology/report (2024): https://unbounce.com/conversion-benchmark-report/methodology/ and https://unbounce.com/average-conversion-rates-landing-pages +- [C3] WordStream, Google Ads benchmarks (2024): https://www.wordstream.com/blog/2024-google-ads-benchmarks +- [C4] LaunchDarkly docs, sample-size calculator/methodology (**Evergreen**): https://launchdarkly.com/docs/guides/experimentation/sample-size-calc +- [C5] Statsig docs, sequential testing (**Evergreen**): https://docs.statsig.com/experiments/advanced-setup/sequential-testing +- [C6] Statsig docs, power analysis (**Evergreen**): https://docs.statsig.com/experiments-plus/power-analysis +- [C7] Analytics-Toolkit, observed power in online A/B tests (2024): https://blog.analytics-toolkit.com/2024/observed-power-in-online-a-b-testing/ +- [C8] Eppo docs, confidence intervals (**Evergreen**): https://docs.geteppo.com/statistics/confidence-intervals/ +- [C9] PreProduct, pre-order payment models with transaction distribution (2025): https://preproduct.io/pre-order-payment-models-complete-guide-to-charge-later-deposits-installments-more-on-shopify/ +- [C10] The Verge, Rivian reservation reporting (2024): https://www.theverge.com/2024/3/8/24094270/rivian-r2-reservation-number-price-refund +- [C11] PCMag, Tesla Cybertruck non-refundable deposit reporting (2024): https://www.pcmag.com/news/tesla-gets-serious-about-cybertruck-orders-with-non-refundable-1k-deposit +- [C12] Thompson Coburn, LOI lessons (2025): https://www.thompsoncoburn.com/insights/before-you-sign-four-lessons-for-using-letters-of-intent-102lx06/ + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +1. **Vanity-demand trap (clicks without commitment).** + Signal: high CTA CTR but low confirmed signup/deposit/pilot conversion. + Consequence: roadmap overinvestment on shallow intent. + Mitigation: define a pre-registered full funnel and require at least one high-friction commitment metric for `validated`. [D1][D2] + +2. **Fake-door misuse for complex workflows.** + Signal: first-click interest collapses after details; users report misunderstanding. + Consequence: false demand signal for offers that fail in real usage paths. + Mitigation: use concierge/manual workflow for multi-step value propositions. [D1][D2] + +3. **Opaque or deceptive post-click flow.** + Signal: backlash/support complaints after "not actually available" screens. + Consequence: trust damage reduces future test quality and brand equity. + Mitigation: immediate disclosure and transparent in-development framing. [D2][D14] + +4. **Pre-sale before fulfillment readiness.** + Signal: shipping delays, rising "where is my order?" tickets, high cancellation requests. + Consequence: refund burden, chargeback risk, enforcement exposure. + Mitigation: pre-sell only with fulfillment basis + delay/consent/refund workflow. [D3][D4] + +5. **Delay/refund non-compliance in commerce-style validation tests.** + Signal: delayed orders without proper notices or consent options. + Consequence: legal action and forced refunds. + Mitigation: auditable delay notices, consent tracking, prompt refunds. [D3][D4] + +6. **Deposit timing ambiguity (surprise capture).** + Signal: complaint/dispute spikes when charges occur unexpectedly. + Consequence: payment disputes and lower trust in future offers. + Mitigation: explicit payment timeline in checkout and pre-capture reminders. [D4][D5] + +7. **Recurring offer cancellation friction.** + Signal: cancellation complaints and dispute growth around renewal windows. + Consequence: invalid retention signal and regulatory risk. + Mitigation: clear cancellation path and consent records. [D5] + +8. **Synthetic/fabricated social proof.** + Signal: suspicious review bursts, undisclosed incentives, or AI-generated testimonial patterns. + Consequence: enforcement + severe trust loss. + Mitigation: prohibit fabricated reviews and verify review provenance. [D6][D7] + +9. **Concierge false positive from unsustainable manual effort.** + Signal: high manual minutes per user and deteriorating service as cohort grows. + Consequence: "validated" claim fails once automation is attempted. + Mitigation: track manual cost-to-serve and automate highest-load step before scale. [D8][D9] + +10. **Pilot proposal without conversion architecture.** + Signal: no executive sponsor, no budget path, no signed success criteria. + Consequence: successful pilots that never convert to paid rollout. + Mitigation: pre-agree KPI outcomes, procurement path, and commercial transition conditions. [D8][D9] + +11. **Stated intent over-weighting despite known hypothetical bias.** + Signal: enthusiastic interviews/surveys but weak payment behavior. + Consequence: demand overestimation and poor pricing decisions. + Mitigation: prioritize observed behavior and include a confidence downgrade for intent-only evidence. [D13] + +**Case-style signals (2024):** +- FTC order against GOAT for Mail Order Rule violations included over $2M for refunds, illustrating direct downside when pre-sale operations and communications are mishandled. [D3] +- Public reporting on startup pre-sale failures (for example crowdfunding non-delivery disputes) shows legal and trust damage when payment is captured without fulfillment reliability. [D11][D12] + +**Sources:** +- [D1] PostHog tutorial, fake door test (current, **Evergreen**): https://posthog.com/tutorials/fake-door-test +- [D2] Amplitude, fake door testing (2025): https://amplitude.com/explore/experiment/fake-door-testing +- [D3] FTC press release, GOAT final order (2024-12): https://www.ftc.gov/news-events/news/press-releases/2024/12/ftc-order-requires-online-retailer-goat-pay-more-2-million-consumers-mail-order-rule-violations +- [D4] FTC business guide, Mail Order Rule (**Evergreen**): https://ftc.gov/business-guidance/resources/business-guide-ftcs-mail-internet-or-telephone-order-merchandise-rule +- [D5] FTC final "Click to Cancel" rule announcement (2024-10): https://www.ftc.gov/news-events/news/press-releases/2024/10/federal-trade-commission-announces-final-click-cancel-rule-making-it-easier-consumers-end-recurring +- [D6] FTC fake reviews/testimonials final rule announcement (2024-08): https://www.ftc.gov/news-events/news/press-releases/2024/08/federal-trade-commission-announces-final-rule-banning-fake-reviews-testimonials +- [D7] FTC final order against Rytr testimonial/review service (2024-12): https://www.ftc.gov/news-events/news/press-releases/2024/12/ftc-approves-final-order-against-rytr-seller-ai-testimonial-review-service-providing-subscribers +- [D8] Gartner press release on GenAI production conversion barriers (2024): https://www.gartner.com/en/newsroom/press-releases/2024-05-07-gartner-survey-finds-generative-ai-is-now-the-most-frequently-deployed-ai-solution-in-organizations +- [D9] UK Government, pilot-to-production checklist (2025): https://www.gov.uk/government/publications/unlocking-space-for-investment-growth-hub/track-3-pilot-to-production-checklist +- [D10] SFGate coverage of Forward shutdown (2024): https://www.sfgate.com/tech/article/forward-shuts-down-doctors-office-19917488.php +- [D11] Electrek, Delfast non-delivery lawsuit report (2024): https://electrek.co/2024/07/05/crowdfunding-gone-wrong-customer-sues-delfast-for-e-bike-non-delivery-and-wins/ +- [D12] Time Extension, SuperSega pre-order charging confusion report (2024): https://timeextension.com/news/2024/11/confusion-reigns-as-supersega-pre-orders-get-charged-for-the-full-amount +- [D13] Research article, hypothetical bias meta-analysis (2024): https://research.rug.nl/en/publications/accurately-measuring-willingness-to-pay-for-consumer-goods-a-meta +- [D14] NNGroup ethics article (**Evergreen**): https://www.nngroup.com/articles/ethical-dilemmas/ + +--- + +### Angle 5+: Regulatory, Ethics, and Trust Constraints +> Add as many additional angles as the domain requires. Examples: regulatory/compliance context, industry-specific nuances, integration patterns with adjacent tools, competitor landscape, pricing benchmarks, etc. + +**Findings:** + +1. **Offer validation methods that involve payment commitments are regulated operational behaviors, not just experiments.** + If a team runs pre-order or deposit tests, shipping basis, delay handling, cancellation path, and refund execution become part of method validity, not legal afterthoughts. [E1][E2] + +2. **Trust-preserving design is now part of experimental quality.** + Fake-door tests that hide reality or mislead users can poison future learning loops. Transparent "in development" messaging and immediate disclosure are now viewed as practical quality controls. [E4] + +3. **Synthetic social proof is explicitly restricted and high-risk.** + FTC fake reviews/testimonials actions in 2024 mean teams should treat testimonial evidence provenance as a quality gate in offer validation outputs. [E3] + +4. **Compliance maturity changes interpretation confidence.** + Two offer tests with identical conversion can carry different decision quality if one has auditable consent/refund/comms logs and the other does not. + +5. **Practical implication:** add `legal_compliance_status` and `trust_risk_notes` to schema and block `validated` when unresolved compliance risk exists for payment-based tests. + +**Sources:** +- [E1] FTC Mail/Internet/Telephone Order Rule (**Evergreen**): https://www.ftc.gov/legal-library/browse/rules/mail-internet-or-telephone-order-merchandise-rule +- [E2] FTC Click-to-Cancel final rule announcement (2024): https://www.ftc.gov/news-events/news/press-releases/2024/10/federal-trade-commission-announces-final-click-cancel-rule-making-it-easier-consumers-end-recurring +- [E3] FTC fake reviews/testimonials final rule (2024): https://www.ftc.gov/news-events/news/press-releases/2024/08/federal-trade-commission-announces-final-rule-banning-fake-reviews-testimonials +- [E4] NNGroup ethical dilemmas guidance (**Evergreen**): https://www.nngroup.com/articles/ethical-dilemmas/ + +--- + +## Synthesis + +The strongest cross-angle pattern is that modern offer validation is not one test and one conversion number; it is a decision system with staged evidence, quality gates, and commitment semantics. Teams that only track top-funnel responses are increasingly seen as running screening experiments, not true validation. + +The second major pattern is that tooling and methodology now force operational maturity earlier. Payment and analytics APIs provide enough precision to run high-quality tests, but their limits, versioning rules, and throttling behavior can quietly degrade evidence integrity if not explicitly modeled. + +A meaningful contradiction remains between speed and rigor. Sequential and always-on practices accelerate decisions, while fixed-horizon and practical-significance guidance warns against early overconfidence. The practical resolution is to separate "directional early signal" from "final validation claim" in method and schema. + +A final cross-cutting insight is that trust/compliance is no longer peripheral for pre-sale style tests. Regulatory and public case evidence shows that a strong conversion signal can still be a bad strategic decision if it is obtained or executed in ways that trigger refund friction, disputes, or enforcement risk. + +--- + +## Recommendations for SKILL.md + +- [x] Add an explicit **evidence ladder** in methodology: intent -> soft commitment -> hard commitment, and require stronger tiers for `validated`. +- [x] Add a **pre-registered decision protocol** section: hypothesis, success threshold, practical threshold, runtime/sample gates, and quality gates before verdict. +- [x] Add a fourth decision state **`invalid_test`** for broken evidence quality (sample, instrumentation, bot/fraud, or compliance failure). +- [x] Upgrade metric guidance from single conversion target to **funnel + commitment depth + guardrail interpretation**. +- [x] Expand tool guidance with internal workflow calls and source-of-truth references for external evidence systems. +- [x] Add named anti-pattern warning blocks with **detection signal, consequence, mitigation, and hard rules**. +- [x] Extend schema with **commitment evidence fields**, **quality gates**, and **compliance/trust status**. +- [x] Add an explicit interpretation rule that **intent-only evidence cannot yield `validated`**. + +--- + +## Draft Content for SKILL.md + +### Draft: Validation Philosophy and Evidence Ladder + +You must treat offer validation as an evidence ladder, not a binary reaction to signup volume. Before you call an offer validated, classify what type of evidence you have: + +1. **Intent evidence**: views, clicks, page engagement, raw email submissions. +2. **Soft commitment evidence**: confirmed email opt-in, scheduled discovery call, signed but non-commercial LOI. +3. **Hard commitment evidence**: non-refundable deposit, paid pilot agreement, purchase event, or signed commercial commitment with accountable owner. + +Your conclusion strength must match evidence tier: + +- If evidence is intent-only, you may classify as `promising` or `inconclusive`, but not `validated`. +- If evidence reaches soft commitment with clean quality gates, you may classify as `directional_validation`. +- Use `validated` only when hard commitment evidence exists and quality/compliance gates pass. + +Do not collapse these tiers into one conversion metric. A high click-through rate and a high non-refundable deposit conversion are not equivalent evidence. Always report both volume and friction level of the action. + +--- + +### Draft: Methodology - Pre-Registered Offer Validation Protocol + +Before running any test, you must pre-register the protocol in your artifact: + +1. **Hypothesis contract** + - Write: `If [segment] sees [offer statement], at least [threshold] will complete [target action] within [window]`. + - Include both statistical and practical thresholds. + - Include explicit falsification condition. + +2. **Method selection** + - Use `fake_door` or `landing_page_test` for early intent screening. + - Use `presale_deposit` or `paid_pilot` when you need willingness-to-pay evidence. + - Use `concierge` when the value proposition depends on multi-step operational delivery. + - Use `pilot_proposal` for B2B account-level conversion proof. + +3. **Decision method declaration** + - Set `decision_method` to `fixed_horizon` or `sequential_adjusted` before launch. + - If `fixed_horizon`, do not make final decision before predeclared window/sample is reached. + - If `sequential_adjusted`, ensure interpretation uses method-consistent confidence logic. + +4. **Quality gates** + - Define minimum sample requirements. + - Define instrumentation integrity requirement. + - Define bot/fraud traffic tolerance. + - Define legal/compliance status requirement for payment-based tests. + - If any critical gate fails, set result to `invalid_test` and stop business interpretation. + +5. **Commitment evidence plan** + - Define primary commitment event in advance (for example, non-refundable deposit or paid pilot acceptance). + - Define fallback commitment event if primary is unavailable. + - Record friction level of each commitment event. + +6. **Guardrail plan** + - Add at least one trust/brand guardrail (complaints, cancellation friction, refund spikes, support burden). + - Guardrail breach blocks `validated` even when primary conversion improves. + +You should never run a payment-based offer test without explicit charge timing, cancellation rules, and refund behavior defined before launch. + +--- + +### Draft: Methodology - Execution and Interpretation Workflow + +Run this step-by-step workflow every time: + +1. **Activate context** + - Pull current offer design, messaging, and segment context before writing or updating a validation artifact. + - If context is missing, stop and gather it first. + +2. **Launch test with one primary commitment objective** + - You can track secondary metrics, but exactly one primary commitment metric determines verdict logic. + - Do not swap primary metric after data is visible. + +3. **Collect full-funnel evidence** + - Track exposures -> CTA clicks -> submissions -> confirmed submissions -> commitment actions. + - Record both absolute counts and rates. + - Segment by traffic source and ICP cohort. + +4. **Run quality gates before metric interpretation** + - If sample floor is not reached, verdict is `inconclusive` or `invalid_test` depending on severity. + - If instrumentation is degraded, verdict is `invalid_test`. + - If bot/fraud share exceeds tolerance, verdict is `invalid_test`. + - If compliance process fails for payment-based tests, verdict is `invalid_test`. + +5. **Interpret with commitment-weighted logic** + - `validated`: hard commitment threshold met, quality gates pass, no severe guardrail breach. + - `failed`: enough clean data and hard commitment materially below threshold. + - `inconclusive`: data quality acceptable but insufficient power, mixed commitments, or conflicting segment signals. + - `invalid_test`: quality/compliance failure prevents valid interpretation. + +6. **Decide next action explicitly** + - If `validated`, specify whether to advance to MVP build or expanded pilot. + - If `failed`, specify whether to stop or redesign value proposition. + - If `inconclusive`, specify required follow-up test with changed method/traffic. + - If `invalid_test`, specify remediation steps before rerun. + +Do NOT: +- use click-rate-only wins as final validation, +- keep peeking and stop when a temporary uplift appears, +- claim willingness to pay from refundable/no-obligation reservations alone, +- mark `validated` when compliance status is unresolved on paid tests. + +--- + +### Draft: Available Tools (paste-ready) + +Use policy context first, then persist evidence: + +```python +flexus_policy_document(op="activate", args={"p": "/strategy/offer-design"}) +flexus_policy_document(op="activate", args={"p": "/strategy/messaging"}) +flexus_policy_document(op="activate", args={"p": "/segments/{segment_id}/icp-scorecard"}) +``` + +Persist your result artifact: + +```python +write_artifact( + artifact_type="offer_validation_results", + path="/strategy/offer-validation-{date}", + data={...} +) +``` + +Operational usage rules: + +1. You must activate at least one offer context and one segment context artifact before writing validation output. +2. You must include evidence references for every high-impact claim (commitment conversion, payment signal, pilot acceptance). +3. You must write full artifact snapshots, not partial updates, so downstream skills can audit the decision path. +4. If external system evidence is cited (for example Stripe or GA4 exports), include stable IDs and retrieval timestamps in `evidence_refs`. + +Reference external API endpoints you may encounter in source evidence (do not invent alternatives): + +```text +Stripe: POST /v1/payment_intents +Stripe: POST /v1/checkout/sessions +PayPal: POST /v2/checkout/orders +Square: POST /v2/payments +GA4 Measurement Protocol: POST https://www.google-analytics.com/mp/collect +Amplitude HTTP v2: POST https://api2.amplitude.com/2/httpapi +``` + +If these references are unavailable or unverifiable in your environment, mark the related evidence as low confidence instead of guessing. + +--- + +### Draft: Anti-Pattern Warning Blocks (paste-ready) + +#### Warning: Vanity Demand (CTR without commitment) + +**What it looks like in practice:** +Top-funnel engagement rises but confirmed signups, deposits, or paid pilot actions remain flat. + +**Detection signal:** +`cta_ctr` exceeds target while `hard_commitment_rate` misses by a wide margin. + +**Consequence if missed:** +You over-invest in messaging resonance that does not convert into business value. + +**Mitigation steps:** +1. Require one hard-commitment metric for `validated`. +2. Segment by traffic source to remove low-intent channel inflation. +3. Downgrade verdict to `inconclusive` when commitment depth is weak. + +**Hard rule:** +Do not set `conclusion=validated` when evidence tier is `intent_only`. + +#### Warning: Post-hoc metric swapping + +**What it looks like in practice:** +Primary success metric changes after data arrives. + +**Detection signal:** +Artifact revision shows changed `primary_commitment_metric` after `start_at`. + +**Consequence if missed:** +Increased false positives and non-reproducible decisions. + +**Mitigation steps:** +1. Lock primary metric before launch. +2. Log exploratory findings separately. +3. Create a new follow-up experiment for any post-hoc discovery. + +**Hard rule:** +Metric swap after launch forces `inconclusive` unless rerun under new protocol. + +#### Warning: Peeking under fixed-horizon method + +**What it looks like in practice:** +Team stops test at first favorable snapshot under a fixed-horizon design. + +**Detection signal:** +Decision timestamp is earlier than predeclared horizon with no sequential method declaration. + +**Consequence if missed:** +Error inflation and unstable post-launch outcomes. + +**Mitigation steps:** +1. Keep fixed-horizon discipline, or +2. Switch to sequential-adjusted protocol before launch in the next run. + +**Hard rule:** +Do not mark `validated` if stop decision violated declared method. + +#### Warning: Compliance-blind pre-sale + +**What it looks like in practice:** +Payment is captured without clear delay/refund communication path. + +**Detection signal:** +Missing consent records, late delay notices, refund complaints, or dispute spikes. + +**Consequence if missed:** +Regulatory risk, forced refunds, and trust damage. + +**Mitigation steps:** +1. Add compliance checklist for payment-based tests. +2. Track delay notice, consent, and refund execution fields. +3. Block final verdict when compliance status is not `pass`. + +**Hard rule:** +`legal_compliance_status=fail` must force `conclusion=invalid_test`. + +#### Warning: Concierge heroics mistaken for scalable demand + +**What it looks like in practice:** +Manual team effort hides weak productized value delivery. + +**Detection signal:** +Manual cost-to-serve and time-per-user rise quickly with cohort size. + +**Consequence if missed:** +False positive validation and failed transition to product. + +**Mitigation steps:** +1. Track manual effort as a required metric. +2. Cap concierge cohort size and define automation handoff threshold. +3. Require stable delivery outcomes at low manual overhead before claiming validation. + +--- + +### Draft: Schema additions + +Use this schema fragment to replace or extend `offer_validation_results`. + +```json +{ + "offer_validation_results": { + "type": "object", + "required": [ + "experiment_id", + "offer_id", + "method", + "stage", + "hypothesis", + "success_threshold", + "design", + "results", + "quality_gates", + "commitment_evidence", + "conclusion", + "next_action", + "evidence_refs", + "updated_at" + ], + "additionalProperties": false, + "properties": { + "experiment_id": { + "type": "string", + "description": "Stable identifier for this validation run." + }, + "offer_id": { + "type": "string", + "description": "Stable identifier for the offer variant being tested." + }, + "method": { + "type": "string", + "enum": [ + "fake_door", + "landing_page_test", + "presale_deposit", + "concierge", + "paid_pilot", + "pilot_proposal", + "loi_outreach" + ], + "description": "Validation method selected before launch." + }, + "stage": { + "type": "string", + "enum": ["intent", "soft_commitment", "hard_commitment"], + "description": "Evidence stage this run is intended to validate." + }, + "hypothesis": { + "type": "object", + "required": [ + "target_segment", + "offer_statement", + "target_action", + "expected_rate", + "falsification_condition" + ], + "additionalProperties": false, + "properties": { + "target_segment": { + "type": "string", + "description": "ICP or segment being tested." + }, + "offer_statement": { + "type": "string", + "description": "Offer proposition shown to the audience." + }, + "target_action": { + "type": "string", + "description": "Primary commitment behavior expected from this segment." + }, + "expected_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Expected conversion rate for the target action." + }, + "falsification_condition": { + "type": "string", + "description": "Condition under which the hypothesis is considered falsified." + } + } + }, + "success_threshold": { + "type": "object", + "required": ["primary_metric", "operator", "value", "practical_minimum"], + "additionalProperties": false, + "properties": { + "primary_metric": { + "type": "string", + "description": "Metric used to determine success." + }, + "operator": { + "type": "string", + "enum": [">", ">=", "<", "<=", "between"], + "description": "Threshold operator for primary metric." + }, + "value": { + "type": "number", + "description": "Threshold value used in success decision." + }, + "upper_value": { + "type": "number", + "description": "Upper bound when operator is between." + }, + "practical_minimum": { + "type": "number", + "description": "Minimum practical effect threshold required for business significance." + } + } + }, + "design": { + "type": "object", + "required": [ + "traffic_source", + "channel", + "decision_method", + "min_sample_size", + "minimum_runtime_days", + "start_at" + ], + "additionalProperties": false, + "properties": { + "traffic_source": { + "type": "string", + "description": "Source of audience traffic (ads, outbound, existing users, etc.)." + }, + "channel": { + "type": "string", + "description": "Primary channel for offer exposure." + }, + "decision_method": { + "type": "string", + "enum": ["fixed_horizon", "sequential_adjusted"], + "description": "Inference method declared before launch." + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Type I error budget for the chosen method." + }, + "target_power": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Target power used in pre-launch planning." + }, + "min_sample_size": { + "type": "integer", + "minimum": 1, + "description": "Minimum planned sample size before final verdict." + }, + "minimum_runtime_days": { + "type": "integer", + "minimum": 1, + "description": "Minimum runtime before final verdict is allowed." + }, + "start_at": { + "type": "string", + "description": "ISO-8601 timestamp when the run started." + }, + "end_at": { + "type": "string", + "description": "ISO-8601 timestamp when the run ended." + }, + "guardrail_metrics": { + "type": "array", + "description": "Metrics that can block validation despite primary metric uplift.", + "items": { + "type": "object", + "required": ["metric_name", "max_allowed_degradation"], + "additionalProperties": false, + "properties": { + "metric_name": { + "type": "string", + "description": "Guardrail metric name." + }, + "max_allowed_degradation": { + "type": "number", + "description": "Maximum allowed negative movement before blocking validation." + } + } + } + } + } + }, + "results": { + "type": "object", + "required": [ + "total_exposures", + "unique_visitors", + "cta_clicks", + "signups", + "confirmed_signups", + "conversion_rate", + "commitment_rate" + ], + "additionalProperties": false, + "properties": { + "total_exposures": { + "type": "integer", + "minimum": 0, + "description": "Number of total offer exposures." + }, + "unique_visitors": { + "type": "integer", + "minimum": 0, + "description": "Unique visitors exposed to the offer." + }, + "cta_clicks": { + "type": "integer", + "minimum": 0, + "description": "Users who clicked primary CTA." + }, + "signups": { + "type": "integer", + "minimum": 0, + "description": "Raw signup submissions captured." + }, + "confirmed_signups": { + "type": "integer", + "minimum": 0, + "description": "Double-opt-in or otherwise confirmed signups." + }, + "deposits_count": { + "type": "integer", + "minimum": 0, + "description": "Number of deposit transactions completed." + }, + "paid_pilot_count": { + "type": "integer", + "minimum": 0, + "description": "Number of paid pilots accepted." + }, + "lois_signed_count": { + "type": "integer", + "minimum": 0, + "description": "Number of LOIs signed." + }, + "conversion_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Primary conversion rate from exposure to target action." + }, + "commitment_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Rate of hard commitment actions among exposed users or qualified leads." + }, + "revenue_evidence": { + "type": "string", + "description": "Summary of payment-backed evidence observed in this run." + } + } + }, + "quality_gates": { + "type": "object", + "required": [ + "sample_size_status", + "instrumentation_status", + "bot_traffic_status", + "legal_compliance_status" + ], + "additionalProperties": false, + "properties": { + "sample_size_status": { + "type": "string", + "enum": ["pass", "fail"], + "description": "Whether minimum sample requirements were satisfied." + }, + "instrumentation_status": { + "type": "string", + "enum": ["healthy", "degraded", "unknown"], + "description": "Telemetry integrity for relevant events and properties." + }, + "bot_traffic_status": { + "type": "string", + "enum": ["pass", "fail", "unknown"], + "description": "Whether bot/fraud traffic remained below acceptable threshold." + }, + "legal_compliance_status": { + "type": "string", + "enum": ["pass", "fail", "not_applicable"], + "description": "Compliance status for payment/fulfillment/cancellation obligations in this run." + }, + "quality_gate_notes": { + "type": "array", + "items": {"type": "string"}, + "description": "Operational notes explaining any gate concerns." + } + } + }, + "commitment_evidence": { + "type": "object", + "required": ["evidence_tier", "payment_model", "revenue_captured"], + "additionalProperties": false, + "properties": { + "evidence_tier": { + "type": "string", + "enum": ["intent_only", "soft_commitment", "hard_commitment"], + "description": "Strongest evidence tier achieved in this run." + }, + "payment_model": { + "type": "string", + "enum": [ + "none", + "refundable_reservation", + "non_refundable_deposit", + "full_prepay", + "paid_pilot" + ], + "description": "Payment commitment model used, if any." + }, + "revenue_captured": { + "type": "number", + "minimum": 0, + "description": "Actual revenue captured during the validation window." + }, + "refund_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Refund ratio for captured commitments in this run." + }, + "chargeback_count": { + "type": "integer", + "minimum": 0, + "description": "Charge disputes linked to this validation run." + } + } + }, + "anti_patterns_detected": { + "type": "array", + "description": "Detected anti-patterns that reduced confidence in the verdict.", + "items": { + "type": "string", + "enum": [ + "vanity_demand", + "metric_swapping", + "peek_and_stop", + "compliance_blind_presale", + "concierge_heroics", + "misleading_fake_door" + ] + } + }, + "conclusion": { + "type": "string", + "enum": ["validated", "failed", "inconclusive", "invalid_test"], + "description": "Final decision state after applying thresholds and quality gates." + }, + "confidence": { + "type": "string", + "enum": ["low", "medium", "high"], + "description": "Confidence in the conclusion after quality and evidence-strength checks." + }, + "key_learnings": { + "type": "array", + "items": {"type": "string"}, + "description": "Most important transferable learnings from this validation run." + }, + "next_action": { + "type": "string", + "enum": [ + "advance_to_mvp_build", + "run_followup_commitment_test", + "iterate_messaging", + "iterate_offer_terms", + "fix_instrumentation_and_rerun", + "stop_offer" + ], + "description": "Required next action chosen from standardized workflow outcomes." + }, + "evidence_refs": { + "type": "array", + "minItems": 1, + "items": {"type": "string"}, + "description": "References to dashboards, payment reports, call logs, or source artifacts." + }, + "updated_at": { + "type": "string", + "description": "ISO-8601 timestamp for last update to this artifact." + } + } + } +} +``` + +--- + +## Gaps & Uncertainties + +- Some high-value operator data on offer validation (especially real conversion benchmarks by vertical) is private or paywalled; public benchmarks should be treated as directional priors. +- Several API docs are evergreen and frequently updated without explicit versioned changelog detail, so exact limits can change by plan, region, or account state. +- Public case reports on startup pilot/pre-sale failures are often journalistic summaries rather than full postmortems; use them as risk signals, not precise prevalence estimates. +- B2B paid-pilot conversion rates are context-dependent (deal size, procurement complexity, security/legal requirements) and should not be hard-coded as universal thresholds. +- LOI legal strength varies heavily by jurisdiction and drafting style; schema should capture LOI quality metadata rather than binary "LOI exists" status. +# Research: offer-validation + +**Skill path:** `flexus_simple_bots/strategist/skills/offer-validation/` +**Bot:** strategist (researcher | strategist | executor) +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`offer-validation` is the pre-build demand validation skill for testing whether a specific offer gets meaningful behavioral commitment from the target segment. The skill already has the right core stance: behavioral evidence beats stated intent. This research expands that stance with 2024-2026 evidence on segmented benchmarking, experiment reliability, signal quality, and compliance/trust constraints for fake-door and presale workflows. + +The main practical gap in the current `SKILL.md` is not method coverage (it already includes fake door, presale, concierge, and pilot proposal), but decision quality: one global threshold, weak confidence framing, and minimal anti-pattern detection can produce false positives or false negatives. The updated guidance below keeps the existing methods, then adds stronger interpretation gates and explicit failure/compliance controls. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024-2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- No generic filler ("it is important to...", "best practices suggest...") without concrete backing +- No invented tool names, method IDs, or API endpoints - only verified real ones +- Contradictions between sources are explicitly noted, not silently resolved +- Volume: findings section is within 800-4000 words and draft content is longer than findings + +Status check: +- Concrete findings include benchmark ranges, confidence rules, and platform constraints +- Tool/API references use documented vendors and known endpoint patterns +- Contradictions are called out in Angle 1, Angle 3, and Synthesis +- Draft content section is intentionally the largest section and paste-ready + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +1. Universal cold-traffic thresholds are weaker than segmented baselines. Recent landing-page reports show wide conversion spread by industry and channel, so a single `>=5%` rule should be fallback only, not a primary go/no-go gate. +2. Channel mix and device mix materially change interpretation. Paid social, paid search, email, desktop, and mobile often show different conversion behavior; pooled rates hide signal quality and can inflate false confidence. +3. Copy complexity affects conversion in measurable ways. Simplified copy often converts better in broad audiences, while some verticals with high-risk buying (for example finance) can reward more professional detail. Message quality should be tested before concluding "offer failed." +4. Practitioners now pair fake-door tests with downstream quality checks (nurture engagement, qualification, payment intent) because top-of-funnel conversion alone is frequently noisy. +5. Presale/deposit validation must account for payment mechanics, especially authorization windows and capture constraints, otherwise "commitment evidence" may be technically invalid. +6. Pilot and concierge validation quality improves when teams define entry/exit criteria before execution and require cost-to-scale checks before calling an offer validated. +7. A practical 2025-2026 shift is from "one KPI test" to "evidence ladder tests": click/signup is weak evidence, financial/legal commitment is strong evidence, and final verdicts combine both. + +**Sources:** +- https://unbounce.com/conversion-benchmark-report/ +- https://unbounce.com/average-conversion-rates-landing-pages +- https://www.wordstream.com/blog/2024-google-ads-benchmarks +- https://localiq.com/blog/search-advertising-benchmarks/ +- https://klaviyo.com/products/email-marketing/benchmarks +- https://docs.stripe.com/payments/place-a-hold-on-a-payment-method +- https://docs.stripe.com/disputes/measuring +- https://docs.aws.amazon.com/prescriptive-guidance/latest/opensearch-service-migration/stage-2-poc.html +- https://docs.aws.amazon.com/redshift/latest/dg/proof-of-concept-playbook.html + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +1. For landing pages and fake doors, Webflow and Unbounce remain common choices. Webflow provides CMS/data APIs and clear per-plan rate limits; Unbounce is conversion-focused with lead/page APIs and generous request limits. +2. Framer Server API is useful for teams that want scripted publish/deploy workflows for experiments; this is valuable when tests are run frequently and need reproducible deploy automation. +3. Stripe Checkout remains a standard choice for willingness-to-pay evidence because it supports hosted checkout, wide payment method coverage, and documentation on rate limits, disputes, and authorization behaviors. +4. Paddle and Lemon Squeezy are strong alternatives when merchant-of-record behavior and international compliance overhead are constraints from day one; both provide documented transaction/checkout APIs and rate-limit details. +5. GA4 Measurement Protocol, PostHog capture endpoints, and Amplitude HTTP V2 are practical event ingestion options for server-side conversion evidence and funnel instrumentation. +6. Statsig exposes experiment-management APIs with documented project-level request limits, making it suitable when the workflow includes repeated controlled tests and formal experiment governance. +7. HubSpot contacts API, Typeform webhooks, and Airtable API are common handoff layers for qualification and sales follow-through, which is critical for B2B pilot validation. +8. Ad-channel APIs (Meta Marketing API, Google Ads API, LinkedIn Marketing APIs) are the practical traffic acquisition control plane for fake-door tests, but quota and versioning constraints differ by platform. +9. De-facto stacks in 2025-2026 commonly combine ad platform + landing page + payment evidence + analytics + CRM rather than using a single all-in-one product. + +**Sources:** +- https://developers.webflow.com/data/docs/working-with-the-cms +- https://developers.webflow.com/data/reference/rate-limits +- https://developer.unbounce.com/getting_started/ +- https://developer.unbounce.com/api_reference/ +- https://www.framer.com/developers/server-api-quick-start +- https://docs.stripe.com/api/checkout/sessions/create?lang=curl +- https://docs.stripe.com/rate-limits +- https://developer.paddle.com/api-reference/transactions/create-transaction +- https://developer.paddle.com/api-reference/about/rate-limiting +- https://docs.lemonsqueezy.com/api/checkouts/create-checkout +- https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference +- https://posthog.com/docs/api/capture +- https://amplitude.com/docs/apis/analytics/http-v2 +- https://docs.statsig.com/api-reference/experiments/create-experiment +- https://developers.hubspot.com/docs/api/crm/contacts +- https://www.typeform.com/developers/webhooks/reference/create-or-update-webhook/ +- https://support.airtable.com/docs/managing-api-call-limits-in-airtable +- https://developers.facebook.com/docs/marketing-api/reference/ad-account/campaigns/ +- https://developers.google.com/google-ads/api/rest/common/search +- https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/account-structure/create-and-manage-campaigns?view=li-lms-2026-01 + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +1. Benchmarks are context-heavy. Landing-page and ad benchmarks vary significantly by industry, audience intent, and funnel stage, so benchmark use must normalize definitions before comparison (`visit->signup` is not comparable to `click->lead->sale` without adjustment). +2. CTR and top-funnel conversion are insufficient as standalone validation evidence. A practical interpretation stack is: top-funnel response + downstream quality + commitment evidence. +3. Confidence interval and power framing are now standard for decision quality in experimentation tooling. If CI crosses zero (or crosses the practical threshold), results should default to inconclusive. +4. Predefined alpha, power, and minimum detectable effect (MDE) prevent post-hoc rationalization and underpowered conclusions. Common defaults are alpha `0.05` and power `0.8`, with MDE tied to business value. +5. Early peeking is a major reliability failure mode. Many practitioners treat first-24h reads as directional diagnostics only, then wait for planned duration and sample size before final verdicts. +6. Low-sample conditions require explicit caution flags. When effective sample is small and uncertainty is high, "no signal" often means "not enough data," not "offer failed." +7. Multiple comparisons and segment slicing increase false discovery risk. Deep segment wins should be treated as exploratory unless replicated. +8. For B2B pilot/proposal flows, interpretation improves when you combine acceptance rates with stakeholder depth (for example number of engaged reviewers, signature progression, and close velocity). + +**Sources:** +- https://unbounce.com/conversion-benchmark-report/ +- https://unbounce.com/conversion-benchmark-report/methodology/ +- https://localiq.com/blog/search-advertising-benchmarks/ +- https://contentsquare.com/blog/2025-digital-experience-benchmarks +- https://docs.statsig.com/experiments/statistical-methods/confidence-intervals +- https://docs.statsig.com/experiments/power-analysis +- https://docs.statsig.com/experiments-plus/read-results +- https://www.statsig.com/perspectives/ab-test-sample-size +- https://www.statsig.com/perspectives/mde-calculate-use-ab-tests +- https://support.optimizely.com/hc/en-us/articles/4410289598989-Why-Stats-Engine-controls-for-false-discovery-instead-of-false-positives +- https://support.optimizely.com/hc/en-us/articles/4410289544589-How-and-why-statistical-significance-changes-over-time-in-Optimizely-Experimentation +- https://www.proposify.com/state-of-proposals-2024 +- https://www.proposify.com/hubfs/State%20of%20Proposals%202025/The%20State%20of%20Proposals%202025.pdf + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +1. Vanity-click validation: teams mark success on clicks or signups without checking downstream commitment quality. +2. Sample ratio mismatch (SRM) blindness: allocation drift or instrumentation defects produce biased experiment results that are still interpreted as valid. +3. Underpowered peeking: tests are stopped early or read too often, then interpreted as conclusive despite unstable uncertainty. +4. Audience mismatch: the experiment reaches the wrong cohort, producing false positive demand from non-buyers or non-ICP users. +5. Opaque fake doors: users encounter a dead end after clicking a fake feature with no disclosure, causing trust erosion and support blowback. +6. Asymmetric consent and manipulative design patterns can introduce compliance risk and invalidate "consent-like" signals. +7. Fake social proof or synthetic urgency in validation pages can violate deceptive-practice rules and poison experiment quality. +8. Presale promise debt: taking payment without delivery-readiness controls, delay consent, and refund flow creates dispute/legal risk. +9. Concierge-to-product cliff: manual delivery succeeds because of human over-service, then collapses during automation because the tested value was not operationally reproducible. + +**Sources:** +- https://www.statsig.com/blog/top-8-common-experimentation-mistakes-how-to-fix +- https://docs.statsig.com/guides/srm +- https://amplitude.com/docs/feature-experiment/troubleshooting/sample-ratio-mismatch +- https://amplitude.com/explore/experiment/fake-door-testing +- https://www.ftc.gov/news-events/news/press-releases/2024/07/ftc-icpen-gpen-announce-results-review-use-dark-patterns-affecting-subscription-services-privacy +- https://www.icpen.org/sites/default/files/2024-07/Public%20Report%20ICPEN%20Dark%20Patterns%20Sweep.pdf +- https://www.ftc.gov/news-events/news/press-releases/2024/08/federal-trade-commission-announces-final-rule-banning-fake-reviews-testimonials +- https://www.ecfr.gov/current/title-16/chapter-I/subchapter-D/part-465/section-465.2 +- https://www.ftc.gov/business-guidance/resources/business-guide-ftcs-mail-internet-or-telephone-order-merchandise-rule (evergreen) +- https://blog.logrocket.com/product-management/concierge-wizard-of-oz-mvp + +--- + +### Angle 5+: Regulatory, Consent, and Trust Constraints +> Offer-validation methods are technically simple, but legal/compliance and trust constraints can invalidate both process and outcomes if ignored. + +**Findings:** + +1. Regulators are converging on anti-manipulation and meaningful user choice, but strictness differs by jurisdiction and context. +2. EU EDPB 2024 guidance on "consent or pay" for large platforms is materially stricter than UK ICO 2025 guidance, which allows compliant implementations when conditions are met. +3. California CPPA guidance emphasizes that dark-pattern analysis focuses on effect on user choice, not intent of the designer. +4. Fake-door experiments should include immediate disclosure and easy exit paths; "realistic test" does not justify misleading flows. +5. For presales, delay-consent and refund controls are not optional operational niceties; they are key legal/trust safeguards. +6. Compliance differences across regions mean global one-size-fits-all validation playbooks are risky; regional deployment constraints should be explicitly tracked. + +**Sources:** +- https://www.edpb.europa.eu/our-work-tools/our-documents/opinion-board-art-64/opinion-082024-valid-consent-context-consent-or_en +- https://www.edpb.europa.eu/system/files/2024-04/edpb_opinion_202408_consentorpay_en.pdf +- https://www.edpb.europa.eu/news/news/2024/edpb-consent-or-pay-models-should-offer-real-choice_en +- https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/online-tracking/consent-or-pay/about-this-guidance/ +- https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/online-tracking/consent-or-pay/privacy-by-design +- https://cppa.ca.gov/pdf/enfadvisory202402.pdf +- https://cppa.ca.gov/regulations/pdf/cppa_regs.pdf + +--- + +## Synthesis + +The strongest cross-angle pattern is that `offer-validation` should shift from a single-threshold mindset to an evidence-ladder mindset. In 2024-2026 data, conversion outcomes vary too much by channel, segment, and device for a global benchmark to be reliable. A universal `>=5%` can still be useful as a temporary floor, but only until segment-relevant baselines are available. + +A second pattern is that practitioners increasingly separate "attention signal" from "commitment signal." CTR and signup are useful diagnostics, but they are not durable demand evidence by themselves. Payment/deposit behavior, signed commitments, and qualified stakeholder progression produce materially stronger validation evidence, especially in B2B pilots and presale contexts. + +A third pattern is methodological maturity in interpretation. Experiment platforms and modern guidance emphasize predefining alpha/power/MDE, watching confidence intervals, and explicitly handling SRM, multiplicity, and underpowered conditions. Without these controls, teams overfit to noise and mislabel outcomes. + +Finally, trust/compliance constraints are now operationally relevant to fake-door and presale tests. There is a real tension between realistic demand testing and non-manipulative user experience. Source disagreement (for example EU vs UK posture on consent-or-pay patterns) means the skill should codify conservative defaults and jurisdiction-aware risk flags rather than claiming universal legal rules. + +--- + +## Recommendations for SKILL.md + +> Concrete, actionable list of what should change / be added in the SKILL.md based on research. + +- [ ] Replace the universal smoke-test threshold with a segmented benchmark policy (channel x industry x device), keeping `>=5%` only as temporary fallback. +- [ ] Add an explicit evidence ladder and require at least one hard commitment signal before verdict `validated`. +- [ ] Add experiment reliability gates: alpha/power/MDE declaration, minimum cycle duration, and SRM checks. +- [ ] Add a formal interpretation rubric for `validated`, `inconclusive`, and `failed` to reduce subjective verdicts. +- [ ] Expand anti-pattern coverage with detection signals and mitigations (vanity clicks, peeking, SRM, audience mismatch, promise debt, concierge cliff). +- [ ] Add compliance/trust guardrails for fake-door and presale tests (transparent disclosure, easy opt-out, no fake social proof, delay-consent/refund controls). +- [ ] Expand `Available Tools` instructions with concrete operational flow (policy activation -> run experiment -> record artifact). +- [ ] Replace the artifact schema with a richer structure capturing benchmark basis, confidence, funnel quality, and hard-signal evidence. + +--- + +## Draft Content for SKILL.md + +> This is the most important section. For every recommendation above, write the actual text that should go into SKILL.md. + +### Draft: Validation standard and evidence ladder + +--- +### Validation standard + +You are not validating opinions. You are validating behavior under real trade-offs. + +Treat offer validation as an evidence ladder: + +1. **Attention evidence (weak):** clicks, page engagement, email signup. +2. **Intent evidence (moderate):** qualified follow-up actions (demo booked, problem interview accepted, detailed response with buying context). +3. **Commitment evidence (strong):** payment method saved, deposit paid, signed LOI, signed/paid pilot. + +A result is **never** `validated` using only attention evidence. You must have at least one strong commitment signal, or a multi-step intent pattern that is historically equivalent for that segment and offer type. + +Use this hard rule: +- If you only have top-of-funnel response, conclusion is `inconclusive` even when conversion looks high. +- If commitment evidence exists but audience fit is weak or quality metrics collapse downstream, conclusion is `inconclusive` or `failed` depending on severity. +- If commitment evidence exists and quality metrics remain healthy, you may conclude `validated`. +--- + +### Draft: Method selection and segmented threshold policy + +--- +### Validation method selection + +Choose the method by uncertainty and commitment needed: + +1. **`fake_door`** for message-demand screening before build. +2. **`presale`** for willingness-to-pay proof before full product readiness. +3. **`concierge` / `manual_delivery`** for job-to-be-done realism and fulfillment learning. +4. **`pilot_proposal`** for B2B commitment and stakeholder depth. + +### Threshold policy (critical) + +Do not use one universal conversion target across all experiments. Instead, set thresholds in this order: + +1. **Segment baseline first:** channel + industry + device comparable benchmark. +2. **Historical baseline second:** your prior experiments with the same audience and traffic quality. +3. **Absolute fallback last:** if no reliable baseline exists, use `>=5%` visitor-to-signup only as temporary fallback for cold-traffic fake-door tests. + +You must document which basis you used. A threshold without basis is invalid. + +### Channel and device normalization + +Before verdict: +- Split conversion by acquisition channel (`paid_search`, `paid_social`, `email`, `referral`, etc.). +- Split conversion by device (`mobile`, `desktop`). +- Explain major deltas before concluding. + +Do not pool channels and devices into one number and call that validation. + +### Suggested method-specific success logic + +- **Fake door:** requires segment-normalized conversion plus post-signup quality signal (for example follow-up engagement). +- **Presale:** requires payment/deposit evidence and operational refund/delay controls. +- **Concierge/manual:** requires repeatable value delivery and preliminary unit-economics realism. +- **Pilot proposal:** requires qualified stakeholder engagement and commitment progression, not just proposal send count. +--- + +### Draft: Experiment design and reliability gates + +--- +### Experiment design (required fields) + +Before launching any validation run, define: + +- **Hypothesis** in this format: + `If [target segment] sees [offer + message], at least [threshold] will take [specific action] within [time window].` +- **Primary metric** (single go/no-go metric). +- **Guardrail metrics** (quality checks that can override apparent success). +- **Success threshold basis** (segment benchmark, historical baseline, or temporary fallback). +- **Duration plan** (minimum one full business cycle; never decide from same-day read). +- **Reliability plan** (`alpha`, `power`, `MDE`, planned sample). + +### Statistical reliability gates + +Use these default settings unless justified otherwise: +- `alpha = 0.05` +- `power = 0.8` +- `MDE` must reflect practical business value, not vanity uplift + +If your confidence interval crosses zero or your practical threshold, mark the result `inconclusive`. + +Do not ship decisions from underpowered tests. If sample is too small to detect your chosen MDE, report the experiment as underpowered and request either longer runtime or stronger-signal method (for example presale instead of signup-only). + +### Peeking and stopping rules + +You may monitor health metrics during execution, but you must not finalize verdicts before: + +1. Planned minimum runtime is complete. +2. Planned minimum sample is reached or a pre-registered stop condition triggers. +3. SRM checks pass for assigned vs observed allocation. + +If SRM fails, invalidate the run and relaunch after instrumentation fix. +--- + +### Draft: Data interpretation rubric and contradiction handling + +--- +### Interpretation rubric + +Use this rubric exactly: + +#### `validated` +- Primary metric clears threshold with acceptable uncertainty. +- At least one hard commitment signal exists (deposit/payment/signed commitment). +- Guardrail metrics do not show severe downstream quality loss. + +#### `inconclusive` +- Directional lift exists but uncertainty remains high. +- Evidence is mostly attention/intent with weak commitment. +- Sample/runtime/reliability constraints prevent strong conclusion. + +#### `failed` +- Primary metric materially misses threshold with confidence. +- Guardrails indicate quality collapse. +- Commitment evidence is absent or clearly weak after adequate exposure. + +### Contradiction handling + +When benchmarks disagree across sources, do not pick one silently. Do this instead: + +1. Normalize metric definitions (for example `visit->signup` vs `click->lead`). +2. Compare only within same funnel stage. +3. Segment by intent and channel. +4. Record why a source was selected as primary benchmark. + +If contradictions remain unresolved, keep verdict `inconclusive` and capture uncertainty explicitly in artifact fields. +--- + +### Draft: Available tools section (operational workflow) + +--- +## Available Tools + +Activate supporting strategy documents before experiment design: + +```python +flexus_policy_document(op="activate", args={"p": "/strategy/offer-design"}) +flexus_policy_document(op="activate", args={"p": "/strategy/messaging"}) +flexus_policy_document(op="activate", args={"p": "/segments/{segment_id}/icp-scorecard"}) +``` + +After execution, persist full evidence using: + +```python +write_artifact( + artifact_type="offer_validation_results", + path="/strategy/offer-validation-{date}", + data={...} +) +``` + +### Optional external stack references (for analyst verification and integrations) + +Use only when your execution environment supports these systems; otherwise skip. + +- Stripe Checkout session creation (willingness-to-pay evidence): `POST https://api.stripe.com/v1/checkout/sessions` +- GA4 Measurement Protocol ingestion (server-side event confirmation): `POST https://www.google-analytics.com/mp/collect?...` +- HubSpot contact upsert/create handoff: `POST https://api.hubapi.com/crm/v3/objects/contacts` +- PostHog capture ingestion: `POST https://us.i.posthog.com/i/v0/e/` + +Do not invent endpoints or methods. If a referenced API method cannot be verified in official docs at run time, remove it from the plan and mark the gap. +--- + +### Draft: Anti-pattern warning blocks + +--- +### Anti-pattern: Vanity Click Validation + +**What it looks like:** You call a fake-door test successful based on high CTR or signup count alone. +**Detection signal:** Strong top-funnel response but weak downstream engagement, qualification, or commitment evidence. +**Consequence if missed:** You build offers that attract curiosity rather than buyers. +**Mitigation steps:** Require at least one downstream quality metric and one hard-signal checkpoint before `validated`. + +### Anti-pattern: Underpowered Peeking + +**What it looks like:** You repeatedly inspect results and finalize early based on unstable movement. +**Detection signal:** Verdict changes day to day; CI remains wide; sample plan not reached. +**Consequence if missed:** False positives/negatives drive roadmap mistakes. +**Mitigation steps:** Pre-register stopping rules, enforce minimum runtime/sample, and default to `inconclusive` when reliability gates fail. + +### Anti-pattern: Sample Ratio Mismatch (SRM) Blindness + +**What it looks like:** Allocation intended as 50/50 appears as materially skewed exposure and is still interpreted. +**Detection signal:** Assignment and observed exposure ratios diverge beyond expected random variation. +**Consequence if missed:** Biased lift estimates and invalid verdicts. +**Mitigation steps:** Stop interpretation, fix assignment/logging, rerun the test. + +### Anti-pattern: Audience Mismatch + +**What it looks like:** Offer is tested on broad traffic that does not match ICP purchase reality. +**Detection signal:** High response from low-fit users, poor conversion to qualified next step. +**Consequence if missed:** False demand signal and poor post-build conversion. +**Mitigation steps:** Segment traffic up front, score audience fit, and report segment-level outcomes. + +### Anti-pattern: Opaque Fake Door + +**What it looks like:** User clicks an unavailable feature and hits a dead end with no explanation. +**Detection signal:** confusion events, repeat clicks, support complaints. +**Consequence if missed:** Trust damage and elevated deceptive-design risk. +**Mitigation steps:** immediate plain-language disclosure, clear next step (waitlist or alternative), and easy exit path. + +### Anti-pattern: Presale Promise Debt + +**What it looks like:** Payment is taken before delivery readiness or without delay/refund controls. +**Detection signal:** delivery slips, refund backlog, dispute increase. +**Consequence if missed:** legal exposure, payment disputes, and brand damage. +**Mitigation steps:** add delivery-window assumptions, consented delay workflow, and automatic refund process before taking presale funds. + +### Anti-pattern: Concierge-to-Product Cliff + +**What it looks like:** Concierge pilot succeeds because humans overcompensate for missing product capability. +**Detection signal:** high manual effort per account and weak automation parity. +**Consequence if missed:** false PMF and churn when automation is introduced. +**Mitigation steps:** test progressive automation, measure value retention as manual effort decreases, and re-scope offer if parity fails. +--- + +### Draft: Compliance and trust safeguards + +--- +### Compliance and trust safeguards + +You must run fake-door and presale validation with transparent user treatment: + +1. Disclose unavailable features immediately after click using plain language. +2. Keep refusal/exit paths as easy as acceptance paths. +3. Do not use fabricated social proof, fabricated urgency, or unsubstantiated claim language. +4. For presale, set and communicate delivery assumptions, delay-consent workflow, and refund policy before payment collection. +5. Add jurisdiction risk notes when running across regions with different regulatory posture. + +Regulatory sources disagree in strictness in some areas (for example EU vs UK interpretation of consent-or-pay patterns). When strictness is uncertain, apply conservative defaults and record the unresolved jurisdiction question in `risk_flags`. +--- + +### Draft: Schema additions + +> Full JSON Schema fragment for `offer_validation_results` with stronger reliability and interpretation metadata. + +```json +{ + "offer_validation_results": { + "type": "object", + "required": [ + "experiment_id", + "experiment_date", + "segment_id", + "method", + "hypothesis", + "primary_metric", + "success_threshold", + "traffic_sources", + "duration_days_planned", + "duration_days_actual", + "results", + "confidence", + "commitment_evidence", + "conclusion", + "conclusion_reason", + "next_action" + ], + "additionalProperties": false, + "properties": { + "experiment_id": { + "type": "string", + "description": "Unique experiment identifier for audit and cross-run comparison." + }, + "experiment_date": { + "type": "string", + "format": "date", + "description": "ISO date (YYYY-MM-DD) representing experiment start date." + }, + "segment_id": { + "type": "string", + "description": "Target segment identifier used to evaluate ICP fit and benchmark relevance." + }, + "method": { + "type": "string", + "enum": [ + "fake_door", + "presale", + "concierge", + "pilot_proposal", + "manual_delivery" + ], + "description": "Validation method used in this experiment." + }, + "hypothesis": { + "type": "string", + "description": "Testable if-then statement including segment, offer, action, and time window." + }, + "primary_metric": { + "type": "string", + "enum": [ + "visitor_to_signup_rate", + "deposit_conversion_rate", + "payment_conversion_rate", + "proposal_to_paid_pilot_rate", + "manual_delivery_paid_conversion_rate" + ], + "description": "Single decision metric used for go/no-go judgment." + }, + "success_threshold": { + "type": "object", + "required": [ + "target_value", + "comparison_basis", + "minimum_practical_lift" + ], + "additionalProperties": false, + "properties": { + "target_value": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Primary threshold value as a normalized rate from 0 to 1." + }, + "comparison_basis": { + "type": "string", + "enum": [ + "segment_benchmark", + "historical_baseline", + "absolute_fallback" + ], + "description": "Basis used to set threshold; absolute_fallback is temporary when better baselines are unavailable." + }, + "minimum_practical_lift": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Smallest absolute lift considered meaningful for business decisions." + } + }, + "description": "Threshold definition including value and rationale basis." + }, + "traffic_sources": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "paid_search", + "paid_social", + "email", + "referral", + "community", + "direct", + "outbound" + ] + }, + "description": "Acquisition sources included in this experiment." + }, + "duration_days_planned": { + "type": "integer", + "minimum": 1, + "description": "Planned experiment duration in whole days." + }, + "duration_days_actual": { + "type": "integer", + "minimum": 1, + "description": "Actual runtime in whole days." + }, + "results": { + "type": "object", + "required": [ + "total_exposures", + "conversions", + "conversion_rate", + "channel_breakdown", + "device_breakdown", + "funnel", + "revenue_evidence" + ], + "additionalProperties": false, + "properties": { + "total_exposures": { + "type": "integer", + "minimum": 0, + "description": "Count of qualified exposures used as denominator for primary metric." + }, + "conversions": { + "type": "integer", + "minimum": 0, + "description": "Count of primary-metric conversions." + }, + "conversion_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Primary conversion metric as conversions / total_exposures." + }, + "channel_breakdown": { + "type": "array", + "items": { + "type": "object", + "required": [ + "channel", + "exposures", + "conversions", + "conversion_rate" + ], + "additionalProperties": false, + "properties": { + "channel": { + "type": "string", + "description": "Traffic channel label used for segmented interpretation." + }, + "exposures": { + "type": "integer", + "minimum": 0, + "description": "Exposures for this channel." + }, + "conversions": { + "type": "integer", + "minimum": 0, + "description": "Conversions for this channel." + }, + "conversion_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Channel conversion rate." + } + } + }, + "description": "Per-channel breakdown required for source-normalized interpretation." + }, + "device_breakdown": { + "type": "array", + "items": { + "type": "object", + "required": [ + "device", + "exposures", + "conversions", + "conversion_rate" + ], + "additionalProperties": false, + "properties": { + "device": { + "type": "string", + "enum": [ + "mobile", + "desktop", + "tablet", + "other" + ], + "description": "Device class used in conversion split." + }, + "exposures": { + "type": "integer", + "minimum": 0, + "description": "Exposures for this device class." + }, + "conversions": { + "type": "integer", + "minimum": 0, + "description": "Conversions for this device class." + }, + "conversion_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Device conversion rate." + } + } + }, + "description": "Per-device breakdown to detect hidden channel-device artifacts." + }, + "funnel": { + "type": "object", + "required": [ + "cta_click_rate", + "signup_rate", + "qualified_next_step_rate", + "commitment_rate" + ], + "additionalProperties": false, + "properties": { + "cta_click_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Fraction of exposures that clicked CTA." + }, + "signup_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Fraction of exposures that completed signup form." + }, + "qualified_next_step_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Fraction of signups that complete a qualified next action (for example demo booked or detailed intent response)." + }, + "commitment_rate": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Fraction of exposures that reach hard commitment evidence." + } + }, + "description": "Multi-step funnel needed to distinguish curiosity from commitment." + }, + "revenue_evidence": { + "type": "string", + "description": "Narrative summary of direct monetary evidence (deposit, payment, paid pilot)." + } + }, + "description": "Observed performance metrics and segmented breakdowns." + }, + "confidence": { + "type": "object", + "required": [ + "alpha", + "power", + "mde", + "ci_lower", + "ci_upper", + "sample_size_planned", + "sample_size_actual", + "srm_check" + ], + "additionalProperties": false, + "properties": { + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Type-I error threshold used for interpretation." + }, + "power": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Statistical power target for detecting chosen MDE." + }, + "mde": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Minimum detectable effect as absolute rate lift." + }, + "ci_lower": { + "type": "number", + "description": "Lower bound of confidence interval for effect estimate." + }, + "ci_upper": { + "type": "number", + "description": "Upper bound of confidence interval for effect estimate." + }, + "sample_size_planned": { + "type": "integer", + "minimum": 0, + "description": "Planned sample size used in reliability planning." + }, + "sample_size_actual": { + "type": "integer", + "minimum": 0, + "description": "Observed sample size at decision time." + }, + "srm_check": { + "type": "string", + "enum": [ + "pass", + "fail", + "not_applicable" + ], + "description": "Sample ratio mismatch status for allocation validity." + } + }, + "description": "Reliability metadata required for statistically defensible conclusions." + }, + "commitment_evidence": { + "type": "object", + "required": [ + "evidence_type", + "count", + "amount_usd" + ], + "additionalProperties": false, + "properties": { + "evidence_type": { + "type": "string", + "enum": [ + "none", + "waitlist_only", + "payment_method_saved", + "deposit_paid", + "signed_loi", + "signed_pilot", + "paid_pilot" + ], + "description": "Strongest evidence class observed in this run." + }, + "count": { + "type": "integer", + "minimum": 0, + "description": "Number of commitment events observed." + }, + "amount_usd": { + "type": "number", + "minimum": 0, + "description": "Total commitment amount in USD-equivalent for financial evidence." + } + }, + "description": "Hard-signal evidence used by evidence-ladder verdict logic." + }, + "source_benchmarks": { + "type": "array", + "items": { + "type": "object", + "required": [ + "source_name", + "source_url", + "published_year", + "metric_name", + "metric_value", + "segment_notes" + ], + "additionalProperties": false, + "properties": { + "source_name": { + "type": "string", + "description": "Human-readable source label (for example Unbounce Conversion Benchmark Report)." + }, + "source_url": { + "type": "string", + "description": "Direct URL used for threshold grounding." + }, + "published_year": { + "type": "string", + "description": "Publication year or 'evergreen' when older but still applicable." + }, + "metric_name": { + "type": "string", + "description": "Benchmark metric name being used for comparison." + }, + "metric_value": { + "type": "string", + "description": "Benchmark value in source-native format (number, range, or percentile)." + }, + "segment_notes": { + "type": "string", + "description": "Context explaining why this benchmark is comparable to the tested audience." + } + } + }, + "description": "External benchmark references used to justify threshold selection." + }, + "risk_flags": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "srm_detected", + "underpowered", + "high_dispute_risk", + "audience_mismatch", + "compliance_review_required", + "contradictory_benchmarks" + ] + }, + "description": "Known reliability, operational, or compliance risks requiring explicit acknowledgment." + }, + "conclusion": { + "type": "string", + "enum": [ + "validated", + "failed", + "inconclusive" + ], + "description": "Final decision state derived from threshold, confidence, and commitment evidence." + }, + "conclusion_reason": { + "type": "string", + "description": "Short explanation linking verdict to evidence and reliability checks." + }, + "key_learnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Top insights that should change offer, segment, or messaging strategy." + }, + "next_action": { + "type": "string", + "description": "Concrete next step (iterate message, rerun with better segment, move to pilot, pause, or stop)." + } + } + } +} +``` + +--- + +## Gaps & Uncertainties + +- Public 2024-2026 benchmark data for high-ticket B2B pilot conversion is still sparse relative to SMB/ecommerce datasets. +- Cross-source metric definitions differ (landing-page conversion, ad conversion, proposal close), so direct numeric comparisons remain imperfect even with normalization. +- Jurisdictional compliance posture is not fully harmonized (EU, UK, California, US federal), so global policy should retain a conservative mode and explicit legal-review flags. +- Tool documentation clarity varies by vendor (especially detailed rate-limit policy and dynamic docs), so API-level operational plans still require pre-run verification. diff --git a/flexus_simple_bots/strategist/skills/_offer-validation/SKILL.md b/flexus_simple_bots/strategist/skills/_offer-validation/SKILL.md new file mode 100644 index 00000000..88687a05 --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_offer-validation/SKILL.md @@ -0,0 +1,47 @@ +--- +name: offer-validation +description: Offer validation with prospects — fake door tests, landing page experiments, early-access sign-ups, and conversion evidence +--- + +You design and execute offer validation experiments before full development. Validation means getting evidence that real humans in your target segment will take a meaningful action (signup, payment, commitment) in response to the offer. + +Core mode: a validated offer has real behavioral evidence, not stated intent. "I would definitely use this" ≠ validation. A credit card capture, a paid pilot commitment, or a signed LOI = validation. Design for behavior, not attitude. + +## Methodology + +### Validation method selection +Match method to confidence level needed and stage: + +1. **Fake door / smoke test**: landing page + signup/waitlist CTA. Validates messaging resonance and demand before building. Metric: conversion rate to email capture (target ≥5% from cold traffic). + +2. **Pre-sale / deposit**: ask early-access candidates for a deposit or commitment. Validates willingness to pay before product is ready. Even $1 deposit separates curious from serious. + +3. **Concierge / manual delivery**: deliver the core value manually (without software) to first customers. Validates that the job is real and completable. No code required. + +4. **Pilot proposal**: write and present a formal pilot proposal to 5-10 target accounts. Track: how many agree to a discovery call? How many agree to a paid pilot? + +### Experiment design +Each validation experiment needs: +- **Hypothesis**: "If [target segment] sees [offer], [% of them] will [specific action]" +- **Success threshold**: what conversion rate would confirm the hypothesis? +- **Traffic source**: where do test subjects come from? +- **Duration**: minimum time to reach statistical significance + +### Metrics +- Smoke test: visitor-to-signup rate, email open rate, click-to-CTA +- Paid pilot: proposal-to-pilot conversion rate, actual payment +- Concierge: willingness to pay for manual delivery, task completion rate + +## Recording + +``` +write_artifact(path="/strategy/offer-validation-{date}", data={...}) +``` + +## Available Tools + +``` +flexus_policy_document(op="activate", args={"p": "/strategy/offer-design"}) +flexus_policy_document(op="activate", args={"p": "/strategy/messaging"}) +flexus_policy_document(op="activate", args={"p": "/segments/{segment_id}/icp-scorecard"}) +``` diff --git a/flexus_simple_bots/strategist/skills/_pricing-competitive-benchmark/RESEARCH.md b/flexus_simple_bots/strategist/skills/_pricing-competitive-benchmark/RESEARCH.md new file mode 100644 index 00000000..6b21c942 --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_pricing-competitive-benchmark/RESEARCH.md @@ -0,0 +1,923 @@ +# Research: pricing-competitive-benchmark + +**Skill path:** `flexus-client-kit/flexus_simple_bots/strategist/skills/pricing-competitive-benchmark/` +**Bot:** strategist (researcher | strategist | executor) +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +This skill benchmarks your pricing against competitors so you can validate positioning, avoid unjustified discounting, and support negotiations with evidence instead of intuition. In practice, this is not a one-time scrape problem. Competitor pages change frequently, many prices are list-only (not realized), and pricing models differ (per-seat, usage, hybrid, enterprise quote). + +Research shows pricing decisions are increasingly frequent, but operating maturity often lags. SBI reports most B2B SaaS teams now update pricing/packaging at least annually, with a sizable quarterly updater segment, while a large share of teams still make important pricing decisions with heavy intuition inputs (SBI 2024/2025). That makes this skill most useful when it enforces methodology guardrails: evidence quality, comparability normalization, confidence scoring, and explicit "insufficient evidence" paths. + +For this skill to be reliable, outputs must separate list-price observations from realized-price signals, include data freshness metadata, and encode anti-pattern detection (promo whiplash, apples-to-oranges tier comparisons, currency/tax mismatch, and stale snapshot bias). It also needs legal and compliance guardrails because algorithmic benchmarking and competitor signal use are under active antitrust scrutiny in the US/EU/UK. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024-2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- No generic filler ("it is important to...", "best practices suggest...") without concrete backing +- No invented tool names, method IDs, or API endpoints - only verified real ones +- Contradictions between sources are explicitly noted, not silently resolved +- Volume: findings section should be 800-4000 words (too short = shallow, too long = unsynthesized) + +Gate check for this file: passed. Contradictions are explicitly called out (e.g., discount benchmarks and contract-term discount direction), and older references are marked as evergreen. + +--- + +## Research Angles + +Each angle was researched by a separate sub-agent. + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +Practitioner methodology in 2024-2026 is converging on a two-speed operating model: frequent monitoring plus slower strategic resets. BCG's 2024 guidance and SBI's 2024/2025 benchmark reports align on this pattern. For this skill, the practical rule is quarterly delta checks plus annual full rebasing. One-off snapshots are useful for awareness, but they are not reliable strategy inputs by themselves. + +Segmentation happens before benchmarking, not after. BCG and Bain both emphasize customer/channel/geo segmentation as a core determinant of price outcomes. If you compute one market-wide "median competitor price" across dissimilar segments, the benchmark is structurally biased. The skill should require segment cell definitions (for example, geo x ICP tier x channel x pricing model) before any category norms are calculated. + +Comparability normalization is now treated as a hard gate. CMA's 2024 unit-pricing consumer research and New Zealand Commerce Commission guidance both show that apparent bargains often disappear after unit normalization. In software, the equivalent failure mode is tier-name matching without capability equivalence. This means the methodology must force feature-bucket mapping and billing-term normalization before a row is considered comparable. + +Scenario modeling has moved from optional analysis to expected practice. BCG 2024 and Bain 2025 both frame pricing recommendations as simulation outputs across multiple conditions, not a single deterministic answer. For usage or hybrid pricing, this skill should always compute low/base/high scenarios with explicit assumptions and overage behavior rather than collapsing to a single estimated point. + +List and realized pricing need explicit separation. Simon-Kucher's 2025 reports and Vendr's 2024/2025 transaction analyses both show realization leakage from discounts, rebates, and deal mechanics. A benchmark that mixes list and realized prices in one metric creates false precision. The skill should output distinct `list_price_benchmark` and `realized_price_benchmark` views with leakage notes. + +Method quality depends on trust and governance, not just source volume. Model N's 2024 revenue survey reports broad external-data use with persistent confidence issues around internal data quality. Combined with SBI's execution/governance findings, this supports mandatory confidence scoring, provenance tagging, and owner-based implementation checkpoints. + +Contradictions matter and should be surfaced. Bain reports strong AI-enabled pricing upside in many teams, while Simon-Kucher reports many organizations still using low-maturity AI pricing methods with limited realized impact. The right methodological response is not to pick one narrative, but to lower confidence when operating maturity evidence is weak. + +**Sources:** +- SBI, *State of B2B SaaS Pricing in 2024* (2024): https://sbigrowth.com/tools-and-solutions/pricing-benchmarks-report-2024 +- SBI, *2025 State of SaaS Pricing Report Part 2* (2025): https://sbigrowth.com/tools-and-solutions/2025_state_of_saas_pricing_report_part2 +- BCG, *Overcoming Retail Complexity with AI-Powered Pricing* (2024): https://www.bcg.com/publications/2024/overcoming-retail-complexity-with-ai-powered-pricing +- Bain, *AI-Enhanced Pricing Can Boost Revenue Growth* (2024): https://www.bain.com/insights/ai-enhanced-pricing-can-boost-revenue-growth/ +- Bain, *Expanding Profit Margin Through Intelligent Pricing* (2025): https://www.bain.com/insights/expanding-profit-margin-through-intelligent-pricing-commercial-excellence-agenda-2025/ +- Simon-Kucher, *State of Pricing 2025* (2025 PDF): https://www.simon-kucher.com/sites/default/files/media-document/2025-05/Simon-Kucher_State%20of%20Pricing%202025.pdf +- Simon-Kucher, *Global Pricing Study 2025* (2025 PDF): https://www.simon-kucher.com/sites/default/files/media-document/2025-06/COR_GPS_2025_Brochure_Digital_Final.pdf +- Model N, *State of Revenue Report* (2024 PDF): https://www.modeln.com/wp-content/uploads/2024/02/fy24-model-n-state-of-revenue-report-final.pdf +- Vendr, *SaaS Trends Report 2025* (2025): https://www.vendr.com/insights/saas-trends-report-2025 +- UK CMA, *A short guide to unit pricing* (2024): https://competitionandmarkets.blog.gov.uk/2024/01/30/a-short-guide-to-unit-pricing/ +- UK Gov/CMA, *Unit pricing analysis and consumer research* (2024): https://www.gov.uk/government/publications/unit-pricing-analysis-and-consumer-research/summary-of-consumer-research-and-unit-pricing-analysis +- NZ Commerce Commission, *Unit pricing regulations guide* (2024 PDF): https://www.comcom.govt.nz/assets/pdf_file/0024/347154/Unit-pricing-regulations-a-guide-for-grocery-retailers-Guideline-March-2024.pdf +- Microsoft 365 Blog, *Flexible billing... annual subscriptions* (2024): https://techcommunity.microsoft.com/blog/microsoft_365blog/flexible-billing-for-microsoft-365-copilot-pricing-updates-for-annual-subscripti/4288536 +- U.S. BLS hedonic methods (evergreen reference): https://www.bls.gov/cpi/quality-adjustment/hedonic-price-adjustment-techniques.htm + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +The tool/API landscape is mature but split into source classes with different evidentiary value. The practical model in 2026 is layered: direct pricing-page capture (primary proof), review metadata (corroboration), context APIs (positioning context), and change/history tooling (auditability). No single class can prove "true payable market price" on its own. + +For direct capture, Firecrawl, Apify, Zyte, and Oxylabs all publish concrete operating constraints. Firecrawl documents credit consumption and plan-level concurrency; Apify publishes platform/API limits (including high global throughput and per-resource defaults); Zyte documents per-plan request ceilings; Oxylabs documents result-based billing and explicitly notes `429` responses are not billed. These are strong options for operational pipelines, but they still observe public list pages, not negotiated pricing outcomes. + +Review-site pricing metadata remains useful but should be labeled as metadata, not transactional truth. G2's packages/pricing docs confirm vendor-managed price/package structures in `my.G2`; Gartner Digital Markets properties often expose "starting at" and plan snippets. Coverage is uneven and some entries are missing or stale, so these sources should be treated as corroboration layers with explicit confidence weight. + +Context APIs are best used for explanatory signals around price moves. Similarweb, Semrush, DataForSEO, SerpAPI, and Trustpilot API docs all provide measurable limits or unit systems, making them operationally usable. However, they generally provide estimated traffic/search/review dynamics, not direct competitor payable-price evidence. + +Change detection and historical archives are underused and high value for benchmark traceability. Distill and Visualping support frequency-controlled monitoring with credit/check constraints; changedetection.io provides self-hosted watch + diff APIs; Wayback CDX and Common Crawl provide reconstruction paths when teams need temporal evidence for disputed claims. + +A durable source-priority stack for this skill is: +1) Official pricing pages with snapshots (`observed_list_price`). +2) Structured review metadata as corroboration (`review_site_metadata`). +3) Context overlays for interpretation (`web_intel_estimate`). +4) Version history and diffs for audit defense. +5) Realized-price benchmarks when licensed/available, separately labeled. + +**Sources:** +- Firecrawl pricing: https://firecrawl.dev/pricing +- Firecrawl billing docs: https://docs.firecrawl.dev/billing +- Firecrawl rate limits: https://docs.firecrawl.dev/rate-limits +- Apify pricing: https://apify.com/pricing +- Apify platform limits: https://docs.apify.com/platform/limits +- Apify API docs: https://docs.apify.com/api/v2 +- Zyte pricing: https://zyte.com/pricing +- Zyte rate limits: https://docs.zyte.com/zyte-api/usage/rate-limit.html +- Oxylabs pricing: https://oxylabs.io/pricing +- Oxylabs billing mechanics: https://developers.oxylabs.io/help-center/billing-and-payments/how-does-web-scraper-api-pricing-work +- G2 Packages/Pricing docs: https://documentation.g2.com/docs/packages-pricing +- Gartner Digital Markets about/reviews: https://www.gartner.com/en/digital-markets/about +- Software Advice methodology context: https://www.softwareadvice.com/resources/proprietary-data-research/ +- Similarweb credits model: https://docs.similarweb.com/api-v5/guides/data-credits-calculations.md +- Similarweb API rate limits: https://developers.similarweb.com/docs/rate-limit +- Semrush API access and units: https://developer.semrush.com/api/basics/how-to-get-api +- DataForSEO SERP API pricing: https://dataforseo.com/apis/serp-api/pricing +- DataForSEO limits: https://dataforseo.com/help-center/rate-limits-and-request-limits +- SerpAPI high-volume limits: https://serpapi.com/high-volume +- Trustpilot API rate limiting: https://developers.trustpilot.com/rate-limiting/ +- Distill pricing: https://distill.io/pricing +- Distill schedule checks: https://distill.io/docs/web-monitor/schedule-checks +- Visualping monitoring frequencies: https://help.visualping.io/en/articles/4439809 +- Visualping credits and rollover: https://help.visualping.io/en/articles/4440385 +- changedetection.io API: https://changedetection.io/docs/api_v1/index.html +- Wayback CDX API docs: https://raw.githubusercontent.com/internetarchive/wayback/master/wayback-cdx-server/README.md +- Common Crawl index: https://data.commoncrawl.org/crawl-data/index.html + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +Interpretation quality depends on measurement design more than source count. ABS 2024 methodology notes that monthly indicators often combine newly observed and carried-forward prices when coverage is incomplete. For this skill, asynchronous competitor refreshes can create timing artifacts that look like strategic price gaps unless freshness windows and carry-forward limits are explicit. + +Big-data scale does not automatically imply representativeness. Eurostat's 2024 HICP manual notes that scanner/web data can improve granularity and comparability, but still warns of uneven coverage and ill-defined target populations. In competitive pricing work, this maps directly to over-weighting highly visible public pages and under-weighting negotiated enterprise realities. + +Comparability must be a hard gate. ONS scanner-data transformation notes and Eurostat quality-adjustment guidance both show that method choice can change measured outcomes. Tier-name matching is therefore insufficient. This skill should require comparability scoring across value metric, feature scope, billing term, all-in fee basis, and geo/currency/tax basis before benchmark medians are treated as decision-grade. + +Staleness should expire automatically. Statistical-methodology references typically allow short carry-forward periods before requiring replacement. Operationally, this skill should mark observations non-actionable after two stale cycles, not keep carrying forward indefinitely. + +All-in and same-stage comparisons matter more under current regulatory scrutiny. FTC fee-transparency rules and CMA dynamic-pricing guidance both reinforce that headline/base prices are weak comparisons when mandatory fees or purchase-stage differences are ignored. Benchmark conclusions should be based on normalized all-in payable totals at matched stage. + +Outlier handling should be robust and auditable. NIST and public statistical methods both support flag-and-sensitivity approaches over silent deletion. Keep candidate outliers with flags, run with/without-outlier sensitivity views, and downgrade confidence when recommendation direction changes. + +Recommended operational thresholds for this skill: +- `<5 percentage-point gap`: non-actionable noise in most cases unless confidence is high and direction persists. +- `5-15 points`: directional signal only; require reversible or staged actions. +- `>15 points`: potentially actionable only when comparability passes, staleness is controlled, and cross-source disagreement is bounded. + +Confidence tiering should include disagreement controls: +- if trusted independent sources differ by `>10pp` on normalized gap, cap at medium confidence. +- if they differ by `>20pp` or opposite sign, force low confidence + `insufficient_evidence`. + +**Sources:** +- ABS Monthly CPI Indicator methodology (2024): https://www.abs.gov.au/methodologies/monthly-consumer-price-index-indicator-methodology/oct-2024 +- ABS CPI price-collection methods (2025): https://www.abs.gov.au/statistics/detailed-methodology-information/concepts-sources-methods/consumer-price-index-concepts-sources-and-methods/2025/price-collection +- ONS scanner-data impact analysis (2025): https://www.ons.gov.uk/economy/inflationandpriceindices/articles/impactanalysisontransformationofukconsumerpricestatisticsrailfaresandsecondhandcars/april2025 +- ONS introducing grocery scanner data (2025): https://www.ons.gov.uk/economy/inflationandpriceindices/methodologies/introducinggroceryscannerdataintoconsumerpricestatistics +- Eurostat HICP methodology page: https://ec.europa.eu/eurostat/statistics-explained/index.php/HICP_methodology +- Eurostat HICP methodological manual (2024 PDF): https://ec.europa.eu/eurostat/documents/3859598/18594110/KS-GQ-24-003-EN-N.pdf +- FTC fee rule FAQ (2025): https://www.ftc.gov/business-guidance/resources/rule-unfair-or-deceptive-fees-frequently-asked-questions +- FTC junk-fee rule announcement (2024): https://www.ftc.gov/news-events/news/press-releases/2024/12/federal-trade-commission-announces-bipartisan-rule-banning-junk-ticket-hotel-fees +- CMA dynamic pricing update (2025): https://www.gov.uk/government/publications/dynamic-pricing-project-update/update-dynamic-pricing +- NIST outlier detection (evergreen): https://www.itl.nist.gov/div898/handbook/eda/section3/eda35h.htm +- NIST MAD reference (2016, evergreen): https://www.itl.nist.gov/div898/software/dataplot/refman2/auxillar/mad.htm + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +1) **Variant and unit mismatch ("same product" illusion)** +What goes wrong: teams compare similarly named tiers/SKUs without strict variant or unit equivalence. +Detection: missing identifier integrity, missing unit normalization, and unstable rank order after normalization. +Consequence: false gap detection and avoidable repricing actions. +Mitigation: enforce strict comparability keys and unit normalization before any ranking. + +2) **Promo window contamination** +What goes wrong: sale prices enter baseline benchmark lanes because promo windows are missing or invalid. +Detection: sale flags without valid start/end timezone metadata; sharp short-lived median dips. +Consequence: benchmark "whiplash" and margin-destructive reactions. +Mitigation: separate regular and promo lanes, validate sale windows, and require persistence checks. + +3) **Illegal or misleading discount baseline assumptions** +What goes wrong: benchmark logic accepts "was/now" claims without reconstructing valid prior-price baselines. +Detection: claimed discount cannot be reproduced against auditable historical windows. +Consequence: compliance exposure and optimization against non-comparable promotional claims. +Mitigation: preserve prior-price history and compute discount comparisons from explicit baseline rules. + +4) **Headline-only comparisons (mandatory fees ignored)** +What goes wrong: base prices are compared while mandatory fees appear later in purchase flow. +Detection: measured gap flips direction after all-in checkout reconstruction. +Consequence: distorted positioning conclusions and negotiation errors. +Mitigation: benchmark minimum transactable all-in prices at matched purchase stage. + +5) **Tax/currency mode mismatch** +What goes wrong: tax-inclusive and tax-exclusive prices are mixed, or FX conversions are stale/opaque. +Detection: unexplained regional deltas that disappear after tax-mode/FX standardization. +Consequence: phantom competitiveness gaps and unstable geo strategies. +Mitigation: maintain explicit tax-mode fields, timestamped FX source metadata, and currency minor-unit validation. + +6) **Asynchronous stale snapshot joins** +What goes wrong: evidence captured at different times is treated as one coherent market view. +Detection: inconsistent `observed_at` windows and recurring mismatch alerts after source refreshes. +Consequence: phantom volatility and unnecessary tactical changes. +Mitigation: enforce coherence windows and stale-expiry rules before actionability. + +7) **Compliance-blind competitor following** +What goes wrong: recommendations mirror competitor motion without independent internal rationale. +Detection: recommendation text cites competitor movement as sole justification. +Consequence: heightened antitrust/governance risk. +Mitigation: require independent demand/cost/value rationale and legal escalation triggers. + +8) **Confidence-free output patterns** +What goes wrong: single-number rank output has no comparability grade, freshness status, or contradiction handling. +Detection: missing provenance/confidence metadata and no explicit non-actionable state. +Consequence: overconfident decisioning and weak audit defense. +Mitigation: require confidence tiers, contradiction reporting, and `insufficient_evidence` outputs when gates fail. + +**Sources:** +- CMA dynamic pricing update (2025): https://www.gov.uk/government/publications/dynamic-pricing-project-update/update-dynamic-pricing +- CMA tips for businesses using dynamic pricing (2025): https://www.gov.uk/government/publications/dynamic-pricing-tips-for-businesses/tips-for-businesses-using-dynamic-pricing +- FTC junk-fee rule announcement (2024): https://www.ftc.gov/news-events/news/press-releases/2024/12/federal-trade-commission-announces-bipartisan-rule-banning-junk-ticket-hotel-fees +- FTC fee rule FAQ (2025): https://www.ftc.gov/business-guidance/resources/rule-unfair-or-deceptive-fees-frequently-asked-questions +- FTC/DOJ algorithmic pricing statement of interest (2024): https://www.ftc.gov/news-events/news/press-releases/2024/03/ftc-doj-file-statement-interest-hotel-room-algorithmic-price-fixing-case +- CJEU Aldi Sud C-330/23 (2024): https://www.bailii.org/eu/cases/EUECJ/2024/C33023.html +- ACM warning against fake discounts (2024): https://www.acm.nl/en/publications/acm-warns-against-fake-discounts +- ACM fines for fake discounts (2024): https://www.acm.nl/en/publications/acm-has-fined-online-stores-using-fake-discounts +- Eurostat HICP methodology: https://ec.europa.eu/eurostat/statistics-explained/index.php/HICP_methodology +- Google Merchant sale-price effective date guidance: https://support.google.com/merchants/answer/6324460?hl=en +- Google Merchant mismatched price policy: https://support.google.com/merchants/answer/12159029?hl=en +- Shopify dynamic tax-inclusive pricing (accessed 2026): https://help.shopify.com/en/manual/markets/pricing/dynamic-tax-inclusive-pricing +- Stripe tax behavior docs (accessed 2026): https://docs.stripe.com/tax/products-prices-tax-codes-tax-behavior +- Stripe currency handling docs (accessed 2026): https://docs.stripe.com/currencies + +--- + +### Angle 5+: Legal, Compliance, and Responsible Use Guardrails +> Additional domain-specific angle: antitrust, privacy, and scraping/terms constraints for competitive pricing intelligence workflows. + +**Findings:** + +Antitrust enforcement has become explicit on algorithmic pricing behavior. FTC/DOJ filings state that using shared algorithms does not shield unlawful coordination. Direct competitor communication is not required for Section 1 exposure when conduct effectively aligns pricing through shared mechanisms. + +The DOJ RealPage case (allegations, ongoing) underscores non-public, competitively sensitive data exchange risk. Even without final adjudication, this is enough to require conservative design: no non-public competitor feeds, no cross-competitor shared recommendation loops, and documented independent decision rationale. + +EU horizontal guidelines treat commercially sensitive information exchange as high-risk and clarify that even "public data" usage can still create concerns if cadence/granularity or commentary drives coordinated outcomes. This implies this skill should produce aggregated trend insights, not near-real-time competitor-specific pricing directives. + +Cross-border enforcers (US/EU/UK) published a joint 2024 AI competition warning, signaling convergence in concern about algorithmic collusion and sensitive-data exchange. + +Scraping legality remains jurisdiction and fact dependent. US precedent (hiQ, evergreen) narrowed one CFAA pathway in specific circumstances, but other claims (contract, copyright, privacy, trespass) remain possible. A 2024 X v Bright Data order shows continuing litigation complexity. This skill should encode "authorized-access-only" rules and source ToS status fields. + +Privacy obligations can apply even to public web data when personal data is processed (EDPB Opinion 28/2024). Therefore, if personal data appears in source payloads, minimization and lawful-basis controls are mandatory. + +**Sources:** +- FTC/DOJ statement of interest in hotel algorithmic pricing case (2024): https://www.ftc.gov/news-events/news/press-releases/2024/03/ftc-doj-file-statement-interest-hotel-room-algorithmic-price-fixing-case +- FTC legal matter page (2024): https://www.ftc.gov/legal-library/browse/amicus-briefs/karen-cornish-adebiyi-et-al-v-caesars-entertainment-inc-et-al +- DOJ RealPage complaint filing (2024): https://www.justice.gov/atr/media/1365471/dl?inline +- DOJ RealPage case page (open 2024, updated 2026): https://www.justice.gov/atr/case/us-and-plaintiff-states-v-realpage-inc +- EU Horizontal Cooperation Guidelines (2023, evergreen): https://competition-policy.ec.europa.eu/system/files/2023-07/2023_revised_horizontal_guidelines_en.pdf +- US/EU/UK joint statement on AI competition issues (2024): https://www.ftc.gov/system/files/ftc_gov/pdf/ai-joint-statement.pdf +- FTC release on joint AI statement (2024): https://www.ftc.gov/news-events/news/press-releases/2024/07/ftc-doj-international-enforcers-issue-joint-statement-ai-competition-issues +- hiQ v LinkedIn (2022, evergreen): https://law.justia.com/cases/federal/appellate-courts/ca9/17-16783/17-16783-2022-04-18.html +- X v Bright Data order (2024): https://storage.courtlistener.com/recap/gov.uscourts.cand.415869/gov.uscourts.cand.415869.83.0.pdf +- EDPB Opinion 28/2024 page: https://www.edpb.europa.eu/our-work-tools/our-documents/opinion-board-art-64/opinion-282024-certain-data-protection-aspects_en +- EDPB Opinion 28/2024 PDF (adopted 2024-12-17): https://www.edpb.europa.eu/system/files/2024-12/edpb_opinion_202428_ai-models_en.pdf + +--- + +## Synthesis + +The strongest cross-angle conclusion remains that benchmark quality is dominated by methodology discipline, not source count. Public pricing pages are easy to collect, but interpretation fails quickly when teams skip comparability scoring, staleness controls, and list-vs-realized separation. This is why the skill should treat comparability as a gate, not a note. + +Tooling is mature enough for robust collection, but each source class answers a different question. Direct page capture proves what was publicly shown at a time; review-site metadata corroborates package structures; context APIs explain demand/visibility shifts; change-detection/history tools provide auditability. This source-priority model should be explicit in SKILL text and in schema truth labels. + +Several meaningful contradictions remain and should not be hidden: method changes can materially shift measured benchmark outcomes; AI-pricing impact claims differ across survey ecosystems; and discount benchmarks vary based on whether they capture list, transaction, or mixed constructs. The practical response is confidence-tiered outputs with disagreement thresholds, not single-point certainty. + +The biggest "surprise" is how close interpretation quality and compliance now sit. Consumer transparency enforcement, discount-baseline enforcement, and algorithmic pricing antitrust scrutiny all directly affect what counts as defensible benchmark output. Compliance-safe operation is no longer a separate appendix; it is a core design requirement for this skill. + +Net result: this skill should be treated as a decision-quality evaluator with explicit evidence classes, comparability gates, stale-expiry logic, and contradiction-aware confidence scoring. + +--- + +## Recommendations for SKILL.md + +> Concrete, actionable list of what should change / be added in the SKILL.md based on research. + +- [ ] Add a two-speed operating cadence: quarterly delta scans + annual full rebasing. +- [ ] Require separate outputs for `list_price_benchmark` and `realized_price_benchmark`. +- [ ] Add a source-priority policy with truth labels (`observed_list_price`, `review_site_metadata`, `web_intel_estimate`, `inferred_range`) and do not mix labels in benchmark math. +- [ ] Add strict normalization instructions (capability mapping, billing-term normalization, currency/tax normalization, usage scenarios). +- [ ] Add a comparability gate (5 dimensions) and stale-expiry rule (non-actionable after two stale cycles). +- [ ] Add confidence scoring (0-100) with decision tiers and an explicit `insufficient_evidence` state. +- [ ] Add anti-pattern warning blocks with detection signals and mitigation steps. +- [ ] Expand `## Available Tools` guidance with provenance classes, authorized-access constraints, rate-limit/backoff behavior, and uncertainty flags. +- [ ] Add legal/compliance guardrails (authorized data only, no coordination logic, independent rationale requirement). +- [ ] Extend artifact schema with evidence provenance, freshness metadata, confidence fields, and risk flags. +- [ ] Add contradiction-handling rule with thresholds: if trusted sources disagree by >10pp cap confidence at medium, >20pp force low + `insufficient_evidence`. + +--- + +## Draft Content for SKILL.md + +> This is the most important section. For every recommendation above, write the actual text that should go into SKILL.md. + +### Draft: Core operating model and cadence + +--- +### Benchmark cadence and operating discipline + +You must run this skill on a deliberate cadence, not only when a deal is at risk. Run a **quarterly delta benchmark** to detect meaningful market movement, and run an **annual full rebasing benchmark** to reset tier mapping, segment norms, and positioning assumptions. Do not treat one-off snapshots as strategic truth. + +Before any pricing verdict, verify three preconditions: +1. **Recency:** every source used in the final comparison has a known `observed_at` date and a defined `stale_after_days` threshold. +2. **Comparability:** all competitor tiers are normalized into your canonical capability buckets before price comparisons. +3. **Evidence class separation:** list prices and realized/transaction pricing are stored and interpreted separately. + +If any precondition is missing, output `insufficient_evidence` for strategic recommendations and restrict output to descriptive observations. + +Do NOT approve repricing recommendations from a single scrape run, a single source class, or unnormalized tier-name matching. +--- + +### Draft: Data collection and normalization workflow + +--- +### Data collection workflow + +You must collect benchmark evidence in layers, from highest provenance to lowest: + +1. **Official pricing pages (required):** + Collect each direct competitor's public pricing page and capture: model (`flat`, `per_seat`, `usage`, `hybrid`, `quote`), tier names, list prices, billing terms, key inclusions, and known exclusions. + +2. **Review-site corroboration (recommended):** + Use review-platform pricing sections (for example, vendor-managed pricing metadata) as corroboration. If a source says "not provided," record that explicitly rather than filling gaps with assumptions. + +3. **Context signals (optional but useful):** + Use traffic/engagement or intent signals only as context for positioning confidence. Never treat traffic-intelligence metrics as direct evidence of price competitiveness. + +4. **Realized-price signals (if available):** + Incorporate transaction, renewal, or procurement benchmark sources in a separate `realized_price_benchmark` view. Never merge realized values into list values without labeling. + +5. **Change evidence capture (recommended):** + Preserve page snapshots or versioned captures for all high-impact observations so later disputes can be resolved against evidence. + +### Normalization rules + +Before comparing any price, normalize across the following dimensions: + +- **Capability equivalence:** map each competitor tier into canonical buckets (for example: governance, automation, integrations, analytics, support, AI). Tier names are not equivalence. +- **Billing-term normalization:** convert all prices to effective monthly equivalent where possible: + - `effective_monthly = total_contract_value / term_months` +- **Annual-vs-monthly comparability:** record both annual prepay discount and monthly-on-annual premium when present. +- **Usage model scenarios:** for usage or hybrid plans, compute `low`, `base`, and `high` usage scenarios with explicit assumptions. +- **Currency and tax normalization:** compute normalized net benchmark values in a base currency; separately report customer-visible gross/all-in values. +- **All-in price reconstruction:** include mandatory fees before final comparison. A headline price without mandatory charges is incomplete. + +Assign comparability on five dimensions: `value_metric`, `feature_scope`, `billing_term`, `all_in_fee_basis`, and `geo_currency_tax_basis`. + +- `5/5` or `4/5`: treat as `comparable`. +- `3/5`: treat as `partially_comparable` and exclude from high-confidence recommendations. +- `0-2/5`: treat as `non_comparable` and exclude from category medians. + +If comparability cannot be established for a competitor tier, mark it `non_comparable` and exclude it from median calculations while keeping the row in the artifact. + +Staleness handling: +- if `source_age_days > stale_after_days`, mark row stale. +- if stale for `>2` consecutive cycles, force `is_actionable: false` for recommendations that depend on that row. +--- + +### Draft: Confidence scoring and decision thresholds + +--- +### Confidence scoring protocol + +You must assign a `confidence_score` from 0 to 100 for every recommendation-bearing benchmark output. Compute the score from weighted components: + +- `realized_price_evidence` (0-20): realized price evidence present and current. +- `comparability_quality` (0-20): feature-level normalization quality. +- `sample_depth` (0-15): adequate cross-competitor and cross-period coverage. +- `uncertainty_visibility` (0-15): ranges/percentiles/confidence notes provided. +- `cross_source_consistency` (0-15): independent sources align, or divergences are explicitly explained. +- `recency` (0-10): evidence is within freshness SLA. +- `method_transparency` (0-5): outlier handling and assumptions are documented. + +Assign decision tier: +- `80-100`: high confidence, recommendation can be action-oriented with guardrails. +- `60-79`: directional confidence only; require staged or reversible action. +- `<60`: low confidence; default to `insufficient_evidence`. + +Additional confidence gates: +- If independent trusted sources disagree by `>10pp`, cap confidence at `medium`. +- If independent trusted sources disagree by `>20pp` (or disagree on direction), force `low` + `insufficient_evidence`. +- If outlier sensitivity changes recommendation direction, force `low` until new evidence resolves the conflict. + +### Practical threshold interpretation + +Use conservative gap interpretation: +- `<5 percentage-point gap`: usually noise unless confidence is high and persistence is proven. +- `5-15 points`: directional signal; not enough for unilateral repricing without additional evidence. +- `>15 points`: potentially actionable if comparability and realized evidence are both strong. + +If major external sources disagree materially (for example, discount benchmarks with non-overlapping ranges), downgrade confidence and present range-based conclusions, not single-point verdicts. + +### Outlier and sensitivity policy + +Do not silently delete outliers. +Detect candidate outliers with robust methods (for example, MAD/modified-z style checks), label them, and run at least two views: with and without outliers. If recommendation direction changes between views, downgrade confidence at least one tier and include an uncertainty warning. +--- + +### Draft: Anti-pattern warning blocks + +```md +> [!WARNING] Anti-pattern: Snapshot-Only Benchmarking +> **What it looks like:** you benchmark from one crawl window and treat it as strategic truth. +> **Detection signal:** no rolling-window evidence and no freshness SLA fields. +> **Consequence:** reactive repricing driven by temporary promos or stale pages. +> **Mitigation:** require timestamped multi-period checks before recommendations. +``` + +```md +> [!WARNING] Anti-pattern: Apples-to-Oranges Tier Matching +> **What it looks like:** direct price comparison between tiers with different capabilities/terms. +> **Detection signal:** missing equivalence map or high `non_comparable` rate. +> **Consequence:** false gap analysis and poor negotiation posture. +> **Mitigation:** enforce capability-bucket normalization before any median or gap computation. +``` + +```md +> [!WARNING] Anti-pattern: Headline-Only Price Comparison +> **What it looks like:** comparing base/list prices while ignoring mandatory fees or billing premiums. +> **Detection signal:** gap reverses after all-in or term normalization. +> **Consequence:** incorrect "we must cut price" recommendations. +> **Mitigation:** compare all-in payable totals at matched purchase stage. +``` + +```md +> [!WARNING] Anti-pattern: Promo Whiplash +> **What it looks like:** matching competitor event promos by default. +> **Detection signal:** repeated emergency repricing around campaign windows with margin degradation. +> **Consequence:** self-inflicted margin compression and inconsistent positioning. +> **Mitigation:** isolate promo observations, apply duration filters, and require persistence checks. +``` + +```md +> [!WARNING] Anti-pattern: Compliance-Blind Competitor Following +> **What it looks like:** recommendations repeatedly mirror competitor moves without internal demand/cost rationale. +> **Detection signal:** suggestion text references competitor behavior as sole reason. +> **Consequence:** elevated antitrust and governance risk. +> **Mitigation:** require independent rationale and legal escalation triggers on sensitive patterns. +``` + +```md +> [!WARNING] Anti-pattern: Tax/FX Mode Mismatch +> **What it looks like:** mixing tax-inclusive and tax-exclusive prices or using stale FX conversions in one benchmark. +> **Detection signal:** cross-geo gaps collapse after tax and FX normalization. +> **Consequence:** phantom pricing gaps and unstable regional strategy. +> **Mitigation:** store tax mode and FX source/timestamp per row, enforce minor-unit checks, and block non-normalized comparisons. +``` + +```md +> [!WARNING] Anti-pattern: Promo Window Contamination +> **What it looks like:** sale prices overwrite baseline levels because promo start/end metadata is missing. +> **Detection signal:** sudden short-lived median drops without valid sale window fields. +> **Consequence:** margin-destructive "match the promo" decisions and misleading trend signals. +> **Mitigation:** separate regular vs promo lanes, validate sale window timestamps, and require persistence checks before action. +``` + +### Draft: Available Tools + +--- +## Available Tools + +Use only verified methods available in your environment. Do not invent method IDs or endpoints. If a method is unavailable at runtime, record a tool-availability gap instead of substituting guessed calls. + +```python +web(open=[{"url": "https://competitor.com/pricing"}]) + +g2( + op="call", + args={ + "method_id": "g2.products.list.v1", + "filter[name]": "competitor name", + }, +) + +similarweb( + op="call", + args={ + "method_id": "similarweb.traffic_and_engagement.get.v1", + "domain": "competitor.com", + }, +) + +flexus_policy_document( + op="activate", + args={"p": "/strategy/positioning-map"}, +) +``` + +Tool usage rules: +1. **`web` is primary for official pricing pages.** Always capture URL, retrieval timestamp, and raw snippet evidence. +2. **`g2` is corroboration, not sole truth.** Vendor coverage can be incomplete; record missing/undisclosed pricing explicitly. +3. **`similarweb` is context only.** Use for positioning context and demand signals, not direct price parity assertions. +4. **`flexus_policy_document` must be activated before final alignment verdicts.** Positioning-vs-price output is invalid without current strategy context. +5. **Authorized access only.** Do not bypass authentication, paywalls, robots controls, or anti-bot protections; skip and log blocked sources instead of forcing collection. +6. **Error handling is mandatory.** On missing methods, rate limits, or blocked pages, set uncertainty flags and continue with available evidence. +7. **Rate-limit backoff is required.** Handle `429` and provider throttling with exponential backoff and jitter; record partial-collection risk flags when retries are exhausted. +--- + +### Draft: Compliance and legal guardrails + +--- +### Competition, privacy, and data-access guardrails + +This skill is for competitive intelligence and strategic benchmarking, not coordinated pricing behavior. + +You must follow these constraints: +1. Use only data you are authorized to access (publicly available or licensed/contractually permitted). +2. Do not ingest non-public competitor pricing/strategy feeds. +3. Do not generate recommendations that imply coordinated behavior (for example, "mirror competitor X in real time"). +4. Require an independent rationale for every pricing recommendation (internal demand, cost, positioning, and value evidence). +5. If source payloads include personal data, apply minimization and lawful-basis requirements before storage/use. +6. Escalate for legal review when recommendations rely on high-frequency competitor-specific signals or ambiguous data-access permissions. + +Required disclaimer text in output metadata: +- "Competitive intelligence output; not legal advice." +- "Do not use this artifact to coordinate pricing or exchange commercially sensitive information with competitors." +- "Final pricing decisions must be independently made and reviewed for legal, policy, and fairness constraints." +--- + +### Draft: Output quality and insufficient-evidence rules + +--- +### Output quality standard + +Your output must be decision-grade, not just descriptive. + +Minimum quality requirements: +- At least 3 direct competitors in scope unless category constraints are documented. +- At least one official pricing-page source per competitor. +- Explicit source provenance tags for each key claim. +- Freshness metadata for each source. +- Comparability gate applied on 5 dimensions for every benchmarked tier. +- Separate list-price and realized-price views. +- Confidence score and confidence tier. +- Contradictions section when sources disagree. +- Persistence check: directional gap should hold for 2 consecutive refreshes before irreversible recommendations. + +If any requirement is missing, return: +- `is_actionable: false` +- `confidence_tier: "low"` +- `insufficient_evidence_reason: ""` + +Do not produce numeric negotiation targets when confidence is low. +--- + +### Draft: Schema additions + +> Full JSON Schema fragment for new/modified artifact fields. + +```json +{ + "pricing_competitive_benchmark": { + "type": "object", + "required": [ + "benchmarked_at", + "benchmark_window", + "category", + "competitors", + "category_norms", + "positioning_alignment", + "confidence" + ], + "additionalProperties": false, + "properties": { + "benchmarked_at": { + "type": "string", + "description": "ISO-8601 timestamp for when the benchmark artifact was produced." + }, + "benchmark_window": { + "type": "object", + "required": ["start_date", "end_date", "stale_after_days", "expired_after_cycles"], + "additionalProperties": false, + "properties": { + "start_date": { + "type": "string", + "description": "Inclusive start date for collected observations (YYYY-MM-DD)." + }, + "end_date": { + "type": "string", + "description": "Inclusive end date for collected observations (YYYY-MM-DD)." + }, + "stale_after_days": { + "type": "integer", + "description": "Maximum age in days before benchmark evidence is treated as stale." + }, + "expired_after_cycles": { + "type": "integer", + "description": "Maximum number of consecutive stale refresh cycles allowed before dependent outputs become non-actionable." + } + } + }, + "category": { + "type": "string", + "description": "Product category used for selecting peer competitors." + }, + "competitors": { + "type": "array", + "description": "Competitor-level benchmark entries with normalized pricing and evidence.", + "minItems": 3, + "items": { + "type": "object", + "required": [ + "name", + "pricing_model", + "tiers", + "data_freshness_days", + "source_records" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Competitor company or product name." + }, + "segment_fit": { + "type": "string", + "description": "How closely this competitor matches the target ICP segment." + }, + "pricing_model": { + "type": "string", + "enum": ["flat", "per_seat", "usage", "hybrid", "enterprise_quote", "unknown"], + "description": "Primary pricing model observed for the competitor." + }, + "tiers": { + "type": "array", + "description": "Normalized tier records for comparable plans.", + "items": { + "type": "object", + "required": [ + "tier_name", + "price_unit", + "currency", + "all_in_monthly_equivalent", + "comparability_dimensions_passed", + "comparability_status" + ], + "additionalProperties": false, + "properties": { + "tier_name": { + "type": "string", + "description": "Vendor-provided tier name." + }, + "list_price": { + "type": ["number", "null"], + "description": "Published list price for the tier, null when quote-only." + }, + "billing_term": { + "type": "string", + "enum": ["monthly", "annual_prepaid", "annual_monthly_billed", "usage", "quote_only", "unknown"], + "description": "Billing term associated with the observed price." + }, + "price_unit": { + "type": "string", + "description": "Value metric unit (e.g., per_user_per_month, per_1000_events)." + }, + "currency": { + "type": "string", + "description": "ISO currency code of observed price." + }, + "includes_tax": { + "type": "boolean", + "description": "Whether the observed displayed price includes tax." + }, + "mandatory_fees_monthly_equivalent": { + "type": ["number", "null"], + "description": "Monthly equivalent of mandatory fees needed for all-in comparability." + }, + "all_in_monthly_equivalent": { + "type": ["number", "null"], + "description": "Normalized monthly equivalent including mandatory fees where known." + }, + "usage_scenarios": { + "type": "object", + "required": ["low", "base", "high"], + "additionalProperties": false, + "properties": { + "low": { + "type": ["number", "null"], + "description": "Estimated monthly cost under low usage assumptions." + }, + "base": { + "type": ["number", "null"], + "description": "Estimated monthly cost under baseline usage assumptions." + }, + "high": { + "type": ["number", "null"], + "description": "Estimated monthly cost under high usage assumptions." + } + } + }, + "key_inclusions": { + "type": "array", + "description": "Major included capabilities used for comparability mapping.", + "items": {"type": "string"} + }, + "comparability_status": { + "type": "string", + "enum": ["comparable", "partially_comparable", "non_comparable"], + "description": "Comparability label after capability and billing normalization." + }, + "comparability_dimensions_passed": { + "type": "integer", + "description": "Count of passed comparability dimensions out of 5: value metric, feature scope, billing term, all-in fee basis, and geo/currency/tax basis." + }, + "comparability_notes": { + "type": "string", + "description": "Explanation of why plan is (or is not) comparable." + } + } + } + }, + "annual_discount": { + "type": ["number", "null"], + "description": "Computed annual-prepay discount vs monthly equivalent when available." + }, + "data_freshness_days": { + "type": "integer", + "description": "Age in days of the newest source observation used for this competitor." + }, + "source_records": { + "type": "array", + "description": "Evidence records supporting this competitor entry.", + "items": { + "type": "object", + "required": ["source_type", "source_name", "source_url", "observed_at"], + "additionalProperties": false, + "properties": { + "source_type": { + "type": "string", + "enum": [ + "official_pricing_page", + "review_site_vendor_submitted", + "review_site_user_submitted", + "web_intel_estimate", + "transaction_benchmark", + "analyst_report" + ], + "description": "Provenance class for confidence weighting." + }, + "source_name": { + "type": "string", + "description": "Human-readable source name." + }, + "source_url": { + "type": "string", + "description": "URL of source evidence." + }, + "observed_at": { + "type": "string", + "description": "ISO-8601 timestamp when this source was observed or retrieved." + }, + "retrieval_method": { + "type": "string", + "enum": ["web_open", "api_call", "manual_reference"], + "description": "How evidence was retrieved." + }, + "confidence_weight": { + "type": "number", + "description": "Relative weight from 0.0 to 1.0 assigned to this source." + } + } + } + }, + "risk_flags": { + "type": "array", + "description": "Known issues affecting interpretation quality.", + "items": { + "type": "string", + "enum": [ + "blocked_by_antibot", + "vendor_pricing_not_provided", + "list_price_only", + "promo_period", + "tax_or_currency_uncertain", + "non_comparable_plan", + "stale_source_window", + "missing_sale_window", + "asynchronous_snapshot" + ] + } + } + } + } + }, + "category_norms": { + "type": "object", + "required": [ + "median_entry_price", + "median_pro_price", + "dominant_model", + "dominant_value_metric", + "based_on_competitor_count" + ], + "additionalProperties": false, + "properties": { + "median_entry_price": { + "type": "number", + "description": "Median normalized all-in monthly equivalent for entry tiers." + }, + "median_pro_price": { + "type": "number", + "description": "Median normalized all-in monthly equivalent for pro tiers." + }, + "dominant_model": { + "type": "string", + "description": "Most common pricing model among comparable competitors." + }, + "dominant_value_metric": { + "type": "string", + "description": "Most common value metric in the category." + }, + "based_on_competitor_count": { + "type": "integer", + "description": "Number of competitors included in norm calculations." + }, + "normalization_notes": { + "type": "string", + "description": "Notes on adjustments and exclusions used in norm calculations." + } + } + }, + "positioning_alignment": { + "type": "object", + "required": ["expected_position", "actual_position", "is_aligned", "misalignment_notes"], + "additionalProperties": false, + "properties": { + "expected_position": { + "type": "string", + "description": "Intended market position from strategy." + }, + "actual_position": { + "type": "string", + "description": "Observed position from normalized benchmark outputs." + }, + "is_aligned": { + "type": "boolean", + "description": "Whether observed pricing supports intended positioning." + }, + "misalignment_notes": { + "type": "string", + "description": "Why positioning and pricing are misaligned, if applicable." + } + } + }, + "confidence": { + "type": "object", + "required": ["confidence_score", "confidence_tier", "is_actionable"], + "additionalProperties": false, + "properties": { + "confidence_score": { + "type": "number", + "description": "Composite score from 0 to 100 across evidence quality dimensions." + }, + "confidence_tier": { + "type": "string", + "enum": ["high", "medium", "low"], + "description": "Tier label mapped from confidence score." + }, + "is_actionable": { + "type": "boolean", + "description": "Whether evidence quality is sufficient for tactical recommendations." + }, + "insufficient_evidence_reason": { + "type": ["string", "null"], + "description": "Specific reason for low confidence or non-actionable status." + }, + "benchmark_disagreement_pp": { + "type": ["number", "null"], + "description": "Absolute percentage-point disagreement between trusted independent benchmark sources on the normalized gap." + }, + "contradictions_noted": { + "type": "array", + "description": "Material source disagreements that affect interpretation.", + "items": {"type": "string"} + } + } + }, + "gaps_and_uncertainties": { + "type": "array", + "description": "Known data gaps and unresolved uncertainties for this benchmark run.", + "items": {"type": "string"} + } + } + } +} +``` + +--- + +## Gaps & Uncertainties + +- Public, source-verified realized-price data is still limited; most realized benchmarks come from vendor reports and may use non-identical definitions. +- Some source pages (especially review platforms) may block automated retrieval depending on region/fingerprint; availability can vary by runtime environment. +- Tool pricing and rate limits change frequently; operational implementations should re-verify current limits before production use. +- Antitrust/privacy guidance is jurisdiction- and fact-specific; this research is operational guidance, not legal advice. +- No universal cross-category threshold exists for "actionable gap"; confidence tiers and context-specific ranges remain necessary. diff --git a/flexus_simple_bots/strategist/skills/_pricing-competitive-benchmark/SKILL.md b/flexus_simple_bots/strategist/skills/_pricing-competitive-benchmark/SKILL.md new file mode 100644 index 00000000..f3136760 --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_pricing-competitive-benchmark/SKILL.md @@ -0,0 +1,57 @@ +--- +name: pricing-competitive-benchmark +description: Competitive pricing benchmark — document competitor pricing structures, identify pricing gaps, and validate positioning vs. price +--- + +You benchmark your pricing against competitors to identify positioning vs. price gaps, avoid underpricing relative to inferior alternatives, and support enterprise pricing negotiations. + +Core mode: competitor pricing pages change frequently. Treat any benchmark as a point-in-time snapshot. Always note when the data was collected. Pricing that differs significantly from category norm requires explicit justification. + +## Methodology + +### Competitor pricing data collection +Sources: +- Competitor pricing pages (directly scraped via web) +- G2 Pricing tab (often contains community-validated pricing with tier structures) +- Capterra pricing section +- Trustpilot and reddit where users discuss pricing complaints + +For each competitor, document: +- Pricing model (flat, per-seat, consumption) +- Tier names and price points (or "pricing on request" if enterprise) +- What's included at each tier +- Discount structure (annual discount %, enterprise negotiation signals) + +### Category norm analysis +Establish the category norm: +- Median price for comparable tier across 3-5 direct competitors +- Common pricing model in the category +- Common value metric in the category + +Deviations from category norm require justification: +- Premium over norm: "We charge more because X" (must be provable) +- Discount vs. norm: "We're cheaper because Y" (must be sustainable) + +### Positioning-price alignment check +The price signal must be consistent with positioning: +- If positioned as "premium for enterprise" → must be priced above median +- If positioned as "best value for SMB" → must be priced below category median for relevant tier +- Mismatch between positioning and price = confused buyer perception + +## Recording + +``` +write_artifact(path="/strategy/pricing-benchmark", data={...}) +``` + +## Available Tools + +``` +web(open=[{"url": "https://competitor.com/pricing"}]) + +g2(op="call", args={"method_id": "g2.products.list.v1", "filter[name]": "competitor name"}) + +similarweb(op="call", args={"method_id": "similarweb.traffic_and_engagement.get.v1", "domain": "competitor.com"}) + +flexus_policy_document(op="activate", args={"p": "/strategy/positioning-map"}) +``` diff --git a/flexus_simple_bots/strategist/skills/_pricing-tier-design/RESEARCH.md b/flexus_simple_bots/strategist/skills/_pricing-tier-design/RESEARCH.md new file mode 100644 index 00000000..3ea795e8 --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_pricing-tier-design/RESEARCH.md @@ -0,0 +1,800 @@ +# Research: pricing-tier-design + +**Skill path:** `flexus_simple_bots/strategist/skills/pricing-tier-design/` +**Bot:** strategist +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`pricing-tier-design` defines concrete price points, tier names, feature fences, upgrade triggers, and upsell architecture after upstream strategy work is done. It consumes the pricing model choice, offer design decisions, and willingness-to-pay evidence, then produces a structured `pricing_tier_structure` artifact. + +The skill currently has a strong WTP-first stance and practical baseline guidance (PSM anchoring, feature fences, and trigger mapping). This research expands it into a more complete 2024-2026-ready operating method: multi-method evidence stacking (not PSM-only), explicit confidence and migration governance, AI-era hybrid monetization guardrails, and stricter anti-pattern detection that can be encoded into schema and generation checks. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024-2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- No generic filler without concrete backing: **passed** +- No invented tool names, method IDs, or API endpoints: **passed** +- Contradictions between sources explicitly noted: **passed** +- Volume target met (Findings sections 800-4000 words): **passed** + +--- + +## Research Angles + +Each angle should be researched by a separate sub-agent. Add more angles if the domain requires it. + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +- Pricing teams are increasingly designing a full monetization system, not isolated price points. Reforge's 2025 framing (`Scale`, `What`, `Amount`, `When`) is useful because it forces value metric, package structure, and payment cadence to be decided together instead of independently. +- For B2B SaaS, 3-4 plans remains a common baseline and Good-Better-Best remains prevalent in benchmark data, making it a reasonable default prior unless segment evidence justifies a different architecture. +- Pricing process cadence has shifted to recurring operations: 2024-2025 benchmark studies show most teams update at least annually and a large share updates quarterly, which implies this skill should output review cadence and ownership metadata, not one-off recommendations. +- In AI-influenced SaaS pricing, hybrid models (seat + usage or seat + AI meter) are increasing faster than pure usage/outcome models. Multiple 2024-2025 strategy sources describe transition to hybrid as the practical near-term path. +- Separately priced AI add-ons can under-convert in many contexts (reported low attach rates in 2025 survey data), so defaulting to separate AI add-ons without explicit segment/cost evidence is risky. +- Feature packaging quality improves when features are role-tagged by value contribution and willingness-to-pay impact (for example, leader/filler/add-on style role mapping), then distributed intentionally across tiers to create valid upgrade pathways. +- Usage meter selection guidance has converged: meter must map to customer-perceived value, remain explainable to non-technical buyers, and preserve spend predictability with included usage, alerts, and guardrails. +- Migration is now a first-class methodology step. Current billing docs and public pricing change announcements show that versioned plans, staged migration rules, and grandfathering/renewal policies are necessary to avoid churn and support load during tier redesign. +- WTP evidence should be triangulated. 2024 IJRM evidence supports closed direct WTP prompts over open-ended prompts in some innovation contexts, while meta-analysis evidence (older but still relevant) reinforces hypothetical bias risk if no behavioral validation is done. +- PSM remains useful for initial range-finding, especially in earlier-stage contexts, but practical guidance favors conjoint/choice methods plus behavioral experiments for final pricing decisions and trade-off resolution. + +**Sources:** +- [How to Price Your AI Product or Feature (Reforge)](https://www.reforge.com/blog/how-to-price-your-ai-product) - 2025 +- [Per-Seat Software Pricing Isn't Dead, but New Models Are Gaining Steam (Bain)](https://www.bain.com/insights/per-seat-software-pricing-isnt-dead-but-new-models-are-gaining-steam/) - 2025 +- [AI-Enhanced Pricing Can Boost Revenue Growth (Bain)](https://www.bain.com/insights/ai-enhanced-pricing-can-boost-revenue-growth/) - 2024 +- [2024 SaaS Benchmarks (High Alpha + OpenView)](https://highalpha.com/saas-benchmarks/2024) - 2024 +- [State of B2B SaaS Pricing 2024 (SBI)](https://sbigrowth.com/hubfs/1-Research%20Reports/11.2024%20State%20of%20B2B%20SaaS%20Pricing%20Price%20Intelligently/SBI_StateofB2BSaaSPricing2024.pdf) - 2024 +- [2025 State of SaaS Pricing (SBI)](https://sbigrowth.com/tools-and-solutions/2025_state_of_saas_pricing_report) - 2025 +- [How to Monetize Generative AI Features in SaaS (Simon-Kucher)](https://www.simon-kucher.com/en/insights/how-monetize-generative-ai-features-saas) - 2024 +- [Best Practices for Generative AI Packaging and Pricing (Simon-Kucher)](https://www.simon-kucher.com/en/insights/best-practices-generative-ai-packaging-and-pricing) - 2024 +- [Hitting the Bullseye: Measuring WTP for Innovations (IJRM)](https://ideas.repec.org/a/eee/ijrema/v41y2024i2p383-402.html) - 2024 +- [Accurately Measuring WTP: Hypothetical Bias Meta-Analysis (JAMS)](https://research.rug.nl/en/publications/accurately-measuring-willingness-to-pay-for-consumer-goods-a-meta) - 2020 (**evergreen**) +- [Van Westendorp Pricing Model (Sawtooth)](https://sawtoothsoftware.com/resources/blog/posts/van-westendorp-pricing-sensitivity-meter) - undated vendor methodology doc, accessed 2026 (**evergreen**) + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +- Stripe Billing is a strong API-native option for implementing tier catalogs and entitlement-aware packaging. Public docs cover pricing, limits, and operational constraints that directly affect pricing architecture rollouts. +- Stripe's 2025 changelog indicates migration away from legacy usage-billing patterns (meter attachment requirements and removed legacy fields), which matters if historical tier logic depended on deprecated usage primitives. +- Paddle remains a practical merchant-of-record option where tax/compliance burden reduction is more important than maximum billing API flexibility. +- Paddle publishes concrete API rate limits and explicit subscription-update caps for immediate chargeable updates; these constraints should inform any auto-upgrade/seat-mutation logic in downstream execution workflows. +- Chargebee provides broad catalog model support (flat/per-unit/tiered/volume/stairstep/package) and publishes rate/concurrency limits, but public pricing transparency is less direct than Stripe/Paddle and often requires sales confirmation. +- PostHog provides experimentation and feature-flag infrastructure with usage-based billing mechanics for flag checks. This is useful for paywall and packaging experiments but requires cost guardrails due to request-based billing behavior. +- Statsig provides modern experimentation capabilities (including higher-tier statistical methods), but documentation and pricing pages can differ on free-tier quotas; any hardcoded quota assumptions should be validated at implementation time. +- Apify, Bright Data, and SerpApi provide competitor-pricing and market collection options with public throughput/pricing docs. They differ meaningfully in cost shape, legal/compliance posture, and operational constraints. +- Similarweb APIs add market-context signals (traffic/channel trends) but with credit and rate constraints; these are directional context signals, not direct willingness-to-pay evidence. +- A practical stack split emerges: (1) billing execution platform, (2) experimentation/in-product telemetry platform, (3) external competitive/market data platform. Mixing these concerns in one tool leads to blind spots. + +**Sources:** +- [Stripe Billing Pricing](https://stripe.com/billing/pricing) - accessed 2026 +- [Stripe Rate Limits](https://docs.stripe.com/rate-limits) - accessed 2026 +- [Stripe Billing APIs](https://docs.stripe.com/billing/billing-apis) - accessed 2026 +- [Stripe Changelog: Removes Legacy Usage-Based Billing](https://docs.stripe.com/changelog/basil/2025-03-31/deprecate-legacy-usage-based-billing) - 2025 +- [Paddle Pricing](https://www.paddle.com/pricing) - accessed 2026 +- [Paddle API Rate Limiting](https://developer.paddle.com/api-reference/about/rate-limiting) - accessed 2026 +- [Paddle Changelog: Preview Rate Limits](https://developer.paddle.com/changelog/2026/rate-limits-preview-prices-transactions) - 2026 +- [Chargebee API Limits](https://www.chargebee.com/docs/2.0/site-configuration/articles-and-faq/what-are-the-chargebee-api-limits.html) - accessed 2026 +- [Chargebee API Error Handling and Limits](https://apidocs.chargebee.com/docs/api/error-handling) - accessed 2026 +- [Chargebee Plans Catalog Docs](https://www.chargebee.com/docs/billing/2.0/product-catalog/plans) - accessed 2026 +- [PostHog Pricing Philosophy](https://posthog.com/pricing/philosophy) - accessed 2026 +- [PostHog Feature Flag Cost Controls](https://posthog.com/docs/feature-flags/cutting-costs) - accessed 2026 +- [Statsig Pricing](https://www.statsig.com/pricing) - accessed 2026 +- [Statsig Experimentation Program Guide](https://docs.statsig.com/guides/experimentation-program) - accessed 2026 +- [Apify Pricing](https://apify.com/pricing) - accessed 2026 +- [Apify Platform Limits](https://docs.apify.com/platform/limits) - accessed 2026 +- [Bright Data Web Scraper Pricing](https://brightdata.com/pricing/web-scraper) - accessed 2026 +- [Bright Data Scraper Overview](https://docs.brightdata.com/datasets/scrapers/scrapers-library/overview) - accessed 2026 +- [SerpApi Pricing](https://serpapi.com/pricing) - accessed 2026 +- [SerpApi Google Shopping API](https://serpapi.com/google-shopping-api) - accessed 2026 +- [Similarweb Rate Limits](https://developers.similarweb.com/docs/rate-limit) - accessed 2026 + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +- PSM should be interpreted as an early boundary signal, not final pricing truth. Vendor methodology guidance explicitly cautions against treating line-crossing outputs as behavior-equivalent decision endpoints. +- PSM data quality checks matter: logical response validation and segment filtering reduce noisy/inconsistent responses; without these gates, "optimal" points can be artifacts of invalid input. +- Practical minimum sample guidance differs by method and source, but conjoint/CBC guidance repeatedly emphasizes level-appearance sufficiency and subgroup adequacy, not just one total sample count. +- Experiment platforms use different defaults (for example, confidence levels and minimum sample/conversion floors). If teams compare results across tools without normalizing these defaults, "winner" labels become inconsistent. +- Minimum duration should combine business-cycle coverage and statistical power. Multiple practitioner sources warn that fixed 7-day minimums alone are insufficient for pricing tests. +- Frequent peeking without sequential methods can dramatically inflate false positives; sequential frameworks or pre-declared stop rules should be mandatory when continuous monitoring is used. +- Sample Ratio Mismatch (SRM) should be treated as a hard validity gate, not a warning. If SRM is present, effect estimates can be invalid regardless of apparent lift. +- Segment mining after the fact increases false discovery risk. Segment slices used for decisioning should be pre-registered whenever possible. +- Pricing decision quality improves when conversion is interpreted together with revenue, expansion, and retention signals, not conversion alone. +- Retention interpretation should include both NRR and GRR and cohort definition matching. Benchmarks can diverge widely by segment definition, ACV bucket, B2B/B2C mix, and time windows. + +**Sources:** +- [Van Westendorp Pricing Model (Sawtooth)](https://sawtoothsoftware.com/resources/blog/posts/van-westendorp-pricing-sensitivity-meter) - accessed 2026 (**evergreen**) +- [SurveyMonkey Van Westendorp Guidance](https://help.surveymonkey.com/en/surveymonkey/solutions/van-westendorp/) - accessed 2026 +- [Conjointly Sample Size Guidance](https://conjointly.com/faq/guidance-on-sample-size) - accessed 2026 +- [Sawtooth CBC Sample Size Rule of Thumb](https://sawtoothsoftware.com/resources/blog/posts/sample-size-rules-of-thumb) - accessed 2026 (**evergreen**) +- [Optimizely Statistical Significance](https://support.optimizely.com/hc/en-us/articles/4410284003341-Statistical-significance) - accessed 2026 +- [Optimizely Test Duration Guidance](https://support.optimizely.com/hc/en-us/articles/4410283969165-How-long-to-run-an-experiment) - accessed 2026 +- [Amplitude Statistical Significance FAQ](https://amplitude.com/docs/faq/statistical-significance) - 2024 +- [Amplitude Statistical Preferences](https://amplitude.com/docs/feature-experiment/workflow/finalize-statistical-preferences) - 2024 +- [Statsig Sequential Testing Perspective](https://www.statsig.com/perspectives/sequential-testing-ab-peek) - 2025 +- [Statsig Monitor Docs](https://docs-legacy.statsig.com/experiments-plus/monitor/) - accessed 2026 +- [Microsoft ExP on SRM](https://www.microsoft.com/en-us/research/group/experimentation-platform-exp/articles/diagnosing-sample-ratio-mismatch-in-a-b-testing/) - 2019 (**evergreen**) +- [Stripe Pricing Experiments Guide](https://stripe.com/resources/more/pricing-experiments) - 2024 +- [SaaS Capital Retention Benchmarks 2025](https://www.saas-capital.com/research/saas-retention-benchmarks-for-private-b2b-companies/) - 2025 +- [ChartMogul SaaS Retention 2025](https://chartmogul.com/reports/saas-retention-the-ai-churn-wave/) - 2025 +- [ChartMogul Retention Report](https://chartmogul.com/reports/saas-retention-report/) - 2023 (**evergreen**) + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +- **Anti-pattern: Tier sprawl.** + - Detection signal: 5+ self-serve tiers with overlapping features and no clear persona mapping. + - Consequence: Buyer confusion, slower decision cycles, lower conversion. + - Mitigation: Cap self-serve tiers by default and require explicit rationale for exceptions. +- **Anti-pattern: Ambiguous names and ordering.** + - Detection signal: Plan names that do not encode buyer type or maturity and trigger frequent "which plan is for me?" questions. + - Consequence: Mis-selection and conversion leakage into sales-assisted channels. + - Mitigation: Require each tier to include target persona and explicit "not for" guidance. +- **Anti-pattern: Weak adjacent fences.** + - Detection signal: Tier differences are mostly cosmetic; users consume high value while staying in low tier. + - Consequence: Monetization leakage and repeated packaging churn. + - Mitigation: Require capability + usage + commercial fence deltas for every adjacent tier pair. +- **Anti-pattern: Repricing shock without migration.** + - Detection signal: Large one-step increases and no grandfathering/cutover policy. + - Consequence: Churn spikes, support burden, trust damage. + - Mitigation: Add staged migration plan with renewal-based cutover and communication checkpoints. +- **Anti-pattern: Overuse of "Contact Sales" for non-enterprise tiers.** + - Detection signal: No public numeric anchors for self-serve offers. + - Consequence: Early funnel friction and delayed buyer qualification. + - Mitigation: Require list price or at least starting range for non-enterprise tiers. +- **Anti-pattern: Seat-only metric for usage-heavy AI value delivery.** + - Detection signal: Value/COGS scale with usage while seat count remains flat. + - Consequence: Margin compression and ACV stagnation despite product adoption. + - Mitigation: Use hybrid base + usage or outcome-linked components where value metric fit is proven. +- **Anti-pattern: Metered pricing without spend safety.** + - Detection signal: No alert thresholds, no hard/soft caps, overage discovered only at invoice. + - Consequence: Bill shock disputes and renewal risk. + - Mitigation: Encode alert policy, cap policy, and committed usage option in pricing design. +- **Anti-pattern: Repackaging without entitlement migration mapping.** + - Detection signal: Sales/support cannot answer "who moves when to what price/entitlement." + - Consequence: Inconsistent exceptions, account confusion, failed renewals. + - Mitigation: Require machine-readable old->new mapping and timeline in artifact. + +**Sources:** +- [State of B2B SaaS Pricing Benchmarks 2024 (SBI)](https://sbigrowth.com/tools-and-solutions/pricing-benchmarks-report-2024) - 2024 +- [Tracking 443 SaaS Pricing Pages (Growth Unhinged)](https://www.growthunhinged.com/p/state-of-saas-pricing-changes-2024) - 2024 +- [G2 Pricing Transparency Analysis](https://learn.g2.com/software-pricing-transparency?hsLang=en) - 2025 +- [SaaS Trends Report Q1 2024 (Vendr)](https://www.vendr.com/insights/saas-trends-report-2024-q1) - 2024 +- [BVP AI Pricing and Monetization Playbook](https://www.bvp.com/atlas/the-ai-pricing-and-monetization-playbook) - 2025 +- [Clouded Judgement Seat-Based Pricing Analysis](https://cloudedjudgement.substack.com/p/clouded-judgement-61424-is-seat-based) - 2024 +- [Stripe AI Pricing Framework](https://stripe.com/blog/a-framework-for-pricing-ai-products) - 2025 +- [Zendesk AI Automated Resolutions Pricing Model](https://support.zendesk.com/hc/en-us/articles/5352026794010-About-automated-resolutions-for-AI-agents) - accessed 2026 +- [Slack 2025 Pricing and Packaging Announcement](https://slack.com/blog/news/june-2025-pricing-and-packaging-announcement) - 2025 +- [Slack 2025 Plan Availability and Pricing Update](https://slack.com/help/articles/39264531104275-Updates-to-feature-availability-and-pricing-for-Slack-plans) - 2025 + +--- + +### Angle 5: Pricing Benchmarks, Contracting, and Transparency Operations +> Additional domain-specific angle: practical commercial design patterns that influence tier decisions (term length, discounting, transparency, and rollout policy). + +**Findings:** + +- Monthly + annual availability remains the practical baseline for self-serve and mid-market motions; forcing long-term commitments too early can slow conversion and increase negotiation friction. +- Multi-year commitments still appear in enterprise motions, but public 2024-2025 commercial analyses suggest discounts should be tied to value/volume certainty, not term length alone. +- Pricing transparency remains low in many B2B categories, but strong evidence from buyer-side analysis shows transparency improves early qualification and trust for non-enterprise tiers. +- Publicly communicated migration mechanics (renewal timing, grandfathering, entitlement changes) are now observable best practice in major pricing-package updates and should be required in artifact output. +- Contract-term strategy should be modeled as risk management: longer terms improve revenue predictability but can reduce repricing agility; this trade-off should be explicit in recommendations. +- This angle reinforces that tier architecture is not complete until commercial policy details are specified (term options, annual discount band rationale, migration and communication rules). + +**Sources:** +- [SaaS Trends Report Q1 2024 (Vendr)](https://www.vendr.com/insights/saas-trends-report-2024-q1) - 2024 +- [Recurly 2025 Industry Report Press Release](https://recurly.com/press/retention-tops-trends-in-recurlys-2025-industry-report/) - 2025 +- [Recurly 2025 Subscription Trends Sneak Peek](https://recurly.com/blog/2025-state-of-subscriptions-sneak-peek/) - 2025 +- [G2 Pricing Transparency](https://learn.g2.com/software-pricing-transparency?hsLang=en) - 2025 +- [Mostly Metrics Multi-Year Deal Analysis](https://www.mostlymetrics.com/p/your-guide-to-negotiating-multi-year-deals) - 2025 +- [Paddle/ProfitWell Contract Length Study](https://www.paddle.com/studios/shows/profitwell-report/contract-length) - 2019 (**evergreen**) +- [Slack 2025 Packaging Update](https://slack.com/blog/news/june-2025-pricing-and-packaging-announcement) - 2025 + +--- + +## Synthesis + +The strongest cross-source conclusion is that pricing-tier design is now an operating system problem, not a one-off arithmetic exercise. Teams that perform well are continuously iterating packaging and pricing at a governance cadence, and they explicitly connect value metric choice, feature fences, price levels, and migration plans. This aligns with both practitioner frameworks and current SaaS benchmark narratives. + +Second, WTP anchoring is necessary but insufficient by itself. The evidence supports keeping PSM for early boundary framing, but major decisions should be triangulated with conjoint/choice evidence and behavioral test data before finalizing tier price points. The key practical implication for this skill is to produce confidence scores and evidence provenance fields, rather than outputting deterministic prices with no uncertainty metadata. + +Third, tool landscape findings show that implementation constraints are no longer optional detail. Billing platform limits, meter changes, and experimentation defaults can directly invalidate a theoretically sound pricing design if ignored. A "best" tier design on paper is operationally weak unless it includes plan versioning/migration rules, spend guardrails for usage components, and experiment interpretation standards. + +Finally, anti-pattern research shows repeated failure signatures: too many tiers, weak fences, ambiguous plan names, hidden pricing overuse, and repricing without migration logic. These are detectable and can be encoded as schema guardrails. The most actionable upgrade is to make these anti-pattern checks explicit reject conditions in the skill's method and artifact requirements. + +--- + +## Recommendations for SKILL.md + +> Concrete, actionable list of what should change / be added in the SKILL.md based on research. +> Each item here has a corresponding draft in the section below. + +- [x] Replace current methodology with an explicit 8-step workflow that includes migration and governance outputs. +- [x] Add a tier-count and architecture prior (default 3-4 tiers, Good-Better-Best baseline, exception justification required). +- [x] Add a WTP evidence stack policy: PSM for boundaries, closed direct WTP + conjoint + behavioral validation for final pricing. +- [x] Add confidence scoring and contradiction handling rules for pricing recommendations. +- [x] Add feature-fence strength tests for each adjacent tier pair (capability + usage + commercial fence minimum). +- [x] Add experiment interpretation rules (confidence normalization, SRM hard stop, minimum sample/conversion floors, duration rules). +- [x] Add usage safety requirements for any metered/overage model (alerts, caps, committed option). +- [x] Add migration/rollout requirements (grandfathering, renewal cutover logic, entitlement map, comms checkpoints). +- [x] Update `## Available Tools` guidance with explicit internal tool sequence and evidence checks. +- [x] Expand artifact schema with governance, evidence, confidence, migration, and guardrail fields. + +--- + +## Draft Content for SKILL.md + +> This is the most important section. For every recommendation above, write the actual text that should go into SKILL.md. + +### Draft: Methodology overhaul (8-step workflow) + +--- +### Methodology + +You design pricing tiers as a system, not as isolated prices. Before setting any number, you must lock four decisions in order: value scale (what grows customer value), package structure (what each tier includes/excludes), amount (price levels), and payment timing (monthly/annual/other cadence). If these are decided independently, your output can look coherent while still producing weak upgrade motion and poor revenue quality. + +Use this mandatory 8-step sequence: + +1. **Load upstream strategy artifacts and declare assumptions.** + Activate `/strategy/pricing-model` and `/strategy/offer-design`, then list WTP evidence under `/pain/`. Start by writing explicit assumptions: ICP scope, deployment model, and whether AI/usage costs are material. If one of these assumptions is unknown, mark it as unresolved and lower confidence in final recommendations. Do not proceed with silent assumptions. + +2. **Define segment map and buyer jobs.** + For each target segment, define the primary job-to-be-done, budget authority pattern, and expected maturity level. Your tiers must map to segment progression, not internal org chart labels. If two segments have different value drivers but are forced into one tier path, you create avoidable leakage and churn risk. + +3. **Select monetization architecture and value metric.** + Start with a default architecture of 3 self-serve/mid-market tiers plus an enterprise path. Deviate only with explicit evidence. If customer value and cost-to-serve both scale with usage (common in AI-heavy workflows), use hybrid monetization (base fee plus usage/meter) unless evidence strongly supports another model. If value is stable and seat count tracks value, seat-led structure can remain primary. + +4. **Build feature-role map before assigning tiers.** + Classify candidate features by monetization role: core adoption drivers, differentiation drivers, and premium controls. Then distribute features so each adjacent tier pair has meaningful progression. Do not build tiers by copying full product capability into top tier and trimming randomly; that pattern creates weak fences and poor self-selection. + +5. **Design fences with explicit strength checks.** + Every adjacent tier transition must include: (a) one capability fence, (b) one usage fence, and (c) one commercial fence. Capability fence example: advanced governance/security controls. Usage fence example: included monthly processing volume. Commercial fence example: SLA/support entitlements. If any adjacent pair lacks this trio, revise before pricing. + +6. **Derive price levels from evidence stack, not single method.** + Use PSM and direct WTP to define a defensible range, then refine with conjoint/choice evidence and behavioral test plans. PSM is boundary input, not final truth. For final price recommendation, include an evidence score that states whether behavior-based validation exists. If no behavioral signal exists yet, output a "provisional" price band and required validation experiment. + +7. **Define upgrade triggers and buyer timeline.** + Map each trigger to observable behavior and expected timing: usage threshold reached, collaborator invited, governance requirement activated, or reporting/export demand. Each trigger must map from source tier to destination tier and include expected timeline assumptions. If trigger timing is unknown, specify what telemetry is needed to validate. + +8. **Publish migration and governance plan with review cadence.** + Every tier recommendation must include migration treatment for existing customers, renewal cutover logic, and owner/review interval. Minimum cadence is annual; quarterly review is preferred in dynamic products. If migration plan is missing, the recommendation is incomplete and should not be finalized. +--- + +### Draft: Evidence policy and confidence scoring + +--- +### Price Point Derivation from WTP and Behavioral Signals + +You must use an evidence stack with three layers: + +1. **Boundary layer (stated preference):** PSM / direct WTP data to define floor/ceiling plausibility. +2. **Trade-off layer (choice modeling):** conjoint/CBC or equivalent to quantify willingness to trade features vs price. +3. **Behavior layer (observed behavior):** live experiment, pilot conversion, expansion, and retention signals. + +If you only have layer 1, you may propose a provisional range but not a finalized point estimate. If you have layers 1 and 2 but no behavior layer, mark confidence as medium and require test plan before rollout. Finalized recommendations require either behavioral validation or a documented operational reason for deferral. + +Use this confidence rubric: + +- **High:** multi-method evidence including behavioral validation in target segment. +- **Medium:** multi-method stated-preference evidence with quality controls but no behavioral confirmation yet. +- **Low:** single-method stated preference or low-quality/contradictory data. + +When sources conflict, do not silently average. Write a contradiction note and choose the conservative interpretation for rollout while defining what data would resolve the contradiction. +--- + +### Draft: Interpretation quality gates + +--- +### Data Interpretation Rules (Signal vs Noise) + +Before accepting any pricing conclusion, enforce these rules: + +1. **PSM quality gate:** validate response logic and segment fit before computing intersections. Invalid ordering responses and out-of-ICP respondents must be excluded from primary decision output. +2. **Conjoint adequacy gate:** do not report segment-level pricing conclusions unless subgroup sample sufficiency is met and per-level exposure is adequate for stable utilities. +3. **Experiment comparability gate:** normalize confidence/error assumptions across tools before comparing outcomes. You must not compare a 90% confidence "win" in one platform with a 95% confidence "non-win" in another without harmonization. +4. **Minimum evidence floor:** require both sample and conversion floors before declaring a winner. If platform thresholds differ, apply the stricter rule for pricing decisions because downside risk is asymmetric. +5. **Duration rule:** run at least one full business cycle and continue until pre-declared power targets are reached. "7 days complete" is not sufficient by itself. +6. **Peeking rule:** if results are checked continuously, use sequential testing methods and pre-declared stop logic. Otherwise review only at planned checkpoints. +7. **SRM hard stop:** if sample ratio mismatch is detected, block decisioning and diagnose instrumentation/routing before interpreting effects. +8. **Multi-metric readout:** do not optimize on conversion alone. Read conversion, revenue/ARPA, and retention/expansion together, segmented by ICP where possible. + +If any gate fails, output must explicitly state "insufficient evidence for final price lock" and provide remediation actions. +--- + +### Draft: Feature fencing and upgrade trigger standards + +--- +### Feature Fencing Logic + +Your fence design must create natural upgrade pressure without making lower tiers unusable. + +For each adjacent tier pair: + +- Include one **capability fence** (admin/security/analytics or other role-specific capability). +- Include one **usage fence** (projects/seats/transactions/credits or other value-aligned meter). +- Include one **commercial fence** (support SLA, onboarding, procurement terms, legal/security posture). + +Weak fence patterns you must reject: + +- Cosmetic differences only ("more of everything" without functional breakpoints). +- Arbitrary scarcity that does not correspond to value or cost. +- Core workflow crippled in lowest paid tier to force upgrade. + +### Upgrade Trigger Design + +Every trigger must include: + +- `trigger_behavior` (what user does), +- `signal_metric` (what you observe), +- `threshold` (where action occurs), +- `from_tier` and `to_tier`, +- `expected_timeline`, +- `risk_if_ignored`. + +Trigger types you should prioritize: + +1. Usage threshold reached. +2. Collaboration/team workflow initiated. +3. Governance/compliance requirement appears. +4. Advanced reporting/export need appears. + +If trigger relies on telemetry not currently instrumented, mark it as speculative and add instrumentation requirement. +--- + +### Draft: Anti-pattern warning blocks + +--- +### Warning: Tier Sprawl + +**What it looks like:** You output more than four self-serve tiers with overlapping value propositions and no clean self-selection path. +**Detection signal:** Multiple tiers target the same persona/job; feature matrix has high overlap and low unique value per step. +**Consequence if missed:** Buyers stall at decision step, conversion falls, and sales receives avoidable qualification load. +**Mitigation:** Reduce to a 3-4 tier default, force one-sentence tier identity per plan, and require non-overlapping progression rationale. + +### Warning: Weak Adjacent Fences + +**What it looks like:** Adjacent tiers differ by minor limits or UI conveniences rather than real capability, usage, and commercial distinctions. +**Detection signal:** High-value users remain in lower tiers while consuming meaningful value; repeated package edits occur to patch leakage. +**Consequence if missed:** Revenue leakage and abrupt repricing pressure later. +**Mitigation:** Enforce capability+usage+commercial fence checks for every adjacent pair before output approval. + +### Warning: Seat-Only Metric Misfit + +**What it looks like:** Pricing remains seat-only while customer value and cost-to-serve scale with usage/automation. +**Detection signal:** Value metrics rise without seat growth; margins compress due to unpriced usage. +**Consequence if missed:** Under-monetization and worsening gross margin profile. +**Mitigation:** Introduce hybrid base+usage component, plus spend predictability controls. + +### Warning: Metered Billing Without Safety Controls + +**What it looks like:** Overage-only design with no alerts, no cap policy, and no committed usage option. +**Detection signal:** Users discover overages only at invoice; disputes rise. +**Consequence if missed:** Bill shock, trust damage, and renewal risk. +**Mitigation:** Require alert thresholds, cap behavior, overage policy clarity, and optional committed tier. + +### Warning: Repricing Without Migration Policy + +**What it looks like:** New prices/features published without explicit old->new mapping and renewal cutover rules. +**Detection signal:** Support/sales cannot answer "who changes when and to what terms." +**Consequence if missed:** Escalations, inconsistent exceptions, and churn spikes. +**Mitigation:** Include migration cohort map, grandfathering window, entitlement diff, and communication checkpoints. +--- + +### Draft: Available Tools section (updated, paste-ready) + +--- +## Available Tools + +Use internal strategy documents first, then write one structured artifact. Do not invent external API calls inside this skill. + +```python +flexus_policy_document(op="activate", args={"p": "/strategy/pricing-model"}) +flexus_policy_document(op="activate", args={"p": "/strategy/offer-design"}) +flexus_policy_document(op="list", args={"p": "/pain/"}) +``` + +Usage rules: + +1. Always activate pricing-model and offer-design documents before deriving any tier. +2. Always list `/pain/` and consume the most relevant WTP evidence artifacts before setting price levels. +3. If WTP evidence is missing or low quality, output provisional ranges and include a required validation step. + +Write the final output as: + +```python +write_artifact( + artifact_type="pricing_tier_structure", + path="/strategy/pricing-tiers", + data={...}, +) +``` +--- + +### Draft: Schema additions + +Use this JSON Schema fragment to extend and tighten `pricing_tier_structure`: + +```json +{ + "pricing_tier_structure": { + "type": "object", + "required": [ + "created_at", + "currency", + "billing_period", + "methodology_version", + "confidence", + "evidence_stack", + "tiers", + "upgrade_triggers", + "wtp_anchoring", + "usage_safety", + "migration_plan", + "pricing_governance" + ], + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string", + "description": "ISO-8601 timestamp when this tier design artifact was generated." + }, + "currency": { + "type": "string", + "description": "ISO currency code used for all monetary values in this artifact." + }, + "billing_period": { + "type": "string", + "enum": ["monthly", "annually", "one_time", "per_seat_monthly"], + "description": "Primary billing cadence used for displayed plan prices." + }, + "methodology_version": { + "type": "string", + "description": "Version tag of the pricing-tier methodology applied (for auditability and future comparisons)." + }, + "confidence": { + "type": "object", + "required": ["overall", "reason", "contradictions_noted"], + "additionalProperties": false, + "properties": { + "overall": { + "type": "string", + "enum": ["high", "medium", "low"], + "description": "Overall confidence score based on evidence quality and behavioral validation depth." + }, + "reason": { + "type": "string", + "description": "Narrative reason explaining why this confidence level was assigned." + }, + "contradictions_noted": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of unresolved source or signal contradictions that influenced confidence." + } + } + }, + "evidence_stack": { + "type": "object", + "required": ["boundary_methods", "tradeoff_methods", "behavior_methods", "sources"], + "additionalProperties": false, + "properties": { + "boundary_methods": { + "type": "array", + "items": { + "type": "string", + "enum": ["psm", "direct_wtp_closed", "direct_wtp_open", "gabor_granger", "other"] + }, + "description": "Methods used to set plausible price boundaries." + }, + "tradeoff_methods": { + "type": "array", + "items": { + "type": "string", + "enum": ["cbc_conjoint", "choice_modeling", "none", "other"] + }, + "description": "Methods used to estimate trade-offs between price and feature bundles." + }, + "behavior_methods": { + "type": "array", + "items": { + "type": "string", + "enum": ["ab_test", "pilot_offer", "historical_conversion", "historical_expansion", "none", "other"] + }, + "description": "Observed behavioral data sources used for validation." + }, + "sources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Source IDs or URLs tied to the evidence methods." + } + } + }, + "tiers": { + "type": "array", + "minItems": 2, + "items": { + "type": "object", + "required": [ + "tier_name", + "target_persona", + "not_for_persona", + "price", + "price_unit", + "value_metric", + "key_limits", + "key_features", + "feature_gates", + "fence_strength" + ], + "additionalProperties": false, + "properties": { + "tier_name": { + "type": "string", + "description": "Customer-facing plan name." + }, + "target_persona": { + "type": "string", + "description": "Primary persona or segment this tier is designed for." + }, + "not_for_persona": { + "type": "string", + "description": "Persona segment this tier is intentionally not optimized for." + }, + "price": { + "type": "number", + "minimum": 0, + "description": "Displayed plan price in the artifact currency." + }, + "price_unit": { + "type": "string", + "description": "Commercial unit for the listed price (for example per month, per seat, per 1k events)." + }, + "value_metric": { + "type": "string", + "description": "Primary metric that best maps monetization to customer value creation." + }, + "key_limits": { + "type": "object", + "additionalProperties": { + "type": ["string", "number", "boolean"] + }, + "description": "Machine-readable usage and capability limits that define this tier boundary." + }, + "key_features": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Most important included features for this tier." + }, + "feature_gates": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Features intentionally reserved for higher tiers." + }, + "fence_strength": { + "type": "object", + "required": ["capability_fence", "usage_fence", "commercial_fence"], + "additionalProperties": false, + "properties": { + "capability_fence": { + "type": "string", + "description": "Primary capability progression to next tier." + }, + "usage_fence": { + "type": "string", + "description": "Primary usage progression to next tier." + }, + "commercial_fence": { + "type": "string", + "description": "Primary commercial/procurement progression to next tier." + } + } + } + } + } + }, + "upgrade_triggers": { + "type": "array", + "items": { + "type": "object", + "required": [ + "trigger", + "signal_metric", + "threshold", + "from_tier", + "to_tier", + "expected_timeline" + ], + "additionalProperties": false, + "properties": { + "trigger": { + "type": "string", + "description": "Behavioral event that indicates upgrade intent or need." + }, + "signal_metric": { + "type": "string", + "description": "Tracked metric used to detect the trigger." + }, + "threshold": { + "type": "string", + "description": "Concrete threshold or condition that activates the trigger." + }, + "from_tier": { + "type": "string", + "description": "Current tier before upgrade." + }, + "to_tier": { + "type": "string", + "description": "Recommended destination tier." + }, + "expected_timeline": { + "type": "string", + "description": "Expected time window in which this trigger appears for typical users." + } + } + } + }, + "wtp_anchoring": { + "type": "object", + "required": ["psm_optimal", "psm_stress_point", "applied_discount"], + "additionalProperties": false, + "properties": { + "psm_optimal": { + "type": "number", + "description": "PSM-derived central reference point used as initial boundary input." + }, + "psm_stress_point": { + "type": "number", + "description": "Upper stress boundary from PSM or equivalent WTP boundary method." + }, + "applied_discount": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Calibration factor applied to stated-preference values before behavioral validation." + } + } + }, + "usage_safety": { + "type": "object", + "required": ["alerts_enabled", "alert_thresholds", "hard_cap_policy", "overage_policy", "committed_usage_option"], + "additionalProperties": false, + "properties": { + "alerts_enabled": { + "type": "boolean", + "description": "Whether proactive usage alerts are designed into the pricing experience." + }, + "alert_thresholds": { + "type": "array", + "items": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "description": "Fractional usage checkpoints (for example 0.8, 0.9, 1.0) that trigger customer notifications." + }, + "hard_cap_policy": { + "type": "string", + "enum": ["none", "soft_cap_with_overage", "hard_stop", "grace_then_stop"], + "description": "Behavior when included usage is exhausted." + }, + "overage_policy": { + "type": "string", + "description": "How overage is billed and communicated." + }, + "committed_usage_option": { + "type": "boolean", + "description": "Whether customers can choose committed/prepaid usage for predictability." + } + } + }, + "migration_plan": { + "type": "object", + "required": ["required", "grandfathering_policy", "renewal_cutover_rule", "entitlement_mapping_status", "customer_comms_checkpoints"], + "additionalProperties": false, + "properties": { + "required": { + "type": "boolean", + "description": "Whether migration handling is required for this tier redesign." + }, + "grandfathering_policy": { + "type": "string", + "description": "Policy for maintaining old terms for existing customers during transition." + }, + "renewal_cutover_rule": { + "type": "string", + "description": "Rule defining when existing contracts move to new pricing/package terms." + }, + "entitlement_mapping_status": { + "type": "string", + "enum": ["defined", "partial", "missing"], + "description": "Completeness status of old-tier to new-tier entitlement mapping." + }, + "customer_comms_checkpoints": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Named communication milestones before and during migration." + } + } + }, + "pricing_governance": { + "type": "object", + "required": ["owner", "review_interval", "next_review_due", "exception_process"], + "additionalProperties": false, + "properties": { + "owner": { + "type": "string", + "description": "Function or role accountable for ongoing tier health and updates." + }, + "review_interval": { + "type": "string", + "enum": ["quarterly", "semi_annual", "annual"], + "description": "Scheduled interval for pricing/package health review." + }, + "next_review_due": { + "type": "string", + "description": "ISO-8601 date for next planned review." + }, + "exception_process": { + "type": "string", + "description": "How exceptions and discount deviations are approved and logged." + } + } + } + } + } +} +``` + +--- + +## Gaps & Uncertainties + +- Public 2025-2026 data on AI add-on attach rates and hybrid model performance is still heavily concentrated in consulting/vendor datasets rather than peer-reviewed longitudinal datasets. +- Vendor documentation for limits/pricing (Stripe, Paddle, PostHog, Statsig, etc.) changes frequently; any production implementation should re-validate exact numbers before enforcement. +- Some experimentation-platform documentation differs from pricing-page language (for example free-tier quota details), so quota-specific guidance should be treated as environment-verified, not static truth. +- There is limited public causal evidence isolating tier naming impact independent of other page/design changes; naming guidance remains strongly practice-based rather than experimentally universal. +- Older foundational references used here are explicitly marked evergreen, but newer replication studies would improve confidence on method-level quantitative assumptions. diff --git a/flexus_simple_bots/strategist/skills/_pricing-tier-design/SKILL.md b/flexus_simple_bots/strategist/skills/_pricing-tier-design/SKILL.md new file mode 100644 index 00000000..92319ef7 --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_pricing-tier-design/SKILL.md @@ -0,0 +1,56 @@ +--- +name: pricing-tier-design +description: Pricing tier structure design — price points, tier names, feature fencing, upgrade triggers, and upsell architecture +--- + +You design the specific price points and tier structure for each packaging tier. Inputs are: pricing model (from `pricing-model-design`), offer design (from `offer-design`), and WTP research (from `pain-wtp-research`). + +Core mode: price is anchored to WTP evidence, not to cost-plus or gut feel. The bottom tier must be low enough to reduce friction for initial adoption. The top tier must capture value from power users without causing mid-market to over-pay. + +## Methodology + +### Price point derivation from WTP data +From Van Westendorp PSM results: +- Bottom tier price: above "too cheap" threshold (≥ 20th percentile "cheap" response) +- Top tier price: below "stress point" for target buyer (≤ 80th percentile "expensive" response) +- Mid tier: optimal price point from PSM (intersection of "expensive" and "not cheap") + +Adjust downward by 15-25% to account for the gap between stated WTP and actual WTP. + +### Tier naming +Effective tier names: +- Reflect persona identity, not internal product labels ("Starter/Pro/Enterprise" vs. "Individual/Team/Organization") +- Avoid "Basic" (sounds cheap and limiting) +- Can use verb/adjective naming ("Launch/Grow/Scale" for a SaaS with an activation metaphor) + +### Feature fencing logic +Strong feature fences are based on: +- **Usage limits** that create natural upgrade pressure (e.g., "5 projects/month") +- **Collaboration features** that require team upgrade (single user gets core; team features require Pro) +- **Reporting/analytics** that managers need but individual users don't + +Weak feature fences: +- Arbitrary limitations with no UX rationale +- Hiding features that the free tier should include to be useful + +### Upgrade trigger design +What causes a user to upgrade? +1. They hit a usage limit (natural trigger — design the limit deliberately) +2. They invite a collaborator and discover team features require upgrade +3. They want a specific report/export that's behind a paywall + +Map each upgrade trigger to an expected timeline (e.g., "typical user hits the 5-project limit in week 3"). + +## Recording + +``` +write_artifact(path="/strategy/pricing-tiers", data={...}) +``` + +## Available Tools + +``` +flexus_policy_document(op="activate", args={"p": "/strategy/pricing-model"}) +flexus_policy_document(op="activate", args={"p": "/strategy/offer-design"}) +flexus_policy_document(op="list", args={"p": "/pain/"}) +``` diff --git a/flexus_simple_bots/strategist/skills/_rtm-partner-channel/RESEARCH.md b/flexus_simple_bots/strategist/skills/_rtm-partner-channel/RESEARCH.md new file mode 100644 index 00000000..d6049b02 --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_rtm-partner-channel/RESEARCH.md @@ -0,0 +1,1043 @@ +# Research: rtm-partner-channel + +**Skill path:** `strategist/skills/rtm-partner-channel/` +**Bot:** strategist (researcher | strategist | executor) +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`rtm-partner-channel` defines how to design and prioritize partner-led route-to-market motions (technology/integration partners, reseller/VAR, referral/affiliate, OEM/white-label), including incentive design, partner ICP, and target account prioritization. + +The core problem this skill solves is execution realism. Most partner strategies fail because they stay at taxonomy level ("we will do reseller + referral") and do not specify partner economics, onboarding gates, channel-conflict rules, and measurement quality. 2024-2026 evidence shows partner programs are expanding, but many teams still operate with immature tooling and unclear incentive design, creating a gap between ambition and outcomes. This research upgrades the skill toward source-backed, operator-grade channel strategy outputs. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024-2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- [x] No generic filler ("it is important to...", "best practices suggest...") without concrete backing +- [x] No invented tool names, method IDs, or API endpoints - only verified real ones +- [x] Contradictions between sources are explicitly noted, not silently resolved +- [x] Volume: findings section is 800-4000 words (synthesized, not source dump) + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +1) **Use dual-horizon channel targets instead of one headline percentage.** +Macro analyst framing still projects partner-dominant tech spend (often cited as ~70% through channel), but operator surveys show many teams in 2025 planning materially lower near-term channel contribution bands (most often 11-25% or 26-50%). The practical implication is to split planning into a 12-month realistic target and a 24-36 month expansion target rather than forcing one blended number. This reduces strategy theater and makes execution sequencing testable. Sources: [M13], [M3]. + +2) **Classify partners by activity mix (sell/service/build), not partner label alone.** +IDC 2024 trend data indicates many partners run mixed models and multi-path monetization (direct customer billing plus ecosystem routes). A strategy that treats "VAR" or "SI" as fixed behavior classes will misprice incentives and assign wrong enablement. Better approach: score each target partner on `sell`, `service`, and `build` contribution and map go-to-market plays accordingly. Sources: [M1]. + +3) **Incentive design is shifting from activity payout to lifecycle outcomes.** +TSIA and related channel research emphasize that XaaS partner value is now distributed across land/adopt/expand/renew motions, and sales-volume-only structures underperform in recurring models. Practical strategy upgrade: tie incentive design and enablement funding to lifecycle role and customer outcomes, not just closed-won initiation. Sources: [M2], [M11]. + +4) **Do not treat MDF as universally obsolete or universally sufficient.** +There is a real contradiction in 2025 sources: one dataset says partners now prefer outcome/capability-based funding over MDF; another shows large portions of partner marketing budgets still funded by MDF/distributor programs; major hyperscalers continue MDF offerings in upper tiers. The skill should encode this contradiction directly: segment incentive portfolios by partner archetype instead of hard "MDF yes/no" rules. Sources: [M11], [M12], [M7]. + +5) **Recruitment and onboarding should be stage-gated with short activation clocks.** +Partner activation benchmarks indicate many partnerships reach first activity quickly (median around days, majority within 10 days in one large network dataset). Practical implication: strategy artifacts should include Day 0-10 activation requirement, Day 30-90 proof milestones, and explicit promotion/deprioritization rules. Slow activation without evidence should be interpreted as risk, not "normal ramp." Sources: [M6], [M3]. + +6) **Program eligibility gates from hyperscalers are concrete and should inform readiness criteria.** +Microsoft partner scoring and AWS tier requirements are explicit and measurable (certifications, opportunity counts, score thresholds). Even when your own channel is not hyperscaler-led, these programs provide an evidence-based pattern: define minimum capability gates before assigning high-priority partner status. Sources: [M8], [M7]. + +7) **Marketplace route planning is now a first-class channel design decision.** +Google Cloud program updates and marketplace growth projections indicate channel planners must decide early when to route through marketplace transacting motions vs direct contracts. Marketplace facilitation is increasingly partner-mediated, so "marketplace path" should appear in partner type and target account planning, not as a later ops afterthought. Sources: [M9], [M10]. + +8) **Capacity realism must be explicit in strategy outputs.** +Recent surveys show many partner teams are small, under-budgeted, and not fully integrated in tooling despite high growth expectations. Strategy quality improves when artifact includes explicit operating capacity assumptions (portfolio caps per partner manager, onboarding SLA gates, and freeze conditions for new partner intake). Sources: [M3], [M2]. + +**Sources:** +- [M1] https://www.idc.com/resource-center/blog/3-trends-that-will-shape-partnering-ecosystems-in-2024-25/ (2024; analyst source with survey-backed directional data) +- [M2] https://www.tsia.com/blog/state-of-xaas-channel-partnerships-2025 (updated 2025-04-08; industry association) +- [M3] https://cdn.prod.website-files.com/65ba9e265a8d0623ea182de2/68b1f2b9a429822a4f41767f_The%20State%20of%20Partnerships%202025%20-%20Results.pdf (2025; transparent sample, non-representative) +- [M6] https://partnerstack.com/resources/research-lab/charts/most-network-partners-have-a-median-time-of-2-days-to-joining-their-first-partnership (2024; vendor network telemetry) +- [M7] https://aws.amazon.com/partners/services-tiers/ (accessed 2026-03-05; first-party program requirements/benefits) +- [M8] https://learn.microsoft.com/en-us/partner-center/membership/partner-capability-score (updated 2026-01-26; first-party qualification model) +- [M9] https://cloud.google.com/blog/topics/partners/introducing-google-cloud-partner-network (2025-12-16; first-party program update) +- [M10] https://www.businesswire.com/news/home/20251006925215/en/Omdia-Hyperscaler-Cloud-Marketplace-Sales-to-Hit-%24163-Billion-by-2030 (2025-10-06; press release citing Omdia forecast) +- [M11] https://techaisle.com/blog/643-techaisle-research-why-4115-partners-say-mdf-is-obsolete-and-what-they-want-instead (2025-10-02; analyst blog with survey claims) +- [M12] https://www.thechannelco.com/article/state-of-partner-marketing-2025 (2025; industry media survey) +- [M13] https://channelpostmea.com/2024/11/29/canalys-it-spending-to-expand-8-in-2025-with-increased-role-for-channel-partners/ (2024-11-29; secondary reporting of analyst forecast) + +--- + +### Angle 2: Tool & API Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +1) **PRM platforms now compete on operability details, not just portal features.** +Salesforce PRM publishes seat pricing and explicit API call allowances by tier, while PartnerStack emphasizes end-to-end partner motion workflows with public API and webhook docs but custom pricing. For SKILL design this means tool recommendations must include known limits/disclosures and explicitly flag pricing unknowns. Sources: [T1], [T2], [T3], [T4]. + +2) **Cloud marketplaces have explicit fee mechanics that materially affect partner economics.** +AWS, Google, and Microsoft now publish concrete marketplace fee structures and private-offer mechanics. Partner-channel strategy outputs should therefore include a marketplace-route decision with fee assumptions documented; otherwise margin assumptions are frequently wrong. Sources: [T6], [T7], [T14], [T10]. + +3) **Co-sell programs are gated with measurable readiness requirements.** +AWS ISV Accelerate, Microsoft co-sell, and Google reseller frameworks all require operational prerequisites (offer status, collateral, eligibility checks, capability thresholds). Treating co-sell as "join and start selling" is inaccurate; strategy outputs should include readiness status and missing prerequisites for each prioritized partner candidate. Sources: [T8], [T9], [T12], [T13]. + +4) **Ecosystem-overlap intelligence is now integration-native.** +Crossbeam and similar ecosystem data platforms ingest CRM/warehouse data and push overlap signals back into CRM workflows. This supports a concrete workflow in skill outputs: overlap evidence should be data-derived and attachable to prioritized target rows, not narrative-only. Sources: [T15], [T16]. + +5) **Firmographic enrichment APIs remain useful but price transparency can be limited.** +Crunchbase API package documentation is clear on capability families (fundamentals, insights, predictions), while public self-serve pricing is less clear than feature packaging. Skill instructions should explicitly separate "available data fields" from "commercial access assumptions." Sources: [T17], [T18]. + +6) **Attribution tooling is maturing toward sourced + influenced support.** +PartnerStack integrations/webhooks, Crossbeam attribution connectors, and impact.com tracking tiers all indicate practitioner movement toward dual attribution pipelines. Strategy artifacts should carry attribution model metadata (first/last/multi-touch) and confidence labels rather than reporting one unqualified revenue number. Sources: [T4], [T15], [T19]. + +7) **App and marketplace governance standards are tightening.** +HubSpot app listing and certification requirements are examples of stricter ecosystem quality controls (OAuth requirements, install thresholds, recertification standards). Channel strategy should include integration-governance readiness checks when selecting technology partners. Sources: [T20], [T21]. + +8) **De-facto technical pattern in 2025-2026: CRM-centered data contract with partner event ingestion.** +Across PRM ecosystems, the common architecture is CRM as source of truth + API/webhook events from partner systems + explicit object-level status model for sourced vs influenced pipeline and partner stage progression. This pattern should be reflected directly in schema recommendations, even when the skill itself only has limited native tools. Sources: [T3], [T4], [T11], [T15]. + +**Sources:** +- [T1] https://www.salesforce.com/sales/partner-relationship-management/pricing/?bc=HA (accessed 2026-03-05; first-party pricing/limits) +- [T2] https://partnerstack.com/pricing (accessed 2026-03-05; first-party, custom pricing disclosure) +- [T3] https://docs.partnerstack.com/docs/partnerstack-api (accessed 2026-03-05; first-party API docs) +- [T4] https://docs.partnerstack.com/docs/partnerstack-webhooks (accessed 2026-03-05; first-party webhook docs) +- [T5] https://docs.partnerstack.com/docs/getting-started-with-the-integration-suite (accessed 2026-03-05) +- [T6] https://docs.aws.amazon.com/marketplace/latest/userguide/listing-fees.html (effective 2024-01-05; still active, evergreen fee reference) +- [T7] https://aws.amazon.com/about-aws/whats-new/2024/01/aws-marketplace-simplified-reduced-listing-fees/ (2024-01-05) +- [T8] https://aws.amazon.com/partners/programs/isv-accelerate/ (accessed 2026-03-05) +- [T9] https://learn.microsoft.com/en-us/partner-center/referrals/co-sell-requirements (updated 2025-09-25) +- [T10] https://learn.microsoft.com/en-us/partner-center/marketplace-offers/marketplace-commercial-transaction-capabilities-and-considerations (updated 2025-09-25) +- [T11] https://learn.microsoft.com/en-us/partner-center/marketplace-offers/transacting-commercial-marketplace (updated 2026-01-27) +- [T12] https://docs.cloud.google.com/marketplace/docs/partners/get-started (accessed 2026-03-05) +- [T13] https://docs.cloud.google.com/marketplace/docs/partners/resellers/resell (accessed 2026-03-05) +- [T14] https://cloud.google.com/terms/marketplace-revenue-share-schedule (effective 2025-04-21; updated 2025-03-10) +- [T15] https://www.crossbeam.com/how-it-works/integrations/ (accessed 2026-03-05) +- [T16] https://www.crossbeam.com/pricing/ (accessed 2026-03-05) +- [T17] https://data.crunchbase.com/docs/api-packages-overview (accessed 2026-03-05) +- [T18] https://about.crunchbase.com/products/data-enrichment/ (accessed 2026-03-05) +- [T19] https://impact.com/integrated-platform-prices/ (accessed 2026-03-05) +- [T20] https://developers.hubspot.com/docs/api/app-marketplace-listing-requirements (accessed 2026-03-05) +- [T21] https://developers.hubspot.com/docs/apps/developer-platform/list-apps/apply-for-certification/certification-requirements (accessed 2026-03-05) + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +1) **Sourced and influenced pipeline must be interpreted together.** +Partner attribution guidance and analyst commentary show that sourced-only measurement systematically underestimates partner impact in co-sell and integration-heavy motions. Skill outputs should therefore require both measures with explicit denominators and attribution model declaration. Sources: [I1], [I8]. + +2) **Attribution method determines confidence class.** +Reported usage patterns still show many teams on first-touch/last-touch frameworks while multi-touch adoption is incomplete. Decision implication: if model is not multi-touch or mixed with defined rules, influenced metrics should be tagged as directional rather than decision-grade. Sources: [I3], [I4]. + +3) **Partner ecosystem maturity segmentation changes interpretation materially.** +Crossbeam telemetry indicates average partner-involved win-rate lift can hide strong subgroup variance (including weak/negative cohorts in transition maturity bands). Strategy artifacts should include segmentation by connected-partner maturity, not only blended topline lift. Source: [I5]. + +4) **Leading indicators (adoption, workflow usage, alignment) must gate lagging revenue expectations.** +Future of Revenue 2025 shows strong relationship between alignment and outcomes (target attainment likelihood, cycle time, loss rates), and partner research shows alignment-linked cycle compression. Practical rule: if enablement/adoption/alignment indicators are weak, down-rank forecast confidence even when early revenue signals appear positive. Sources: [I6], [I11]. + +5) **Healthy benchmark ranges are context-dependent and maturity-constrained.** +2025 partnership survey bands and tooling maturity statistics suggest high channel-share forecasts without stack/process maturity should be flagged high risk. Skill outputs should present `base`, `stretch`, and `speculative` scenarios with confidence labels instead of one deterministic target. Source: [I7]. + +6) **Common misinterpretation: bookings-first scorecards are enough in XaaS.** +TSIA framing indicates recurring-revenue partner models require adoption/retention success contribution and customer success collaboration metrics, not only initial bookings. Strategy schema should include at least one post-sale partner contribution metric class. Sources: [I9], [I10]. + +7) **Contradiction to keep explicit: available benchmark numbers are useful but not universal.** +Many public benchmarks come from vendor-owned datasets or targeted samples. They are still actionable for default priors, but outputs should explicitly label evidence quality and transferability assumptions. Sources: [I1], [I5], [I7]. + +**Sources:** +- [I1] https://partnerstack.com/articles/partner-attribution-measure-sourced-vs-influenced-revenue (updated 2026-01-16; vendor guidance with definitions) +- [I3] https://partnerstack.com/resources/research-lab/charts/multi-touch-is-the-most-common-partner-tribution-method-for-senior-leaders-in-b2b-saas (2025; vendor survey chart) +- [I4] https://partnerstack.com/resources/research-lab/the-state-of-partnerships-in-gtm-2026 (2025 release for 2026 planning context) +- [I5] https://insider.crossbeam.com/entry/new-data-involving-partners-in-deals-increases-win-rate-for-nearly-every-ecosystem-size-and-type (2024-11-08; vendor telemetry with segmentation) +- [I6] https://www.joinpavilion.com/hubfs/Crossbeam-Pavilion-Future-of-Revenue-2025.pdf?hsLang=en (2025; survey report with methodology notes) +- [I7] https://cdn.prod.website-files.com/65ba9e265a8d0623ea182de2/68b1f2b9a429822a4f41767f_The%20State%20of%20Partnerships%202025%20-%20Results.pdf (2025; sample limitations explicitly stated) +- [I8] https://www.forrester.com/blogs/partner-attribution-is-broken-heres-why-b2b-executives-must-lead-the-fix/ (2025; analyst viewpoint) +- [I9] https://www.tsia.com/blog/the-state-of-xaas-channel-partnerships-2024 (updated 2024-04-16) +- [I10] https://www.tsia.com/blog/channel-partner-success-score (updated 2025-06-23) +- [I11] https://partnerstack.com/resources/research-lab/charts/partnerships-lead-to-higher-average-contracts-through-larger-initial-deals-upsells-and-renewels (2025; directional partner impact data) + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +1) **Anti-pattern: optimize payouts without validating partner unit economics.** +What it looks like: strategy outputs define commission percentages but omit partner workload cost, services attach assumptions, and post-sale responsibilities. Consequence: activity spikes but weak renewals/adoption and partner churn. Detection signal: high recruited count + low active-revenue partners. Mitigation: require per-partner-archetype economics table and lifecycle incentive map. Sources: [F7], [F9], [F14]. + +2) **Anti-pattern: abrupt account ownership shifts create channel conflict shock.** +Broadcom/VMware public reports in 2024 show ecosystem disruption when account/control rules changed abruptly with weak transition clarity. Detection signal: partner confusion over deal registration eligibility and account ownership. Mitigation: encode segmentation criteria, transition period, and protection logic directly in strategy artifact before policy rollout. Sources: [F1], [F2], [F4]. + +3) **Anti-pattern: deal registration policy exists on paper but fails operationally.** +Microsoft program docs show concrete timing and eligibility constraints; failure to operationalize those details turns deal reg into rejection churn rather than conflict prevention. Detection signal: high rejection/escalation volume due to process or data quality. Mitigation: include pre-submit validation and exception path SLA in operating model. Sources: [F5], [F6]. + +4) **Anti-pattern: training-heavy onboarding without field execution path.** +Recent channel preference data shows increased demand for co-sell and execution support. Training alone without first-opportunity milestones creates "certified but inactive" partners. Mitigation: bind onboarding to first joint opportunity deadlines and stage exit criteria. Sources: [F7], [F9]. + +5) **Anti-pattern: third-party compliance treated as one-time onboarding checkbox.** +SEC enforcement and DOJ guidance emphasize continuous controls for intermediaries. Strategy outputs lacking ongoing due diligence and compensation reasonableness checks increase regulatory risk. Mitigation: include lifecycle compliance controls in partner operating model. Sources: [F10], [F11]. + +6) **Anti-pattern: margin-protection behavior crosses competition-law boundaries.** +2024 competition enforcement in Europe highlights resale price maintenance and distribution pressure risk. Strategy should never encode implicit/explicit resale price control mechanics without legal review. Mitigation: include antitrust guardrails and jurisdictional legal review requirement. Sources: [F12], [F13 - evergreen legal baseline]. + +7) **Contradiction: partner consolidation can help or hurt depending on execution quality.** +Some examples show severe backlash from abrupt consolidation, while others report cleaner partner focus with less disruption. Implication: "reduce partner count" is not inherently good or bad; artifact must justify consolidation criteria and transition safeguards. Sources: [F2], [F3], [F15]. + +8) **Bad output shape vs good output shape.** +Bad: "launch partner program, offer MDF, recruit top 20 partners." Good: segmented economics, explicit ownership/deal-reg policy, activation milestones, attribution confidence policy, and compliance guardrails. This distinction should be encoded as quality checklist in SKILL draft content. Sources: [F5], [F6], [F9], [F11]. + +**Sources:** +- [F1] https://www.crn.com/news/virtualization/2024/broadcom-takes-top-vmware-accounts-direct-effective-immediately (2024; trade reporting with policy details) +- [F2] https://www.crn.com/news/virtualization/2024/scared-angry-and-terminated-vmware-partners-unload-on-broadcom (2024; partner sentiment evidence) +- [F3] https://www.crn.com/news/virtualization/2024/broadcom-ceo-hock-tan-old-vmware-model-created-channel-chaos-and-conflict-in-the-marketplace (2024; executive framing) +- [F4] https://www.theregister.com/2024/01/10/broadcom_ends_vmware_partner_program (2024-01-10; independent tech reporting) +- [F5] https://learn.microsoft.com/en-us/partner-center/referrals/co-sell-requirements (updated 2025-09-25; first-party requirements) +- [F6] https://learn.microsoft.com/en-us/partner-center/register-deals (updated 2025-08-29; first-party process rules) +- [F7] https://futurumgroup.com/press-release/co-sell-support-jumps-14-6-points-to-39-2-displacing-developer-tools-as-channel-partners-1-vendor-priority/ (2026-03-04; analyst press release) +- [F9] https://www.tsia.com/blog/the-state-of-xaas-channel-partnerships-2024 (updated 2024-04-16) +- [F10] https://www.sec.gov/newsroom/press-releases/2024-4 (2024-01-10; primary enforcement source) +- [F11] https://www.justice.gov/criminal/criminal-fraud/page/file/937501/dl?inline (DOJ ECCP guidance, updated 2024-09; primary) +- [F12] https://autoritedelaconcurrence.fr/en/press-release/autorite-imposes-fines-eu611-million-10-manufacturers-and-2-distributors-household (2024-12-19; primary regulator) +- [F13] https://competition-policy.ec.europa.eu/antitrust/legislation/vertical-block-exemptions_en (2022, evergreen legal baseline) and https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=oj:JOC_2022_248_R_0001 +- [F14] https://www.computerweekly.com/microscope/news/366632557/Canalys-The-spotlight-falls-on-partner-programmes (2025-10-10; trade source citing analyst context) +- [F15] https://www.crn.com/news/networking/2024/riverbed-simplifies-channel-program-focuses-only-on-partners-on-the-journey-that-we-re-on (2024; alternate consolidation outcome) + +--- + +### Angle 5+: Marketplace & Program-Gating Integration Patterns +> Domain-specific angle: how partner-channel strategy should integrate marketplace/compliance/program-gating constraints so recommendations are executable, not abstract. + +**Findings:** + +1) Marketplace economics should be a first-class field in channel strategy artifacts, not a side note, because fee schedules and private-offer mechanics change net margin materially. Sources: [T6], [T14], [T10]. + +2) Co-sell readiness and partner-tier eligibility are operational constraints that should be represented as explicit "readiness gaps" per prioritized target. Sources: [T8], [T9], [M8]. + +3) Program policy complexity (deal registration, transacting constraints, reseller authorization) creates predictable execution risk; strategy artifacts should include "policy dependency" and "owner" fields for each critical constraint. Sources: [F5], [F6], [T13]. + +4) Legal/compliance controls should be embedded in channel strategy output from day one, because channel growth can scale risk through third parties as fast as it scales revenue. Sources: [F10], [F11], [F12]. + +5) Contradiction to encode: strong growth narratives for ecosystem channels can coexist with immature tooling and governance reality; therefore confidence scoring must be mandatory in final strategy output. Sources: [M3], [I7], [M10]. + +**Sources:** +- [T6] https://docs.aws.amazon.com/marketplace/latest/userguide/listing-fees.html +- [T8] https://aws.amazon.com/partners/programs/isv-accelerate/ +- [T9] https://learn.microsoft.com/en-us/partner-center/referrals/co-sell-requirements +- [T10] https://learn.microsoft.com/en-us/partner-center/marketplace-offers/marketplace-commercial-transaction-capabilities-and-considerations +- [T13] https://docs.cloud.google.com/marketplace/docs/partners/resellers/resell +- [T14] https://cloud.google.com/terms/marketplace-revenue-share-schedule +- [M3] https://cdn.prod.website-files.com/65ba9e265a8d0623ea182de2/68b1f2b9a429822a4f41767f_The%20State%20of%20Partnerships%202025%20-%20Results.pdf +- [M8] https://learn.microsoft.com/en-us/partner-center/membership/partner-capability-score +- [M10] https://www.businesswire.com/news/home/20251006925215/en/Omdia-Hyperscaler-Cloud-Marketplace-Sales-to-Hit-%24163-Billion-by-2030 +- [I7] https://cdn.prod.website-files.com/65ba9e265a8d0623ea182de2/68b1f2b9a429822a4f41767f_The%20State%20of%20Partnerships%202025%20-%20Results.pdf +- [F10] https://www.sec.gov/newsroom/press-releases/2024-4 +- [F11] https://www.justice.gov/criminal/criminal-fraud/page/file/937501/dl?inline +- [F12] https://autoritedelaconcurrence.fr/en/press-release/autorite-imposes-fines-eu611-million-10-manufacturers-and-2-distributors-household + +--- + +## Synthesis + +The strongest 2024-2026 signal is that partner-channel strategy quality is now determined less by partner type taxonomy and more by operating discipline: lifecycle incentives, onboarding gates, conflict governance, and attribution quality. High-level channel ambition is widespread, but many teams still run with limited infrastructure and incomplete measurement contracts. That gap is exactly where low-quality strategy artifacts fail. + +There are two critical contradictions that should remain explicit in the skill: first, macro forecasts about partner-dominant spend do not mean near-term channel share targets should be aggressive without maturity evidence; second, MDF cannot be treated as either universally outdated or universally sufficient. The right strategy is segmented and evidence-driven, not doctrinal. + +Tooling has matured materially (PRM APIs, marketplace fee schedules, co-sell programs, ecosystem overlap platforms), but tooling alone does not remove interpretation risk. The same revenue number can mean very different things depending on attribution model, maturity segment, and lifecycle context. Therefore the skill should require attribution metadata and confidence labeling as part of the artifact, not as optional narrative. + +Failure mode evidence is consistent: abrupt ownership changes, weak deal-reg operations, training-only onboarding, and missing compliance controls produce partner distrust or legal risk. These are not edge cases. They should be encoded as mandatory anti-pattern checks with detection signals and mitigation fields, so the strategy artifact can be audited before execution. + +Most actionable outcome: upgrade `rtm-partner-channel` from descriptive planning to execution-grade contract. The upgraded draft content below provides concrete methodology steps, hard decision rules, tool usage guidance constrained to verified methods, warning blocks, and a stricter schema that supports downstream handoff and governance. + +--- + +## Recommendations for SKILL.md + +Concrete, actionable changes for `rtm-partner-channel/SKILL.md`: + +- [x] Add a staged methodology that forces dual-horizon targets (`12-month realistic` and `24-36 month expansion`) with confidence labeling. +- [x] Replace pure partner-type taxonomy with activity-mix profiling (`sell/service/build`) and lifecycle role mapping. +- [x] Add incentive design rules that distinguish recurring lifecycle outcomes from simple upfront payout. +- [x] Add onboarding stage gates with activation windows and promotion/deprioritization rules. +- [x] Add channel conflict operating rules (account ownership policy + deal registration controls + transition policy). +- [x] Add measurement interpretation contract: sourced vs influenced, attribution model, leading vs lagging indicators, confidence class. +- [x] Add anti-pattern warning blocks with detection signal, consequence, and mitigation. +- [x] Expand available-tools guidance using only verified existing tool syntax and explicitly flag non-available data as unknown. +- [x] Expand artifact schema with marketplace route, readiness gaps, attribution policy, risk controls, and quality checks. +- [x] Add a completion checklist that blocks artifact write when critical evidence/governance fields are missing. + +--- + +## Draft Content for SKILL.md + +### Draft: Core mode rewrite + +--- +### Core mode + +You are designing a partner channel strategy that can be executed, measured, and governed. Do not stop at naming partner types. Your output must prove five things: + +1. Why each partner motion is selected for this segment and time horizon. +2. Why the economics work for both sides (partner and vendor). +3. How partner activation moves from signed to productive. +4. How channel conflict and compliance risks are controlled. +5. How success is measured with explicit signal quality rules. + +If any of these five areas are vague, the strategy is incomplete. + +--- + +### Draft: Methodology section replacement + +--- +### Methodology + +#### Step 1: Set horizon and ambition realism before partner selection + +Before selecting partner types, define two explicit channel-share targets: + +- `months_12_target`: realistic near-term contribution based on current operating capacity and stack maturity. +- `months_24_36_target`: expansion target assuming successful capability buildout. + +Decision rules: + +1. If current tooling/process maturity is low (manual spreadsheets, weak integration, no reliable attribution), keep the 12-month target conservative and mark confidence as `low` or `medium`. +2. If target exceeds your evidence-supported maturity, include required capability investments and date-bound milestones; otherwise downgrade the target to `stretch` not `base`. +3. Never publish one single channel-percentage target without confidence class and assumptions. + +Rationale: 2025 surveys and operator data show ambition frequently outpaces infrastructure, creating predictable forecast error. + +#### Step 2: Profile partner archetypes by behavior, not labels + +For each candidate partner, score contribution across: + +- `sell`: pipeline creation / deal progression contribution, +- `service`: implementation, onboarding, managed services capability, +- `build`: integration/product extension capability. + +Then assign partner motion: + +- `technology_integration` when build + influence is strong, +- `reseller_var` when sell + service economics are strong, +- `referral_affiliate` when lightweight demand transfer is strong, +- `oem_whitelabel` when distribution leverage outweighs brand control. + +Do not classify partner motion from brand/category labels alone. Category labels hide mixed behaviors and lead to bad incentive design. + +#### Step 3: Design partner value proposition before commission + +For each partner motion, write a partner-first value proposition: + +- What partner pain or growth objective this motion solves. +- What monetization route exists for partner (license margin, services attach, co-sell leverage, retention uplift). +- What capability requirement partner must meet to realize that value. + +Rule: "we pay 20% commission" is not a value proposition. Commission is one instrument. The proposition is partner business improvement. + +#### Step 4: Build lifecycle incentive map (land/adopt/expand/renew) + +Define incentives by lifecycle stage: + +1. `Land`: lead generation, opportunity creation, deal progression. +2. `Adopt`: onboarding quality, activation speed, implementation outcomes. +3. `Expand`: upsell/cross-sell participation and attach growth. +4. `Renew`: retention support and renewal-risk reduction. + +Design guidance: + +- Keep commission structures transparent and easy to compute. +- Add capability funding where partner enablement bottlenecks block scale. +- Do not apply one global MDF policy; segment by partner archetype and proof of impact. +- If incentives only reward initial bookings, your recurring model is misaligned. + +#### Step 5: Enforce onboarding stage gates and activation windows + +For each signed partner, define required exit criteria by stage: + +- `recruited` -> `enabled`: contractual + technical + commercial setup complete. +- `enabled` -> `active`: first meaningful joint action (registered deal, qualified referral, integration milestone) within defined days. +- `active` -> `productive`: measurable pipeline or delivery contribution within defined 30-90 day window. + +Decision rules: + +1. If partner misses activation window, assign `at_risk` status and trigger remediation plan. +2. If partner misses productivity gate after remediation, deprioritize from high-touch investment pool. +3. Do not continue unlimited onboarding with no activity evidence. + +#### Step 6: Encode channel conflict policy before go-live + +The strategy must include: + +- account ownership segmentation criteria, +- deal registration policy and eligibility checks, +- exception handling path and SLA, +- transition policy for changed ownership rules. + +Do not defer this section to "sales operations later." Missing conflict policy is one of the fastest paths to partner distrust. + +#### Step 7: Add attribution and interpretation contract + +For each reporting period, require: + +- `partner_sourced_pipeline` +- `partner_influenced_pipeline` +- attribution model declaration (`first_touch`, `last_touch`, `multi_touch`, `hybrid`) +- confidence class (`directional`, `decision_grade`) + +Interpretation rules: + +1. If attribution model is single-touch only, influenced metrics default to `directional`. +2. If leading indicators (activation, partner tool adoption, co-sell participation) are weak, downgrade confidence in lagging revenue projections. +3. Segment outcomes by partner maturity band to avoid blended-metric distortion. + +#### Step 8: Add compliance and legal guardrails + +Include explicit controls for: + +- third-party due diligence lifecycle (onboarding + monitoring), +- compensation reasonableness checks, +- anti-corruption policy compliance, +- competition-law review for pricing/channel governance terms. + +Do not encode resale price controls or punitive pricing enforcement behavior without legal approval. + +#### Step 9: Final quality gate before artifact write + +Before writing artifact, verify all mandatory areas are present: + +1. dual-horizon targets + confidence +2. partner activity profile + value proposition +3. lifecycle incentives + economics +4. onboarding gates + conflict policy +5. attribution contract + risk controls + +If any area is missing, do not write final artifact. + +--- + +### Draft: Available Tools section rewrite + +```text +## Available Tools + +flexus_policy_document(op="activate", args={"p": "/strategy/gtm-channel-strategy"}) +flexus_policy_document(op="activate", args={"p": "/segments/{segment_id}/icp-scorecard"}) + +crunchbase( + op="call", + args={ + "method_id": "crunchbase.searches.organizations.post.v1", + "field_ids": ["name", "categories", "funding_total"], + "query": [] + } +) + +write_artifact( + artifact_type="partner_channel_strategy", + path="/strategy/rtm-partner-channel", + data={...} +) +``` + +Tool usage guidance: + +- Always activate `/strategy/gtm-channel-strategy` before selecting partner motions; this anchors channel strategy to the current GTM context. +- Use `/segments/{segment_id}/icp-scorecard` to validate market overlap and avoid partner selection based on intuition-only fit. +- Use `crunchbase.searches.organizations.post.v1` to gather candidate company evidence (category and funding context), but do not infer partner readiness from firmographics alone. +- Do not invent additional Crunchbase method IDs or undocumented endpoints. +- If required data is unavailable in current tools (for example, private pricing terms, partner certification status, or legal risk details), record it as a gap in the artifact instead of fabricating. + +--- + +### Draft: Anti-pattern warning blocks + +### Warning: Commission-Only Strategy + +**What it looks like in practice** +Your strategy describes payout percentages but does not model partner workload cost, services attach assumptions, or post-sale responsibilities. + +**Detection signal** +Partner recruitment count increases, but active revenue-contributing partner share remains low beyond the first onboarding cycle. + +**Consequence if missed** +High top-of-funnel activity with weak adoption/renewals, declining partner trust, and low retained channel value. + +**Mitigation steps** +1. Add partner economics per archetype (`expected margin`, `services opportunity`, `time-to-value`). +2. Add lifecycle incentive map across land/adopt/expand/renew. +3. Add a policy that deprioritizes partner types where economics are structurally unattractive even with incentives. + +### Warning: Conflict Policy Deferred + +**What it looks like in practice** +You define partner tiers and target list but leave account ownership, deal registration exceptions, and transition rules undefined. + +**Detection signal** +Frequent disputes around account entitlement, deal registration rejections, and "who owns this customer" escalations. + +**Consequence if missed** +Partner frustration, slowed co-sell motions, and increased churn to competitor ecosystems. + +**Mitigation steps** +1. Publish segmentation criteria and conflict resolution workflow before launch. +2. Add pre-submit deal-reg validation checks and rejection reason taxonomy. +3. Add explicit transition window and protection policy for account ownership changes. + +### Warning: Training-Only Onboarding + +**What it looks like in practice** +Program success is measured by certification completions or content consumption, not field execution outcomes. + +**Detection signal** +High enablement completion but low first-opportunity creation and weak 30-90 day productivity. + +**Consequence if missed** +"Certified but inactive" partner population and poor channel ROI. + +**Mitigation steps** +1. Define mandatory first-opportunity milestone deadlines. +2. Tie onboarding completion to executed joint actions, not course completion alone. +3. Create remediation and deprioritization policies for missed stage gates. + +### Warning: Sourced-Only Measurement + +**What it looks like in practice** +Reporting and compensation rely only on partner-sourced pipeline/revenue. + +**Detection signal** +Influence-heavy partners appear low value despite significant contribution to deal progression and expansion outcomes. + +**Consequence if missed** +Systematic under-crediting of strategic partners and distorted budget/investment decisions. + +**Mitigation steps** +1. Require sourced and influenced metrics together. +2. Require attribution model declaration and confidence class in every report. +3. Mark single-touch influenced figures as directional, not decision-grade. + +### Warning: Compliance as Onboarding Checkbox + +**What it looks like in practice** +You run one-time due diligence at contract signature and assume contractual language is sufficient. + +**Detection signal** +No periodic third-party monitoring cadence, no compensation reasonableness review, weak documentation controls. + +**Consequence if missed** +Elevated anti-corruption and regulatory exposure as partner network scales. + +**Mitigation steps** +1. Add continuous due-diligence and review cadence. +2. Add controls for partner compensation rationale and approval traceability. +3. Add jurisdiction-aware legal review path for channel policy changes. + +--- + +### Draft: Partner economics and measurement thresholds block + +Use these as default calibration ranges, not universal truths: + +- Near-term channel contribution planning commonly clusters in lower bands (for example 11-50% in 2025 survey distributions); treat high targets as `stretch` unless maturity evidence is strong. +- Referral commissions commonly appear in 20-40% ranges in partner-network benchmark sources; use this as initial calibration, then validate against your unit economics. +- Activation velocity matters: absence of meaningful first action in early onboarding windows is a risk signal requiring remediation, not passive waiting. + +Interpretation instructions: + +1. Do not import benchmark percentages blindly across motions, geographies, and ACV profiles. +2. Every threshold used in strategy must include: source, applicability assumptions, and confidence class. +3. If assumptions are weak or source is non-representative, mark threshold as directional. + +--- + +### Draft: Artifact schema additions + +The JSON schema fragment below is a complete replacement proposal for `partner_channel_strategy`. It preserves existing core fields while adding execution-grade structures for planning horizon, attribution quality, readiness gaps, and risk controls. + +```json +{ + "partner_channel_strategy": { + "type": "object", + "required": [ + "created_at", + "planning_horizon", + "channel_mix_targets", + "partner_types", + "partner_economics", + "partner_icp", + "onboarding_stages", + "attribution_policy", + "measurement_framework", + "risk_controls", + "prioritized_targets" + ], + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string", + "description": "ISO-8601 timestamp when strategy was generated." + }, + "planning_horizon": { + "type": "object", + "required": ["months_12_target", "months_24_36_target", "assumptions"], + "additionalProperties": false, + "properties": { + "months_12_target": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Near-term channel revenue share target as fraction of total revenue." + }, + "months_24_36_target": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Expansion horizon channel revenue share target as fraction of total revenue." + }, + "assumptions": { + "type": "array", + "items": {"type": "string"}, + "description": "Critical assumptions required for targets to remain valid." + } + } + }, + "channel_mix_targets": { + "type": "object", + "required": ["base_case", "stretch_case", "confidence_level"], + "additionalProperties": false, + "properties": { + "base_case": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Most likely channel-share outcome given current maturity." + }, + "stretch_case": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Ambitious but plausible channel-share outcome requiring additional investments." + }, + "confidence_level": { + "type": "string", + "enum": ["low", "medium", "high"], + "description": "Overall confidence in target realism based on operating readiness and evidence quality." + } + } + }, + "partner_types": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "rationale", + "priority", + "value_prop_to_partner", + "activity_mix", + "lifecycle_role", + "marketplace_route" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "technology_integration", + "reseller_var", + "referral_affiliate", + "oem_whitelabel" + ], + "description": "Primary channel motion archetype." + }, + "rationale": { + "type": "string", + "description": "Evidence-backed reason this partner motion is selected." + }, + "priority": { + "type": "string", + "enum": ["high", "medium", "low"], + "description": "Execution priority for this motion." + }, + "value_prop_to_partner": { + "type": "string", + "description": "Partner-first value proposition, beyond commission." + }, + "activity_mix": { + "type": "object", + "required": ["sell_score", "service_score", "build_score"], + "additionalProperties": false, + "properties": { + "sell_score": { + "type": "number", + "minimum": 0, + "maximum": 5, + "description": "Relative strength of partner in pipeline creation/progression." + }, + "service_score": { + "type": "number", + "minimum": 0, + "maximum": 5, + "description": "Relative strength in implementation, onboarding, and managed-service delivery." + }, + "build_score": { + "type": "number", + "minimum": 0, + "maximum": 5, + "description": "Relative strength in integration or product-extension capability." + } + } + }, + "lifecycle_role": { + "type": "array", + "items": { + "type": "string", + "enum": ["land", "adopt", "expand", "renew"] + }, + "description": "Customer lifecycle stages where this partner type is expected to contribute." + }, + "marketplace_route": { + "type": "string", + "enum": ["none", "aws_marketplace", "microsoft_marketplace", "google_marketplace", "multi_marketplace"], + "description": "Primary marketplace transacting route expected for this partner type." + } + } + } + }, + "partner_economics": { + "type": "object", + "required": [ + "revenue_share_pct", + "deal_registration_policy", + "minimum_deal_size", + "target_partner_margin_pct_min", + "lifecycle_incentives" + ], + "additionalProperties": false, + "properties": { + "revenue_share_pct": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Baseline revenue share as fraction of contract value." + }, + "recurring_vs_onetime": { + "type": "string", + "enum": ["recurring", "onetime", "hybrid"], + "description": "Payout structure shape." + }, + "deal_registration_policy": { + "type": "string", + "description": "Summary of registration ownership and approval policy." + }, + "minimum_deal_size": { + "type": "number", + "minimum": 0, + "description": "Minimum deal size for standard partner economics to apply." + }, + "target_partner_margin_pct_min": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Minimum expected partner gross margin required for motion viability." + }, + "lifecycle_incentives": { + "type": "object", + "required": ["land", "adopt", "expand", "renew"], + "additionalProperties": false, + "properties": { + "land": {"type": "string", "description": "Incentive design for initial deal acquisition."}, + "adopt": {"type": "string", "description": "Incentive design for onboarding and active usage outcomes."}, + "expand": {"type": "string", "description": "Incentive design for upsell/cross-sell contribution."}, + "renew": {"type": "string", "description": "Incentive design for retention/renewal contribution."} + } + }, + "marketplace_fee_assumptions": { + "type": "array", + "items": {"type": "string"}, + "description": "Fee assumptions used when marketplace routes are selected." + } + } + }, + "partner_icp": { + "type": "object", + "required": [ + "market_overlap_min", + "competitive_exclusion", + "capacity_requirements", + "fit_evidence_requirements" + ], + "additionalProperties": false, + "properties": { + "market_overlap_min": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Minimum required customer overlap between partner base and target ICP." + }, + "competitive_exclusion": { + "type": "string", + "description": "Rules for excluding direct competitive conflicts." + }, + "capacity_requirements": { + "type": "string", + "description": "Minimum sales/technical capacity required to execute the motion." + }, + "fit_evidence_requirements": { + "type": "array", + "items": {"type": "string"}, + "description": "Evidence types required to validate partner fit (overlap proof, category match, prior delivery examples)." + } + } + }, + "onboarding_stages": { + "type": "array", + "items": { + "type": "object", + "required": ["stage", "max_days", "exit_criteria", "failure_action"], + "additionalProperties": false, + "properties": { + "stage": { + "type": "string", + "enum": ["recruited", "enabled", "active", "productive"], + "description": "Partner progression stage." + }, + "max_days": { + "type": "integer", + "minimum": 1, + "description": "Maximum days allowed in stage before escalation." + }, + "exit_criteria": { + "type": "array", + "items": {"type": "string"}, + "description": "Objective criteria required to move to next stage." + }, + "failure_action": { + "type": "string", + "enum": ["remediate", "deprioritize", "exit"], + "description": "Action if stage exit criteria are not met in time." + } + } + } + }, + "attribution_policy": { + "type": "object", + "required": [ + "model", + "sourced_definition", + "influenced_definition", + "confidence_rules" + ], + "additionalProperties": false, + "properties": { + "model": { + "type": "string", + "enum": ["first_touch", "last_touch", "multi_touch", "hybrid"], + "description": "Attribution model used for partner impact reporting." + }, + "sourced_definition": { + "type": "string", + "description": "Definition of partner-sourced pipeline/revenue used in this strategy." + }, + "influenced_definition": { + "type": "string", + "description": "Definition of partner-influenced pipeline/revenue used in this strategy." + }, + "confidence_rules": { + "type": "array", + "items": {"type": "string"}, + "description": "Rules mapping attribution method and data completeness to confidence class." + } + } + }, + "measurement_framework": { + "type": "object", + "required": ["leading_indicators", "lagging_indicators", "review_cadence"], + "additionalProperties": false, + "properties": { + "leading_indicators": { + "type": "array", + "items": {"type": "string"}, + "description": "Early indicators such as activation speed, co-sell participation, enablement completion, and partner workflow adoption." + }, + "lagging_indicators": { + "type": "array", + "items": {"type": "string"}, + "description": "Outcome indicators such as sourced pipeline, influenced pipeline, win rate, expansion, and renewal contribution." + }, + "review_cadence": { + "type": "string", + "enum": ["weekly", "biweekly", "monthly", "quarterly"], + "description": "Cadence for reviewing partner program performance and policy adjustments." + }, + "maturity_segmentation": { + "type": "string", + "description": "How metrics are segmented by ecosystem maturity to avoid blended-signal distortion." + } + } + }, + "risk_controls": { + "type": "object", + "required": [ + "channel_conflict_policy", + "compliance_controls", + "competition_law_guardrail", + "anti_pattern_checks" + ], + "additionalProperties": false, + "properties": { + "channel_conflict_policy": { + "type": "object", + "required": ["account_segmentation_rules", "deal_registration_rules", "exception_sla"], + "additionalProperties": false, + "properties": { + "account_segmentation_rules": { + "type": "string", + "description": "How accounts are assigned between direct and partner-led motions." + }, + "deal_registration_rules": { + "type": "string", + "description": "Operational rules for eligibility, approvals, and ownership." + }, + "exception_sla": { + "type": "string", + "description": "Resolution SLA for registration disputes and ownership conflicts." + } + } + }, + "compliance_controls": { + "type": "array", + "items": {"type": "string"}, + "description": "Third-party risk controls for onboarding and ongoing monitoring." + }, + "competition_law_guardrail": { + "type": "string", + "description": "Explicit policy to require legal review for pricing/distribution terms with antitrust sensitivity." + }, + "anti_pattern_checks": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "status", "detection_signal", "mitigation"], + "additionalProperties": false, + "properties": { + "name": {"type": "string", "description": "Anti-pattern label."}, + "status": { + "type": "string", + "enum": ["clear", "warning", "triggered"], + "description": "Current risk status for the anti-pattern." + }, + "detection_signal": { + "type": "string", + "description": "Observed signal suggesting this anti-pattern may be present." + }, + "mitigation": { + "type": "string", + "description": "Required mitigation action." + } + } + } + } + } + }, + "prioritized_targets": { + "type": "array", + "items": { + "type": "object", + "required": [ + "company_name", + "partner_type", + "overlap_evidence", + "status", + "readiness_gaps", + "next_action" + ], + "additionalProperties": false, + "properties": { + "company_name": {"type": "string", "description": "Partner candidate company name."}, + "partner_type": { + "type": "string", + "enum": [ + "technology_integration", + "reseller_var", + "referral_affiliate", + "oem_whitelabel" + ], + "description": "Selected partner motion for this target." + }, + "overlap_evidence": { + "type": "string", + "description": "Evidence of customer/segment overlap supporting prioritization." + }, + "status": { + "type": "string", + "enum": ["identified", "approached", "in_discussion", "signed", "inactive"], + "description": "Current progression status." + }, + "readiness_gaps": { + "type": "array", + "items": {"type": "string"}, + "description": "Known gating gaps (capability, program eligibility, legal/compliance, technical integration)." + }, + "marketplace_path": { + "type": "string", + "enum": ["none", "aws_marketplace", "microsoft_marketplace", "google_marketplace", "multi_marketplace"], + "description": "Expected marketplace transacting route for this target." + }, + "next_action": { + "type": "string", + "description": "Most important next execution step for this target." + } + } + } + } + } + } +} +``` + +### Draft: Final quality-check block for end of SKILL.md + +Use this completion check before final `write_artifact` call: + +1. **Target realism check:** dual-horizon targets exist and include assumptions + confidence. +2. **Incentive alignment check:** lifecycle incentive map exists and partner economics are viable. +3. **Activation check:** onboarding stages include timed exit criteria and failure actions. +4. **Conflict/governance check:** account segmentation + deal registration + exception SLA are documented. +5. **Signal quality check:** sourced and influenced definitions + attribution model + confidence rules are explicit. +6. **Risk check:** compliance controls and legal guardrails are present. +7. **Evidence check:** prioritized targets include overlap evidence and readiness gaps. + +If any check fails, output `needs-revision` rationale instead of writing a finalized channel strategy. + +--- + +## Gaps & Uncertainties + +- Many benchmark figures are from vendor-owned datasets and non-representative samples; they are useful as priors, not universal constants. +- Several vendor programs expose requirements clearly but not all commercial terms publicly (for example, negotiated pricing and custom tiers), so some economics assumptions must remain provisional. +- Public data on long-run partner channel contribution by segment/ACV remains fragmented; a second pass with proprietary analyst datasets would improve precision. +- Legal guidance is jurisdiction-dependent; this research includes high-level guardrails but does not replace country-specific counsel for pricing/distribution policy. +- Some growth forecasts are sourced via press summaries rather than full analyst reports; where this occurs, confidence should be treated as medium unless primary report access is obtained. diff --git a/flexus_simple_bots/strategist/skills/_rtm-partner-channel/SKILL.md b/flexus_simple_bots/strategist/skills/_rtm-partner-channel/SKILL.md new file mode 100644 index 00000000..49b22b7f --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_rtm-partner-channel/SKILL.md @@ -0,0 +1,50 @@ +--- +name: rtm-partner-channel +description: Route-to-market partner channel design — reseller, integration, and co-selling partner strategy +--- + +You design the partner channel strategy: which types of partners to engage, what the partner value proposition is, and how the economics work. Partner channels extend reach without proportional headcount increase — but only work when the partner's incentive is genuinely aligned. + +Core mode: partners will not sell your product unless it makes their business better. "We'll pay 20% commission" is not a partner value proposition. "This fills a capability gap your customers complain about, and you can bill them for the implementation" is a partner value proposition. + +## Methodology + +### Partner type selection +**Technology / Integration partners**: companies whose product your ICP already uses. Co-selling advantage: joint landing in the account, shared infrastructure costs, integration removes switching friction. + +**Reseller / VAR**: companies that sell complementary services and bundle your product. Best when: your product is hard to implement (they make margin on services) or when they have exclusive channel access to your ICP. + +**Referral / Affiliate**: informal channel where customers or adjacent service providers refer leads. Low investment, low control. Best for high-volume, lower-ACV products. + +**OEM / White-label**: another company embeds your product in theirs. High volume, low brand presence, margin compression. Use when: distribution matters more than brand. + +### Partner economics +For each partner type, define: +- Revenue share: % of contract value paid to partner +- Co-selling commission: one-time vs. recurring +- Minimum deal size / deal registration process +- Partner tier structure (if applicable) + +Economics must work for both sides: +- Partner needs ≥20% margin OR embedded in a larger services deal +- You need to net positive after partner margin at your target CAC + +### Partner ICP +Not every company is a good partner. Define partner ICP: +- Market overlap: >50% of their customers should match your ICP +- Non-competitive: no direct overlap in core functionality +- Commitment capacity: do they have a dedicated sales team or technical team? + +## Recording + +``` +write_artifact(path="/strategy/rtm-partner-channel", data={...}) +``` + +## Available Tools + +``` +flexus_policy_document(op="activate", args={"p": "/strategy/gtm-channel-strategy"}) +flexus_policy_document(op="activate", args={"p": "/segments/{segment_id}/icp-scorecard"}) +crunchbase(op="call", args={"method_id": "crunchbase.searches.organizations.post.v1", "field_ids": ["name", "categories", "funding_total"], "query": []}) +``` diff --git a/flexus_simple_bots/strategist/skills/_rtm-sales-playbook/RESEARCH.md b/flexus_simple_bots/strategist/skills/_rtm-sales-playbook/RESEARCH.md new file mode 100644 index 00000000..dd3bdee4 --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_rtm-sales-playbook/RESEARCH.md @@ -0,0 +1,1007 @@ +# Research: rtm-sales-playbook + +**Skill path:** `strategist/skills/rtm-sales-playbook/` +**Bot:** strategist +**Research date:** 2026-03-05 +**Status:** complete + +--- + +## Context + +`rtm-sales-playbook` defines how the strategist bot should produce an operational B2B sales playbook from evidence, not brainstorming. The skill scope is discovery call structure, qualification criteria, objection handling scripts, and demo flow. The key constraint is that recommendations must come from real conversation artifacts (for example, call intelligence and pipeline outcomes), so the playbook reflects what closes in-market rather than generic sales advice. + +Research focus for this pass: update methodology and structure using 2024-2026 evidence, map current tools/channels/data constraints, define interpretation quality thresholds (signal vs noise), and codify anti-pattern detection/mitigation. Older sources are only used where still operationally useful and are explicitly labeled Evergreen. + +--- + +## Definition of Done + +Research is complete when ALL of the following are satisfied: + +- [x] At least 4 distinct research angles are covered (see Research Angles section) +- [x] Each finding has a source URL or named reference +- [x] Methodology section covers practical how-to, not just theory +- [x] Tool/API landscape is mapped with at least 3-5 concrete options +- [x] At least one "what not to do" / common failure mode is documented +- [x] Output schema recommendations are grounded in real-world data shapes +- [x] Gaps section honestly lists what was NOT found or is uncertain +- [x] All findings are from 2024-2026 unless explicitly marked as evergreen + +--- + +## Quality Gates + +Before marking status `complete`, verify: + +- [x] No generic filler without concrete backing +- [x] No invented tool names, method IDs, or API endpoints +- [x] Contradictions between sources are explicitly noted +- [x] Findings volume is within 800-4000 words + +--- + +## Research Angles + +### Angle 1: Domain Methodology & Best Practices +> How do practitioners actually do this in 2025-2026? What frameworks, mental models, step-by-step processes exist? What has changed recently? + +**Findings:** + +1. Discovery quality is strongly linked to disciplined dialogue rather than rep-dominant monologue. Gong’s 2025 analysis reported won calls around 57% rep talk vs 62% for lost calls, and winners asking fewer but better-targeted questions (roughly 15-16 vs ~20 for lost calls) [A1-S1]. This suggests the playbook should gate progression on balanced interaction and question quality, not just checklist completion. + +2. Evergreen Gong benchmarks still matter, but they should be treated as directional: earlier datasets suggest strongest performance around 11-14 high-quality discovery questions and identification of multiple business problems [A1-S2][A1-S3]. Tension with newer data (15-16 on won calls) implies threshold bands should be team-calibrated, not fixed constants. + +3. Modern BANT usage is multi-conversation and committee-aware, not a one-call interrogation. HubSpot’s 2025 updates emphasize ongoing qualification threads (budget, authority, need, timeline) that can be completed over time [A1-S4][A1-S5]. The playbook should require evidence-backed fields before stage advancement. + +4. MEDDICC-style elements remain useful as strict stage gates in complex B2B deals: clear economic buyer, validated champion, and explicit decision criteria [A1-S6][A1-S7][A1-S8]. The methodology implication is to forbid proposal-stage progression when these are unknown in higher-ACV opportunities. + +5. Buyer behavior data indicates reps often enter late in the journey and need immediate multi-threading. 6sense-reported findings (2024 coverage) indicate many buyers initiate first contact near ~70% through process, often with requirements and a preferred vendor already in mind [A1-S9][A1-S10]. Practical impact: early calls must map stakeholders and evaluation criteria immediately, not just run generic discovery. + +6. Demo methodology should be a discovery-mirrored story arc, not a feature tour. Evergreen Gong demo analysis found stronger outcomes when demos follow discovery topic order and avoid long uninterrupted pitching (no won demo in that dataset with >76s monologue) [A1-S11]. This still maps well to current “problem -> workflow -> outcome -> next step” patterns. + +7. Objection handling should be frequency-weighted and evidence-derived. Gong’s 2024 large-sample objection analysis found top objection clusters account for most objection volume [A1-S12]. This supports curating objection scripts by observed frequency/severity from call artifacts, not brainstormed edge cases. + +8. Next-step discipline (Mutual Action Plan behavior) is a practical win-rate lever. Salesforce guidance and Outreach reporting show that explicit owner/date/outcome commitments improve execution and correlate with better win outcomes [A1-S13][A1-S14]. The playbook should include mandatory MAP fields in handoff and stage exit. + +9. Follow-up coverage and speed are now foundational process controls, not optional productivity tips. Salesforce’s 2025 customer-zero case highlights measurable gains when lead response and re-engagement are operationalized with tight SLAs [A1-S15]. Discovery quality is weakened if qualified leads are not handled quickly and consistently. + +**Sources:** +- [A1-S1] Gong Labs, "Mastering the talk-to-listen ratio in sales calls," 2025-08-21 (updated 2025-03-20): https://www.gong.io/resources/labs/talk-to-listen-conversion-ratio +- [A1-S2] Gong Labs (Evergreen), "Effective strategies for successful sales discovery calls," 2017-06-18: https://www.gong.io/resources/labs/nailing-your-sales-discovery-calls/ +- [A1-S3] Gong Labs (Evergreen), "Mastering discovery calls to close deals effectively," 2017-07-05: https://www.gong.io/resources/labs/deal-closing-discovery-call/ +- [A1-S4] HubSpot, "How I use BANT to qualify prospects," updated 2025-07-31: https://blog.hubspot.com/sales/bant +- [A1-S5] HubSpot, "Sales Qualification: Gauging Whether a Lead Aligns With Your Offering," updated 2025-03-18: https://blog.hubspot.com/sales/ultimate-guide-to-sales-qualification +- [A1-S6] MEDDICC, "Economic Buyer": https://meddicc.com/what-is-meddpicc/economic-buyer +- [A1-S7] MEDDICC, "Champion": https://meddicc.com/what-is-meddpicc/champion +- [A1-S8] MEDDICC, "Decision Criteria": https://meddicc.com/what-is-meddpicc/decision-criteria +- [A1-S9] Demand Gen Report (reporting 6sense findings), 2024-10-10: https://www.demandgenreport.com/industry-news/80-of-b2b-buyers-initiate-first-contact-once-theyre-70-through-their-buying-journey/48394/ +- [A1-S10] 6sense (Evergreen article covering survey findings), "The Point of First Contact Constant": https://6sense.com/blog/dont-call-us-well-call-you-what-research-says-about-when-b2b-buyers-reach-out-to-sellers/ +- [A1-S11] Gong Labs (Evergreen), "Effective strategies for conducting successful sales demos," 2017-09-14: https://www.gong.io/resources/labs/sales-demos/ +- [A1-S12] Gong Labs, "Top objections across 300M cold calls," 2024-07-31: https://www.gong.io/resources/labs/we-found-the-top-objections-across-300m-cold-calls-heres-how-to-handle-them-all/ +- [A1-S13] Salesforce, "A Guide to Using a Mutual Action Plan," 2024-04-12: https://www.salesforce.com/blog/mutual-action-plan/?bc=HA +- [A1-S14] Outreach, "How to improve win rates by 26% with a best-in-class mutual action plan," 2024-02-19: https://www.outreach.io/resources/blog/how-to-use-mutual-action-plans +- [A1-S15] Salesforce, "Agentic Sales: How Salesforce Found a Better Way To Sell," 2025-10-09: https://www.salesforce.com/blog/ai-for-lead-qualification/ + +--- + +### Angle 2: Tool, Channel & Data Landscape +> What tools, APIs, and data providers exist for this domain? What are their actual capabilities, limitations, pricing tiers, rate limits? What are the de-facto industry standards? + +**Findings:** + +1. Call intelligence tooling is mature enough to support evidence-first playbook loops. Gong documents capture/transcribe/analyze workflows and 2025-2026 updates include AI features for pattern detection and extraction [A2-S1][A2-S2]. This makes recurring objection/theme mining feasible without full manual coding. + +2. HubSpot and Salesloft provide concrete integration points for conversation and pipeline data, but with non-trivial technical constraints. HubSpot recording/transcription ingestion has strict media/transport requirements [A2-S3], while Salesloft documents conversation/transcription endpoints including “extensive” payloads [A2-S7][A2-S8][A2-S9]. Practical implication: ingestion QA must be part of the methodology. + +3. Pipeline normalization is a prerequisite for interpretation quality. HubSpot’s pipeline and deal APIs, stage IDs, and history access require explicit ID mapping and stage discipline [A2-S4][A2-S5][A2-S6]. Without normalized stage taxonomy, signal comparisons and win/loss learning are unreliable. + +4. Sales engagement telemetry can be misleading if semantics are ignored. Gong Engage export behavior has completion-state caveats [A2-S10], and Outreach metrics include aggregate/non-unique semantics in places [A2-S11][A2-S12]. You should not combine cross-platform activity metrics without a common metric dictionary. + +5. Consent and retention controls vary materially by platform and jurisdiction. Gong exposes consent profile controls with provider dependencies [A2-S13]; Microsoft Teams policies include explicit consent/retention behaviors and default expiration controls [A2-S14]. This should be encoded as compliance guardrails in the playbook artifact, not external tribal knowledge. + +6. Regulatory constraints influence data channels and usage permissions. ICO guidance (2025 update) emphasizes lawful basis/transparency/objector handling for direct marketing and related processing [A2-S15]. For EU/UK teams, this directly affects which call artifacts can be used for model and script optimization. + +7. Vendor capabilities are often well-documented while pricing/rate limits are not. Salesloft and Outreach references provide endpoint/shape clarity but not complete public quota/pricing tables [A2-S7][A2-S11]. A safe research posture is “not publicly documented” rather than assumptions. + +8. Win/loss enrichment loops are increasingly productized (for example, closed-won linked intent features) [A2-S16], but claims are often release-note level; teams still need local validation before hard-coding thresholds. + +**Tool Matrix (compact):** + +| Tool | Category | Strengths | Limitations | Sources | +|---|---|---|---|---| +| Gong | Call intelligence + analytics | Conversation capture, transcript analytics, AI pattern features | Some feature/plan boundaries not fully public | [A2-S1][A2-S2] | +| HubSpot | CRM + conversation ingestion | Structured pipelines/deals; explicit ingestion requirements | Data quality depends on media and stage ID discipline | [A2-S3][A2-S4][A2-S5][A2-S6] | +| Salesloft | Conversation API | Documented conversation/transcription endpoints | Pricing/rate limits not fully public | [A2-S7][A2-S8][A2-S9] | +| Outreach | Sales engagement API | Sequence/event primitives; activity telemetry | Metric semantics require care; pricing/rate limits not fully public | [A2-S11][A2-S12] | +| Microsoft Teams | Recording governance | Explicit consent and retention controls | Policy behavior requires tenant configuration | [A2-S14] | +| Gong Engage export | Channel-to-CRM data sync | Multi-channel activity capture | Export timing/state caveats | [A2-S10] | + +**Sources:** +- [A2-S1] Gong Help, "Capture and analyze calls": https://help.gong.io/docs/capturing-and-analyzing-your-calls +- [A2-S2] Gong Help, "Our monthly updates" (2025-2026): https://help.gong.io/docs/our-monthly-updates +- [A2-S3] HubSpot Developers, "Call recordings and transcripts": https://developers.hubspot.com/docs/guides/apps/extensions/calling-extensions/recordings-and-transcriptions +- [A2-S4] HubSpot Developers, "CRM API | Pipelines": https://developers.hubspot.com/docs/api/crm/pipelines +- [A2-S5] HubSpot Knowledge Base, "Set up and manage object pipelines," updated 2026-01-27: https://knowledge.hubspot.com/object-settings/set-up-and-customize-your-deal-pipelines-and-deal-stages +- [A2-S6] HubSpot Developers, "CRM API | Deals": https://developers.hubspot.com/docs/api/crm/deals +- [A2-S7] Salesloft Developers, "Conversations": https://developers.salesloft.com/docs/api/conversations/ +- [A2-S8] Salesloft Developers, "Fetch an extensive conversation": https://developers.salesloft.com/docs/api/conversations-find-one-extensive/ +- [A2-S9] Salesloft Developers, "Fetch a transcription": https://developers.salesloft.com/docs/api/conversations-transcriptions-find-one-transcript/ +- [A2-S10] Gong Help, "How Engage data is exported to your CRM": https://help.gong.io/docs/how-engage-exports-data-to-your-crm +- [A2-S11] Outreach Developers, "Common API patterns": https://developers.outreach.io/api/common-patterns +- [A2-S12] Outreach Developers, "Sequence Step API reference": https://developers.outreach.io/api/reference/tag/Sequence-Step/ +- [A2-S13] Gong Help, "Call recording and consent settings": https://help.gong.io/docs/call-recording-and-consent-settings +- [A2-S14] Microsoft Learn, "Manage Teams recording policies," updated 2025-05-13: https://learn.microsoft.com/en-us/microsoftteams/meeting-recording?tabs=meeting-policy +- [A2-S15] ICO, direct marketing/live calls guidance, updated 2025-08-20: https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/guidance-on-direct-marketing-using-live-calls/what-else-do-we-need-to-consider +- [A2-S16] ZoomInfo Pipeline, "Q4 2024 release notes": https://pipeline.zoominfo.com/sales/zoominfo-q4-2024-release + +--- + +### Angle 3: Data Interpretation & Signal Quality +> How do practitioners interpret the data from these tools? What thresholds matter? What's signal vs noise? What are common misinterpretations? What benchmarks exist? + +**Findings:** + +1. Multithreading is a high-signal indicator, but thresholds vary by segment. Forrester reports large buying groups, Gong finds major uplift with higher contact coverage in larger deals, and Ebsta reports elite sellers with broader active stakeholder engagement [A3-S1][A3-S2][A3-S3]. Implication: enforce stakeholder coverage by ACV band, not one global target. + +2. Early economic-buyer/champion involvement is a stronger signal than “friendly user engagement.” Ebsta’s findings point to substantial performance differences when decision-maker access happens earlier [A3-S3][A3-S4]. Playbook interpretation should treat delayed EB access as risk escalation. + +3. Stage velocity and meeting cadence are practical warning signals. Ebsta benchmark reporting ties long inactivity/slippage to materially lower outcomes [A3-S4]. Interpretation policy should include inactivity thresholds and slippage bands that trigger reset/re-qualification. + +4. Next-step quality outperforms “calendar next step” as a predictor. MAP-driven execution correlates with better outcomes in Outreach reporting [A3-S5]. Interpretation should score next steps for owner/date/outcome/stakeholder specificity. + +5. Talk/listen metrics are useful but easy to overfit. Gong’s newer data shows modest won/lost average differences while consistency and interaction quality matter more [A3-S6]. Avoid rigid single-number coaching. + +6. AI adoption claims are noisy without data readiness context. Salesforce and Clari show that AI usage can correlate with growth while many teams still miss targets and report low trust/readiness in core revenue data [A3-S7][A3-S8]. Interpretation must gate playbook changes on data quality checks. + +7. Robust playbook updates require validation discipline: cohort slicing, recent win/loss feedback, and scorer calibration. Clari/Gong/Klue references support larger-sample, freshness-aware, and method-aware interpretation approaches [A3-S8][A3-S9][A3-S10][A3-S11]. This should become an explicit “change approval” protocol. + +8. Source disagreement is common and should be handled procedurally, not rhetorically. Stakeholder benchmarks and talk-ratio guidance differ by source and population [A3-S2][A3-S3][A3-S6]. Resolve by local baselineing and requiring confirmation from both conversation and pipeline data before changing scripts. + +**Practical Thresholds / Decision Rules:** + +- Treat single-threaded opportunities as high risk; use segment-specific stakeholder minimums informed by ACV and motion complexity [A3-S1][A3-S2][A3-S3]. +- Require economic buyer identified before proposal in complex deals; require champion evidence before forecast commit [A3-S3][A3-S4]. +- Flag opportunities with >7 days meeting gap or stale weekly progress as “re-qualify required” [A3-S4]. +- Use slippage bands to trigger escalation and pipeline hygiene checks [A3-S4]. +- Use talk ratio as a band + consistency check (not a universal golden ratio) [A3-S6]. +- Require structured MAP fields before high-confidence stage advancement [A3-S5]. +- Apply a data readiness gate before script updates (coverage/trust/completeness checks) [A3-S7][A3-S8]. +- Require monthly (or faster) win/loss freshness reviews for all major playbook edits [A3-S10]. + +**Sources:** +- [A3-S1] Forrester press release, "The State Of Business Buying, 2024," 2024-12-04: https://www.forrester.com/press-newsroom/forrester-the-state-of-business-buying-2024/ +- [A3-S2] Gong Labs, "Top reps orchestrate with AI," 2025-04-28: https://www.gong.io/blog/data-shows-top-reps-dont-just-sell-they-orchestrate-with-ai +- [A3-S3] Ebsta, "What sets elite sellers apart," 2025-02-18 (updated 2025-08-27): https://www.ebsta.com/blog/what-sets-elite-sellers-apart/ +- [A3-S4] Ebsta, "GTM Benchmark Report 2025: Sales Efficiency": https://benchmarks.ebsta.com/hubfs/V3%202025%20Benchmark%20Report/2025_gtm_digest_-_sales_efficiency.pdf?hsLang=en +- [A3-S5] Outreach, "How to improve win rates by 26% with MAP," 2024-02-19: https://www.outreach.io/resources/blog/how-to-use-mutual-action-plans +- [A3-S6] Gong Labs, "Talk-to-listen ratio," 2025 update: https://www.gong.io/resources/labs/talk-to-listen-conversion-ratio +- [A3-S7] Salesforce News, sales AI statistics, 2024-07-25: https://www.salesforce.com/news/stories/sales-ai-statistics-2024/?bc=HA +- [A3-S8] Clari press release, 2026-01-14: https://www.clari.com/press/new-clari-labs-research-reveals-enterprises-missed-revenue-targets-in-2025/ +- [A3-S9] Gong Labs, "State of Revenue AI 2026" report PDF: https://www.gong.io/files/gong-labs-state-of-revenue-ai-2026.pdf +- [A3-S10] Klue, "2025 Win-Loss Trends Report": https://klue.com/win-loss-trends-report +- [A3-S11] Gong Help, "All about scorecards": https://help.gong.io/docs/all-about-scorecards + +--- + +### Angle 4: Failure Modes & Anti-Patterns +> What goes wrong in practice? What are the most common mistakes, gotchas, and traps? What does "bad output" look like vs "good output"? Case studies of failure if available. + +**Findings:** + +1. **Playbook shelfware:** teams publish playbooks but do not instrument coaching/adoption, leading to inconsistent execution [A4-S1]. + - Detection signal: no measurable coaching loops, no adoption KPI trend, no behavior consistency checks. + - Consequence: weak rep consistency and poor transfer to outcomes. + - Mitigation: build recurring reinforcement cycles tied to observed call and pipeline behavior. + +2. **Premature pitching in discovery:** reps pivot to product too early. + - Detection signal: talk-share drift toward lost-call profile and low buyer participation [A4-S4]. + - Consequence: poor problem diagnosis and weaker conversion. + - Mitigation: enforce diagnose-first stage exit criteria. + +3. **Checklist interrogation:** question-volume spikes without context. + - Detection signal: high question count with low problem-depth capture [A4-S4][A4-S5]. + - Consequence: buyer fatigue and noisy qualification data. + - Mitigation: quality-weighted questioning with explicit intent per question. + +4. **Fake qualification (box-checking):** “BANT complete” but weak fit/value proof. + - Detection signal: late-stage losses tagged poor fit/value [A4-S3]. + - Consequence: forecast pollution and wasted demo/proposal effort. + - Mitigation: evidence-gated qualification with disqualifier rules. + +5. **Script recitation for objections:** reps answer before diagnosing objection class. + - Detection signal: transactional buyer feedback and weak trust signals [A4-S2]. + - Consequence: lower persuasion and more discount pressure. + - Mitigation: pause -> clarify -> respond -> confirm -> pivot protocol. + +6. **Discount-first reflex:** immediate price concession. + - Detection signal: repeated value-for-money loss reasons and pricing-heavy late demos [A4-S3][A4-S6]. + - Consequence: margin erosion without solving value proof gaps. + - Mitigation: value-first sequence, pricing only after quantified outcome framing. + +7. **Feature-tour demos:** broad product tour disconnected from discovery priorities. + - Detection signal: demo order does not map to discovery pain rank [A4-S6]. + - Consequence: low relevance perception. + - Mitigation: discovery-mirrored demo narrative with one core workflow. + +8. **Monologue-heavy demos:** + - Detection signal: long uninterrupted pitch segments (Evergreen benchmark >76s risk marker) [A4-S6]. + - Consequence: reduced interaction and weak next-step commitments. + - Mitigation: short explanation bursts with explicit interaction checkpoints. + +9. **Consent blind spots in call recording/transcription:** + - Detection signal: no auditable consent trail or jurisdiction-aware routing [A4-S7][A4-S10]. + - Consequence: legal/regulatory risk (TCPA/all-party contexts). + - Mitigation: explicit consent capture policy + audit log requirements. + +10. **Data minimization failure (hoarding transcripts/PII):** + - Detection signal: indefinite retention and unclear lawful basis [A4-S8][A4-S9]. + - Consequence: elevated enforcement and breach exposure. + - Mitigation: minimization, purpose limitation, retention boundaries, and lawful-basis documentation. + +**Bad vs Good Output Patterns:** + +- `Discovery`: Bad = pitch-first; Good = diagnose-first with interaction discipline [A4-S4][A4-S5] +- `Qualification`: Bad = checkbox progression; Good = evidence-gated advancement [A4-S3] +- `Objections`: Bad = canned rebuttal; Good = diagnosis-driven response [A4-S2] +- `Demo`: Bad = feature dump + long monologues; Good = discovery-linked story + short interactive blocks [A4-S6] +- `Compliance`: Bad = blanket recording; Good = jurisdiction-aware consent and retention controls [A4-S7][A4-S8][A4-S9][A4-S10] + +**Sources:** +- [A4-S1] Highspot, "State of Sales Enablement Report 2024": https://www.highspot.com/resource/state-of-sales-enablement-report-2024/ +- [A4-S2] Salesforce, "Sales statistics," 2024: https://www.salesforce.com/in/blog/15-sales-statistics/ +- [A4-S3] HubSpot, "State of Sales Report," updated 2025-08-29: https://blog.hubspot.com/sales/hubspot-sales-strategy-report +- [A4-S4] Gong Labs, "Talk-to-listen ratio," 2025: https://www.gong.io/resources/labs/talk-to-listen-conversion-ratio +- [A4-S5] Gong Labs (Evergreen), "Discovery calls," 2017: https://www.gong.io/resources/labs/nailing-your-sales-discovery-calls/ +- [A4-S6] Gong Labs (Evergreen), "Sales demos," 2017: https://www.gong.io/resources/labs/sales-demos/ +- [A4-S7] FCC, "FCC 24-17 Declaratory Ruling," 2024-02-08: https://docs.fcc.gov/public/attachments/FCC-24-17A1.pdf +- [A4-S8] CPPA, "Enforcement Advisory No. 2024-01," 2024-04-02: https://cppa.ca.gov/pdf/enfadvisory202401.pdf +- [A4-S9] EDPB, "Guidelines 1/2024 on Article 6(1)(f)," 2024-10: https://www.edpb.europa.eu/our-work-tools/documents/public-consultations/2024/guidelines-12024-processing-personal-data-based_en +- [A4-S10] Kilpatrick Townsend, "Wiretap Laws in the United States," 2024-07-29: https://ktslaw.com/Blog/globalprivacy%20and%20cybersecuritylaw/2024/7/wiretap%20laws%20in%20the%20united%20states + +--- + +### Angle 5+: Regulatory & Governance Context for Conversation-Derived Playbooks +> Compliance constraints can invalidate otherwise strong playbook logic if evidence collection/use is non-compliant. + +**Findings:** + +1. AI/artificial voice treatment under TCPA received explicit FCC attention in 2024, which raises risk for scripted outreach and automated objection-handling contexts if consent assumptions are weak [A5-S1]. + +2. US wiretap/recording consent obligations are jurisdiction-sensitive (for example, all-party consent states), and cross-state calls increase policy complexity [A5-S2]. The skill should instruct conservative defaults and legal review triggers for uncertain jurisdictions. + +3. UK/EU-style lawful basis and balancing tests are not optional formalities for call transcript reuse; they determine whether secondary analytical use is allowed [A5-S3]. For multinational teams, this means transcript usage policy must be codified as part of playbook generation. + +4. Data minimization enforcement priorities (2024) show that over-collection and indefinite retention are active risk areas [A5-S4]. The playbook should include data retention and evidence minimization fields in the artifact schema. + +5. Platform policy controls (for example, Teams recording retention/expiration) can enforce guardrails technically [A5-S5]. Methodology should treat these controls as requirements, not post-hoc recommendations. + +**Sources:** +- [A5-S1] FCC, "FCC 24-17 Declaratory Ruling," 2024-02-08: https://docs.fcc.gov/public/attachments/FCC-24-17A1.pdf +- [A5-S2] Kilpatrick Townsend, "Wiretap Laws in the United States," 2024-07-29: https://ktslaw.com/Blog/globalprivacy%20and%20cybersecuritylaw/2024/7/wiretap%20laws%20in%20the%20united%20states +- [A5-S3] EDPB, "Guidelines 1/2024 on Article 6(1)(f)," 2024-10: https://www.edpb.europa.eu/our-work-tools/documents/public-consultations/2024/guidelines-12024-processing-personal-data-based_en +- [A5-S4] CPPA, "Enforcement Advisory No. 2024-01," 2024-04-02: https://cppa.ca.gov/pdf/enfadvisory202401.pdf +- [A5-S5] Microsoft Learn, "Manage Teams recording policies," updated 2025-05-13: https://learn.microsoft.com/en-us/microsoftteams/meeting-recording?tabs=meeting-policy + +--- + +## Synthesis + +Recent evidence supports the current skill’s core principle (evidence over hypotheticals), but the methodology needs sharper operating gates. The biggest update from 2024-2026 data is not a brand-new framework; it is stricter instrumentation of discovery quality, qualification evidence, and stage progression. Discovery and demo outcomes still track with conversational quality and narrative relevance, but rigid “one-number” metrics are risky without segment context [A1-S1][A3-S6]. + +Qualification frameworks (BANT/MEDDICC families) are still useful, but best teams run them as decision gates over multiple interactions rather than one-call scripts. This aligns with buyer behavior data showing later seller entry and larger buying groups, which increases the importance of stakeholder mapping and economic-buyer/champion proof early in cycle [A1-S4][A1-S9][A3-S1][A3-S3]. In practice, the playbook should explicitly prevent progression when mandatory evidence is missing. + +A strong contradiction appears in metric interpretation: some sources imply actionable benchmark thresholds (talk ratio, stakeholder counts, slippage bands), while newer analyses warn that consistency and local baselines matter more than universal targets [A1-S1][A3-S6]. The right synthesis is to use benchmarks as guardrails and require local validation (conversation + pipeline confirmation) before changing scripts or gates. + +Tooling is adequate for evidence loops, but data quality and governance are the practical bottlenecks. API/docs can provide transcript and stage-history access, but metric semantics, consent policy, retention windows, and lawful-basis constraints can invalidate downstream analysis if ignored [A2-S3][A2-S10][A2-S14][A5-S3]. The most important improvement to SKILL.md is therefore an explicit interpretation/compliance layer plus schema fields that force evidence provenance and change-control metadata. + +--- + +## Recommendations for SKILL.md + +- [x] Add an explicit **Evidence Standard & Update Cadence** section with minimum sample, freshness window, and dual-source validation (conversation + pipeline) before changing playbook rules. +- [x] Upgrade **Discovery Framework** with quality gates (interaction balance, targeted question quality, problem-depth capture) and explicit no-advance conditions. +- [x] Replace soft qualification language with **hard stage-exit decision gates** combining BANT and MEDDICC evidence, especially for higher-ACV deals. +- [x] Expand **Objection Handling** into a diagnosis-first protocol with required evidence citations and discount-control rules. +- [x] Expand **Demo Structure** into a discovery-mirrored narrative with monologue guardrails and mandatory mutual next-step commitments. +- [x] Add **Handoff + MAP requirements** so SDR/AE transition and next steps are measurable and rejectable when incomplete. +- [x] Add **Interpretation Quality** guidance (signal/noise rules, benchmark caveats, contradiction handling, and re-validation protocol). +- [x] Add a named **Anti-Patterns** block library with detection signals, consequences, and mitigations. +- [x] Extend artifact schema with `evidence_ledger`, `stage_exit_rules`, `interpretation_policy`, `handoff_protocol`, and `compliance_guardrails`. + +--- + +## Draft Content for SKILL.md + +### Draft: Evidence Standard & Operating Mode + +You must build this playbook from observed evidence, never from hypothetical objection lists or generic sales templates. Before writing any rule (question, disqualifier, objection response, demo step), you should confirm that rule from at least one conversation-derived artifact and one pipeline outcome signal. If the conversation signal says a behavior is good but pipeline conversion does not improve (or worsens), treat the rule as unproven and keep it out of the default playbook. + +Use this evidence standard: + +1. **Minimum evidence volume:** collect a meaningful set of recent calls before changing baseline rules. If recent volume is low, label confidence as low and avoid hard thresholds. +2. **Freshness window:** default to recent evidence first; if recent coverage is insufficient, expand window and explicitly label the confidence tradeoff. +3. **Dual-source confirmation:** do not ship a behavior rule from call patterns alone. Confirm in opportunity progression or win/loss trend by segment. +4. **Segment discipline:** do not mix SMB, mid-market, and enterprise patterns into one rule unless evidence shows consistency across segments. +5. **Contradiction logging:** if credible sources disagree, document the disagreement and choose a local policy rule (for example: “band, not fixed value”) instead of pretending certainty. + +Do not write “best practice” rules without evidence references. A valid rule in this skill should include `why this exists` plus `what evidence supports it` and `when it should not be used`. + +### Draft: Discovery Call Framework (Evidence-Gated) + +The discovery call has one job: determine whether this prospect should advance, based on evidence of need, fit, and next-step feasibility. You should run discovery as a structured diagnostic conversation, not an interview script and not a product pitch. + +Use this sequence: + +1. **Opener (2-3 min):** state why this call exists now, grounded in a known signal. Set expectation that the first goal is diagnosis, not a feature walkthrough. +2. **Problem exploration (12-18 min):** ask targeted questions tied to JTBD outcomes and downstream impact. You should prioritize depth over question count. If you are asking many questions but capturing shallow evidence, stop and reframe. +3. **Qualification evidence capture (5-8 min):** gather budget/authority/need/timeline evidence and stakeholder map. If a field is unknown, mark unknown explicitly; do not infer. +4. **Conditional mini-pitch (3-5 min):** only if qualification floor is met. Keep this to outcome framing and one relevant workflow; do not start full demo. +5. **Next-step commitment (3-5 min):** secure a specific owner/date/outcome commitment. “We will follow up” is not a valid next step. + +Apply these discovery quality guardrails: + +- Keep conversation interactive and buyer-centered. If the call trends toward rep-dominant monologue, pause and return to diagnosis. +- Optimize for high-quality targeted questions, not maximum volume. +- Capture at least multiple concrete problem statements with downstream business impact before advancing. +- Log evidence quotes and source references for each major qualification claim. + +No-advance conditions from discovery: + +- You cannot articulate the prospect’s current-state workflow and failure modes. +- Need evidence is generic (“they want efficiency”) with no concrete business impact. +- Stakeholder landscape is unknown for multi-stakeholder deals. +- No mutually agreed next step with owner/date/outcome. + +### Draft: Qualification Decision Gates (BANT + MEDDICC Logic) + +You should use BANT as a living evidence thread and MEDDICC elements as hard gates for complex opportunities. Treat qualification as a set of stage-exit criteria, not a checklist completed in one meeting. + +**BANT evidence rules:** + +- **Budget:** record concrete budget signal (allocated, range, procurement path, or validated spend authority). If budget is unknown, keep stage at risk and define a discovery action to resolve. +- **Authority:** identify real decision roles, not just active participants. Document who can approve, who can block, and who influences. +- **Need:** map need to specific JTBD pain and measurable consequence. +- **Timeline:** capture decision timing and trigger event; treat open-ended timelines as risk. + +**MEDDICC gate rules for higher-complexity opportunities:** + +- Require identified economic buyer before proposal/commit stage. +- Require validated champion (influence + motivation + organizational access), not “friendly contact.” +- Require explicit decision criteria and proof plan aligned to buyer process. + +Decision rule: + +- If any mandatory gate is missing, do not advance stage. Instead, write a qualification recovery plan with owner/date and exact data needed. +- If qualification evidence is contradictory (for example, strong champion but unknown buying authority), mark confidence reduced and avoid forecast optimism. + +Do not mark deals “qualified” because fields are syntactically filled. Qualification is valid only when evidence quality is high enough that another seller could audit and reach the same conclusion. + +### Draft: Objection Handling Protocol (Evidence-Cited, Diagnosis-First) + +You must build objection scripts from real objection instances observed in conversation artifacts. For each objection class, include: trigger phrase pattern, diagnosis questions, response options, proof asset, and pivot step. + +Use this sequence every time: + +1. **Pause:** do not immediately rebut. +2. **Clarify objection type:** isolate whether this is budget, timing, fit, risk, incumbent preference, or internal priority conflict. +3. **Confirm consequence:** ask what happens if this concern is not resolved. +4. **Respond with evidence:** use concise response tied to relevant proof. +5. **Pivot to decision movement:** ask for a concrete next action tied to this resolved concern. + +Script quality rules: + +- Every script must include `evidence_source` from real artifacts. +- Every script must define `use_when` and `do_not_use_when` conditions. +- If the objection is price-related, do not default to discounting. Re-anchor on value and decision criteria first. +- Retire scripts that repeatedly fail to move next-step commitment. + +Bad pattern to avoid: + +- Rebuttal monologue that ignores the buyer’s specific concern category. + +Good pattern to enforce: + +- Diagnostic questioning + short evidence-backed response + explicit next-step proposal. + +### Draft: Demo Structure (Discovery-Mirrored Story, Not Feature Tour) + +You should structure demos as a narrative that mirrors discovery priorities. Start with the most painful confirmed problem, show one complete workflow that resolves it, and end with quantified after-state plus next-step commitment. + +Recommended flow: + +1. **Scene set:** restate the agreed problem and decision criteria. +2. **Before state:** show current process friction in concrete terms. +3. **Core workflow:** demonstrate one end-to-end path that resolves priority pain. +4. **Proof point:** highlight one “aha” moment tied to the buyer’s stated outcome. +5. **After state:** summarize expected business effect and implementation reality. +6. **Mutual next step:** define owner/date/success criterion. + +Delivery guardrails: + +- Avoid long uninterrupted pitching; design deliberate interaction checkpoints. +- Do not “save best for last” if discovery already identified highest-priority pain. +- Do not add unrelated feature branches unless requested. +- If a critical stakeholder is absent, avoid commitment-heavy close behavior and schedule stakeholder-complete follow-up. + +No-advance conditions from demo: + +- Demo order does not map to discovery priorities. +- No explicit outcome linkage from shown workflow to buyer criteria. +- No buyer-side owner/date commitment at close. + +### Draft: Handoff Protocol and Mutual Action Plan (MAP) + +You must treat SDR-to-AE (or initial-to-advanced seller) handoff as a qualification gate, not an administrative transfer. A handoff is valid only if required evidence is complete and auditable. + +Required handoff payload: + +- Prospect context and trigger event +- Confirmed pain statements and business impact +- Stakeholder map (decision, influence, blockers) +- Qualification status with known unknowns +- Objections encountered + response outcomes +- Proposed next meeting objective +- Draft mutual action plan entries (owner/date/outcome) + +Handoff rejection rules: + +- Reject if required fields are missing. +- Reject if “qualified” status exists without supporting evidence references. +- Reject if next-step commitment is vague or buyer-agnostic. + +MAP instruction: + +You should create and maintain a mutual action plan for opportunities beyond initial qualification. MAP entries must include both seller and buyer owners, due dates, dependency notes, and success criteria. A calendar invite without explicit expected outcome does not count as MAP progress. + +### Draft: Interpretation Quality and Playbook Update Cadence + +You should update this playbook on a fixed cadence and only after passing interpretation checks. Do not update scripts after isolated anecdotal wins/losses. + +Update protocol: + +1. **Collect:** aggregate recent calls, objections, stage movement, and closed outcomes by segment. +2. **Score signal quality:** verify sample adequacy, freshness, and data completeness. +3. **Compare cohorts:** evaluate winners vs losers across conversation behavior and qualification completeness. +4. **Test candidate change:** apply one change at a time where possible; monitor stage conversion and next-step quality. +5. **Promote or rollback:** keep changes that improve outcomes and maintain compliance; retire those that do not. + +Signal/noise rules: + +- Treat stakeholder breadth and stage velocity as stronger leading signals than isolated talk-ratio values. +- Use talk ratio as a contextual band plus consistency check, not a universal target. +- Require both conversation evidence and pipeline outcome support before making a hard playbook change. +- If source benchmarks conflict, prefer local segment baselines and annotate uncertainty. + +Quality gate for any playbook edit: + +- Evidence references attached +- Contradictions logged +- Compliance impact reviewed +- Confidence level labeled + +### Draft: Anti-Pattern Warning Blocks + +#### Warning: Discovery Pitch-First Drift + +- **What it looks like:** You start product explanation before diagnosing pain and workflow. +- **Detection signal:** Rep-dominant call behavior and shallow pain evidence capture. +- **Consequence if missed:** You progress deals with weak problem clarity and low relevance. +- **Mitigation:** Pause demo behavior, run diagnostic question sequence, re-establish pain map before solution discussion. + +#### Warning: Qualification Checkbox Illusion + +- **What it looks like:** BANT/MEDDICC fields are present but unsupported by evidence. +- **Detection signal:** Late-stage losses citing fit/value mismatch despite “qualified” status. +- **Consequence if missed:** Forecast inflation and wasted late-stage resources. +- **Mitigation:** Enforce evidence-cited fields and stage rejection on missing gate criteria. + +#### Warning: Objection Rebuttal Without Diagnosis + +- **What it looks like:** You deliver canned responses immediately. +- **Detection signal:** High objection recurrence and low next-step conversion after objections. +- **Consequence if missed:** Trust erosion and discount pressure. +- **Mitigation:** Force pause/clarify/respond/pivot sequence and script use-conditions. + +#### Warning: Feature Tour Demo + +- **What it looks like:** Demo covers many features but not the buyer’s priority pains. +- **Detection signal:** Demo order diverges from discovery ranking; weak buyer interaction. +- **Consequence if missed:** Low perceived fit and weak buying momentum. +- **Mitigation:** Rebuild demo around one core workflow tied to top-priority outcome. + +#### Warning: Compliance-Oblivious Evidence Capture + +- **What it looks like:** Call recording/transcription runs without auditable consent and retention boundaries. +- **Detection signal:** Missing consent artifacts, undefined jurisdiction policy, indefinite data retention. +- **Consequence if missed:** Regulatory and legal exposure; evidence may become unusable. +- **Mitigation:** Apply jurisdiction-aware consent defaults, retention rules, and lawful-basis documentation. + +### Draft: Available Tools (Operational Usage Guidance) + +Use the following tools to ground the playbook in active policy and discovery artifacts. + +```python +flexus_policy_document(op="activate", args={"p": "/strategy/messaging"}) +``` + +Call this before drafting discovery and objection language so positioning and value claims align with approved messaging. + +```python +flexus_policy_document(op="activate", args={"p": "/discovery/{study_id}/jtbd-outcomes"}) +``` + +Call this before writing qualification need evidence and demo flow. You should map discovery questions and demo storyline to documented JTBD outcomes. + +```python +flexus_policy_document(op="list", args={"p": "/pipeline/"}) +``` + +Call this to inspect available pipeline artifacts and choose which stage/outcome evidence can validate playbook changes. Do not infer unavailable pipeline fields. + +```python +write_artifact( + artifact_type="sales_playbook", + path="/strategy/rtm-sales-playbook", + data={...}, +) +``` + +Write only after all mandatory evidence and stage gate fields are complete. If required fields are missing, do not write partial “final” output. + +### Draft: Schema additions + +```json +{ + "sales_playbook": { + "type": "object", + "required": [ + "created_at", + "target_segment", + "qualification_criteria", + "discovery_framework", + "objection_scripts", + "demo_structure", + "stage_exit_rules", + "evidence_ledger", + "interpretation_policy", + "handoff_protocol", + "compliance_guardrails" + ], + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string", + "format": "date-time", + "description": "ISO-8601 timestamp when this playbook artifact was generated." + }, + "target_segment": { + "type": "string", + "description": "Segment this playbook applies to (for example: SMB, mid-market, enterprise, or named ICP slice)." + }, + "qualification_criteria": { + "type": "object", + "required": [ + "budget_signal", + "authority_roles", + "need_evidence", + "timeline_max_days", + "disqualifiers", + "economic_buyer_required", + "champion_required" + ], + "additionalProperties": false, + "properties": { + "budget_signal": { + "type": "string", + "description": "Concrete budget evidence format, such as allocated range, approved budget line, or procurement path." + }, + "authority_roles": { + "type": "array", + "description": "Decision and influence roles that satisfy authority criteria for this segment.", + "items": { + "type": "string" + } + }, + "need_evidence": { + "type": "array", + "description": "Observed JTBD pain statements and impacts that must be present to qualify.", + "items": { + "type": "string" + } + }, + "timeline_max_days": { + "type": "integer", + "minimum": 0, + "description": "Maximum acceptable decision window in days before opportunity is considered low-priority or nurture." + }, + "disqualifiers": { + "type": "array", + "description": "Hard no-go conditions that block progression.", + "items": { + "type": "string" + } + }, + "economic_buyer_required": { + "type": "boolean", + "description": "Whether economic buyer identification is mandatory before proposal/commit stages." + }, + "champion_required": { + "type": "boolean", + "description": "Whether a validated internal champion is required for advancement." + } + } + }, + "discovery_framework": { + "type": "array", + "description": "Ordered discovery phases with operational goals, prompts, and exit evidence.", + "items": { + "type": "object", + "required": [ + "phase", + "duration_min", + "goal", + "questions", + "exit_evidence" + ], + "additionalProperties": false, + "properties": { + "phase": { + "type": "string", + "description": "Discovery phase name." + }, + "duration_min": { + "type": "integer", + "minimum": 0, + "description": "Recommended duration for this phase in minutes." + }, + "goal": { + "type": "string", + "description": "Operational purpose of this phase." + }, + "questions": { + "type": "array", + "description": "Question prompts used in this phase.", + "items": { + "type": "string" + } + }, + "exit_evidence": { + "type": "array", + "description": "Evidence that must be captured before leaving the phase.", + "items": { + "type": "string" + } + } + } + } + }, + "objection_scripts": { + "type": "array", + "description": "Evidence-backed objection handling modules.", + "items": { + "type": "object", + "required": [ + "objection_type", + "trigger_phrase", + "response", + "pivot", + "evidence_source", + "use_when", + "do_not_use_when", + "last_verified_at" + ], + "additionalProperties": false, + "properties": { + "objection_type": { + "type": "string", + "description": "Normalized objection class (for example budget, timing, incumbent, risk)." + }, + "trigger_phrase": { + "type": "string", + "description": "Representative objection statement from calls." + }, + "response": { + "type": "string", + "description": "Primary evidence-backed response text." + }, + "pivot": { + "type": "string", + "description": "Next-step question or action that advances decision process." + }, + "evidence_source": { + "type": "string", + "description": "Reference to conversation artifact(s) supporting this script." + }, + "use_when": { + "type": "array", + "description": "Conditions where this script is recommended.", + "items": { + "type": "string" + } + }, + "do_not_use_when": { + "type": "array", + "description": "Conditions where this script should be avoided.", + "items": { + "type": "string" + } + }, + "last_verified_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp of latest validation against recent outcomes." + } + } + } + }, + "demo_structure": { + "type": "array", + "description": "Ordered demo scenes linked to discovery evidence and expected buyer outcome.", + "items": { + "type": "object", + "required": [ + "scene", + "duration_min", + "key_message", + "wow_moment", + "proof_asset", + "linked_discovery_evidence" + ], + "additionalProperties": false, + "properties": { + "scene": { + "type": "string", + "description": "Scene identifier (before-state, core workflow, after-state, etc.)." + }, + "duration_min": { + "type": "integer", + "minimum": 0, + "description": "Target scene duration in minutes." + }, + "key_message": { + "type": "string", + "description": "Primary message to communicate in this scene." + }, + "wow_moment": { + "type": "boolean", + "description": "Whether this scene is the intended high-impact moment." + }, + "proof_asset": { + "type": "string", + "description": "Evidence or artifact used to substantiate this scene." + }, + "linked_discovery_evidence": { + "type": "array", + "description": "Discovery evidence references that justify showing this scene.", + "items": { + "type": "string" + } + } + } + } + }, + "stage_exit_rules": { + "type": "object", + "required": [ + "discovery_exit", + "qualification_exit", + "demo_exit", + "no_advance_if_missing" + ], + "additionalProperties": false, + "properties": { + "discovery_exit": { + "type": "array", + "description": "Conditions that must be true to leave discovery stage.", + "items": { + "type": "string" + } + }, + "qualification_exit": { + "type": "array", + "description": "Conditions that must be true to leave qualification stage.", + "items": { + "type": "string" + } + }, + "demo_exit": { + "type": "array", + "description": "Conditions that must be true to leave demo stage.", + "items": { + "type": "string" + } + }, + "no_advance_if_missing": { + "type": "array", + "description": "Global hard blockers that prevent stage progression when unresolved.", + "items": { + "type": "string" + } + } + } + }, + "evidence_ledger": { + "type": "array", + "description": "Auditable list of evidence snippets used to justify playbook rules.", + "items": { + "type": "object", + "required": [ + "evidence_id", + "source_type", + "source_ref", + "call_date", + "segment", + "deal_outcome", + "quote", + "supports" + ], + "additionalProperties": false, + "properties": { + "evidence_id": { + "type": "string", + "description": "Unique identifier for this evidence entry." + }, + "source_type": { + "type": "string", + "enum": [ + "call_transcript", + "call_summary", + "crm_opportunity", + "win_loss_note", + "other" + ], + "description": "Source category for the evidence entry." + }, + "source_ref": { + "type": "string", + "description": "Pointer to the underlying artifact path or external identifier." + }, + "call_date": { + "type": "string", + "format": "date", + "description": "Date of the related call or event." + }, + "segment": { + "type": "string", + "description": "Segment label for this evidence (must align with target segmentation rules)." + }, + "deal_outcome": { + "type": "string", + "enum": [ + "won", + "lost", + "open", + "unknown" + ], + "description": "Observed opportunity outcome at time of extraction." + }, + "quote": { + "type": "string", + "description": "Verbatim or near-verbatim snippet that supports a playbook element." + }, + "supports": { + "type": "array", + "description": "Playbook areas this evidence supports.", + "items": { + "type": "string", + "enum": [ + "discovery", + "qualification", + "objections", + "demo", + "handoff" + ] + } + } + } + } + }, + "interpretation_policy": { + "type": "object", + "required": [ + "review_cadence_days", + "min_calls_for_change", + "freshness_window_days", + "require_dual_source_validation", + "contradiction_resolution_rule" + ], + "additionalProperties": false, + "properties": { + "review_cadence_days": { + "type": "integer", + "minimum": 1, + "description": "How often this playbook must be reviewed and potentially updated." + }, + "min_calls_for_change": { + "type": "integer", + "minimum": 1, + "description": "Minimum number of relevant calls required before approving a new rule." + }, + "freshness_window_days": { + "type": "integer", + "minimum": 1, + "description": "Preferred evidence freshness window for change decisions." + }, + "require_dual_source_validation": { + "type": "boolean", + "description": "Whether playbook changes require both conversation and pipeline confirmation." + }, + "contradiction_resolution_rule": { + "type": "string", + "description": "How to decide when sources disagree (for example: segment baseline overrides global benchmark)." + } + } + }, + "handoff_protocol": { + "type": "object", + "required": [ + "mandatory_fields", + "reject_if_missing", + "map_required" + ], + "additionalProperties": false, + "properties": { + "mandatory_fields": { + "type": "array", + "description": "Fields that must be present for SDR-to-AE or stage handoff acceptance.", + "items": { + "type": "string" + } + }, + "reject_if_missing": { + "type": "array", + "description": "Blocking conditions that force handoff rejection.", + "items": { + "type": "string" + } + }, + "map_required": { + "type": "boolean", + "description": "Whether a mutual action plan is mandatory at handoff." + } + } + }, + "compliance_guardrails": { + "type": "object", + "required": [ + "recording_consent_policy", + "retention_window_days", + "lawful_basis_note", + "jurisdiction_handling_rule" + ], + "additionalProperties": false, + "properties": { + "recording_consent_policy": { + "type": "string", + "description": "Operational policy for obtaining and storing consent for call recording/transcription." + }, + "retention_window_days": { + "type": "integer", + "minimum": 1, + "description": "Maximum retention period for call-derived evidence before review/deletion." + }, + "lawful_basis_note": { + "type": "string", + "description": "Documented lawful basis or legal rationale for processing conversation data." + }, + "jurisdiction_handling_rule": { + "type": "string", + "description": "How to apply stricter consent/processing standards across mixed jurisdictions." + } + } + } + } + } +} +``` + +--- + +## Gaps & Uncertainties + +- Public sources provide uneven transparency on pricing, hard API rate limits, and plan-level feature access for major revenue tools; these should be validated in tenant-specific docs before operational commitments. +- Some highly cited behavior benchmarks (especially conversation micro-patterns) still rely on Evergreen analyses; they are directionally useful but should be validated against current team data before hard-coding numeric thresholds. +- Several benchmark claims come from vendor-led research and may include methodology bias; this is why local validation gates are recommended before playbook changes. +- Jurisdiction-specific compliance requirements (US state-by-state, EU member-state overlays, non-UK/EU geographies) were not exhaustively mapped in this pass and may require legal review for deployment beyond a single region. +- Not all “AI impact” claims isolate causal effects; correlations should not be interpreted as guaranteed uplift without controlled implementation and data-quality checks. diff --git a/flexus_simple_bots/strategist/skills/_rtm-sales-playbook/SKILL.md b/flexus_simple_bots/strategist/skills/_rtm-sales-playbook/SKILL.md new file mode 100644 index 00000000..b94f53bc --- /dev/null +++ b/flexus_simple_bots/strategist/skills/_rtm-sales-playbook/SKILL.md @@ -0,0 +1,60 @@ +--- +name: rtm-sales-playbook +description: Sales playbook design — discovery call framework, demo structure, objection handling scripts, and deal qualification criteria +--- + +You design the operational sales playbook: the structured conversation frameworks, qualification criteria, and objection responses that any salesperson (or founder doing sales) follows. A playbook converts art into process. + +Core mode: the playbook must be based on evidence from real conversations, not hypothetical scenarios. Every objection response must come from an interview or call intelligence artifact — not from brainstorming what might come up. + +## Methodology + +### Discovery call framework +The discovery call has one job: determine if this prospect matches ICP well enough to invest in a demo/proposal. + +Structure: +1. **Opener** (2 min): why this prospect / why now — reference a specific signal +2. **Problem exploration** (15 min): open-ended questions mapped to JTBD categories + - "Walk me through how you currently handle [target workflow]" + - "What happens when [pain scenario]?" + - "How often does that happen?" + - "What's the downstream impact when it happens?" +3. **Qualification** (5 min): explicit disqualification criteria + - Authority: "Who makes the final decision on tools like this?" + - Timeline: "Are you actively looking to solve this now or at a future date?" + - Budget: frame as "Do you typically budget for solutions in this space?" +4. **Mini-pitch** (5 min): only if qualified — 3-sentence overview, no features +5. **Next step** (3 min): specific commitment (demo date, pilot discussion) + +### BANT qualification criteria +Define each threshold for this specific ICP and ACV: +- Budget: minimum viable budget signal +- Authority: acceptable decision-maker roles +- Need: must-have evidence of the specific pain from `jtbd_outcomes` +- Timeline: maximum acceptable decision timeline + +### Objection handling +Pull from `call_intelligence_report` artifacts — use real quotes. +For each top objection: trigger statement → response → pivot to next step. + +### Demo structure +The demo tells a story, not a feature tour: +1. Scene setting: "Let me show you the problem this solves" +2. Before state: show the pain (e.g., current state in competitor tool or spreadsheet) +3. Core workflow: one complete use case, not a feature list +4. Wow moment: the single feature that produces the clearest "aha" +5. After state: outcome delivered + +## Recording + +``` +write_artifact(path="/strategy/rtm-sales-playbook", data={...}) +``` + +## Available Tools + +``` +flexus_policy_document(op="activate", args={"p": "/strategy/messaging"}) +flexus_policy_document(op="activate", args={"p": "/discovery/{study_id}/jtbd-outcomes"}) +flexus_policy_document(op="list", args={"p": "/pipeline/"}) +``` diff --git a/flexus_simple_bots/strategist/strategist_bot.py b/flexus_simple_bots/strategist/strategist_bot.py index 4669d77d..6b58213f 100644 --- a/flexus_simple_bots/strategist/strategist_bot.py +++ b/flexus_simple_bots/strategist/strategist_bot.py @@ -1,6 +1,7 @@ import asyncio import json import logging +import re from pathlib import Path from typing import Any, Dict @@ -19,26 +20,54 @@ BOT_NAME = "strategist" BOT_VERSION = SIMPLE_BOTS_COMMON_VERSION + +def load_artifact_schemas() -> Dict[str, Any]: + """Read JSON artifact schemas from each skill's SKILL.md; skip filling-* dirs (pipeline templates, not artifacts).""" + skills_dir = BOT_DIR / "skills" + schemas: Dict[str, Any] = {} + for skill_dir in sorted(d for d in skills_dir.iterdir() if not d.name.startswith("filling-")): + skill_md = skill_dir / "SKILL.md" + if not skill_md.exists(): + continue + md = skill_md.read_text(encoding="utf-8") + m = re.search(r"```json\s*(\{.*?\})\s*```", md, re.DOTALL) + if not m: + continue + parsed = json.loads(m.group(1)) + schemas.update(parsed) + return schemas + + +ARTIFACT_SCHEMAS = load_artifact_schemas() +ARTIFACT_TYPES = sorted(ARTIFACT_SCHEMAS.keys()) + + WRITE_ARTIFACT_TOOL = ckit_cloudtool.CloudTool( strict=False, name="write_artifact", - description="Write a structured artifact to the document store. Path and data shape are defined by the active skill.", + description="Write a structured artifact to the document store. Artifact type and schema are defined by the active skill.", parameters={ "type": "object", "properties": { + "artifact_type": { + "type": "string", + "enum": ARTIFACT_TYPES, + "description": "Artifact type as specified by the active skill", + }, "path": { "type": "string", - "description": "Document path as specified by the active skill", + "description": "Document path, e.g. /experiments/cards/exp001-2024-01-15", }, "data": { "type": "object", - "description": "Artifact content as specified by the active skill", + "description": "Artifact content matching the schema for this artifact_type", }, }, - "required": ["path", "data"], + "required": ["artifact_type", "path", "data"], "additionalProperties": False, }, ) + STRATEGIST_INTEGRATIONS = strategist_install.STRATEGIST_INTEGRATIONS TOOLS = [ @@ -48,18 +77,26 @@ async def strategist_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext) -> None: - setup = ckit_bot_exec.official_setup_mixing_procedure(strategist_install.STRATEGIST_SETUP_SCHEMA, rcx.persona.persona_setup) - integr_objects = await ckit_integrations_db.main_loop_integrations_init(STRATEGIST_INTEGRATIONS, rcx, setup) + integr_objects = await ckit_integrations_db.main_loop_integrations_init(STRATEGIST_INTEGRATIONS, rcx) pdoc_integration = integr_objects["flexus_policy_document"] @rcx.on_tool_call(WRITE_ARTIFACT_TOOL.name) async def _h_write_artifact(toolcall: ckit_cloudtool.FCloudtoolCall, args: Dict[str, Any]) -> str: + artifact_type = str(args.get("artifact_type", "")).strip() path = str(args.get("path", "")).strip() data = args.get("data") - if not path or data is None: - return "Error: path and data are required." - await pdoc_integration.pdoc_overwrite(path, json.dumps(data, ensure_ascii=False), fcall_untrusted_key=toolcall.fcall_untrusted_key) - return f"Written: {path}" + if not artifact_type or not path or data is None: + return "Error: artifact_type, path, and data are required." + if artifact_type not in ARTIFACT_SCHEMAS: + return f"Error: unknown artifact_type {artifact_type!r}. Must be one of: {', '.join(ARTIFACT_TYPES)}" + doc = dict(data) + doc["schema"] = ARTIFACT_SCHEMAS[artifact_type] + await pdoc_integration.pdoc_overwrite( + path, + json.dumps(doc, ensure_ascii=False), + fcall_untrusted_key=toolcall.fcall_untrusted_key, + ) + return f"Written: {path}\n\nArtifact {artifact_type} saved." try: while not ckit_shutdown.shutdown_event.is_set(): diff --git a/flexus_simple_bots/strategist/strategist_install.py b/flexus_simple_bots/strategist/strategist_install.py index 6bc342a1..8fc98b3a 100644 --- a/flexus_simple_bots/strategist/strategist_install.py +++ b/flexus_simple_bots/strategist/strategist_install.py @@ -8,38 +8,24 @@ from flexus_client_kit import ckit_cloudtool from flexus_client_kit import ckit_integrations_db from flexus_client_kit import ckit_skills +from flexus_client_kit.integrations import fi_linkedin_b2b from flexus_simple_bots import prompts_common from flexus_simple_bots.strategist import strategist_prompts STRATEGIST_ROOTDIR = Path(__file__).parent STRATEGIST_SKILLS = ckit_skills.static_skills_find(STRATEGIST_ROOTDIR, shared_skills_allowlist="") STRATEGIST_SETUP_SCHEMA = json.loads((STRATEGIST_ROOTDIR / "setup_schema.json").read_text()) +STRATEGIST_SETUP_SCHEMA.extend(fi_linkedin_b2b.LINKEDIN_B2B_SETUP_SCHEMA) STRATEGIST_INTEGRATIONS: list[ckit_integrations_db.IntegrationRecord] = ckit_integrations_db.static_integrations_load( STRATEGIST_ROOTDIR, [ "flexus_policy_document", "skills", "print_widget", - "linkedin", - # "chargebee", - # "crunchbase", - # "datadog", - # "ga4", - # "gnews", - # "google_ads", - # "launchdarkly", - # "meta", - # "mixpanel", - # "optimizely", - # "paddle", - # "pipedrive", - # "qualtrics", - # "recurly", - # "salesforce", - # "segment", - # "statsig", - # "surveymonkey", - # "typeform", - # "zendesk", + "apify", + "chargebee", "crunchbase", "datadog", "ga4", "gnews", "google_ads", + "launchdarkly", "linkedin", "linkedin_b2b", "meta", "mixpanel", "optimizely", "paddle", + "pipedrive", "qualtrics", "recurly", "salesforce", "segment", "statsig", + "surveymonkey", "typeform", "zendesk", ], builtin_skills=STRATEGIST_SKILLS, ) @@ -62,6 +48,21 @@ async def install( bot_version: str, tools: list[ckit_cloudtool.CloudTool], ) -> None: + auth_supported: list[str] = [] + auth_scopes: dict[str, list[str]] = {} + for rec in STRATEGIST_INTEGRATIONS: + if not rec.integr_provider: + continue + if rec.integr_provider not in auth_supported: + auth_supported.append(rec.integr_provider) + existing = auth_scopes.get(rec.integr_provider, []) + auth_scopes[rec.integr_provider] = list(dict.fromkeys(existing + rec.integr_scopes)) + # Apify auth is intentionally enabled only for Strategist. + # The integration reads rcx.external_auth["apify"], so exposing the provider here + # makes the UI render a workspace key field without changing the global generic loader. + if "apify" not in auth_supported: + auth_supported.append("apify") + auth_scopes["apify"] = [] pic_big = base64.b64encode((STRATEGIST_ROOTDIR / "strategist-1024x1536.webp").read_bytes()).decode("ascii") pic_small = base64.b64encode((STRATEGIST_ROOTDIR / "strategist-256x256.webp").read_bytes()).decode("ascii") await ckit_bot_install.marketplace_upsert_dev_bot( @@ -99,10 +100,8 @@ async def install( marketable_picture_big_b64=pic_big, marketable_picture_small_b64=pic_small, marketable_forms={}, - marketable_auth_supported=["linkedin"], - marketable_auth_scopes={ - "linkedin": ["r_profile_basicinfo", "email", "w_member_social"], - }, + marketable_auth_supported=auth_supported, + marketable_auth_scopes=auth_scopes, ) diff --git a/flexus_simple_bots/strategist/strategist_prompts.py b/flexus_simple_bots/strategist/strategist_prompts.py index e4d65d31..9ae37dd1 100644 --- a/flexus_simple_bots/strategist/strategist_prompts.py +++ b/flexus_simple_bots/strategist/strategist_prompts.py @@ -15,6 +15,8 @@ - pricing-pilot-packaging: structure pilot commercials for conversion quality, not short-term pilot revenue {prompts_common.PROMPT_KANBAN} +{prompts_common.PROMPT_PRINT_WIDGET} +{prompts_common.PROMPT_POLICY_DOCUMENTS} {prompts_common.PROMPT_A2A_COMMUNICATION} {prompts_common.PROMPT_HERE_GOES_SETUP} """ diff --git a/setup.py b/setup.py index b18ba449..4ca78ec9 100644 --- a/setup.py +++ b/setup.py @@ -38,6 +38,7 @@ def run(self): "aiohttp", "pymongo", "requests", + "requests-oauthlib", "slack_bolt", "discord", "python-dotenv", @@ -51,6 +52,7 @@ def run(self): "beautifulsoup4", "PyJWT", "pyyaml", + "google-auth", "langchain_google_community", "langchain-community", "atlassian-python-api", From af8e84f73ecdf6aad468306c4ab0eb05f4090bbb Mon Sep 17 00:00:00 2001 From: lev-goryachev Date: Thu, 12 Mar 2026 14:59:11 +0100 Subject: [PATCH 2/4] Expose Facebook auth in Executor. Register Facebook scopes for Executor and make the runtime status flow report existing Meta connections instead of asking users to reconnect. --- .../integrations/facebook/fi_facebook.py | 57 +++++++++++++------ .../executor/executor_install.py | 9 +++ 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/flexus_client_kit/integrations/facebook/fi_facebook.py b/flexus_client_kit/integrations/facebook/fi_facebook.py index 883fc690..ebdc15f6 100644 --- a/flexus_client_kit/integrations/facebook/fi_facebook.py +++ b/flexus_client_kit/integrations/facebook/fi_facebook.py @@ -351,29 +351,54 @@ async def called_by_model( return f"ERROR: {str(e)}" async def _handle_connect(self) -> str: - return """Click this link to connect your Facebook account in workspace settings. + try: + auth_error = await self.client.ensure_auth() + if auth_error: + return auth_error + return """Facebook / Meta is already connected for this bot persona. -After authorizing, return here and try your request again. +You can continue with: +- list_ad_accounts +- list_pages +- status with a specific act_... account -Requirements: -- Facebook Business Manager account -- Access to an Ad Account (starts with act_...)""" +If you want, ask me to list your ad accounts and I will show the available act_... IDs.""" + except (FacebookAuthError, FacebookAPIError, FacebookValidationError) as e: + return e.message + except Exception as e: + logger.error("Unexpected error in connect", exc_info=e) + return f"ERROR: {str(e)}" async def _handle_status(self, args: Dict[str, Any]) -> str: - ad_account_id = args.get("ad_account_id", "") or self.client.ad_account_id - if ad_account_id: - self.client.ad_account_id = ad_account_id - if not self.client.ad_account_id: - return "ERROR: ad_account_id parameter required for status" - if self.client.is_test_mode: - return f"""Facebook Ads Account: {self.client.ad_account_id} + try: + ad_account_id = args.get("ad_account_id", "") or self.client.ad_account_id + if ad_account_id: + self.client.ad_account_id = ad_account_id + if not self.client.ad_account_id: + auth_error = await self.client.ensure_auth() + if auth_error: + return auth_error + accounts_result = await list_ad_accounts(self.client) + return ( + "Facebook / Meta is connected and the OAuth token is available.\n\n" + "Available ad accounts:\n\n" + f"{accounts_result}\n" + "Pick any act_... account ID and call status again if you want account-specific details." + ) + if self.client.is_test_mode: + return f"""Facebook Ads Account: {self.client.ad_account_id} Active Campaigns (2): Test Campaign 1 (ID: 123456789) Status: ACTIVE, Objective: OUTCOME_TRAFFIC, Daily Budget: $50.00 Test Campaign 2 (ID: 987654321) Status: ACTIVE, Objective: OUTCOME_SALES, Daily Budget: $100.00 """ - result = f"Facebook Ads Account: {self.client.ad_account_id}\n" - campaigns_result = await list_campaigns(self.client, self.client.ad_account_id, "ACTIVE") - result += campaigns_result - return result + result = f"Facebook Ads Account: {self.client.ad_account_id}\n" + campaigns_result = await list_campaigns(self.client, self.client.ad_account_id, "ACTIVE") + result += campaigns_result + return result + except (FacebookAuthError, FacebookAPIError, FacebookValidationError) as e: + return e.message + except Exception as e: + logger.error("Unexpected error in status", exc_info=e) + return f"ERROR: {str(e)}" diff --git a/flexus_simple_bots/executor/executor_install.py b/flexus_simple_bots/executor/executor_install.py index e6a70b9b..7f323ae4 100644 --- a/flexus_simple_bots/executor/executor_install.py +++ b/flexus_simple_bots/executor/executor_install.py @@ -46,6 +46,15 @@ async def install( ) -> None: auth_supported = ["google"] auth_scopes: dict[str, list[str]] = {"google": []} + auth_scopes["facebook"] = [ + "ads_management", + "ads_read", + "business_management", + "pages_manage_ads", + "pages_read_engagement", + "pages_show_list", + ] + auth_supported.append("facebook") for rec in EXECUTOR_INTEGRATIONS: if not rec.integr_provider: continue From 53920321e3a9c4008422aab7f2291fc8ea6be87b Mon Sep 17 00:00:00 2001 From: lev-goryachev Date: Mon, 16 Mar 2026 12:18:15 +0100 Subject: [PATCH 3/4] refactor(meta): split Meta integrations by use case (one file per API use case) Replace fi_meta.py, fi_instagram.py, and facebook/fi_facebook.py with flat per-use-case files matching Meta developer console use cases: fi_meta_marketing_manage.py - Create & manage ads (campaigns, adsets, ads, creatives, audiences, pixels, targeting, rules) fi_meta_marketing_metrics.py - Measure ad performance (all insights endpoints) fi_meta_pages.py - Manage Pages and ad accounts fi_meta_ad_leads.py - Capture & manage ad leads (stub) fi_meta_app_ads.py - App ads (stub) fi_meta_threads.py - Threads API (stub) fi_meta_whatsapp.py - WhatsApp Business (stub) fi_meta_catalog.py - Catalog API (stub) fi_meta_messenger.py - Messenger (stub) fi_meta_instagram.py - Instagram messaging & content (stub) facebook/ subpackage is kept as internal implementation library used by the three real integration files above. --- .../integrations/facebook/fi_facebook.py | 404 ---------------- .../integrations/fi_instagram.py | 48 -- flexus_client_kit/integrations/fi_meta.py | 440 ------------------ .../integrations/fi_meta_ad_leads.py | 21 + .../integrations/fi_meta_app_ads.py | 20 + .../integrations/fi_meta_catalog.py | 20 + .../integrations/fi_meta_instagram.py | 20 + .../integrations/fi_meta_marketing_manage.py | 238 ++++++++++ .../integrations/fi_meta_marketing_metrics.py | 98 ++++ .../integrations/fi_meta_messenger.py | 20 + .../integrations/fi_meta_pages.py | 96 ++++ .../integrations/fi_meta_threads.py | 20 + .../integrations/fi_meta_whatsapp.py | 20 + 13 files changed, 573 insertions(+), 892 deletions(-) delete mode 100644 flexus_client_kit/integrations/facebook/fi_facebook.py delete mode 100644 flexus_client_kit/integrations/fi_instagram.py delete mode 100644 flexus_client_kit/integrations/fi_meta.py create mode 100644 flexus_client_kit/integrations/fi_meta_ad_leads.py create mode 100644 flexus_client_kit/integrations/fi_meta_app_ads.py create mode 100644 flexus_client_kit/integrations/fi_meta_catalog.py create mode 100644 flexus_client_kit/integrations/fi_meta_instagram.py create mode 100644 flexus_client_kit/integrations/fi_meta_marketing_manage.py create mode 100644 flexus_client_kit/integrations/fi_meta_marketing_metrics.py create mode 100644 flexus_client_kit/integrations/fi_meta_messenger.py create mode 100644 flexus_client_kit/integrations/fi_meta_pages.py create mode 100644 flexus_client_kit/integrations/fi_meta_threads.py create mode 100644 flexus_client_kit/integrations/fi_meta_whatsapp.py diff --git a/flexus_client_kit/integrations/facebook/fi_facebook.py b/flexus_client_kit/integrations/facebook/fi_facebook.py deleted file mode 100644 index ebdc15f6..00000000 --- a/flexus_client_kit/integrations/facebook/fi_facebook.py +++ /dev/null @@ -1,404 +0,0 @@ -from __future__ import annotations -import logging -from typing import Any, Dict, Optional, TYPE_CHECKING - -from flexus_client_kit import ckit_cloudtool -from flexus_client_kit.integrations.facebook.client import FacebookAdsClient -from flexus_client_kit.integrations.facebook.exceptions import ( - FacebookAPIError, - FacebookAuthError, - FacebookValidationError, -) -from flexus_client_kit.integrations.facebook.accounts import ( - list_ad_accounts, get_ad_account_info, update_spending_limit, - list_account_users, list_pages, -) -from flexus_client_kit.integrations.facebook.campaigns import ( - list_campaigns, create_campaign, update_campaign, duplicate_campaign, - archive_campaign, bulk_update_campaigns, get_insights, - get_campaign, delete_campaign, -) -from flexus_client_kit.integrations.facebook.adsets import ( - list_adsets, create_adset, update_adset, validate_targeting, - get_adset, delete_adset, list_adsets_for_account, -) -from flexus_client_kit.integrations.facebook.ads import ( - upload_image, create_creative, create_ad, preview_ad, - get_ad, update_ad, delete_ad, list_ads, - list_creatives, get_creative, update_creative, delete_creative, preview_creative, - upload_video, list_videos, -) -from flexus_client_kit.integrations.facebook.insights import ( - get_account_insights, get_campaign_insights, get_adset_insights, get_ad_insights, - create_async_report, get_async_report_status, -) -from flexus_client_kit.integrations.facebook.audiences import ( - list_custom_audiences, create_custom_audience, create_lookalike_audience, - get_custom_audience, update_custom_audience, delete_custom_audience, add_users_to_audience, -) -from flexus_client_kit.integrations.facebook.pixels import list_pixels, create_pixel, get_pixel_stats -from flexus_client_kit.integrations.facebook.targeting import ( - search_interests, search_behaviors, get_reach_estimate, get_delivery_estimate, -) -from flexus_client_kit.integrations.facebook.rules import ( - list_ad_rules, create_ad_rule, update_ad_rule, delete_ad_rule, execute_ad_rule, -) - -if TYPE_CHECKING: - from flexus_client_kit import ckit_client, ckit_bot_exec - -logger = logging.getLogger("facebook") - -FACEBOOK_TOOL = ckit_cloudtool.CloudTool( - strict=False, - name="facebook", - description="Interact with Facebook/Instagram Marketing API. Call with op=\"help\" for usage.", - parameters={ - "type": "object", - "properties": { - "op": { - "type": "string", - "description": "Operation name (e.g., 'status', 'list_campaigns', 'create_campaign')" - }, - "args": { - "type": "object", - "description": "Arguments for the operation" - }, - }, - "required": ["op"] - }, -) - -HELP = """Facebook Marketing API — Available Operations: - -**Connection:** - connect — Generate OAuth link to connect your Facebook account. - -**Account Operations:** - list_ad_accounts — List all accessible ad accounts. - get_ad_account_info(ad_account_id) — Detailed info about an ad account. - update_spending_limit(ad_account_id, spending_limit) — Set monthly spending cap (cents). - list_account_users(ad_account_id) — List users with access to an ad account. - list_pages() — List Facebook Pages you manage (for page_id in creatives). - status(ad_account_id) — Current account status and active campaigns. - -**Campaign Operations:** - list_campaigns(ad_account_id, status?) — List campaigns, optional status filter. - get_campaign(campaign_id) — Get full details of a single campaign. - create_campaign(ad_account_id, name, objective, daily_budget?, lifetime_budget?, status?) — Create campaign. Budget in cents. - update_campaign(campaign_id, name?, status?, daily_budget?, lifetime_budget?) — Update campaign. - delete_campaign(campaign_id) — Permanently delete a campaign. - duplicate_campaign(campaign_id, new_name, ad_account_id?) — Duplicate a campaign. - archive_campaign(campaign_id) — Archive a campaign. - bulk_update_campaigns(campaigns) — Bulk update multiple campaigns. - -**Ad Set Operations:** - list_adsets(campaign_id) — List ad sets in a campaign. - list_adsets_for_account(ad_account_id, status_filter?) — List all ad sets in an account. - get_adset(adset_id) — Get full details of a single ad set. - create_adset(ad_account_id, campaign_id, name, targeting, optimization_goal?, billing_event?, status?) — Create ad set. - update_adset(adset_id, name?, status?, daily_budget?, bid_amount?) — Update ad set. - delete_adset(adset_id) — Permanently delete an ad set. - validate_targeting(targeting_spec, ad_account_id?) — Validate targeting spec. - -**Ads Operations:** - list_ads(ad_account_id?, adset_id?, status_filter?) — List ads in account or ad set. - get_ad(ad_id) — Get full details of a single ad. - create_ad(name, adset_id, creative_id, status?, ad_account_id?) — Create an ad. - update_ad(ad_id, name?, status?) — Update ad name or status. - delete_ad(ad_id) — Permanently delete an ad. - preview_ad(ad_id, ad_format?) — Generate ad preview. - -**Creative Operations:** - list_creatives(ad_account_id) — List all ad creatives in account. - get_creative(creative_id) — Get creative details. - create_creative(name, page_id, image_hash, link, message?, headline?, description?, call_to_action_type?, ad_account_id?) — Create creative. - update_creative(creative_id, name) — Update creative name. - delete_creative(creative_id) — Delete a creative. - preview_creative(creative_id, ad_format?) — Preview a creative. - upload_image(image_url?, image_path?, ad_account_id?) — Upload image, returns image_hash. - upload_video(video_url, ad_account_id?, title?, description?) — Upload video from URL. - list_videos(ad_account_id) — List ad videos in account. - -**Insights & Reporting:** - get_insights(campaign_id, days?) — Campaign performance metrics. - get_account_insights(ad_account_id, days?, breakdowns?, metrics?, date_preset?) — Account-level insights. - get_campaign_insights(campaign_id, days?, breakdowns?, metrics?, date_preset?) — Campaign insights with breakdowns. - get_adset_insights(adset_id, days?, breakdowns?, metrics?, date_preset?) — Ad set insights. - get_ad_insights(ad_id, days?, breakdowns?, metrics?, date_preset?) — Ad-level insights. - create_async_report(ad_account_id, level?, fields?, date_preset?, breakdowns?) — Create async report job. - get_async_report_status(report_run_id) — Check async report status. - -**Custom Audiences:** - list_custom_audiences(ad_account_id) — List custom audiences. - create_custom_audience(ad_account_id, name, subtype?, description?) — Create custom audience. Subtypes: CUSTOM, WEBSITE, APP, ENGAGEMENT. - create_lookalike_audience(ad_account_id, origin_audience_id, country, ratio?, name?) — Create lookalike audience (ratio: 0.01–0.20). - get_custom_audience(audience_id) — Get audience details. - update_custom_audience(audience_id, name?, description?) — Update audience. - delete_custom_audience(audience_id) — Delete audience. - add_users_to_audience(audience_id, emails, phones?) — Add users via SHA-256 hashed emails/phones. - -**Pixels:** - list_pixels(ad_account_id) — List Meta pixels in account. - create_pixel(ad_account_id, name) — Create new pixel. - get_pixel_stats(pixel_id, start_time?, end_time?, aggregation?) — Get pixel event stats. - -**Targeting Research:** - search_interests(q, limit?) — Search interest targeting options by keyword. - search_behaviors(q, limit?) — Search behavior targeting options by keyword. - get_reach_estimate(ad_account_id, targeting, optimization_goal?) — Estimate audience reach. - get_delivery_estimate(ad_account_id, targeting, optimization_goal?, bid_amount?) — Estimate delivery curve. - -**Ad Rules Engine:** - list_ad_rules(ad_account_id) — List automated ad rules. - create_ad_rule(ad_account_id, name, evaluation_spec, execution_spec, schedule_spec?, status?) — Create automated rule. - update_ad_rule(rule_id, name?, status?) — Update rule name or status. - delete_ad_rule(rule_id) — Delete a rule. - execute_ad_rule(rule_id) — Manually trigger a rule immediately. -""" - - -_OPERATION_HANDLERS = { - "list_ad_accounts": lambda client, args: list_ad_accounts(client), - "get_ad_account_info": lambda client, args: get_ad_account_info(client, args.get("ad_account_id", "")), - "update_spending_limit": lambda client, args: update_spending_limit(client, args.get("ad_account_id", ""), args.get("spending_limit", 0)), - "list_campaigns": lambda client, args: list_campaigns(client, args.get("ad_account_id"), args.get("status")), - "create_campaign": lambda client, args: create_campaign( - client, - args.get("ad_account_id", ""), - args.get("name", ""), - args.get("objective", "OUTCOME_TRAFFIC"), - args.get("status", "PAUSED"), - args.get("daily_budget"), - args.get("lifetime_budget"), - args.get("special_ad_categories"), - ), - "update_campaign": lambda client, args: update_campaign( - client, - args.get("campaign_id", ""), - args.get("name"), - args.get("status"), - args.get("daily_budget"), - args.get("lifetime_budget"), - ), - "duplicate_campaign": lambda client, args: duplicate_campaign(client, args.get("campaign_id", ""), args.get("new_name", ""), args.get("ad_account_id")), - "archive_campaign": lambda client, args: archive_campaign(client, args.get("campaign_id", "")), - "bulk_update_campaigns": lambda client, args: bulk_update_campaigns(client, args.get("campaigns", [])), - "get_insights": lambda client, args: get_insights(client, args.get("campaign_id", ""), int(args.get("days", 30))), - "list_adsets": lambda client, args: list_adsets(client, args.get("campaign_id", "")), - "create_adset": lambda client, args: create_adset( - client, - args.get("ad_account_id", ""), - args.get("campaign_id", ""), - args.get("name", ""), - args.get("targeting", {}), - args.get("optimization_goal", "LINK_CLICKS"), - args.get("billing_event", "IMPRESSIONS"), - args.get("bid_strategy", "LOWEST_COST_WITHOUT_CAP"), - args.get("status", "PAUSED"), - args.get("daily_budget"), - args.get("lifetime_budget"), - args.get("bid_amount"), - args.get("start_time"), - args.get("end_time"), - args.get("promoted_object"), - ), - "update_adset": lambda client, args: update_adset( - client, - args.get("adset_id", ""), - args.get("name"), - args.get("status"), - args.get("daily_budget"), - args.get("bid_amount"), - ), - "validate_targeting": lambda client, args: validate_targeting(client, args.get("targeting_spec", args.get("targeting", {})), args.get("ad_account_id")), - "upload_image": lambda client, args: upload_image(client, args.get("image_path"), args.get("image_url"), args.get("ad_account_id")), - "create_creative": lambda client, args: create_creative( - client, - args.get("name", ""), - args.get("page_id", ""), - args.get("image_hash", ""), - args.get("link", ""), - args.get("message"), - args.get("headline"), - args.get("description"), - args.get("call_to_action_type", "LEARN_MORE"), - args.get("ad_account_id"), - ), - "create_ad": lambda client, args: create_ad( - client, - args.get("name", ""), - args.get("adset_id", ""), - args.get("creative_id", ""), - args.get("status", "PAUSED"), - args.get("ad_account_id"), - ), - "preview_ad": lambda client, args: preview_ad(client, args.get("ad_id", ""), args.get("ad_format", "DESKTOP_FEED_STANDARD")), - # Account extensions - "list_account_users": lambda client, args: list_account_users(client, args.get("ad_account_id", "")), - "list_pages": lambda client, args: list_pages(client), - # Campaign extensions - "get_campaign": lambda client, args: get_campaign(client, args.get("campaign_id", "")), - "delete_campaign": lambda client, args: delete_campaign(client, args.get("campaign_id", "")), - # Ad set extensions - "get_adset": lambda client, args: get_adset(client, args.get("adset_id", "")), - "delete_adset": lambda client, args: delete_adset(client, args.get("adset_id", "")), - "list_adsets_for_account": lambda client, args: list_adsets_for_account(client, args.get("ad_account_id", ""), args.get("status_filter")), - # Ads extensions - "get_ad": lambda client, args: get_ad(client, args.get("ad_id", "")), - "update_ad": lambda client, args: update_ad(client, args.get("ad_id", ""), args.get("name"), args.get("status")), - "delete_ad": lambda client, args: delete_ad(client, args.get("ad_id", "")), - "list_ads": lambda client, args: list_ads(client, args.get("ad_account_id"), args.get("adset_id"), args.get("status_filter")), - "list_creatives": lambda client, args: list_creatives(client, args.get("ad_account_id", "")), - "get_creative": lambda client, args: get_creative(client, args.get("creative_id", "")), - "update_creative": lambda client, args: update_creative(client, args.get("creative_id", ""), args.get("name")), - "delete_creative": lambda client, args: delete_creative(client, args.get("creative_id", "")), - "preview_creative": lambda client, args: preview_creative(client, args.get("creative_id", ""), args.get("ad_format", "DESKTOP_FEED_STANDARD")), - "upload_video": lambda client, args: upload_video(client, args.get("video_url", ""), args.get("ad_account_id"), args.get("title"), args.get("description")), - "list_videos": lambda client, args: list_videos(client, args.get("ad_account_id", "")), - # Insights - "get_account_insights": lambda client, args: get_account_insights(client, args.get("ad_account_id", ""), int(args.get("days", 30)), args.get("breakdowns"), args.get("metrics"), args.get("date_preset")), - "get_campaign_insights": lambda client, args: get_campaign_insights(client, args.get("campaign_id", ""), int(args.get("days", 30)), args.get("breakdowns"), args.get("metrics"), args.get("date_preset")), - "get_adset_insights": lambda client, args: get_adset_insights(client, args.get("adset_id", ""), int(args.get("days", 30)), args.get("breakdowns"), args.get("metrics"), args.get("date_preset")), - "get_ad_insights": lambda client, args: get_ad_insights(client, args.get("ad_id", ""), int(args.get("days", 30)), args.get("breakdowns"), args.get("metrics"), args.get("date_preset")), - "create_async_report": lambda client, args: create_async_report(client, args.get("ad_account_id", ""), args.get("level", "campaign"), args.get("fields"), args.get("date_preset", "last_30d"), args.get("breakdowns")), - "get_async_report_status": lambda client, args: get_async_report_status(client, args.get("report_run_id", "")), - # Audiences - "list_custom_audiences": lambda client, args: list_custom_audiences(client, args.get("ad_account_id", "")), - "create_custom_audience": lambda client, args: create_custom_audience(client, args.get("ad_account_id", ""), args.get("name", ""), args.get("subtype", "CUSTOM"), args.get("description"), args.get("customer_file_source")), - "create_lookalike_audience": lambda client, args: create_lookalike_audience(client, args.get("ad_account_id", ""), args.get("origin_audience_id", ""), args.get("country", ""), float(args.get("ratio", 0.01)), args.get("name")), - "get_custom_audience": lambda client, args: get_custom_audience(client, args.get("audience_id", "")), - "update_custom_audience": lambda client, args: update_custom_audience(client, args.get("audience_id", ""), args.get("name"), args.get("description")), - "delete_custom_audience": lambda client, args: delete_custom_audience(client, args.get("audience_id", "")), - "add_users_to_audience": lambda client, args: add_users_to_audience(client, args.get("audience_id", ""), args.get("emails", []), args.get("phones")), - # Pixels - "list_pixels": lambda client, args: list_pixels(client, args.get("ad_account_id", "")), - "create_pixel": lambda client, args: create_pixel(client, args.get("ad_account_id", ""), args.get("name", "")), - "get_pixel_stats": lambda client, args: get_pixel_stats(client, args.get("pixel_id", ""), args.get("start_time"), args.get("end_time"), args.get("aggregation", "day")), - # Targeting - "search_interests": lambda client, args: search_interests(client, args.get("q", ""), int(args.get("limit", 20))), - "search_behaviors": lambda client, args: search_behaviors(client, args.get("q", ""), int(args.get("limit", 20))), - "get_reach_estimate": lambda client, args: get_reach_estimate(client, args.get("ad_account_id", ""), args.get("targeting", {}), args.get("optimization_goal", "LINK_CLICKS")), - "get_delivery_estimate": lambda client, args: get_delivery_estimate(client, args.get("ad_account_id", ""), args.get("targeting", {}), args.get("optimization_goal", "LINK_CLICKS"), args.get("bid_amount")), - # Ad Rules - "list_ad_rules": lambda client, args: list_ad_rules(client, args.get("ad_account_id", "")), - "create_ad_rule": lambda client, args: create_ad_rule(client, args.get("ad_account_id", ""), args.get("name", ""), args.get("evaluation_spec", {}), args.get("execution_spec", {}), args.get("schedule_spec"), args.get("status", "ENABLED")), - "update_ad_rule": lambda client, args: update_ad_rule(client, args.get("rule_id", ""), args.get("name"), args.get("status")), - "delete_ad_rule": lambda client, args: delete_ad_rule(client, args.get("rule_id", "")), - "execute_ad_rule": lambda client, args: execute_ad_rule(client, args.get("rule_id", "")), -} - - -class IntegrationFacebook: - def __init__( - self, - fclient: "ckit_client.FlexusClient", - rcx: "ckit_bot_exec.RobotContext", - ad_account_id: str = "", - pdoc_integration: Optional[Any] = None, - ): - self.client = FacebookAdsClient(fclient, rcx, ad_account_id) - self.fclient = fclient - self.rcx = rcx - self.pdoc_integration = pdoc_integration - - async def _ensure_ad_account_id(self, toolcall: ckit_cloudtool.FCloudtoolCall) -> None: - if self.client.ad_account_id or not self.pdoc_integration: - return - try: - config = await self.pdoc_integration.pdoc_cat("/company/ad-ops-config", fcall_untrusted_key=toolcall.fcall_untrusted_key) - ad_account_id = config.pdoc_content.get("facebook_ad_account_id", "") - if ad_account_id: - self.client.ad_account_id = ad_account_id - except (AttributeError, KeyError, ValueError) as e: - logger.debug("Could not load ad_account_id from pdoc", exc_info=e) - - async def called_by_model( - self, - toolcall: ckit_cloudtool.FCloudtoolCall, - model_produced_args: Optional[Dict[str, Any]], - ) -> str: - if not model_produced_args: - return HELP - op = model_produced_args.get("op", "") - args = model_produced_args.get("args", {}) - for key in ["ad_account_id", "campaign_id", "adset_id", "creative_id", "ad_id"]: - if key in model_produced_args and key not in args: - args[key] = model_produced_args[key] - if not op or "help" in op.lower(): - return HELP - # Auto-load ad_account_id from pdoc before operations that need it - if op not in ["connect", "list_ad_accounts", "help"]: - await self._ensure_ad_account_id(toolcall) - if op == "connect": - return await self._handle_connect() - if op == "status": - return await self._handle_status(args) - handler = _OPERATION_HANDLERS.get(op) - if not handler: - return f"Unknown operation '{op}'. Try op=\"help\" for available operations." - try: - return await handler(self.client, args) - except FacebookAuthError as e: - return e.message - except FacebookAPIError as e: - logger.info(f"Facebook API error: {e}") - return e.format_for_user() - except FacebookValidationError as e: - return f"ERROR: {e.message}" - except Exception as e: - logger.error("Unexpected error in %s", op, exc_info=e) - return f"ERROR: {str(e)}" - - async def _handle_connect(self) -> str: - try: - auth_error = await self.client.ensure_auth() - if auth_error: - return auth_error - return """Facebook / Meta is already connected for this bot persona. - -You can continue with: -- list_ad_accounts -- list_pages -- status with a specific act_... account - -If you want, ask me to list your ad accounts and I will show the available act_... IDs.""" - except (FacebookAuthError, FacebookAPIError, FacebookValidationError) as e: - return e.message - except Exception as e: - logger.error("Unexpected error in connect", exc_info=e) - return f"ERROR: {str(e)}" - - async def _handle_status(self, args: Dict[str, Any]) -> str: - try: - ad_account_id = args.get("ad_account_id", "") or self.client.ad_account_id - if ad_account_id: - self.client.ad_account_id = ad_account_id - if not self.client.ad_account_id: - auth_error = await self.client.ensure_auth() - if auth_error: - return auth_error - accounts_result = await list_ad_accounts(self.client) - return ( - "Facebook / Meta is connected and the OAuth token is available.\n\n" - "Available ad accounts:\n\n" - f"{accounts_result}\n" - "Pick any act_... account ID and call status again if you want account-specific details." - ) - if self.client.is_test_mode: - return f"""Facebook Ads Account: {self.client.ad_account_id} -Active Campaigns (2): - Test Campaign 1 (ID: 123456789) - Status: ACTIVE, Objective: OUTCOME_TRAFFIC, Daily Budget: $50.00 - Test Campaign 2 (ID: 987654321) - Status: ACTIVE, Objective: OUTCOME_SALES, Daily Budget: $100.00 -""" - result = f"Facebook Ads Account: {self.client.ad_account_id}\n" - campaigns_result = await list_campaigns(self.client, self.client.ad_account_id, "ACTIVE") - result += campaigns_result - return result - except (FacebookAuthError, FacebookAPIError, FacebookValidationError) as e: - return e.message - except Exception as e: - logger.error("Unexpected error in status", exc_info=e) - return f"ERROR: {str(e)}" diff --git a/flexus_client_kit/integrations/fi_instagram.py b/flexus_client_kit/integrations/fi_instagram.py deleted file mode 100644 index bb494f56..00000000 --- a/flexus_client_kit/integrations/fi_instagram.py +++ /dev/null @@ -1,48 +0,0 @@ -import json -from typing import Any, Dict - -from flexus_client_kit import ckit_cloudtool - -PROVIDER_NAME = "instagram" -METHOD_IDS = [ - "instagram.hashtag.recent_media.v1", - "instagram.hashtag.search.v1", - "instagram.hashtag.top_media.v1", -] - - -class IntegrationInstagram: - async def called_by_model( - self, - toolcall: ckit_cloudtool.FCloudtoolCall, - model_produced_args: Dict[str, Any], - ) -> str: - args = model_produced_args or {} - op = str(args.get("op", "help")).strip() - if op == "help": - return ( - f"provider={PROVIDER_NAME}\n" - "op=help | status | list_methods | call\n" - f"methods: {', '.join(METHOD_IDS)}" - ) - if op == "status": - return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "available", "method_count": len(METHOD_IDS)}, indent=2, ensure_ascii=False) - if op == "list_methods": - return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) - if op != "call": - return "Error: unknown op. Use help/status/list_methods/call." - call_args = args.get("args") or {} - method_id = str(call_args.get("method_id", "")).strip() - if not method_id: - return "Error: args.method_id required for op=call." - if method_id not in METHOD_IDS: - return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) - return await self._dispatch(method_id, call_args) - - async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: - return json.dumps({ - "ok": False, - "error_code": "AUTH_REQUIRED", - "provider": PROVIDER_NAME, - "message": "Connect your Instagram account via the integrations panel. Instagram Graph API requires OAuth2 via Facebook and a Business or Creator account.", - }, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_meta.py b/flexus_client_kit/integrations/fi_meta.py deleted file mode 100644 index 0caff017..00000000 --- a/flexus_client_kit/integrations/fi_meta.py +++ /dev/null @@ -1,440 +0,0 @@ -import json -import logging -import os -from typing import Any, Dict, List, Optional - -import httpx - -from flexus_client_kit import ckit_cloudtool - -logger = logging.getLogger("meta") - -PROVIDER_NAME = "meta" -METHOD_IDS = [ - "meta.adcreatives.create.v1", - "meta.adcreatives.list.v1", - "meta.adimages.create.v1", - "meta.ads_insights.get.v1", - "meta.adsets.create.v1", - "meta.campaigns.create.v1", - "meta.insights.query.v1", -] - -_BASE_URL = "https://graph.facebook.com/v19.0" -_TIMEOUT = 30.0 - - -class IntegrationMeta: - async def called_by_model( - self, - toolcall: ckit_cloudtool.FCloudtoolCall, - model_produced_args: Dict[str, Any], - ) -> str: - args = model_produced_args or {} - op = str(args.get("op", "help")).strip() - if op == "help": - return ( - f"provider={PROVIDER_NAME}\n" - "op=help | status | list_methods | call\n" - f"methods: {', '.join(METHOD_IDS)}\n" - "note: Requires META_ACCESS_TOKEN and META_AD_ACCOUNT_ID env vars." - ) - if op == "status": - token = self._token() - account = self._ad_account() - ok = bool(token and account) - return json.dumps({ - "ok": ok, - "provider": PROVIDER_NAME, - "status": "ready" if ok else "missing_credentials", - "method_count": len(METHOD_IDS), - "has_token": bool(token), - "has_ad_account": bool(account), - }, indent=2, ensure_ascii=False) - if op == "list_methods": - return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) - if op != "call": - return "Error: unknown op. Use help/status/list_methods/call." - call_args = args.get("args") or {} - method_id = str(call_args.get("method_id", "")).strip() - if not method_id: - return "Error: args.method_id required for op=call." - if method_id not in METHOD_IDS: - return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False) - return await self._dispatch(method_id, call_args) - - def _token(self) -> str: - return os.environ.get("META_ACCESS_TOKEN", "") - - def _ad_account(self) -> str: - return os.environ.get("META_AD_ACCOUNT_ID", "") - - def _no_creds(self, method_id: str) -> str: - return json.dumps({ - "ok": False, - "error_code": "NO_CREDENTIALS", - "provider": PROVIDER_NAME, - "method_id": method_id, - "message": "Set META_ACCESS_TOKEN and META_AD_ACCOUNT_ID environment variables.", - }, indent=2, ensure_ascii=False) - - def _api_error(self, method_id: str, status_code: int, body: str) -> str: - try: - data = json.loads(body) - fb_error = data.get("error", {}) - message = fb_error.get("message", body) - code = fb_error.get("code", status_code) - except json.JSONDecodeError: - message = body - code = status_code - logger.info("meta api error method=%s status=%s code=%s msg=%s", method_id, status_code, code, message) - return json.dumps({ - "ok": False, - "error_code": "API_ERROR", - "provider": PROVIDER_NAME, - "method_id": method_id, - "http_status": status_code, - "fb_code": code, - "message": message, - }, indent=2, ensure_ascii=False) - - async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str: - if method_id == "meta.adcreatives.create.v1": - return await self._adcreatives_create(method_id, args) - if method_id == "meta.adcreatives.list.v1": - return await self._adcreatives_list(method_id, args) - if method_id == "meta.adimages.create.v1": - return await self._adimages_create(method_id, args) - if method_id == "meta.ads_insights.get.v1": - return await self._ads_insights_get(method_id, args) - if method_id == "meta.adsets.create.v1": - return await self._adsets_create(method_id, args) - if method_id == "meta.campaigns.create.v1": - return await self._campaigns_create(method_id, args) - if method_id == "meta.insights.query.v1": - return await self._insights_query(method_id, args) - return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False) - - # ── meta.adcreatives.create.v1 ────────────────────────────────────────── - - async def _adcreatives_create(self, method_id: str, args: Dict[str, Any]) -> str: - token = self._token() - account = self._ad_account() - if not token or not account: - return self._no_creds(method_id) - - name = str(args.get("name", "")).strip() - object_story_spec = args.get("object_story_spec") - if not name: - return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "name is required"}, indent=2, ensure_ascii=False) - if not isinstance(object_story_spec, dict): - return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "object_story_spec (dict) is required"}, indent=2, ensure_ascii=False) - - status = str(args.get("status", "PAUSED")).upper() - body: Dict[str, Any] = { - "name": name, - "object_story_spec": object_story_spec, - "status": status, - "access_token": token, - } - - url = f"{_BASE_URL}/{account}/adcreatives" - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - resp = await client.post(url, json=body) - except httpx.TimeoutException: - return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "method_id": method_id}, indent=2, ensure_ascii=False) - except httpx.HTTPError as e: - return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "method_id": method_id, "message": str(e)}, indent=2, ensure_ascii=False) - - if resp.status_code not in (200, 201): - return self._api_error(method_id, resp.status_code, resp.text) - - try: - data = resp.json() - except json.JSONDecodeError: - return json.dumps({"ok": False, "error_code": "INVALID_RESPONSE", "method_id": method_id}, indent=2, ensure_ascii=False) - - return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": data}, indent=2, ensure_ascii=False) - - # ── meta.adcreatives.list.v1 ──────────────────────────────────────────── - - async def _adcreatives_list(self, method_id: str, args: Dict[str, Any]) -> str: - token = self._token() - account = self._ad_account() - if not token or not account: - return self._no_creds(method_id) - - limit = int(args.get("limit", 25)) - fields = args.get("fields", ["id", "name", "status", "object_story_spec"]) - if isinstance(fields, list): - fields_str = ",".join(fields) - else: - fields_str = str(fields) - - url = f"{_BASE_URL}/{account}/adcreatives" - params: Dict[str, Any] = { - "access_token": token, - "fields": fields_str, - "limit": limit, - } - - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - resp = await client.get(url, params=params) - except httpx.TimeoutException: - return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "method_id": method_id}, indent=2, ensure_ascii=False) - except httpx.HTTPError as e: - return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "method_id": method_id, "message": str(e)}, indent=2, ensure_ascii=False) - - if resp.status_code != 200: - return self._api_error(method_id, resp.status_code, resp.text) - - try: - data = resp.json() - except json.JSONDecodeError: - return json.dumps({"ok": False, "error_code": "INVALID_RESPONSE", "method_id": method_id}, indent=2, ensure_ascii=False) - - return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": data}, indent=2, ensure_ascii=False) - - # ── meta.adimages.create.v1 ───────────────────────────────────────────── - - async def _adimages_create(self, method_id: str, args: Dict[str, Any]) -> str: - token = self._token() - account = self._ad_account() - if not token or not account: - return self._no_creds(method_id) - - # image_url: fetch-and-upload via url param; or bytes (not supported here) - image_url = str(args.get("image_url", "")).strip() - filename = str(args.get("filename", "image.jpg")).strip() - - if not image_url: - return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "image_url is required"}, indent=2, ensure_ascii=False) - - url = f"{_BASE_URL}/{account}/adimages" - body: Dict[str, Any] = { - "filename": filename, - "url": image_url, - "access_token": token, - } - - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - resp = await client.post(url, json=body) - except httpx.TimeoutException: - return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "method_id": method_id}, indent=2, ensure_ascii=False) - except httpx.HTTPError as e: - return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "method_id": method_id, "message": str(e)}, indent=2, ensure_ascii=False) - - if resp.status_code not in (200, 201): - return self._api_error(method_id, resp.status_code, resp.text) - - try: - data = resp.json() - except json.JSONDecodeError: - return json.dumps({"ok": False, "error_code": "INVALID_RESPONSE", "method_id": method_id}, indent=2, ensure_ascii=False) - - return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": data}, indent=2, ensure_ascii=False) - - # ── meta.ads_insights.get.v1 ──────────────────────────────────────────── - - async def _ads_insights_get(self, method_id: str, args: Dict[str, Any]) -> str: - token = self._token() - account = self._ad_account() - if not token or not account: - return self._no_creds(method_id) - - default_fields = ["impressions", "clicks", "spend", "ctr", "cpc"] - fields = args.get("fields", default_fields) - if isinstance(fields, list): - fields_str = ",".join(fields) - else: - fields_str = str(fields) - - date_preset: Optional[str] = args.get("date_preset") - time_range: Optional[Dict[str, str]] = args.get("time_range") - level: Optional[str] = args.get("level") - limit = int(args.get("limit", 25)) - - url = f"{_BASE_URL}/{account}/insights" - params: Dict[str, Any] = { - "access_token": token, - "fields": fields_str, - "limit": limit, - } - if date_preset: - params["date_preset"] = date_preset - if time_range and isinstance(time_range, dict): - params["time_range"] = json.dumps(time_range, ensure_ascii=False) - if level: - params["level"] = level - - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - resp = await client.get(url, params=params) - except httpx.TimeoutException: - return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "method_id": method_id}, indent=2, ensure_ascii=False) - except httpx.HTTPError as e: - return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "method_id": method_id, "message": str(e)}, indent=2, ensure_ascii=False) - - if resp.status_code != 200: - return self._api_error(method_id, resp.status_code, resp.text) - - try: - data = resp.json() - except json.JSONDecodeError: - return json.dumps({"ok": False, "error_code": "INVALID_RESPONSE", "method_id": method_id}, indent=2, ensure_ascii=False) - - return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": data}, indent=2, ensure_ascii=False) - - # ── meta.adsets.create.v1 ─────────────────────────────────────────────── - - async def _adsets_create(self, method_id: str, args: Dict[str, Any]) -> str: - token = self._token() - account = self._ad_account() - if not token or not account: - return self._no_creds(method_id) - - name = str(args.get("name", "")).strip() - campaign_id = str(args.get("campaign_id", "")).strip() - optimization_goal = str(args.get("optimization_goal", "REACH")).strip() - billing_event = str(args.get("billing_event", "IMPRESSIONS")).strip() - daily_budget = args.get("daily_budget") - targeting = args.get("targeting") - status = str(args.get("status", "PAUSED")).upper() - - if not name: - return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "name is required"}, indent=2, ensure_ascii=False) - if not campaign_id: - return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "campaign_id is required"}, indent=2, ensure_ascii=False) - - body: Dict[str, Any] = { - "name": name, - "campaign_id": campaign_id, - "optimization_goal": optimization_goal, - "billing_event": billing_event, - "status": status, - "access_token": token, - } - if daily_budget is not None: - body["daily_budget"] = int(daily_budget) - if isinstance(targeting, dict): - body["targeting"] = targeting - - url = f"{_BASE_URL}/{account}/adsets" - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - resp = await client.post(url, json=body) - except httpx.TimeoutException: - return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "method_id": method_id}, indent=2, ensure_ascii=False) - except httpx.HTTPError as e: - return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "method_id": method_id, "message": str(e)}, indent=2, ensure_ascii=False) - - if resp.status_code not in (200, 201): - return self._api_error(method_id, resp.status_code, resp.text) - - try: - data = resp.json() - except json.JSONDecodeError: - return json.dumps({"ok": False, "error_code": "INVALID_RESPONSE", "method_id": method_id}, indent=2, ensure_ascii=False) - - return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": data}, indent=2, ensure_ascii=False) - - # ── meta.campaigns.create.v1 ──────────────────────────────────────────── - - async def _campaigns_create(self, method_id: str, args: Dict[str, Any]) -> str: - token = self._token() - account = self._ad_account() - if not token or not account: - return self._no_creds(method_id) - - name = str(args.get("name", "")).strip() - objective = str(args.get("objective", "OUTCOME_AWARENESS")).strip() - status = str(args.get("status", "PAUSED")).upper() - special_ad_categories = args.get("special_ad_categories", []) - - if not name: - return json.dumps({"ok": False, "error_code": "INVALID_ARGS", "message": "name is required"}, indent=2, ensure_ascii=False) - - body: Dict[str, Any] = { - "name": name, - "objective": objective, - "status": status, - "special_ad_categories": special_ad_categories if isinstance(special_ad_categories, list) else [], - "access_token": token, - } - - url = f"{_BASE_URL}/{account}/campaigns" - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - resp = await client.post(url, json=body) - except httpx.TimeoutException: - return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "method_id": method_id}, indent=2, ensure_ascii=False) - except httpx.HTTPError as e: - return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "method_id": method_id, "message": str(e)}, indent=2, ensure_ascii=False) - - if resp.status_code not in (200, 201): - return self._api_error(method_id, resp.status_code, resp.text) - - try: - data = resp.json() - except json.JSONDecodeError: - return json.dumps({"ok": False, "error_code": "INVALID_RESPONSE", "method_id": method_id}, indent=2, ensure_ascii=False) - - return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": data}, indent=2, ensure_ascii=False) - - # ── meta.insights.query.v1 ────────────────────────────────────────────── - - async def _insights_query(self, method_id: str, args: Dict[str, Any]) -> str: - token = self._token() - account = self._ad_account() - if not token or not account: - return self._no_creds(method_id) - - # object_id: use specific campaign/adset/ad id, or fall back to ad account - object_id = str(args.get("object_id", account)).strip() - fields = args.get("fields", ["impressions", "clicks", "spend", "ctr", "cpc", "reach"]) - if isinstance(fields, list): - fields_str = ",".join(fields) - else: - fields_str = str(fields) - - breakdowns = args.get("breakdowns") - date_preset: Optional[str] = args.get("date_preset") - time_range: Optional[Dict[str, str]] = args.get("time_range") - level: Optional[str] = args.get("level") - limit = int(args.get("limit", 25)) - - url = f"{_BASE_URL}/{object_id}/insights" - params: Dict[str, Any] = { - "access_token": token, - "fields": fields_str, - "limit": limit, - } - if breakdowns: - params["breakdowns"] = breakdowns if isinstance(breakdowns, str) else ",".join(breakdowns) - if date_preset: - params["date_preset"] = date_preset - if time_range and isinstance(time_range, dict): - params["time_range"] = json.dumps(time_range, ensure_ascii=False) - if level: - params["level"] = level - - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - resp = await client.get(url, params=params) - except httpx.TimeoutException: - return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME, "method_id": method_id}, indent=2, ensure_ascii=False) - except httpx.HTTPError as e: - return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "provider": PROVIDER_NAME, "method_id": method_id, "message": str(e)}, indent=2, ensure_ascii=False) - - if resp.status_code != 200: - return self._api_error(method_id, resp.status_code, resp.text) - - try: - data = resp.json() - except json.JSONDecodeError: - return json.dumps({"ok": False, "error_code": "INVALID_RESPONSE", "method_id": method_id}, indent=2, ensure_ascii=False) - - return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_id": method_id, "result": data}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_meta_ad_leads.py b/flexus_client_kit/integrations/fi_meta_ad_leads.py new file mode 100644 index 00000000..b35d5b8e --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_ad_leads.py @@ -0,0 +1,21 @@ +import json +from typing import Any, Dict +from flexus_client_kit import ckit_cloudtool + +# Use case: "Capture & manage ad leads with Marketing API" +PROVIDER_NAME = "meta_ad_leads" +METHOD_IDS: list[str] = [] + + +class IntegrationMetaAdLeads: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nstatus: not yet implemented" + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "not_implemented", "method_count": 0}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + method_id = str((args.get("args") or {}).get("method_id", "")).strip() + return json.dumps({"ok": False, "error_code": "INTEGRATION_UNAVAILABLE", "provider": PROVIDER_NAME, "method_id": method_id, "message": "Not yet implemented."}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_meta_app_ads.py b/flexus_client_kit/integrations/fi_meta_app_ads.py new file mode 100644 index 00000000..130ffc73 --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_app_ads.py @@ -0,0 +1,20 @@ +import json +from typing import Any, Dict +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = "meta_app_ads" +METHOD_IDS: list[str] = [] + + +class IntegrationMetaAppAds: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nstatus: not yet implemented" + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "not_implemented", "method_count": 0}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + method_id = str((args.get("args") or {}).get("method_id", "")).strip() + return json.dumps({"ok": False, "error_code": "INTEGRATION_UNAVAILABLE", "provider": PROVIDER_NAME, "method_id": method_id, "message": "Not yet implemented."}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_meta_catalog.py b/flexus_client_kit/integrations/fi_meta_catalog.py new file mode 100644 index 00000000..d70a3596 --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_catalog.py @@ -0,0 +1,20 @@ +import json +from typing import Any, Dict +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = "meta_catalog" +METHOD_IDS: list[str] = [] + + +class IntegrationMetaCatalog: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nstatus: not yet implemented" + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "not_implemented", "method_count": 0}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + method_id = str((args.get("args") or {}).get("method_id", "")).strip() + return json.dumps({"ok": False, "error_code": "INTEGRATION_UNAVAILABLE", "provider": PROVIDER_NAME, "method_id": method_id, "message": "Not yet implemented."}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_meta_instagram.py b/flexus_client_kit/integrations/fi_meta_instagram.py new file mode 100644 index 00000000..9772805a --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_instagram.py @@ -0,0 +1,20 @@ +import json +from typing import Any, Dict +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = "meta_instagram" +METHOD_IDS: list[str] = [] + + +class IntegrationMetaInstagram: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nstatus: not yet implemented" + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "not_implemented", "method_count": 0}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + method_id = str((args.get("args") or {}).get("method_id", "")).strip() + return json.dumps({"ok": False, "error_code": "INTEGRATION_UNAVAILABLE", "provider": PROVIDER_NAME, "method_id": method_id, "message": "Not yet implemented."}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_meta_marketing_manage.py b/flexus_client_kit/integrations/fi_meta_marketing_manage.py new file mode 100644 index 00000000..c961d90f --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_marketing_manage.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional, TYPE_CHECKING + +from flexus_client_kit import ckit_cloudtool +from flexus_client_kit.integrations.facebook.client import FacebookAdsClient +from flexus_client_kit.integrations.facebook.exceptions import ( + FacebookAPIError, + FacebookAuthError, + FacebookValidationError, +) +from flexus_client_kit.integrations.facebook.campaigns import ( + list_campaigns, create_campaign, update_campaign, duplicate_campaign, + archive_campaign, bulk_update_campaigns, get_campaign, delete_campaign, +) +from flexus_client_kit.integrations.facebook.adsets import ( + list_adsets, create_adset, update_adset, validate_targeting, + get_adset, delete_adset, list_adsets_for_account, +) +from flexus_client_kit.integrations.facebook.ads import ( + upload_image, create_creative, create_ad, preview_ad, + list_ads, get_ad, update_ad, delete_ad, + list_creatives, get_creative, update_creative, delete_creative, preview_creative, + upload_video, list_videos, +) +from flexus_client_kit.integrations.facebook.audiences import ( + list_custom_audiences, create_custom_audience, create_lookalike_audience, + get_custom_audience, update_custom_audience, delete_custom_audience, add_users_to_audience, +) +from flexus_client_kit.integrations.facebook.pixels import list_pixels, create_pixel, get_pixel_stats +from flexus_client_kit.integrations.facebook.targeting import ( + search_interests, search_behaviors, get_reach_estimate, get_delivery_estimate, +) +from flexus_client_kit.integrations.facebook.rules import ( + list_ad_rules, create_ad_rule, update_ad_rule, delete_ad_rule, execute_ad_rule, +) + +if TYPE_CHECKING: + from flexus_client_kit import ckit_client, ckit_bot_exec + +logger = logging.getLogger("meta_marketing_manage") + +# Use case: "Create & manage ads with Marketing API" +# Covers campaigns, ad sets, ads, creatives, audiences, pixels, targeting, rules. +PROVIDER_NAME = "meta_marketing_manage" + +_HELP = """meta_marketing_manage: Create & manage ads with Meta Marketing API. +op=help | status | list_methods | call(args={method_id, ...}) + +Campaign management: + list_campaigns(ad_account_id, status?) + get_campaign(campaign_id) + create_campaign(ad_account_id, name, objective, daily_budget?, lifetime_budget?, status?) + update_campaign(campaign_id, name?, status?, daily_budget?, lifetime_budget?) + delete_campaign(campaign_id) + duplicate_campaign(campaign_id, new_name, ad_account_id?) + archive_campaign(campaign_id) + bulk_update_campaigns(campaigns) + +Ad set management: + list_adsets(campaign_id) + list_adsets_for_account(ad_account_id, status_filter?) + get_adset(adset_id) + create_adset(ad_account_id, campaign_id, name, targeting, optimization_goal?, billing_event?, status?) + update_adset(adset_id, name?, status?, daily_budget?, targeting?) + delete_adset(adset_id) + validate_targeting(targeting_spec, ad_account_id?) + +Ad & creative management: + list_ads(ad_account_id?, adset_id?, status_filter?) + get_ad(ad_id) + create_ad(ad_account_id, adset_id, creative_id, name?, status?) + update_ad(ad_id, name?, status?) + delete_ad(ad_id) + preview_ad(ad_id, ad_format?) + list_creatives(ad_account_id) + get_creative(creative_id) + create_creative(ad_account_id, name, page_id, message?, link?, image_hash?, video_id?, call_to_action_type?) + update_creative(creative_id, name?) + delete_creative(creative_id) + preview_creative(creative_id, ad_format?) + upload_image(image_path?, image_url?, ad_account_id?) + upload_video(video_url, ad_account_id?, title?, description?) + list_videos(ad_account_id) + +Audiences: + list_custom_audiences(ad_account_id) + get_custom_audience(audience_id) + create_custom_audience(ad_account_id, name, subtype?, description?, customer_file_source?) + create_lookalike_audience(ad_account_id, origin_audience_id, country, ratio?, name?) + update_custom_audience(audience_id, name?, description?) + delete_custom_audience(audience_id) + add_users_to_audience(audience_id, emails, phones?) + +Pixels: + list_pixels(ad_account_id) + create_pixel(ad_account_id, name) + get_pixel_stats(pixel_id, start_time?, end_time?, aggregation?) + +Targeting research: + search_interests(q, limit?) + search_behaviors(q, limit?) + get_reach_estimate(ad_account_id, targeting, optimization_goal?) + get_delivery_estimate(ad_account_id, targeting, optimization_goal?, bid_amount?) + +Automation rules: + list_ad_rules(ad_account_id) + create_ad_rule(ad_account_id, name, evaluation_spec, execution_spec, schedule_spec?, status?) + update_ad_rule(rule_id, name?, status?) + delete_ad_rule(rule_id) + execute_ad_rule(rule_id) +""" + +# Maps op string -> lambda(client, args) for the generic dispatch table. +_HANDLERS: Dict[str, Any] = { + "list_campaigns": lambda c, a: list_campaigns(c, a.get("ad_account_id"), a.get("status")), + "get_campaign": lambda c, a: get_campaign(c, a.get("campaign_id", "")), + "create_campaign": lambda c, a: create_campaign( + c, a.get("ad_account_id", ""), a.get("name", ""), + a.get("objective", "OUTCOME_AWARENESS"), + a.get("daily_budget"), a.get("lifetime_budget"), + a.get("status", "PAUSED"), + ), + "update_campaign": lambda c, a: update_campaign( + c, a.get("campaign_id", ""), a.get("name"), a.get("status"), + a.get("daily_budget"), a.get("lifetime_budget"), + ), + "delete_campaign": lambda c, a: delete_campaign(c, a.get("campaign_id", "")), + "duplicate_campaign": lambda c, a: duplicate_campaign(c, a.get("campaign_id", ""), a.get("new_name", ""), a.get("ad_account_id")), + "archive_campaign": lambda c, a: archive_campaign(c, a.get("campaign_id", "")), + "bulk_update_campaigns": lambda c, a: bulk_update_campaigns(c, a.get("campaigns", [])), + "list_adsets": lambda c, a: list_adsets(c, a.get("campaign_id", "")), + "list_adsets_for_account": lambda c, a: list_adsets_for_account(c, a.get("ad_account_id", ""), a.get("status_filter")), + "get_adset": lambda c, a: get_adset(c, a.get("adset_id", "")), + "create_adset": lambda c, a: create_adset( + c, a.get("ad_account_id", ""), a.get("campaign_id", ""), + a.get("name", ""), a.get("targeting", {}), + a.get("optimization_goal"), a.get("billing_event"), + a.get("daily_budget"), a.get("lifetime_budget"), + a.get("status", "PAUSED"), + ), + "update_adset": lambda c, a: update_adset(c, a.get("adset_id", ""), a.get("name"), a.get("status"), a.get("daily_budget"), a.get("targeting")), + "delete_adset": lambda c, a: delete_adset(c, a.get("adset_id", "")), + "validate_targeting": lambda c, a: validate_targeting(c, a.get("targeting_spec", a.get("targeting", {})), a.get("ad_account_id")), + "list_ads": lambda c, a: list_ads(c, a.get("ad_account_id"), a.get("adset_id"), a.get("status_filter")), + "get_ad": lambda c, a: get_ad(c, a.get("ad_id", "")), + "create_ad": lambda c, a: create_ad(c, a.get("ad_account_id", ""), a.get("adset_id", ""), a.get("creative_id", ""), a.get("name"), a.get("status", "PAUSED")), + "update_ad": lambda c, a: update_ad(c, a.get("ad_id", ""), a.get("name"), a.get("status")), + "delete_ad": lambda c, a: delete_ad(c, a.get("ad_id", "")), + "preview_ad": lambda c, a: preview_ad(c, a.get("ad_id", ""), a.get("ad_format", "DESKTOP_FEED_STANDARD")), + "list_creatives": lambda c, a: list_creatives(c, a.get("ad_account_id", "")), + "get_creative": lambda c, a: get_creative(c, a.get("creative_id", "")), + "create_creative": lambda c, a: create_creative( + c, a.get("ad_account_id", ""), a.get("name", ""), + a.get("page_id", ""), a.get("message"), a.get("link"), + a.get("image_hash"), a.get("video_id"), a.get("call_to_action_type"), + ), + "update_creative": lambda c, a: update_creative(c, a.get("creative_id", ""), a.get("name")), + "delete_creative": lambda c, a: delete_creative(c, a.get("creative_id", "")), + "preview_creative": lambda c, a: preview_creative(c, a.get("creative_id", ""), a.get("ad_format", "DESKTOP_FEED_STANDARD")), + "upload_image": lambda c, a: upload_image(c, a.get("image_path"), a.get("image_url"), a.get("ad_account_id")), + "upload_video": lambda c, a: upload_video(c, a.get("video_url", ""), a.get("ad_account_id"), a.get("title"), a.get("description")), + "list_videos": lambda c, a: list_videos(c, a.get("ad_account_id", "")), + "list_custom_audiences": lambda c, a: list_custom_audiences(c, a.get("ad_account_id", "")), + "get_custom_audience": lambda c, a: get_custom_audience(c, a.get("audience_id", "")), + "create_custom_audience": lambda c, a: create_custom_audience(c, a.get("ad_account_id", ""), a.get("name", ""), a.get("subtype", "CUSTOM"), a.get("description"), a.get("customer_file_source")), + "create_lookalike_audience": lambda c, a: create_lookalike_audience(c, a.get("ad_account_id", ""), a.get("origin_audience_id", ""), a.get("country", ""), float(a.get("ratio", 0.01)), a.get("name")), + "update_custom_audience": lambda c, a: update_custom_audience(c, a.get("audience_id", ""), a.get("name"), a.get("description")), + "delete_custom_audience": lambda c, a: delete_custom_audience(c, a.get("audience_id", "")), + "add_users_to_audience": lambda c, a: add_users_to_audience(c, a.get("audience_id", ""), a.get("emails", []), a.get("phones")), + "list_pixels": lambda c, a: list_pixels(c, a.get("ad_account_id", "")), + "create_pixel": lambda c, a: create_pixel(c, a.get("ad_account_id", ""), a.get("name", "")), + "get_pixel_stats": lambda c, a: get_pixel_stats(c, a.get("pixel_id", ""), a.get("start_time"), a.get("end_time"), a.get("aggregation", "day")), + "search_interests": lambda c, a: search_interests(c, a.get("q", ""), int(a.get("limit", 20))), + "search_behaviors": lambda c, a: search_behaviors(c, a.get("q", ""), int(a.get("limit", 20))), + "get_reach_estimate": lambda c, a: get_reach_estimate(c, a.get("ad_account_id", ""), a.get("targeting", {}), a.get("optimization_goal", "LINK_CLICKS")), + "get_delivery_estimate": lambda c, a: get_delivery_estimate(c, a.get("ad_account_id", ""), a.get("targeting", {}), a.get("optimization_goal", "LINK_CLICKS"), a.get("bid_amount")), + "list_ad_rules": lambda c, a: list_ad_rules(c, a.get("ad_account_id", "")), + "create_ad_rule": lambda c, a: create_ad_rule(c, a.get("ad_account_id", ""), a.get("name", ""), a.get("evaluation_spec", {}), a.get("execution_spec", {}), a.get("schedule_spec"), a.get("status", "ENABLED")), + "update_ad_rule": lambda c, a: update_ad_rule(c, a.get("rule_id", ""), a.get("name"), a.get("status")), + "delete_ad_rule": lambda c, a: delete_ad_rule(c, a.get("rule_id", "")), + "execute_ad_rule": lambda c, a: execute_ad_rule(c, a.get("rule_id", "")), +} + + +class IntegrationMetaMarketingManage: + # Wraps FacebookAdsClient and delegates all manage operations through _HANDLERS. + # Uses OAuth token from rcx (Flexus stores the Meta access token per persona). + def __init__(self, rcx: "ckit_bot_exec.RobotContext"): + self.client = FacebookAdsClient(rcx.fclient, rcx) + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + try: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return _HELP + if op == "status": + return await self._status() + if op == "list_methods": + return "\n".join(sorted(_HANDLERS.keys())) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + handler = _HANDLERS.get(method_id) + if handler is None: + return f"Error: unknown method_id={method_id!r}. Use op=list_methods to see available methods." + return await handler(self.client, call_args) + except FacebookAuthError as e: + return e.message + except FacebookAPIError as e: + logger.info("meta_marketing_manage api error: %s", e) + return e.format_for_user() + except FacebookValidationError as e: + return f"Error: {e.message}" + except Exception as e: + logger.error("Unexpected error in meta_marketing_manage op=%s", (model_produced_args or {}).get("op"), exc_info=e) + return f"Error: {e}" + + async def _status(self) -> str: + try: + auth_error = await self.client.ensure_auth() + if auth_error: + return auth_error + return "meta_marketing_manage: connected. Use op=help to see available operations." + except (FacebookAuthError, FacebookAPIError, FacebookValidationError) as e: + return e.message + except Exception as e: + logger.error("Unexpected error in meta_marketing_manage status", exc_info=e) + return f"Error: {e}" diff --git a/flexus_client_kit/integrations/fi_meta_marketing_metrics.py b/flexus_client_kit/integrations/fi_meta_marketing_metrics.py new file mode 100644 index 00000000..840500ed --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_marketing_metrics.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import logging +from typing import Any, Dict, TYPE_CHECKING + +from flexus_client_kit import ckit_cloudtool +from flexus_client_kit.integrations.facebook.client import FacebookAdsClient +from flexus_client_kit.integrations.facebook.exceptions import ( + FacebookAPIError, + FacebookAuthError, + FacebookValidationError, +) +from flexus_client_kit.integrations.facebook.insights import ( + get_account_insights, get_campaign_insights, get_adset_insights, get_ad_insights, + create_async_report, get_async_report_status, +) + +if TYPE_CHECKING: + from flexus_client_kit import ckit_bot_exec + +logger = logging.getLogger("meta_marketing_metrics") + +# Use case: "Measure ad performance data with Marketing API" +# Covers all insights endpoints: account, campaign, ad set, ad level, async reports. +PROVIDER_NAME = "meta_marketing_metrics" + +_HELP = """meta_marketing_metrics: Measure ad performance with Meta Marketing API. +op=help | status | list_methods | call(args={method_id, ...}) + + get_account_insights(ad_account_id, days?, breakdowns?, metrics?, date_preset?) + get_campaign_insights(campaign_id, days?, breakdowns?, metrics?, date_preset?) + get_adset_insights(adset_id, days?, breakdowns?, metrics?, date_preset?) + get_ad_insights(ad_id, days?, breakdowns?, metrics?, date_preset?) + create_async_report(ad_account_id, level?, fields?, date_preset?, breakdowns?) + get_async_report_status(report_run_id) +""" + +_HANDLERS: Dict[str, Any] = { + "get_account_insights": lambda c, a: get_account_insights(c, a.get("ad_account_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), + "get_campaign_insights": lambda c, a: get_campaign_insights(c, a.get("campaign_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), + "get_adset_insights": lambda c, a: get_adset_insights(c, a.get("adset_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), + "get_ad_insights": lambda c, a: get_ad_insights(c, a.get("ad_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), + "create_async_report": lambda c, a: create_async_report(c, a.get("ad_account_id", ""), a.get("level", "campaign"), a.get("fields"), a.get("date_preset", "last_30d"), a.get("breakdowns")), + "get_async_report_status": lambda c, a: get_async_report_status(c, a.get("report_run_id", "")), +} + + +class IntegrationMetaMarketingMetrics: + # Wraps FacebookAdsClient and delegates all insights/metrics operations. + def __init__(self, rcx: "ckit_bot_exec.RobotContext"): + self.client = FacebookAdsClient(rcx.fclient, rcx) + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + try: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return _HELP + if op == "status": + return await self._status() + if op == "list_methods": + return "\n".join(sorted(_HANDLERS.keys())) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + handler = _HANDLERS.get(method_id) + if handler is None: + return f"Error: unknown method_id={method_id!r}. Use op=list_methods to see available methods." + return await handler(self.client, call_args) + except FacebookAuthError as e: + return e.message + except FacebookAPIError as e: + logger.info("meta_marketing_metrics api error: %s", e) + return e.format_for_user() + except FacebookValidationError as e: + return f"Error: {e.message}" + except Exception as e: + logger.error("Unexpected error in meta_marketing_metrics op=%s", (model_produced_args or {}).get("op"), exc_info=e) + return f"Error: {e}" + + async def _status(self) -> str: + try: + auth_error = await self.client.ensure_auth() + if auth_error: + return auth_error + return "meta_marketing_metrics: connected. Use op=help to see available operations." + except (FacebookAuthError, FacebookAPIError, FacebookValidationError) as e: + return e.message + except Exception as e: + logger.error("Unexpected error in meta_marketing_metrics status", exc_info=e) + return f"Error: {e}" diff --git a/flexus_client_kit/integrations/fi_meta_messenger.py b/flexus_client_kit/integrations/fi_meta_messenger.py new file mode 100644 index 00000000..0c4fca33 --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_messenger.py @@ -0,0 +1,20 @@ +import json +from typing import Any, Dict +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = "meta_messenger" +METHOD_IDS: list[str] = [] + + +class IntegrationMetaMessenger: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nstatus: not yet implemented" + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "not_implemented", "method_count": 0}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + method_id = str((args.get("args") or {}).get("method_id", "")).strip() + return json.dumps({"ok": False, "error_code": "INTEGRATION_UNAVAILABLE", "provider": PROVIDER_NAME, "method_id": method_id, "message": "Not yet implemented."}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_meta_pages.py b/flexus_client_kit/integrations/fi_meta_pages.py new file mode 100644 index 00000000..903e1e0b --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_pages.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import logging +from typing import Any, Dict, TYPE_CHECKING + +from flexus_client_kit import ckit_cloudtool +from flexus_client_kit.integrations.facebook.client import FacebookAdsClient +from flexus_client_kit.integrations.facebook.exceptions import ( + FacebookAPIError, + FacebookAuthError, + FacebookValidationError, +) +from flexus_client_kit.integrations.facebook.accounts import ( + list_ad_accounts, get_ad_account_info, update_spending_limit, + list_account_users, list_pages, +) + +if TYPE_CHECKING: + from flexus_client_kit import ckit_bot_exec + +logger = logging.getLogger("meta_pages") + +# Use case: "Manage everything on your Page" +# Covers Facebook Pages, ad accounts, users — the account-level layer above campaigns. +PROVIDER_NAME = "meta_pages" + +_HELP = """meta_pages: Manage Facebook Pages and ad accounts. +op=help | status | list_methods | call(args={method_id, ...}) + + list_pages() -- Facebook Pages you manage (needed for ad creatives) + list_ad_accounts() -- All ad accounts accessible with your token + get_ad_account_info(ad_account_id) + update_spending_limit(ad_account_id, spending_limit) + list_account_users(ad_account_id) +""" + +_HANDLERS: Dict[str, Any] = { + "list_pages": lambda c, a: list_pages(c), + "list_ad_accounts": lambda c, a: list_ad_accounts(c), + "get_ad_account_info": lambda c, a: get_ad_account_info(c, a.get("ad_account_id", "")), + "update_spending_limit": lambda c, a: update_spending_limit(c, a.get("ad_account_id", ""), a.get("spending_limit", 0)), + "list_account_users": lambda c, a: list_account_users(c, a.get("ad_account_id", "")), +} + + +class IntegrationMetaPages: + # Wraps FacebookAdsClient for page/account-level operations. + def __init__(self, rcx: "ckit_bot_exec.RobotContext"): + self.client = FacebookAdsClient(rcx.fclient, rcx) + + async def called_by_model( + self, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], + ) -> str: + try: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return _HELP + if op == "status": + return await self._status() + if op == "list_methods": + return "\n".join(sorted(_HANDLERS.keys())) + if op != "call": + return "Error: unknown op. Use help/status/list_methods/call." + call_args = args.get("args") or {} + method_id = str(call_args.get("method_id", "")).strip() + if not method_id: + return "Error: args.method_id required for op=call." + handler = _HANDLERS.get(method_id) + if handler is None: + return f"Error: unknown method_id={method_id!r}. Use op=list_methods to see available methods." + return await handler(self.client, call_args) + except FacebookAuthError as e: + return e.message + except FacebookAPIError as e: + logger.info("meta_pages api error: %s", e) + return e.format_for_user() + except FacebookValidationError as e: + return f"Error: {e.message}" + except Exception as e: + logger.error("Unexpected error in meta_pages op=%s", (model_produced_args or {}).get("op"), exc_info=e) + return f"Error: {e}" + + async def _status(self) -> str: + try: + auth_error = await self.client.ensure_auth() + if auth_error: + return auth_error + return "meta_pages: connected. Use op=help to see available operations." + except (FacebookAuthError, FacebookAPIError, FacebookValidationError) as e: + return e.message + except Exception as e: + logger.error("Unexpected error in meta_pages status", exc_info=e) + return f"Error: {e}" diff --git a/flexus_client_kit/integrations/fi_meta_threads.py b/flexus_client_kit/integrations/fi_meta_threads.py new file mode 100644 index 00000000..4774aa4b --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_threads.py @@ -0,0 +1,20 @@ +import json +from typing import Any, Dict +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = "meta_threads" +METHOD_IDS: list[str] = [] + + +class IntegrationMetaThreads: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nstatus: not yet implemented" + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "not_implemented", "method_count": 0}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + method_id = str((args.get("args") or {}).get("method_id", "")).strip() + return json.dumps({"ok": False, "error_code": "INTEGRATION_UNAVAILABLE", "provider": PROVIDER_NAME, "method_id": method_id, "message": "Not yet implemented."}, indent=2, ensure_ascii=False) diff --git a/flexus_client_kit/integrations/fi_meta_whatsapp.py b/flexus_client_kit/integrations/fi_meta_whatsapp.py new file mode 100644 index 00000000..aded17d7 --- /dev/null +++ b/flexus_client_kit/integrations/fi_meta_whatsapp.py @@ -0,0 +1,20 @@ +import json +from typing import Any, Dict +from flexus_client_kit import ckit_cloudtool + +PROVIDER_NAME = "meta_whatsapp" +METHOD_IDS: list[str] = [] + + +class IntegrationMetaWhatsapp: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + args = model_produced_args or {} + op = str(args.get("op", "help")).strip() + if op == "help": + return f"provider={PROVIDER_NAME}\nop=help | status | list_methods | call\nstatus: not yet implemented" + if op == "status": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "status": "not_implemented", "method_count": 0}, indent=2, ensure_ascii=False) + if op == "list_methods": + return json.dumps({"ok": True, "provider": PROVIDER_NAME, "method_ids": METHOD_IDS}, indent=2, ensure_ascii=False) + method_id = str((args.get("args") or {}).get("method_id", "")).strip() + return json.dumps({"ok": False, "error_code": "INTEGRATION_UNAVAILABLE", "provider": PROVIDER_NAME, "method_id": method_id, "message": "Not yet implemented."}, indent=2, ensure_ascii=False) From 2ef073ed17dd6d4d9a0c40d6baec84ed2ccd40c1 Mon Sep 17 00:00:00 2001 From: lev-goryachev Date: Mon, 16 Mar 2026 13:35:20 +0100 Subject: [PATCH 4/4] refactor(meta): flatten facebook/ subpackage into root integrations - Merge client.py + exceptions.py + models.py + utils.py into _fi_meta_helpers.py - Inline campaigns/adsets/ads/audiences/pixels/targeting/rules into fi_meta_marketing_manage.py - Inline insights.py into fi_meta_marketing_metrics.py - Inline accounts.py into fi_meta_pages.py - Delete facebook/ subpackage entirely All fi_meta_*.py files now self-contained with single helper dep. --- .../integrations/_fi_meta_helpers.py | 567 +++++++++ .../integrations/facebook/__init__.py | 0 .../integrations/facebook/accounts.py | 204 --- .../integrations/facebook/ads.py | 489 ------- .../integrations/facebook/adsets.py | 324 ----- .../integrations/facebook/audiences.py | 207 --- .../integrations/facebook/campaigns.py | 352 ------ .../integrations/facebook/client.py | 186 --- .../integrations/facebook/exceptions.py | 121 -- .../integrations/facebook/insights.py | 211 ---- .../integrations/facebook/models.py | 301 ----- .../integrations/facebook/pixels.py | 90 -- .../integrations/facebook/rules.py | 129 -- .../integrations/facebook/targeting.py | 134 -- .../integrations/facebook/utils.py | 140 -- .../integrations/fi_meta_marketing_manage.py | 1123 ++++++++++++++--- .../integrations/fi_meta_marketing_metrics.py | 157 ++- .../integrations/fi_meta_pages.py | 163 ++- 18 files changed, 1798 insertions(+), 3100 deletions(-) create mode 100644 flexus_client_kit/integrations/_fi_meta_helpers.py delete mode 100644 flexus_client_kit/integrations/facebook/__init__.py delete mode 100644 flexus_client_kit/integrations/facebook/accounts.py delete mode 100644 flexus_client_kit/integrations/facebook/ads.py delete mode 100644 flexus_client_kit/integrations/facebook/adsets.py delete mode 100644 flexus_client_kit/integrations/facebook/audiences.py delete mode 100644 flexus_client_kit/integrations/facebook/campaigns.py delete mode 100644 flexus_client_kit/integrations/facebook/client.py delete mode 100644 flexus_client_kit/integrations/facebook/exceptions.py delete mode 100644 flexus_client_kit/integrations/facebook/insights.py delete mode 100644 flexus_client_kit/integrations/facebook/models.py delete mode 100644 flexus_client_kit/integrations/facebook/pixels.py delete mode 100644 flexus_client_kit/integrations/facebook/rules.py delete mode 100644 flexus_client_kit/integrations/facebook/targeting.py delete mode 100644 flexus_client_kit/integrations/facebook/utils.py diff --git a/flexus_client_kit/integrations/_fi_meta_helpers.py b/flexus_client_kit/integrations/_fi_meta_helpers.py new file mode 100644 index 00000000..e1b59f01 --- /dev/null +++ b/flexus_client_kit/integrations/_fi_meta_helpers.py @@ -0,0 +1,567 @@ +from __future__ import annotations + +import asyncio +import hashlib +import logging +import time +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +import httpx +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +if TYPE_CHECKING: + from flexus_client_kit import ckit_client, ckit_bot_exec + +# ── Exceptions ──────────────────────────────────────────────────────────────── + +logger = logging.getLogger("meta") + + +class FacebookError(Exception): + def __init__(self, message: str, details: Optional[str] = None): + self.message = message + self.details = details + super().__init__(message) + + def __str__(self) -> str: + if self.details: + return f"{self.message}\n{self.details}" + return self.message + + +class FacebookAPIError(FacebookError): + CODE_INVALID_PARAMS = 100 + CODE_AUTH_EXPIRED = 190 + CODE_RATE_LIMIT_1 = 4 + CODE_RATE_LIMIT_2 = 17 + CODE_RATE_LIMIT_3 = 32 + CODE_INSUFFICIENT_PERMISSIONS = 80004 + CODE_AD_ACCOUNT_DISABLED = 2635 + CODE_BUDGET_TOO_LOW = 1487387 + RATE_LIMIT_CODES = {CODE_RATE_LIMIT_1, CODE_RATE_LIMIT_2, CODE_RATE_LIMIT_3} + + def __init__( + self, + code: int, + message: str, + error_type: str = "", + user_title: Optional[str] = None, + user_msg: Optional[str] = None, + fbtrace_id: Optional[str] = None, + ): + self.code = code + self.error_type = error_type + self.user_title = user_title + self.user_msg = user_msg + self.fbtrace_id = fbtrace_id + details_parts = [] + if user_title: + details_parts.append(f"**{user_title}**") + if user_msg: + details_parts.append(user_msg) + if not details_parts: + details_parts.append(message) + super().__init__(message, "\n".join(details_parts)) + + @property + def is_rate_limit(self) -> bool: + return self.code in self.RATE_LIMIT_CODES + + @property + def is_auth_error(self) -> bool: + return self.code == self.CODE_AUTH_EXPIRED + + def format_for_user(self) -> str: + if self.code == self.CODE_AUTH_EXPIRED: + return f"Authentication failed. Please reconnect Facebook.\n{self.details}" + elif self.is_rate_limit: + return f"Rate limit reached. Please try again in a few minutes.\n{self.details}" + elif self.code == self.CODE_INVALID_PARAMS: + return f"Invalid parameters (code {self.code}):\n{self.details}" + elif self.code == self.CODE_AD_ACCOUNT_DISABLED: + return f"Ad account is disabled.\n{self.details}" + elif self.code == self.CODE_BUDGET_TOO_LOW: + return f"Budget too low:\n{self.details}" + elif self.code == self.CODE_INSUFFICIENT_PERMISSIONS: + return f"Insufficient permissions.\n{self.details}" + else: + return f"Facebook API Error ({self.code}):\n{self.details}" + + +class FacebookAuthError(FacebookError): + def __init__(self, message: str = "Facebook authentication required"): + super().__init__(message) + + +class FacebookValidationError(FacebookError): + def __init__(self, field: str, message: str): + self.field = field + super().__init__(f"Validation error for '{field}': {message}") + + +class FacebookTimeoutError(FacebookError): + def __init__(self, timeout: float): + super().__init__(f"Request timed out after {timeout} seconds") + + +async def parse_api_error(response: httpx.Response) -> FacebookAPIError: + try: + error_data = response.json() + if "error" in error_data: + err = error_data["error"] + return FacebookAPIError( + code=err.get("code", response.status_code), + message=err.get("message", "Unknown error"), + error_type=err.get("type", ""), + user_title=err.get("error_user_title"), + user_msg=err.get("error_user_msg"), + fbtrace_id=err.get("fbtrace_id"), + ) + return FacebookAPIError(code=response.status_code, message=f"HTTP {response.status_code}: {response.text[:500]}") + except (KeyError, ValueError) as e: + logger.warning("Error parsing FB API error response", exc_info=e) + return FacebookAPIError(code=response.status_code, message=f"HTTP {response.status_code}: {response.text[:500]}") + + +# ── Models ──────────────────────────────────────────────────────────────────── + +class CustomAudienceSubtype(str, Enum): + CUSTOM = "CUSTOM" + WEBSITE = "WEBSITE" + APP = "APP" + ENGAGEMENT = "ENGAGEMENT" + LOOKALIKE = "LOOKALIKE" + VIDEO = "VIDEO" + LEAD_GENERATION = "LEAD_GENERATION" + ON_SITE_LEAD = "ON_SITE_LEAD" + + +class InsightsBreakdown(str, Enum): + AGE = "age" + GENDER = "gender" + COUNTRY = "country" + REGION = "region" + PLACEMENT = "publisher_platform" + DEVICE = "device_platform" + IMPRESSION_DEVICE = "impression_device" + PLATFORM_POSITION = "platform_position" + + +class InsightsDatePreset(str, Enum): + TODAY = "today" + YESTERDAY = "yesterday" + LAST_7D = "last_7d" + LAST_14D = "last_14d" + LAST_28D = "last_28d" + LAST_30D = "last_30d" + LAST_90D = "last_90d" + THIS_MONTH = "this_month" + LAST_MONTH = "last_month" + MAXIMUM = "maximum" + + +class AdRuleStatus(str, Enum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + DELETED = "DELETED" + HAS_ISSUES = "HAS_ISSUES" + + +class CampaignObjective(str, Enum): + TRAFFIC = "OUTCOME_TRAFFIC" + SALES = "OUTCOME_SALES" + ENGAGEMENT = "OUTCOME_ENGAGEMENT" + AWARENESS = "OUTCOME_AWARENESS" + LEADS = "OUTCOME_LEADS" + APP_PROMOTION = "OUTCOME_APP_PROMOTION" + + +class CampaignStatus(str, Enum): + ACTIVE = "ACTIVE" + PAUSED = "PAUSED" + ARCHIVED = "ARCHIVED" + + +class AccountStatus(int, Enum): + ACTIVE = 1 + DISABLED = 2 + UNSETTLED = 3 + PENDING_RISK_REVIEW = 7 + PENDING_SETTLEMENT = 8 + IN_GRACE_PERIOD = 9 + PENDING_CLOSURE = 100 + CLOSED = 101 + TEMPORARILY_UNAVAILABLE = 201 + + +class OptimizationGoal(str, Enum): + LINK_CLICKS = "LINK_CLICKS" + LANDING_PAGE_VIEWS = "LANDING_PAGE_VIEWS" + IMPRESSIONS = "IMPRESSIONS" + REACH = "REACH" + CONVERSIONS = "CONVERSIONS" + VALUE = "VALUE" + LEAD_GENERATION = "LEAD_GENERATION" + APP_INSTALLS = "APP_INSTALLS" + OFFSITE_CONVERSIONS = "OFFSITE_CONVERSIONS" + POST_ENGAGEMENT = "POST_ENGAGEMENT" + VIDEO_VIEWS = "VIDEO_VIEWS" + THRUPLAY = "THRUPLAY" + + +class BillingEvent(str, Enum): + IMPRESSIONS = "IMPRESSIONS" + LINK_CLICKS = "LINK_CLICKS" + APP_INSTALLS = "APP_INSTALLS" + THRUPLAY = "THRUPLAY" + + +class BidStrategy(str, Enum): + LOWEST_COST_WITHOUT_CAP = "LOWEST_COST_WITHOUT_CAP" + LOWEST_COST_WITH_BID_CAP = "LOWEST_COST_WITH_BID_CAP" + COST_CAP = "COST_CAP" + + +class CallToActionType(str, Enum): + LEARN_MORE = "LEARN_MORE" + SHOP_NOW = "SHOP_NOW" + SIGN_UP = "SIGN_UP" + BOOK_NOW = "BOOK_NOW" + DOWNLOAD = "DOWNLOAD" + GET_OFFER = "GET_OFFER" + GET_QUOTE = "GET_QUOTE" + CONTACT_US = "CONTACT_US" + SUBSCRIBE = "SUBSCRIBE" + APPLY_NOW = "APPLY_NOW" + BUY_NOW = "BUY_NOW" + WATCH_MORE = "WATCH_MORE" + + +class AdFormat(str, Enum): + DESKTOP_FEED_STANDARD = "DESKTOP_FEED_STANDARD" + MOBILE_FEED_STANDARD = "MOBILE_FEED_STANDARD" + INSTAGRAM_STANDARD = "INSTAGRAM_STANDARD" + INSTAGRAM_STORY = "INSTAGRAM_STORY" + MOBILE_BANNER = "MOBILE_BANNER" + MOBILE_INTERSTITIAL = "MOBILE_INTERSTITIAL" + MOBILE_NATIVE = "MOBILE_NATIVE" + RIGHT_COLUMN_STANDARD = "RIGHT_COLUMN_STANDARD" + + +class GeoLocation(BaseModel): + countries: List[str] = Field(default_factory=list) + regions: List[Dict[str, Any]] = Field(default_factory=list) + cities: List[Dict[str, Any]] = Field(default_factory=list) + zips: List[Dict[str, Any]] = Field(default_factory=list) + location_types: List[str] = Field(default_factory=lambda: ["home", "recent"]) + model_config = ConfigDict(extra="allow") + + +class TargetingSpec(BaseModel): + geo_locations: GeoLocation + age_min: int = Field(default=18, ge=13, le=65) + age_max: int = Field(default=65, ge=13, le=65) + genders: List[int] = Field(default_factory=list) + interests: List[Dict[str, Any]] = Field(default_factory=list) + behaviors: List[Dict[str, Any]] = Field(default_factory=list) + custom_audiences: List[Dict[str, Any]] = Field(default_factory=list) + excluded_custom_audiences: List[Dict[str, Any]] = Field(default_factory=list) + locales: List[int] = Field(default_factory=list) + publisher_platforms: List[str] = Field(default_factory=list) + device_platforms: List[str] = Field(default_factory=list) + model_config = ConfigDict(extra="allow") + + @model_validator(mode="after") + def validate_geo_locations(self) -> "TargetingSpec": + geo = self.geo_locations + if not geo.countries and not geo.regions and not geo.cities: + raise ValueError("At least one geo_location (country, region, or city) is required") + return self + + @model_validator(mode="after") + def validate_age_range(self) -> "TargetingSpec": + if self.age_min > self.age_max: + raise ValueError("age_min cannot be greater than age_max") + return self + + +class ActionBreakdown(BaseModel): + action_type: str + value: int + + @field_validator("value", mode="before") + @classmethod + def coerce_value(cls, v: Any) -> int: + return int(v) + + +class Insights(BaseModel): + impressions: int = 0 + clicks: int = 0 + spend: float = 0.0 + reach: int = 0 + frequency: float = 0.0 + ctr: float = 0.0 + cpc: float = 0.0 + cpm: float = 0.0 + actions: List[ActionBreakdown] = Field(default_factory=list) + date_start: Optional[str] = None + date_stop: Optional[str] = None + model_config = ConfigDict(extra="allow") + + @field_validator("impressions", "clicks", "reach", mode="before") + @classmethod + def coerce_int(cls, v: Any) -> int: + return int(v) if v else 0 + + @field_validator("spend", "frequency", "ctr", "cpc", "cpm", mode="before") + @classmethod + def coerce_float(cls, v: Any) -> float: + return float(v) if v else 0.0 + + +# ── Utils ───────────────────────────────────────────────────────────────────── + +def validate_ad_account_id(ad_account_id: str) -> str: + if not ad_account_id: + raise FacebookValidationError("ad_account_id", "is required") + ad_account_id = str(ad_account_id).strip() + if not ad_account_id: + raise FacebookValidationError("ad_account_id", "cannot be empty") + if not ad_account_id.startswith("act_"): + return f"act_{ad_account_id}" + return ad_account_id + + +def validate_budget(budget: int, min_budget: int = 100, currency: str = "USD") -> int: + if not isinstance(budget, int): + try: + budget = int(budget) + except (TypeError, ValueError): + raise FacebookValidationError("budget", "must be an integer (cents)") + if budget < min_budget: + raise FacebookValidationError("budget", f"must be at least {format_currency(min_budget, currency)}") + return budget + + +def validate_targeting_spec(spec: Dict[str, Any]) -> Tuple[bool, str]: + try: + if not spec: + return False, "Targeting spec cannot be empty" + if "geo_locations" not in spec: + return False, "geo_locations is required in targeting" + geo = spec["geo_locations"] + if not isinstance(geo, dict): + return False, "geo_locations must be a dictionary" + if not geo.get("countries") and not geo.get("regions") and not geo.get("cities"): + return False, "At least one geo_location (country, region, or city) is required" + if "age_min" in spec: + age_min = spec["age_min"] + if not isinstance(age_min, int) or age_min < 13 or age_min > 65: + return False, "age_min must be between 13 and 65" + if "age_max" in spec: + age_max = spec["age_max"] + if not isinstance(age_max, int) or age_max < 13 or age_max > 65: + return False, "age_max must be between 13 and 65" + if "age_min" in spec and "age_max" in spec: + if spec["age_min"] > spec["age_max"]: + return False, "age_min cannot be greater than age_max" + return True, "" + except (KeyError, TypeError, ValueError) as e: + return False, f"Validation error: {str(e)}" + + +def format_currency(cents: int, currency: str = "USD") -> str: + return f"{cents / 100:.2f} {currency}" + + +def format_account_status(status_code: int) -> str: + status_map = { + 1: "Active", 2: "Disabled", 3: "Unsettled", 7: "Pending Risk Review", + 8: "Pending Settlement", 9: "In Grace Period", 100: "Pending Closure", + 101: "Closed", 201: "Temporarily Unavailable", + } + return status_map.get(status_code, f"Unknown ({status_code})") + + +def normalize_insights_data(raw_data: Dict[str, Any]) -> Insights: + try: + impressions = int(raw_data.get("impressions", 0)) + clicks = int(raw_data.get("clicks", 0)) + spend = float(raw_data.get("spend", 0.0)) + reach = int(raw_data.get("reach", 0)) + frequency = float(raw_data.get("frequency", 0.0)) + ctr = raw_data.get("ctr") + ctr = float(ctr) if ctr else ((clicks / impressions) * 100 if impressions > 0 else 0.0) + cpc = raw_data.get("cpc") + cpc = float(cpc) if cpc else (spend / clicks if clicks > 0 else 0.0) + cpm = raw_data.get("cpm") + cpm = float(cpm) if cpm else ((spend / impressions) * 1000 if impressions > 0 else 0.0) + actions = [] + for action in raw_data.get("actions", []): + if isinstance(action, dict): + actions.append({"action_type": action.get("action_type", "unknown"), "value": int(action.get("value", 0))}) + return Insights( + impressions=impressions, clicks=clicks, spend=spend, reach=reach, + frequency=frequency, ctr=ctr, cpc=cpc, cpm=cpm, actions=actions, + date_start=raw_data.get("date_start"), date_stop=raw_data.get("date_stop"), + ) + except (KeyError, TypeError, ValueError) as e: + logger.warning("Error normalizing insights data", exc_info=e) + return Insights() + + +def hash_for_audience(value: str, field_type: str) -> str: + value = value.strip().lower() + if field_type == "PHONE": + value = ''.join(c for c in value if c.isdigit()) + elif field_type in ["FN", "LN", "CT", "ST"]: + value = value.replace(" ", "") + return hashlib.sha256(value.encode()).hexdigest() + + +# ── HTTP Client ─────────────────────────────────────────────────────────────── + +API_BASE = "https://graph.facebook.com" +API_VERSION = "v19.0" +DEFAULT_TIMEOUT = 30.0 +MAX_RETRIES = 3 +INITIAL_RETRY_DELAY = 1.0 + + +class FacebookAdsClient: + # Handles OAuth token retrieval, HTTP request execution, and retry-with-backoff. + # All fi_meta_*.py integration classes use this as their sole HTTP layer. + def __init__( + self, + fclient: "ckit_client.FlexusClient", + rcx: "ckit_bot_exec.RobotContext", + ad_account_id: str = "", + ): + self.fclient = fclient + self.rcx = rcx + self._ad_account_id = "" + if ad_account_id: + self._ad_account_id = validate_ad_account_id(ad_account_id) + self._access_token: str = "" + self._headers: Dict[str, str] = {} + + @property + def ad_account_id(self) -> str: + return self._ad_account_id + + @ad_account_id.setter + def ad_account_id(self, value: str) -> None: + self._ad_account_id = validate_ad_account_id(value) if value else "" + + @property + def is_test_mode(self) -> bool: + return self.rcx.running_test_scenario + + @property + def access_token(self) -> str: + return self._access_token + + async def ensure_auth(self) -> Optional[str]: + try: + if self.is_test_mode: + return None + if not self._access_token: + self._access_token = await self._fetch_token() + self._headers = { + "Authorization": f"Bearer {self._access_token}", + "Content-Type": "application/json", + } + return None + except (AttributeError, KeyError, ValueError) as e: + logger.info("Failed to get Facebook token", exc_info=e) + return await self._prompt_oauth_connection() + + async def request( + self, + method: str, + endpoint: str, + params: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + form_data: Optional[Dict[str, Any]] = None, + timeout: float = DEFAULT_TIMEOUT, + ) -> Dict[str, Any]: + auth_error = await self.ensure_auth() + if auth_error: + raise FacebookAuthError(auth_error) + url = f"{API_BASE}/{API_VERSION}/{endpoint}" + + async def _make() -> Dict[str, Any]: + async with httpx.AsyncClient() as client: + if method == "GET": + response = await client.get(url, params=params, headers=self._headers, timeout=timeout) + elif method == "POST": + if form_data: + response = await client.post(url, data=form_data, timeout=timeout) + else: + response = await client.post(url, json=data, headers=self._headers, timeout=timeout) + elif method == "DELETE": + response = await client.delete(url, json=data, headers=self._headers, timeout=timeout) + else: + raise ValueError(f"Unsupported method: {method}") + return response.json() + + return await self._retry_with_backoff(_make) + + async def _retry_with_backoff(self, func, max_retries: int = MAX_RETRIES, initial_delay: float = INITIAL_RETRY_DELAY) -> Dict[str, Any]: + last_exception = None + for attempt in range(max_retries): + try: + return await func() + except (httpx.HTTPError, httpx.TimeoutException) as e: + last_exception = e + if attempt == max_retries - 1: + raise FacebookTimeoutError(DEFAULT_TIMEOUT) + delay = initial_delay * (2 ** attempt) + logger.warning("Retry %s/%s after %.1fs due to: %s", attempt + 1, max_retries, delay, e) + await asyncio.sleep(delay) + except FacebookAPIError as e: + if e.is_rate_limit: + last_exception = e + if attempt == max_retries - 1: + raise + delay = initial_delay * (2 ** attempt) * 2 + logger.warning("Rate limit hit, retry %s/%s after %.1fs", attempt + 1, max_retries, delay) + await asyncio.sleep(delay) + else: + raise + if last_exception: + raise last_exception + raise FacebookAPIError(500, "Unexpected retry loop exit") + + async def _fetch_token(self) -> str: + facebook_auth = self.rcx.external_auth.get("facebook") or {} + token_obj = facebook_auth.get("token") or {} + access_token = token_obj.get("access_token", "") + if not access_token: + raise ValueError("No Facebook OAuth connection found") + logger.info("Facebook token retrieved for %s", self.rcx.persona.owner_fuser_id) + return access_token + + async def _prompt_oauth_connection(self) -> str: + from flexus_client_kit import ckit_client + http = await self.fclient.use_http() + async with http as h: + result = await h.execute( + ckit_client.gql.gql(""" + query GetFacebookToken($fuser_id: String!, $ws_id: String!, $provider: String!, $scopes: [String!]!) { + external_auth_token(fuser_id: $fuser_id ws_id: $ws_id provider: $provider scopes: $scopes) + } + """), + variable_values={ + "fuser_id": self.rcx.persona.owner_fuser_id, + "ws_id": self.rcx.persona.ws_id, + "provider": "facebook", + "scopes": ["ads_management", "ads_read", "business_management", "pages_manage_ads"], + }, + ), + auth_url = result.get("external_auth_token", "") + return f"Facebook authorization required.\n\nClick this link to connect:\n{auth_url}\n\nAfter authorizing, return here and try again." diff --git a/flexus_client_kit/integrations/facebook/__init__.py b/flexus_client_kit/integrations/facebook/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/flexus_client_kit/integrations/facebook/accounts.py b/flexus_client_kit/integrations/facebook/accounts.py deleted file mode 100644 index b7d4b572..00000000 --- a/flexus_client_kit/integrations/facebook/accounts.py +++ /dev/null @@ -1,204 +0,0 @@ -from __future__ import annotations -import logging -from typing import Any, Dict, List, TYPE_CHECKING -from flexus_client_kit.integrations.facebook.utils import format_currency, format_account_status, validate_ad_account_id -from flexus_client_kit.integrations.facebook.exceptions import FacebookValidationError - -if TYPE_CHECKING: - from flexus_client_kit.integrations.facebook.client import FacebookAdsClient - -AD_ACCOUNT_FIELDS = ( - "id,account_id,name,currency,timezone_name,account_status," - "balance,amount_spent,spend_cap,business{id,name}" -) -AD_ACCOUNT_DETAIL_FIELDS = ( - "id,account_id,name,currency,timezone_name,account_status," - "balance,amount_spent,spend_cap,business,funding_source_details," - "min_daily_budget,created_time" -) - - -async def list_ad_accounts(client: "FacebookAdsClient") -> str: - if client.is_test_mode: - return _mock_list_ad_accounts() - data = await client.request("GET", "me/adaccounts", params={"fields": AD_ACCOUNT_FIELDS, "limit": 50}) - accounts = data.get("data", []) - if not accounts: - return ( - "No ad accounts found. You may need to:\n" - "1. Create an ad account in Facebook Business Manager\n" - "2. Ensure you have proper permissions" - ) - business_accounts: Dict[str, List[Any]] = {} - personal_accounts: List[Any] = [] - for acc in accounts: - business = acc.get("business") - if business: - biz_name = business.get("name", f"Business {business.get('id', 'Unknown')}") - if biz_name not in business_accounts: - business_accounts[biz_name] = [] - business_accounts[biz_name].append(acc) - else: - personal_accounts.append(acc) - result = f"Found {len(accounts)} ad account{'s' if len(accounts) != 1 else ''}:\n\n" - for biz_name, biz_accounts in business_accounts.items(): - count = len(biz_accounts) - result += f"**Business Portfolio: {biz_name}** ({count} account{'s' if count != 1 else ''})\n\n" - for acc in biz_accounts: - result += _format_account_summary(acc) - if personal_accounts: - count = len(personal_accounts) - result += f"**Personal Account** ({count} account{'s' if count != 1 else ''})\n\n" - for acc in personal_accounts: - result += _format_account_summary(acc) - return result - - -async def get_ad_account_info(client: "FacebookAdsClient", ad_account_id: str) -> str: - if not ad_account_id: - return "ERROR: ad_account_id parameter is required" - try: - ad_account_id = validate_ad_account_id(ad_account_id) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - if client.is_test_mode: - return _mock_get_ad_account_info(ad_account_id) - acc = await client.request("GET", ad_account_id, params={"fields": AD_ACCOUNT_DETAIL_FIELDS}) - account_status = acc.get("account_status", 1) - status_text = format_account_status(account_status) - currency = acc.get("currency", "USD") - result = "Ad Account Details:\n\n" - result += f"**{acc.get('name', 'Unnamed')}**\n" - result += f" ID: {acc['id']}\n" - result += f" Account ID: {acc.get('account_id', 'N/A')}\n" - result += f" Currency: {currency}\n" - result += f" Timezone: {acc.get('timezone_name', 'N/A')}\n" - result += f" Status: {status_text}\n" - result += f" Created: {acc.get('created_time', 'N/A')}\n" - result += "\n**Financial Info:**\n" - balance = int(acc.get('balance', 0)) - amount_spent = int(acc.get('amount_spent', 0)) - spend_cap = int(acc.get('spend_cap', 0)) - result += f" Balance: {format_currency(balance, currency)}\n" - result += f" Total Spent: {format_currency(amount_spent, currency)}\n" - if spend_cap > 0: - result += f" Spend Cap: {format_currency(spend_cap, currency)}\n" - remaining = spend_cap - amount_spent - result += f" Remaining: {format_currency(remaining, currency)}\n" - percent_used = (amount_spent / spend_cap) * 100 if spend_cap > 0 else 0 - if percent_used > 90: - result += f" Warning: {percent_used:.1f}% of spend cap used!\n" - if 'min_daily_budget' in acc: - result += f" Min Daily Budget: {format_currency(int(acc['min_daily_budget']), currency)}\n" - if 'business' in acc: - business = acc['business'] - result += f"\n**Business:** {business.get('name', 'N/A')} (ID: {business.get('id', 'N/A')})\n" - return result - - -async def update_spending_limit(client: "FacebookAdsClient", ad_account_id: str, spending_limit: int) -> str: - if not ad_account_id: - return "ERROR: ad_account_id parameter is required" - try: - ad_account_id = validate_ad_account_id(ad_account_id) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - spending_limit = int(spending_limit) - if spending_limit < 0: - return "ERROR: spending_limit must be a positive number" - if client.is_test_mode: - return f"Spending limit updated to {format_currency(spending_limit)} for account {ad_account_id}\n\n(Note: This is a test/mock operation)" - result = await client.request("POST", ad_account_id, data={"spend_cap": spending_limit}) - if result.get("success"): - return f"Spending limit updated to {format_currency(spending_limit)} for account {ad_account_id}" - else: - return f"Failed to update spending limit. Response: {result}" - - -def _format_account_summary(acc: Dict[str, Any]) -> str: - account_status = acc.get("account_status", 1) - status_text = format_account_status(account_status) - currency = acc.get("currency", "USD") - result = f" **{acc.get('name', 'Unnamed')}**\n" - result += f" ID: {acc['id']}\n" - result += f" Currency: {currency}\n" - result += f" Timezone: {acc.get('timezone_name', 'N/A')}\n" - result += f" Status: {status_text}\n" - if 'balance' in acc: - result += f" Balance: {format_currency(int(acc['balance']), currency)}\n" - if 'amount_spent' in acc: - result += f" Total Spent: {format_currency(int(acc['amount_spent']), currency)}\n" - if 'spend_cap' in acc and int(acc.get('spend_cap', 0)) > 0: - result += f" Spend Cap: {format_currency(int(acc['spend_cap']), currency)}\n" - result += "\n" - return result - - -async def list_account_users(client: "FacebookAdsClient", ad_account_id: str) -> str: - if not ad_account_id: - return "ERROR: ad_account_id parameter is required" - try: - ad_account_id = validate_ad_account_id(ad_account_id) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - if client.is_test_mode: - return f"Users for {ad_account_id}:\n Test User (ID: 123456789) — ADMIN\n" - data = await client.request( - "GET", f"{ad_account_id}/users", - params={"fields": "id,name,role,status", "limit": 50}, - ) - users = data.get("data", []) - if not users: - return f"No users found for {ad_account_id}" - result = f"Users for {ad_account_id} ({len(users)} total):\n\n" - for u in users: - result += f" **{u.get('name', 'Unknown')}** (ID: {u.get('id', 'N/A')}) — {u.get('role', 'N/A')}\n" - return result - - -async def list_pages(client: "FacebookAdsClient") -> str: - if client.is_test_mode: - return "Pages you manage:\n Test Page (ID: 111111111) — ACTIVE\n" - data = await client.request( - "GET", "me/accounts", - params={"fields": "id,name,category,tasks,access_token", "limit": 50}, - ) - pages = data.get("data", []) - if not pages: - return "No pages found. You need to be an admin of at least one Facebook Page to create ads." - result = f"Pages you manage ({len(pages)} total):\n\n" - for page in pages: - tasks = ", ".join(page.get("tasks", [])) - result += f" **{page.get('name', 'Unnamed')}** (ID: {page['id']})\n" - result += f" Category: {page.get('category', 'N/A')}\n" - if tasks: - result += f" Tasks: {tasks}\n" - result += "\n" - return result - - -def _mock_list_ad_accounts() -> str: - return """Found 1 ad account: -**Test Ad Account** - ID: act_MOCK_TEST_000 - Currency: USD - Status: Active - Balance: 500.00 USD - Spend Cap: 10000.00 USD -""" - - -def _mock_get_ad_account_info(ad_account_id: str) -> str: - return f"""Ad Account Details: -**Test Ad Account** - ID: {ad_account_id} - Account ID: MOCK_TEST_000 - Currency: USD - Timezone: America/Los_Angeles - Status: Active -**Financial Info:** - Balance: 500.00 USD - Total Spent: 1234.56 USD - Spend Cap: 10000.00 USD - Remaining: 8765.44 USD -""" diff --git a/flexus_client_kit/integrations/facebook/ads.py b/flexus_client_kit/integrations/facebook/ads.py deleted file mode 100644 index 0e1a59b1..00000000 --- a/flexus_client_kit/integrations/facebook/ads.py +++ /dev/null @@ -1,489 +0,0 @@ -from __future__ import annotations -import logging -from pathlib import Path -from typing import Any, Dict, Optional, TYPE_CHECKING -import httpx -from flexus_client_kit.integrations.facebook.models import AdFormat, CallToActionType -from flexus_client_kit.integrations.facebook.utils import validate_ad_account_id -from flexus_client_kit.integrations.facebook.exceptions import FacebookValidationError -if TYPE_CHECKING: - from flexus_client_kit.integrations.facebook.client import FacebookAdsClient -logger = logging.getLogger("facebook.operations.ads") - - -async def upload_image( - client: "FacebookAdsClient", - image_path: Optional[str] = None, - image_url: Optional[str] = None, - ad_account_id: Optional[str] = None, -) -> str: - if not image_path and not image_url: - return "ERROR: Either image_path or image_url is required" - account_id = ad_account_id or client.ad_account_id - if not account_id: - try: - account_id = validate_ad_account_id(account_id) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - if client.is_test_mode: - return f"""Image uploaded successfully! -Image Hash: abc123def456 -Ad Account: {account_id} -You can now use this image hash in create_creative() -""" - endpoint = f"{account_id}/adimages" - if image_url: - form_data = { - "url": image_url, - "access_token": client.access_token, - } - logger.info(f"Uploading image from URL to {endpoint}") - result = await client.request("POST", endpoint, form_data=form_data) - elif image_path: - image_file = Path(image_path) - if not image_file.exists(): - return f"ERROR: Image file not found: {image_path}" - await client.ensure_auth() - with open(image_file, 'rb') as f: - image_bytes = f.read() - from flexus_client_kit.integrations.facebook.client import API_BASE, API_VERSION - files = {"filename": (image_file.name, image_bytes, "image/jpeg")} - form_data = {"access_token": client.access_token} - url = f"{API_BASE}/{API_VERSION}/{endpoint}" - logger.info(f"Uploading image file to {url}") - async with httpx.AsyncClient() as http_client: - response = await http_client.post(url, data=form_data, files=files, timeout=60.0) - if response.status_code != 200: - return f"ERROR: Failed to upload image: {response.text}" - result = response.json() - images = result.get("images", {}) - if images: - image_hash = list(images.values())[0].get("hash", "unknown") - return f"""Image uploaded successfully! -Image Hash: {image_hash} -Ad Account: {account_id} -Use this hash in create_creative(): - facebook(op="create_creative", args={{ - "image_hash": "{image_hash}", - ... - }}) -""" - else: - return f"Failed to upload image. Response: {result}" - - -async def create_creative( - client: "FacebookAdsClient", - name: str, - page_id: str, - image_hash: str, - link: str, - message: Optional[str] = None, - headline: Optional[str] = None, - description: Optional[str] = None, - call_to_action_type: str = "LEARN_MORE", - ad_account_id: Optional[str] = None, -) -> str: - if not name: - return "ERROR: name is required" - if not page_id: - return "ERROR: page_id is required" - if not image_hash: - return "ERROR: image_hash is required (use upload_image first)" - if not link: - return "ERROR: link is required" - try: - CallToActionType(call_to_action_type) - except ValueError: - valid = [c.value for c in CallToActionType] - return f"ERROR: Invalid call_to_action_type. Must be one of: {', '.join(valid)}" - account_id = ad_account_id or client.ad_account_id - if not account_id: - return "ERROR: ad_account_id is required" - if client.is_test_mode: - return f"""Creative created successfully! -Creative ID: 987654321 -Name: {name} -Page ID: {page_id} -Image Hash: {image_hash} -Link: {link} -CTA: {call_to_action_type} -Now create an ad using this creative: - facebook(op="create_ad", args={{ - "adset_id": "...", - "creative_id": "987654321", - ... - }}) -""" - link_data: Dict[str, Any] = { - "image_hash": image_hash, - "link": link, - "call_to_action": {"type": call_to_action_type} - } - if message: - link_data["message"] = message - if headline: - link_data["name"] = headline - if description: - link_data["description"] = description - data = { - "name": name, - "object_story_spec": { - "page_id": page_id, - "link_data": link_data - } - } - result = await client.request("POST", f"{account_id}/adcreatives", data=data) - creative_id = result.get("id") - if not creative_id: - return f"Failed to create creative. Response: {result}" - return f"""Creative created successfully! -Creative ID: {creative_id} -Name: {name} -Page ID: {page_id} -Image Hash: {image_hash} -Link: {link} -CTA: {call_to_action_type} -Now create an ad using this creative: - facebook(op="create_ad", args={{ - "adset_id": "YOUR_ADSET_ID", - "creative_id": "{creative_id}", - "name": "Your Ad Name" - }}) -""" - - -async def create_ad( - client: "FacebookAdsClient", - name: str, - adset_id: str, - creative_id: str, - status: str = "PAUSED", - ad_account_id: Optional[str] = None, -) -> str: - if not name: - return "ERROR: name is required" - if not adset_id: - return "ERROR: adset_id is required" - if not creative_id: - return "ERROR: creative_id is required" - if status not in ["ACTIVE", "PAUSED"]: - return "ERROR: status must be 'ACTIVE' or 'PAUSED'" - account_id = ad_account_id or client.ad_account_id - if not account_id: - return "ERROR: ad_account_id is required" - if client.is_test_mode: - status_msg = ( - "Ad is paused. Activate it when ready to start delivery." - if status == "PAUSED" - else "Ad is active and will start delivery." - ) - return f"""Ad created successfully! -Ad ID: 111222333444555 -Name: {name} -Ad Set ID: {adset_id} -Creative ID: {creative_id} -Status: {status} -{status_msg} -Preview your ad: - facebook(op="preview_ad", args={{"ad_id": "111222333444555"}}) -""" - data = { - "name": name, - "adset_id": adset_id, - "creative": {"creative_id": creative_id}, - "status": status - } - result = await client.request("POST", f"{account_id}/ads", data=data) - ad_id = result.get("id") - if not ad_id: - return f"Failed to create ad. Response: {result}" - status_msg = ( - "Ad is paused. Activate it when ready to start delivery." - if status == "PAUSED" - else "Ad is active and will start delivery." - ) - return f"""Ad created successfully! -Ad ID: {ad_id} -Name: {name} -Ad Set ID: {adset_id} -Creative ID: {creative_id} -Status: {status} -{status_msg} -Preview your ad: - facebook(op="preview_ad", args={{"ad_id": "{ad_id}"}}) -""" - - -async def get_ad(client: "FacebookAdsClient", ad_id: str) -> str: - if not ad_id: - return "ERROR: ad_id is required" - if client.is_test_mode: - return f"Ad {ad_id}:\n Name: Test Ad\n Status: PAUSED\n Adset ID: 234567890\n Creative ID: 987654321\n" - data = await client.request( - "GET", ad_id, - params={"fields": "id,name,status,adset_id,creative,created_time,updated_time,effective_status,bid_amount"}, - ) - result = f"Ad {ad_id}:\n" - result += f" Name: {data.get('name', 'N/A')}\n" - result += f" Status: {data.get('status', 'N/A')} (effective: {data.get('effective_status', 'N/A')})\n" - result += f" Adset ID: {data.get('adset_id', 'N/A')}\n" - creative = data.get("creative", {}) - if creative: - result += f" Creative ID: {creative.get('id', 'N/A')}\n" - result += f" Created: {data.get('created_time', 'N/A')}\n" - return result - - -async def update_ad( - client: "FacebookAdsClient", - ad_id: str, - name: Optional[str] = None, - status: Optional[str] = None, -) -> str: - if not ad_id: - return "ERROR: ad_id is required" - if not any([name, status]): - return "ERROR: At least one field to update is required (name, status)" - if status and status not in ["ACTIVE", "PAUSED", "ARCHIVED", "DELETED"]: - return "ERROR: status must be one of: ACTIVE, PAUSED, ARCHIVED, DELETED" - if client.is_test_mode: - updates = [] - if name: - updates.append(f"name -> {name}") - if status: - updates.append(f"status -> {status}") - return f"Ad {ad_id} updated:\n" + "\n".join(f" - {u}" for u in updates) - data: Dict[str, Any] = {} - if name: - data["name"] = name - if status: - data["status"] = status - result = await client.request("POST", ad_id, data=data) - if result.get("success"): - return f"Ad {ad_id} updated successfully." - return f"Failed to update ad. Response: {result}" - - -async def delete_ad(client: "FacebookAdsClient", ad_id: str) -> str: - if not ad_id: - return "ERROR: ad_id is required" - if client.is_test_mode: - return f"Ad {ad_id} deleted successfully." - result = await client.request("DELETE", ad_id) - if result.get("success"): - return f"Ad {ad_id} deleted successfully." - return f"Failed to delete ad. Response: {result}" - - -async def list_ads( - client: "FacebookAdsClient", - ad_account_id: Optional[str] = None, - adset_id: Optional[str] = None, - status_filter: Optional[str] = None, -) -> str: - if not ad_account_id and not adset_id: - return "ERROR: Either ad_account_id or adset_id is required" - parent = adset_id if adset_id else ad_account_id - endpoint = f"{parent}/ads" - if client.is_test_mode: - return f"Ads for {parent}:\n Test Ad (ID: 111222333444555) — PAUSED\n" - params: Dict[str, Any] = { - "fields": "id,name,status,adset_id,creative{id},effective_status", - "limit": 100, - } - if status_filter: - params["effective_status"] = f'["{status_filter.upper()}"]' - data = await client.request("GET", endpoint, params=params) - ads = data.get("data", []) - if not ads: - return f"No ads found for {parent}" - result = f"Ads for {parent} ({len(ads)} total):\n\n" - for ad in ads: - creative_id = ad.get("creative", {}).get("id", "N/A") if ad.get("creative") else "N/A" - result += f" **{ad.get('name', 'Unnamed')}** (ID: {ad['id']})\n" - result += f" Status: {ad.get('status', 'N/A')} | Adset: {ad.get('adset_id', 'N/A')} | Creative: {creative_id}\n\n" - return result - - -async def list_creatives( - client: "FacebookAdsClient", - ad_account_id: str, -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id is required" - if client.is_test_mode: - return f"Creatives for {ad_account_id}:\n Test Creative (ID: 987654321)\n" - data = await client.request( - "GET", f"{ad_account_id}/adcreatives", - params={"fields": "id,name,status,object_story_spec,thumbnail_url", "limit": 100}, - ) - creatives = data.get("data", []) - if not creatives: - return f"No creatives found for {ad_account_id}" - result = f"Ad Creatives for {ad_account_id} ({len(creatives)} total):\n\n" - for c in creatives: - result += f" **{c.get('name', 'Unnamed')}** (ID: {c['id']})\n" - if c.get("status"): - result += f" Status: {c['status']}\n" - result += "\n" - return result - - -async def get_creative(client: "FacebookAdsClient", creative_id: str) -> str: - if not creative_id: - return "ERROR: creative_id is required" - if client.is_test_mode: - return f"Creative {creative_id}:\n Name: Test Creative\n Status: ACTIVE\n" - data = await client.request( - "GET", creative_id, - params={"fields": "id,name,status,object_story_spec,call_to_action_type,thumbnail_url,image_hash,video_id"}, - ) - result = f"Creative {creative_id}:\n" - result += f" Name: {data.get('name', 'N/A')}\n" - result += f" Status: {data.get('status', 'N/A')}\n" - if data.get("call_to_action_type"): - result += f" CTA: {data['call_to_action_type']}\n" - if data.get("image_hash"): - result += f" Image Hash: {data['image_hash']}\n" - if data.get("video_id"): - result += f" Video ID: {data['video_id']}\n" - return result - - -async def update_creative( - client: "FacebookAdsClient", - creative_id: str, - name: Optional[str] = None, -) -> str: - if not creative_id: - return "ERROR: creative_id is required" - if not name: - return "ERROR: name is required (only name can be updated on existing creatives)" - if client.is_test_mode: - return f"Creative {creative_id} updated: name -> {name}" - result = await client.request("POST", creative_id, data={"name": name}) - if result.get("success"): - return f"Creative {creative_id} updated: name -> {name}" - return f"Failed to update creative. Response: {result}" - - -async def delete_creative(client: "FacebookAdsClient", creative_id: str) -> str: - if not creative_id: - return "ERROR: creative_id is required" - if client.is_test_mode: - return f"Creative {creative_id} deleted successfully." - result = await client.request("DELETE", creative_id) - if result.get("success"): - return f"Creative {creative_id} deleted successfully." - return f"Failed to delete creative. Response: {result}" - - -async def preview_creative( - client: "FacebookAdsClient", - creative_id: str, - ad_format: str = "DESKTOP_FEED_STANDARD", -) -> str: - if not creative_id: - return "ERROR: creative_id is required" - try: - AdFormat(ad_format) - except ValueError: - valid = [f.value for f in AdFormat] - return f"ERROR: Invalid ad_format. Must be one of: {', '.join(valid)}" - if client.is_test_mode: - return f"Creative Preview for {creative_id}:\n Format: {ad_format}\n Preview URL: https://facebook.com/ads/preview/mock_{creative_id}\n" - data = await client.request("GET", f"{creative_id}/previews", params={"ad_format": ad_format}) - previews = data.get("data", []) - if not previews: - return "No preview available for this creative" - body = previews[0].get("body", "") - if body: - return f"Creative Preview for {creative_id} ({ad_format}):\n{body[:500]}...\n" - return f"Preview available but no body content. Response: {previews[0]}" - - -async def upload_video( - client: "FacebookAdsClient", - video_url: str, - ad_account_id: Optional[str] = None, - title: Optional[str] = None, - description: Optional[str] = None, -) -> str: - if not video_url: - return "ERROR: video_url is required" - account_id = ad_account_id or client.ad_account_id - if not account_id: - return "ERROR: ad_account_id is required" - if client.is_test_mode: - return f"Video uploaded from URL:\n Video ID: mock_video_123\n Account: {account_id}\n URL: {video_url}\n" - form_data: Dict[str, Any] = { - "file_url": video_url, - "access_token": client.access_token, - } - if title: - form_data["title"] = title - if description: - form_data["description"] = description - logger.info(f"Uploading video from URL to {account_id}/advideos") - result = await client.request("POST", f"{account_id}/advideos", form_data=form_data) - video_id = result.get("id") - if not video_id: - return f"Failed to upload video. Response: {result}" - return f"Video uploaded successfully!\n Video ID: {video_id}\n Account: {account_id}\n Use video_id in create_creative with video_data spec.\n" - - -async def list_videos( - client: "FacebookAdsClient", - ad_account_id: str, -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id is required" - if client.is_test_mode: - return f"Videos for {ad_account_id}:\n Test Video (ID: mock_video_123) — READY\n" - data = await client.request( - "GET", f"{ad_account_id}/advideos", - params={"fields": "id,title,description,length,status,created_time", "limit": 50}, - ) - videos = data.get("data", []) - if not videos: - return f"No videos found for {ad_account_id}" - result = f"Ad Videos for {ad_account_id} ({len(videos)} total):\n\n" - for v in videos: - result += f" **{v.get('title', 'Untitled')}** (ID: {v['id']})\n" - result += f" Status: {v.get('status', 'N/A')} | Length: {v.get('length', 'N/A')}s\n" - if v.get("description"): - result += f" Description: {v['description'][:80]}\n" - result += "\n" - return result - - -async def preview_ad(client: "FacebookAdsClient", ad_id: str, ad_format: str = "DESKTOP_FEED_STANDARD") -> str: - if not ad_id: - return "ERROR: ad_id is required" - try: - AdFormat(ad_format) - except ValueError: - valid = [f.value for f in AdFormat] - return f"ERROR: Invalid ad_format. Must be one of: {', '.join(valid)}" - if client.is_test_mode: - return f"""Ad Preview for {ad_id}: -Format: {ad_format} -Preview URL: https://facebook.com/ads/preview/mock_{ad_id} -Note: This is a mock preview. In production, Facebook will provide actual preview HTML/URL. -""" - data = await client.request("GET", f"{ad_id}/previews", params={"ad_format": ad_format}) - previews = data.get("data", []) - if not previews: - return "No preview available for this ad" - preview = previews[0] - body = preview.get("body", "") - if body: - return f"""Ad Preview for {ad_id}: -Format: {ad_format} -Preview HTML available (truncated): -{body[:500]}... -To view full preview, open the ad in Facebook Ads Manager. -""" - else: - return f"Preview generated but no body content available. Response: {preview}" diff --git a/flexus_client_kit/integrations/facebook/adsets.py b/flexus_client_kit/integrations/facebook/adsets.py deleted file mode 100644 index 981f248f..00000000 --- a/flexus_client_kit/integrations/facebook/adsets.py +++ /dev/null @@ -1,324 +0,0 @@ -from __future__ import annotations -import json -import logging -from typing import Any, Dict, Optional, TYPE_CHECKING -from flexus_client_kit.integrations.facebook.utils import format_currency, validate_budget, validate_targeting_spec -from flexus_client_kit.integrations.facebook.exceptions import FacebookValidationError -if TYPE_CHECKING: - from flexus_client_kit.integrations.facebook.client import FacebookAdsClient -logger = logging.getLogger("facebook.operations.adsets") - -ADSET_FIELDS = "id,name,status,optimization_goal,billing_event,daily_budget,lifetime_budget,targeting" - - -async def list_adsets(client: "FacebookAdsClient", campaign_id: str) -> str: - if not campaign_id: - return "ERROR: campaign_id is required" - if client.is_test_mode: - return _mock_list_adsets(campaign_id) - data = await client.request("GET", f"{campaign_id}/adsets", params={"fields": ADSET_FIELDS, "limit": 50}) - adsets = data.get("data", []) - if not adsets: - return f"No ad sets found for campaign {campaign_id}" - result = f"Ad Sets for Campaign {campaign_id} (found {len(adsets)}):\n\n" - for adset in adsets: - result += f"**{adset['name']}**\n" - result += f" ID: {adset['id']}\n" - result += f" Status: {adset['status']}\n" - result += f" Optimization: {adset.get('optimization_goal', 'N/A')}\n" - result += f" Billing: {adset.get('billing_event', 'N/A')}\n" - if 'daily_budget' in adset: - result += f" Daily Budget: {format_currency(int(adset['daily_budget']))}\n" - elif 'lifetime_budget' in adset: - result += f" Lifetime Budget: {format_currency(int(adset['lifetime_budget']))}\n" - targeting = adset.get("targeting", {}) - if targeting: - geo = targeting.get("geo_locations", {}) - countries = geo.get("countries", []) - if countries: - result += f" Targeting: {', '.join(countries[:3])}" - if len(countries) > 3: - result += f" +{len(countries)-3} more" - result += "\n" - result += "\n" - return result - - -async def create_adset( - client: "FacebookAdsClient", - ad_account_id: str, - campaign_id: str, - name: str, - targeting: Dict[str, Any], - optimization_goal: str = "LINK_CLICKS", - billing_event: str = "IMPRESSIONS", - bid_strategy: str = "LOWEST_COST_WITHOUT_CAP", - status: str = "PAUSED", - daily_budget: Optional[int] = None, - lifetime_budget: Optional[int] = None, - bid_amount: Optional[int] = None, - start_time: Optional[str] = None, - end_time: Optional[str] = None, - promoted_object: Optional[Dict[str, Any]] = None, -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id is required (e.g. act_123456)" - if not campaign_id: - return "ERROR: campaign_id is required" - if not name: - return "ERROR: name is required" - if not targeting: - return "ERROR: targeting is required" - targeting_valid, targeting_error = validate_targeting_spec(targeting) - if not targeting_valid: - return f"ERROR: Invalid targeting: {targeting_error}" - if daily_budget: - try: - daily_budget = validate_budget(daily_budget) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - if lifetime_budget: - try: - lifetime_budget = validate_budget(lifetime_budget) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - if client.is_test_mode: - return _mock_create_adset(campaign_id, name, optimization_goal, daily_budget, targeting) - form_data: Dict[str, Any] = { - "name": name, - "campaign_id": campaign_id, - "optimization_goal": optimization_goal, - "billing_event": billing_event, - "bid_strategy": bid_strategy, - "targeting": json.dumps(targeting), - "status": status, - "access_token": client.access_token, - } - if daily_budget: - form_data["daily_budget"] = str(daily_budget) - if lifetime_budget: - form_data["lifetime_budget"] = str(lifetime_budget) - if start_time: - form_data["start_time"] = start_time - if end_time: - form_data["end_time"] = end_time - if bid_amount: - form_data["bid_amount"] = str(bid_amount) - if bid_strategy == "LOWEST_COST_WITHOUT_CAP": - form_data["bid_strategy"] = "LOWEST_COST_WITH_BID_CAP" - if promoted_object: - form_data["promoted_object"] = json.dumps(promoted_object) - logger.info(f"Creating adset for campaign {campaign_id}") - result = await client.request("POST", f"{ad_account_id}/adsets", form_data=form_data) - adset_id = result.get("id") - if not adset_id: - return f"Failed to create ad set. Response: {result}" - output = f"""Ad Set created successfully! -ID: {adset_id} -Name: {name} -Campaign ID: {campaign_id} -Status: {status} -Optimization Goal: {optimization_goal} -Billing Event: {billing_event} -""" - if daily_budget: - output += f"Daily Budget: {format_currency(daily_budget)}\n" - if lifetime_budget: - output += f"Lifetime Budget: {format_currency(lifetime_budget)}\n" - geo = targeting.get("geo_locations", {}) - countries = geo.get("countries", []) - if countries: - output += f"\nTargeting:\n - Locations: {', '.join(countries)}\n" - if "age_min" in targeting or "age_max" in targeting: - age_min = targeting.get("age_min", 18) - age_max = targeting.get("age_max", 65) - output += f" - Age: {age_min}-{age_max}\n" - if status == "PAUSED": - output += "\nAd set is paused. Activate it when ready to start delivery." - return output - - -async def update_adset( - client: "FacebookAdsClient", - adset_id: str, - name: Optional[str] = None, - status: Optional[str] = None, - daily_budget: Optional[int] = None, - bid_amount: Optional[int] = None, -) -> str: - if not adset_id: - return "ERROR: adset_id is required" - if not any([name, status, daily_budget, bid_amount]): - return "ERROR: At least one field to update is required" - if status and status not in ["ACTIVE", "PAUSED"]: - return "ERROR: status must be 'ACTIVE' or 'PAUSED'" - if daily_budget: - try: - daily_budget = validate_budget(daily_budget) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - updates = [] - if name: - updates.append(f"name -> {name}") - if status: - updates.append(f"status -> {status}") - if daily_budget: - updates.append(f"daily_budget -> {format_currency(daily_budget)}") - if bid_amount: - updates.append(f"bid_amount -> {bid_amount} cents") - if client.is_test_mode: - return f"Ad Set {adset_id} updated:\n" + "\n".join(f" - {u}" for u in updates) - data: Dict[str, Any] = {} - if name: - data["name"] = name - if status: - data["status"] = status - if daily_budget is not None: - data["daily_budget"] = daily_budget - if bid_amount is not None: - data["bid_amount"] = bid_amount - result = await client.request("POST", adset_id, data=data) - if result.get("success"): - return f"Ad Set {adset_id} updated:\n" + "\n".join(f" - {u}" for u in updates) - else: - return f"Failed to update ad set. Response: {result}" - - -async def validate_targeting( - client: "FacebookAdsClient", - targeting_spec: Dict[str, Any], - ad_account_id: Optional[str] = None, -) -> str: - if not targeting_spec: - return "ERROR: targeting_spec is required" - valid, error = validate_targeting_spec(targeting_spec) - if not valid: - return f"Invalid targeting: {error}" - geo = targeting_spec.get('geo_locations', {}) - countries = geo.get('countries', ['Not specified']) - age_min = targeting_spec.get('age_min', 18) - age_max = targeting_spec.get('age_max', 65) - if client.is_test_mode: - return f"""Targeting is valid! -Estimated Audience Size: ~1,000,000 - 1,500,000 people -Targeting Summary: - - Locations: {', '.join(countries)} - - Age: {age_min}-{age_max} - - Device Platforms: {', '.join(targeting_spec.get('device_platforms', ['all']))} -This is a test validation. In production, Facebook will provide actual estimated reach. -""" - account_id = ad_account_id or client.ad_account_id - if not account_id: - return "ERROR: ad_account_id required for validate_targeting" - result = await client.request( - "GET", f"{account_id}/targetingsentencelines", - params={"targeting_spec": json.dumps(targeting_spec)} - ) - output = "Targeting is valid!\n\n" - if "targetingsentencelines" in result: - output += "Targeting Summary:\n" - for line in result["targetingsentencelines"]: - output += f" - {line.get('content', '')}\n" - return output - - -async def get_adset(client: "FacebookAdsClient", adset_id: str) -> str: - if not adset_id: - return "ERROR: adset_id is required" - if client.is_test_mode: - return f"Ad Set {adset_id}:\n Name: Test Ad Set\n Status: ACTIVE\n Optimization: LINK_CLICKS\n Daily Budget: 20.00 USD\n" - data = await client.request( - "GET", adset_id, - params={"fields": "id,name,status,optimization_goal,billing_event,bid_strategy,bid_amount,daily_budget,lifetime_budget,targeting,campaign_id,created_time,updated_time,start_time,end_time"}, - ) - result = f"Ad Set {adset_id}:\n" - result += f" Name: {data.get('name', 'N/A')}\n" - result += f" Status: {data.get('status', 'N/A')}\n" - result += f" Campaign ID: {data.get('campaign_id', 'N/A')}\n" - result += f" Optimization: {data.get('optimization_goal', 'N/A')}\n" - result += f" Billing Event: {data.get('billing_event', 'N/A')}\n" - result += f" Bid Strategy: {data.get('bid_strategy', 'N/A')}\n" - if data.get("daily_budget"): - result += f" Daily Budget: {format_currency(int(data['daily_budget']))}\n" - if data.get("lifetime_budget"): - result += f" Lifetime Budget: {format_currency(int(data['lifetime_budget']))}\n" - result += f" Created: {data.get('created_time', 'N/A')}\n" - return result - - -async def delete_adset(client: "FacebookAdsClient", adset_id: str) -> str: - if not adset_id: - return "ERROR: adset_id is required" - if client.is_test_mode: - return f"Ad Set {adset_id} deleted successfully." - result = await client.request("DELETE", adset_id) - if result.get("success"): - return f"Ad Set {adset_id} deleted successfully." - return f"Failed to delete ad set. Response: {result}" - - -async def list_adsets_for_account( - client: "FacebookAdsClient", - ad_account_id: str, - status_filter: Optional[str] = None, -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id is required" - if client.is_test_mode: - return f"Ad Sets for account {ad_account_id}:\n Test Ad Set (ID: 234567890123456) — ACTIVE\n" - params: Dict[str, Any] = {"fields": ADSET_FIELDS + ",campaign_id", "limit": 100} - if status_filter: - params["filtering"] = json.dumps([{"field": "adset.delivery_info", "operator": "IN", "value": [status_filter.upper()]}]) - data = await client.request("GET", f"{ad_account_id}/adsets", params=params) - adsets = data.get("data", []) - if not adsets: - return f"No ad sets found for account {ad_account_id}" - result = f"Ad Sets for account {ad_account_id} ({len(adsets)} total):\n\n" - for adset in adsets: - result += f" **{adset.get('name', 'Unnamed')}** (ID: {adset['id']})\n" - result += f" Status: {adset.get('status', 'N/A')} | Campaign: {adset.get('campaign_id', 'N/A')}\n" - if adset.get("daily_budget"): - result += f" Daily Budget: {format_currency(int(adset['daily_budget']))}\n" - result += "\n" - return result - - -def _mock_list_adsets(campaign_id: str) -> str: - return f"""Ad Sets for Campaign {campaign_id}: -**Test Ad Set** - ID: 234567890123456 - Status: ACTIVE - Optimization: LINK_CLICKS - Daily Budget: 20.00 USD - Targeting: US -""" - - -def _mock_create_adset( - campaign_id: str, - name: str, - optimization_goal: str, - daily_budget: Optional[int], - targeting: Dict[str, Any], -) -> str: - budget_line = "" - if daily_budget: - budget_line = f"Daily Budget: {format_currency(daily_budget)}\n" - else: - budget_line = "Budget: Using Campaign Budget (CBO)\n" - countries = targeting.get('geo_locations', {}).get('countries', ['Not specified']) - age_min = targeting.get('age_min', 18) - age_max = targeting.get('age_max', 65) - return f"""Ad Set created successfully! -ID: mock_adset_123456 -Name: {name} -Campaign ID: {campaign_id} -Status: PAUSED -Optimization Goal: {optimization_goal} -Billing Event: IMPRESSIONS -{budget_line} -Targeting: - - Locations: {', '.join(countries)} - - Age: {age_min}-{age_max} -Ad set is paused. Activate it when ready to start delivery. -""" diff --git a/flexus_client_kit/integrations/facebook/audiences.py b/flexus_client_kit/integrations/facebook/audiences.py deleted file mode 100644 index b0a630c5..00000000 --- a/flexus_client_kit/integrations/facebook/audiences.py +++ /dev/null @@ -1,207 +0,0 @@ -from __future__ import annotations -import hashlib -import logging -from typing import Any, Dict, List, Optional, TYPE_CHECKING - -from flexus_client_kit.integrations.facebook.models import CustomAudienceSubtype - -if TYPE_CHECKING: - from flexus_client_kit.integrations.facebook.client import FacebookAdsClient - -logger = logging.getLogger("facebook.operations.audiences") - -AUDIENCE_FIELDS = "id,name,subtype,description,approximate_count,delivery_status,created_time,updated_time" - - -async def list_custom_audiences( - client: "FacebookAdsClient", - ad_account_id: str, -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id is required" - if client.is_test_mode: - return f"Custom Audiences for {ad_account_id}:\n Test Audience (ID: 111222333) — CUSTOM — ~5,000 people\n" - data = await client.request( - "GET", f"{ad_account_id}/customaudiences", - params={"fields": AUDIENCE_FIELDS, "limit": 100}, - ) - audiences = data.get("data", []) - if not audiences: - return f"No custom audiences found for {ad_account_id}" - result = f"Custom Audiences for {ad_account_id} ({len(audiences)} total):\n\n" - for a in audiences: - count = a.get("approximate_count", "Unknown") - result += f" **{a.get('name', 'Unnamed')}** (ID: {a['id']})\n" - result += f" Type: {a.get('subtype', 'N/A')} | Size: ~{count} people\n" - if a.get("description"): - result += f" Description: {a['description'][:80]}\n" - result += "\n" - return result - - -async def create_custom_audience( - client: "FacebookAdsClient", - ad_account_id: str, - name: str, - subtype: str = "CUSTOM", - description: Optional[str] = None, - customer_file_source: Optional[str] = None, -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id is required" - if not name: - return "ERROR: name is required" - try: - CustomAudienceSubtype(subtype) - except ValueError: - valid = [s.value for s in CustomAudienceSubtype] - return f"ERROR: Invalid subtype. Must be one of: {', '.join(valid)}" - if client.is_test_mode: - return f"Custom audience created:\n Name: {name}\n ID: mock_audience_123\n Subtype: {subtype}\n" - data: Dict[str, Any] = { - "name": name, - "subtype": subtype, - } - if description: - data["description"] = description - if customer_file_source: - data["customer_file_source"] = customer_file_source - result = await client.request("POST", f"{ad_account_id}/customaudiences", data=data) - audience_id = result.get("id") - if not audience_id: - return f"Failed to create audience. Response: {result}" - return f"Custom audience created:\n Name: {name}\n ID: {audience_id}\n Subtype: {subtype}\n Use add_users_to_audience to populate it.\n" - - -async def create_lookalike_audience( - client: "FacebookAdsClient", - ad_account_id: str, - origin_audience_id: str, - country: str, - ratio: float = 0.01, - name: Optional[str] = None, -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id is required" - if not origin_audience_id: - return "ERROR: origin_audience_id is required" - if not country: - return "ERROR: country is required (e.g. 'US', 'GB')" - if not 0.01 <= ratio <= 0.20: - return "ERROR: ratio must be between 0.01 (1%) and 0.20 (20%)" - audience_name = name or f"Lookalike ({country}, {int(ratio*100)}%) of {origin_audience_id}" - if client.is_test_mode: - return f"Lookalike audience created:\n Name: {audience_name}\n ID: mock_lookalike_456\n Country: {country}\n Ratio: {ratio*100:.0f}%\n" - data: Dict[str, Any] = { - "name": audience_name, - "subtype": "LOOKALIKE", - "origin_audience_id": origin_audience_id, - "lookalike_spec": { - "country": country, - "ratio": ratio, - "type": "similarity", - }, - } - result = await client.request("POST", f"{ad_account_id}/customaudiences", data=data) - audience_id = result.get("id") - if not audience_id: - return f"Failed to create lookalike audience. Response: {result}" - return f"Lookalike audience created:\n Name: {audience_name}\n ID: {audience_id}\n Country: {country}\n Ratio: {ratio*100:.0f}%\n Source: {origin_audience_id}\n" - - -async def get_custom_audience( - client: "FacebookAdsClient", - audience_id: str, -) -> str: - if not audience_id: - return "ERROR: audience_id is required" - if client.is_test_mode: - return f"Audience {audience_id}:\n Name: Test Audience\n Subtype: CUSTOM\n Size: ~5,000 people\n Status: ready\n" - data = await client.request( - "GET", audience_id, - params={"fields": AUDIENCE_FIELDS + ",rule,lookalike_spec,pixel_id"}, - ) - result = f"Audience {audience_id}:\n" - result += f" Name: {data.get('name', 'N/A')}\n" - result += f" Subtype: {data.get('subtype', 'N/A')}\n" - result += f" Size: ~{data.get('approximate_count', 'Unknown')} people\n" - if data.get("description"): - result += f" Description: {data['description']}\n" - if data.get("delivery_status"): - delivery = data["delivery_status"] - result += f" Delivery Status: {delivery.get('code', 'N/A')} — {delivery.get('description', '')}\n" - result += f" Created: {data.get('created_time', 'N/A')}\n" - return result - - -async def update_custom_audience( - client: "FacebookAdsClient", - audience_id: str, - name: Optional[str] = None, - description: Optional[str] = None, -) -> str: - if not audience_id: - return "ERROR: audience_id is required" - if not any([name, description]): - return "ERROR: At least one field to update is required (name, description)" - if client.is_test_mode: - updates = [] - if name: - updates.append(f"name -> {name}") - if description: - updates.append(f"description -> {description}") - return f"Audience {audience_id} updated:\n" + "\n".join(f" - {u}" for u in updates) - data: Dict[str, Any] = {} - if name: - data["name"] = name - if description: - data["description"] = description - result = await client.request("POST", audience_id, data=data) - if result.get("success"): - return f"Audience {audience_id} updated successfully." - return f"Failed to update audience. Response: {result}" - - -async def delete_custom_audience( - client: "FacebookAdsClient", - audience_id: str, -) -> str: - if not audience_id: - return "ERROR: audience_id is required" - if client.is_test_mode: - return f"Audience {audience_id} deleted successfully." - result = await client.request("DELETE", audience_id) - if result.get("success"): - return f"Audience {audience_id} deleted successfully." - return f"Failed to delete audience. Response: {result}" - - -async def add_users_to_audience( - client: "FacebookAdsClient", - audience_id: str, - emails: List[str], - phones: Optional[List[str]] = None, -) -> str: - if not audience_id: - return "ERROR: audience_id is required" - if not emails: - return "ERROR: emails list is required and cannot be empty" - hashed_emails = [hashlib.sha256(e.strip().lower().encode()).hexdigest() for e in emails] - schema = ["EMAIL"] - user_data = [[h] for h in hashed_emails] - if phones: - schema = ["EMAIL", "PHONE"] - hashed_phones = [hashlib.sha256(''.join(c for c in p if c.isdigit()).encode()).hexdigest() for p in phones] - user_data = [[e, p] for e, p in zip(hashed_emails, hashed_phones)] - if client.is_test_mode: - return f"Users added to audience {audience_id}:\n Emails: {len(emails)} (SHA-256 hashed)\n Schema: {schema}\n" - payload: Dict[str, Any] = { - "payload": { - "schema": schema, - "data": user_data, - } - } - result = await client.request("POST", f"{audience_id}/users", data=payload) - num_received = result.get("num_received", 0) - num_invalid = result.get("num_invalid_entries", 0) - return f"Users added to audience {audience_id}:\n Received: {num_received}\n Invalid: {num_invalid}\n Accepted: {num_received - num_invalid}\n" diff --git a/flexus_client_kit/integrations/facebook/campaigns.py b/flexus_client_kit/integrations/facebook/campaigns.py deleted file mode 100644 index 06d88fb6..00000000 --- a/flexus_client_kit/integrations/facebook/campaigns.py +++ /dev/null @@ -1,352 +0,0 @@ -from __future__ import annotations -import logging -from typing import Any, Dict, List, Optional, TYPE_CHECKING -from flexus_client_kit.integrations.facebook.models import CampaignObjective -from flexus_client_kit.integrations.facebook.utils import format_currency, validate_budget, normalize_insights_data -from flexus_client_kit.integrations.facebook.exceptions import FacebookAPIError, FacebookError, FacebookValidationError -if TYPE_CHECKING: - from flexus_client_kit.integrations.facebook.client import FacebookAdsClient -logger = logging.getLogger("facebook.operations.campaigns") - -CAMPAIGN_FIELDS = "id,name,status,objective,daily_budget,lifetime_budget" - - -async def list_campaigns( - client: "FacebookAdsClient", - ad_account_id: Optional[str] = None, - status_filter: Optional[str] = None, -) -> str: - account_id = ad_account_id or client.ad_account_id - if not account_id: - return "ERROR: ad_account_id parameter required for list_campaigns" - if client.is_test_mode: - return _mock_list_campaigns() - params: Dict[str, Any] = {"fields": CAMPAIGN_FIELDS, "limit": 50} - if status_filter: - params["effective_status"] = f"['{status_filter}']" - data = await client.request("GET", f"{account_id}/campaigns", params=params) - campaigns = data.get("data", []) - if not campaigns: - return "No campaigns found." - result = f"Found {len(campaigns)} campaign{'s' if len(campaigns) != 1 else ''}:\n" - for c in campaigns: - budget_str = "" - if c.get("daily_budget"): - budget_str = f", Daily: {format_currency(int(c['daily_budget']))}" - elif c.get("lifetime_budget"): - budget_str = f", Lifetime: {format_currency(int(c['lifetime_budget']))}" - result += f" {c['name']} (ID: {c['id']}) - {c['status']}{budget_str}\n" - return result - - -async def create_campaign( - client: "FacebookAdsClient", - ad_account_id: str, - name: str, - objective: str, - status: str = "PAUSED", - daily_budget: Optional[int] = None, - lifetime_budget: Optional[int] = None, - special_ad_categories: Optional[List[str]] = None, -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id parameter required for create_campaign" - if not name: - return "ERROR: name parameter required for create_campaign" - try: - CampaignObjective(objective) - except ValueError: - valid = [o.value for o in CampaignObjective] - return f"ERROR: Invalid objective. Must be one of: {', '.join(valid)}" - if daily_budget: - try: - daily_budget = validate_budget(daily_budget) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - if lifetime_budget: - try: - lifetime_budget = validate_budget(lifetime_budget) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - if client.is_test_mode: - return _mock_create_campaign(name, objective, status, daily_budget) - payload: Dict[str, Any] = { - "name": name, - "objective": objective, - "status": status, - "special_ad_categories": special_ad_categories or [], - } - if daily_budget: - payload["daily_budget"] = daily_budget - if lifetime_budget: - payload["lifetime_budget"] = lifetime_budget - result = await client.request("POST", f"{ad_account_id}/campaigns", data=payload) - campaign_id = result.get("id") - if not campaign_id: - return f"Failed to create campaign. Response: {result}" - output = f"Campaign created: {name} (ID: {campaign_id})\n" - output += f" Status: {status}, Objective: {objective}\n" - if daily_budget: - output += f" Daily Budget: {format_currency(daily_budget)}\n" - if lifetime_budget: - output += f" Lifetime Budget: {format_currency(lifetime_budget)}\n" - return output - - -async def update_campaign( - client: "FacebookAdsClient", - campaign_id: str, - name: Optional[str] = None, - status: Optional[str] = None, - daily_budget: Optional[int] = None, - lifetime_budget: Optional[int] = None, -) -> str: - if not campaign_id: - return "ERROR: campaign_id parameter is required" - if not any([name, status, daily_budget, lifetime_budget]): - return "ERROR: At least one field to update is required (name, status, daily_budget, or lifetime_budget)" - if status and status not in ["ACTIVE", "PAUSED"]: - return "ERROR: status must be either 'ACTIVE' or 'PAUSED'" - if daily_budget: - try: - daily_budget = validate_budget(daily_budget) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - if lifetime_budget: - try: - lifetime_budget = validate_budget(lifetime_budget) - except FacebookValidationError as e: - return f"ERROR: {e.message}" - updates = [] - if name: - updates.append(f"name -> {name}") - if status: - updates.append(f"status -> {status}") - if daily_budget: - updates.append(f"daily_budget -> {format_currency(daily_budget)}") - if lifetime_budget: - updates.append(f"lifetime_budget -> {format_currency(lifetime_budget)}") - if client.is_test_mode: - return f"Campaign {campaign_id} updated:\n" + "\n".join(f" - {u}" for u in updates) - data: Dict[str, Any] = {} - if name: - data["name"] = name - if status: - data["status"] = status - if daily_budget is not None: - data["daily_budget"] = daily_budget - if lifetime_budget is not None: - data["lifetime_budget"] = lifetime_budget - result = await client.request("POST", campaign_id, data=data) - if result.get("success"): - return f"Campaign {campaign_id} updated:\n" + "\n".join(f" - {u}" for u in updates) - else: - return f"Failed to update campaign. Response: {result}" - - -async def duplicate_campaign( - client: "FacebookAdsClient", - campaign_id: str, - new_name: str, - ad_account_id: Optional[str] = None, -) -> str: - if not campaign_id: - return "ERROR: campaign_id parameter is required" - if not new_name: - return "ERROR: new_name parameter is required" - if client.is_test_mode: - return _mock_duplicate_campaign(campaign_id, new_name) - original = await client.request( - "GET", campaign_id, - params={"fields": "name,objective,status,daily_budget,lifetime_budget,special_ad_categories"} - ) - account_id = ad_account_id or client.ad_account_id - if not account_id: - return "ERROR: ad_account_id required for duplicate_campaign" - create_data: Dict[str, Any] = { - "name": new_name, - "objective": original["objective"], - "status": "PAUSED", - "special_ad_categories": original.get("special_ad_categories", []), - } - if "daily_budget" in original: - create_data["daily_budget"] = original["daily_budget"] - if "lifetime_budget" in original: - create_data["lifetime_budget"] = original["lifetime_budget"] - new_campaign = await client.request("POST", f"{account_id}/campaigns", data=create_data) - return f"""Campaign duplicated successfully! -Original Campaign ID: {campaign_id} -New Campaign ID: {new_campaign.get('id')} -New Campaign Name: {new_name} -Status: PAUSED (activate when ready) -Note: Only the campaign was copied. To copy ad sets and ads, use the Facebook Ads Manager UI. -""" - - -async def archive_campaign(client: "FacebookAdsClient", campaign_id: str) -> str: - if not campaign_id: - return "ERROR: campaign_id parameter is required" - if client.is_test_mode: - return f"Campaign {campaign_id} archived successfully.\n\nThe campaign is now hidden from active views but can be restored if needed." - result = await client.request("POST", campaign_id, data={"status": "ARCHIVED"}) - if result.get("success"): - return f"Campaign {campaign_id} archived successfully.\n\nThe campaign is now hidden from active views but can be restored if needed." - else: - return f"Failed to archive campaign. Response: {result}" - - -async def bulk_update_campaigns(client: "FacebookAdsClient", campaigns: List[Dict[str, Any]]) -> str: - if not campaigns: - return "ERROR: campaigns parameter is required (list of {id, ...fields})" - if not isinstance(campaigns, list): - return "ERROR: campaigns must be a list" - if len(campaigns) > 50: - return "ERROR: Maximum 50 campaigns can be updated at once" - if client.is_test_mode: - results = [] - for camp in campaigns: - campaign_id = camp.get("id", "unknown") - status = camp.get("status", "unchanged") - results.append(f" {campaign_id} -> {status}") - return f"Bulk update completed for {len(campaigns)} campaigns:\n" + "\n".join(results) - results = [] - errors = [] - for camp in campaigns: - campaign_id = camp.get("id") - if not campaign_id: - errors.append("Missing campaign ID in one of the campaigns") - continue - data: Dict[str, Any] = {} - if "name" in camp: - data["name"] = camp["name"] - if "status" in camp: - if camp["status"] not in ["ACTIVE", "PAUSED", "ARCHIVED"]: - errors.append(f"{campaign_id}: Invalid status") - continue - data["status"] = camp["status"] - if "daily_budget" in camp: - try: - data["daily_budget"] = validate_budget(camp["daily_budget"]) - except FacebookValidationError as e: - errors.append(f"{campaign_id}: {e.message}") - continue - if not data: - errors.append(f"{campaign_id}: No fields to update") - continue - try: - result = await client.request("POST", campaign_id, data=data) - if result.get("success"): - updates = ", ".join([f"{k}={v}" for k, v in data.items()]) - results.append(f" {campaign_id}: {updates}") - else: - errors.append(f"{campaign_id}: Update failed") - except FacebookAPIError as e: - errors.append(f"{campaign_id}: {e.message}") - except (FacebookError, ValueError) as e: - errors.append(f"{campaign_id}: {str(e)}") - output = f"Bulk update completed:\n\n" - output += f"Success: {len(results)}\n" - output += f"Errors: {len(errors)}\n\n" - if results: - output += "Successful updates:\n" + "\n".join(results) + "\n\n" - if errors: - output += "Errors:\n" + "\n".join(f" {e}" for e in errors) - return output - - -async def get_insights(client: "FacebookAdsClient", campaign_id: str, days: int = 30) -> str: - if not campaign_id: - return "ERROR: campaign_id required" - if client.is_test_mode: - return _mock_get_insights(campaign_id, days) - params = { - "fields": "impressions,clicks,spend,cpc,ctr,reach,frequency", - "date_preset": "last_30d" if days == 30 else "maximum", - } - data = await client.request("GET", f"{campaign_id}/insights", params=params) - if not data.get("data"): - return f"No insights data found for campaign {campaign_id}" - raw = data["data"][0] - insights = normalize_insights_data(raw) - return f"""Insights for Campaign {campaign_id} (Last {days} days): - Impressions: {insights.impressions:,} - Clicks: {insights.clicks:,} - Spend: ${insights.spend:.2f} - CTR: {insights.ctr:.2f}% - CPC: ${insights.cpc:.2f} - Reach: {insights.reach:,} - Frequency: {insights.frequency:.2f} -""" - - -async def get_campaign(client: "FacebookAdsClient", campaign_id: str) -> str: - if not campaign_id: - return "ERROR: campaign_id is required" - if client.is_test_mode: - return f"Campaign {campaign_id}:\n Name: Test Campaign\n Status: ACTIVE\n Objective: OUTCOME_TRAFFIC\n Daily Budget: 50.00 USD\n" - data = await client.request( - "GET", campaign_id, - params={"fields": "id,name,status,objective,daily_budget,lifetime_budget,special_ad_categories,created_time,updated_time,start_time,stop_time,budget_remaining"}, - ) - result = f"Campaign {campaign_id}:\n" - result += f" Name: {data.get('name', 'N/A')}\n" - result += f" Status: {data.get('status', 'N/A')}\n" - result += f" Objective: {data.get('objective', 'N/A')}\n" - if data.get("daily_budget"): - result += f" Daily Budget: {format_currency(int(data['daily_budget']))}\n" - if data.get("lifetime_budget"): - result += f" Lifetime Budget: {format_currency(int(data['lifetime_budget']))}\n" - if data.get("budget_remaining"): - result += f" Budget Remaining: {format_currency(int(data['budget_remaining']))}\n" - result += f" Created: {data.get('created_time', 'N/A')}\n" - result += f" Updated: {data.get('updated_time', 'N/A')}\n" - return result - - -async def delete_campaign(client: "FacebookAdsClient", campaign_id: str) -> str: - if not campaign_id: - return "ERROR: campaign_id is required" - if client.is_test_mode: - return f"Campaign {campaign_id} deleted successfully." - result = await client.request("DELETE", campaign_id) - if result.get("success"): - return f"Campaign {campaign_id} deleted successfully." - return f"Failed to delete campaign. Response: {result}" - - -def _mock_list_campaigns() -> str: - return """Found 2 campaigns: - Test Campaign 1 (ID: 123456789) - ACTIVE, Daily: 50.00 USD - Test Campaign 2 (ID: 987654321) - PAUSED, Daily: 100.00 USD -""" - - -def _mock_create_campaign(name: str, objective: str, status: str, daily_budget: Optional[int]) -> str: - budget_str = "" - if daily_budget: - budget_str = f"\n Daily Budget: {format_currency(daily_budget)}" - return f"""Campaign created: {name} (ID: mock_123456789) - Status: {status}, Objective: {objective}{budget_str} -""" - - -def _mock_duplicate_campaign(campaign_id: str, new_name: str) -> str: - return f"""Campaign duplicated successfully! -Original Campaign ID: {campaign_id} -New Campaign ID: {campaign_id}_copy -New Campaign Name: {new_name} -Status: PAUSED (activate when ready) -Note: Only the campaign was copied. To copy ad sets and ads, use the Facebook Ads Manager UI. -""" - - -def _mock_get_insights(campaign_id: str, days: int) -> str: - return f"""Insights for Campaign {campaign_id} (Last {days} days): - Impressions: 125,000 - Clicks: 3,450 - Spend: $500.00 - CTR: 2.76% - CPC: $0.14 - Reach: 95,000 - Frequency: 1.32 -""" diff --git a/flexus_client_kit/integrations/facebook/client.py b/flexus_client_kit/integrations/facebook/client.py deleted file mode 100644 index f46f2bcd..00000000 --- a/flexus_client_kit/integrations/facebook/client.py +++ /dev/null @@ -1,186 +0,0 @@ -from __future__ import annotations -import asyncio -import logging -import os -import time -from typing import Any, Dict, Optional, TYPE_CHECKING - -import httpx - -from flexus_client_kit.integrations.facebook.exceptions import ( - FacebookAPIError, - FacebookAuthError, - FacebookTimeoutError, - parse_api_error, -) -from flexus_client_kit.integrations.facebook.utils import validate_ad_account_id - -if TYPE_CHECKING: - from flexus_client_kit import ckit_client, ckit_bot_exec - -logger = logging.getLogger("facebook.client") - -API_BASE = "https://graph.facebook.com" -API_VERSION = "v19.0" -DEFAULT_TIMEOUT = 30.0 -MAX_RETRIES = 3 -INITIAL_RETRY_DELAY = 1.0 - - -class FacebookAdsClient: - def __init__( - self, - fclient: "ckit_client.FlexusClient", - rcx: "ckit_bot_exec.RobotContext", - ad_account_id: str = "", - ): - self.fclient = fclient - self.rcx = rcx - self._ad_account_id = "" - if ad_account_id: - self._ad_account_id = validate_ad_account_id(ad_account_id) - self._access_token: str = "" - self._headers: Dict[str, str] = {} - - @property - def ad_account_id(self) -> str: - return self._ad_account_id - - @ad_account_id.setter - def ad_account_id(self, value: str) -> None: - if value: - self._ad_account_id = validate_ad_account_id(value) - else: - self._ad_account_id = "" - - @property - def is_test_mode(self) -> bool: - return self.rcx.running_test_scenario - - @property - def access_token(self) -> str: - return self._access_token - - async def ensure_auth(self) -> Optional[str]: - try: - if self.is_test_mode: - return None - if not self._access_token: - self._access_token = await self._fetch_token() - self._headers = { - "Authorization": f"Bearer {self._access_token}", - "Content-Type": "application/json", - } - return None - except (AttributeError, KeyError, ValueError) as e: - logger.info("Failed to get Facebook token", exc_info=e) - return await self._prompt_oauth_connection() - - async def request( - self, - method: str, - endpoint: str, - params: Optional[Dict[str, Any]] = None, - data: Optional[Dict[str, Any]] = None, - form_data: Optional[Dict[str, Any]] = None, - timeout: float = DEFAULT_TIMEOUT, - ) -> Dict[str, Any]: - auth_error = await self.ensure_auth() - if auth_error: - raise FacebookAuthError(auth_error) - url = f"{API_BASE}/{API_VERSION}/{endpoint}" - - async def make_request() -> Dict[str, Any]: - async with httpx.AsyncClient() as client: - if method == "GET": - response = await client.get(url, params=params, headers=self._headers, timeout=timeout) - elif method == "POST": - if form_data: - response = await client.post(url, data=form_data, timeout=timeout) - else: - response = await client.post(url, json=data, headers=self._headers, timeout=timeout) - elif method == "DELETE": - response = await client.delete(url, json=data, headers=self._headers, timeout=timeout) - else: - raise ValueError(f"Unsupported method: {method}") - return response.json() - - return await self._retry_with_backoff(make_request) - - async def _retry_with_backoff( - self, - func, - max_retries: int = MAX_RETRIES, - initial_delay: float = INITIAL_RETRY_DELAY, - ) -> Dict[str, Any]: - last_exception = None - for attempt in range(max_retries): - try: - return await func() - except (httpx.HTTPError, httpx.TimeoutException) as e: - last_exception = e - if attempt == max_retries - 1: - raise FacebookTimeoutError(DEFAULT_TIMEOUT) - delay = initial_delay * (2 ** attempt) - logger.warning(f"Retry {attempt + 1}/{max_retries} after {delay}s due to: {e}") - await asyncio.sleep(delay) - except FacebookAPIError as e: - if e.is_rate_limit: - last_exception = e - if attempt == max_retries - 1: - raise - delay = initial_delay * (2 ** attempt) * 2 - logger.warning(f"Rate limit hit, retry {attempt + 1}/{max_retries} after {delay}s") - await asyncio.sleep(delay) - else: - raise - if last_exception: - raise last_exception - raise FacebookAPIError(500, "Unexpected retry loop exit") - - - async def _fetch_token(self) -> str: - facebook_auth = self.rcx.external_auth.get("facebook") or {} - token_obj = facebook_auth.get("token") or {} - access_token = token_obj.get("access_token", "") - if not access_token: - raise ValueError("No Facebook OAuth connection found") - logger.info("Facebook token retrieved for %s", self.rcx.persona.owner_fuser_id) - return access_token - - - async def _prompt_oauth_connection(self) -> str: - from flexus_client_kit import ckit_client - http = await self.fclient.use_http() - async with http as h: - result = await h.execute( - ckit_client.gql.gql(""" - query GetFacebookToken($fuser_id: String!, $ws_id: String!, $provider: String!, $scopes: [String!]!) { - external_auth_token( - fuser_id: $fuser_id - ws_id: $ws_id - provider: $provider - scopes: $scopes - ) - } - """), - variable_values={ - "fuser_id": self.rcx.persona.owner_fuser_id, - "ws_id": self.rcx.persona.ws_id, - "provider": "facebook", - "scopes": ["ads_management", "ads_read", "business_management", "pages_manage_ads"], - }, - ), - auth_url = result.get("external_auth_token", "") - return f"""Facebook authorization required. - -Click this link to connect your Facebook account: - -{auth_url} - -After authorizing, return here and try your request again. - -Requirements: -- Facebook Business Manager account -- Access to an Ad Account (starts with act_...) -""" diff --git a/flexus_client_kit/integrations/facebook/exceptions.py b/flexus_client_kit/integrations/facebook/exceptions.py deleted file mode 100644 index 6cd44110..00000000 --- a/flexus_client_kit/integrations/facebook/exceptions.py +++ /dev/null @@ -1,121 +0,0 @@ -from __future__ import annotations -import logging -from typing import Optional - -import httpx - -logger = logging.getLogger("facebook.exceptions") - - -class FacebookError(Exception): - def __init__(self, message: str, details: Optional[str] = None): - self.message = message - self.details = details - super().__init__(message) - - def __str__(self) -> str: - if self.details: - return f"{self.message}\n{self.details}" - return self.message - - -class FacebookAPIError(FacebookError): - CODE_INVALID_PARAMS = 100 - CODE_AUTH_EXPIRED = 190 - CODE_RATE_LIMIT_1 = 4 - CODE_RATE_LIMIT_2 = 17 - CODE_RATE_LIMIT_3 = 32 - CODE_INSUFFICIENT_PERMISSIONS = 80004 - CODE_AD_ACCOUNT_DISABLED = 2635 - CODE_BUDGET_TOO_LOW = 1487387 - RATE_LIMIT_CODES = {CODE_RATE_LIMIT_1, CODE_RATE_LIMIT_2, CODE_RATE_LIMIT_3} - - def __init__( - self, - code: int, - message: str, - error_type: str = "", - user_title: Optional[str] = None, - user_msg: Optional[str] = None, - fbtrace_id: Optional[str] = None, - ): - self.code = code - self.error_type = error_type - self.user_title = user_title - self.user_msg = user_msg - self.fbtrace_id = fbtrace_id - details_parts = [] - if user_title: - details_parts.append(f"**{user_title}**") - if user_msg: - details_parts.append(user_msg) - if not details_parts: - details_parts.append(message) - details = "\n".join(details_parts) - super().__init__(message, details) - - @property - def is_rate_limit(self) -> bool: - return self.code in self.RATE_LIMIT_CODES - - @property - def is_auth_error(self) -> bool: - return self.code == self.CODE_AUTH_EXPIRED - - def format_for_user(self) -> str: - if self.code == self.CODE_AUTH_EXPIRED: - return f"Authentication failed. Please reconnect Facebook.\n{self.details}" - elif self.is_rate_limit: - return f"Rate limit reached. Please try again in a few minutes.\n{self.details}" - elif self.code == self.CODE_INVALID_PARAMS: - return f"Invalid parameters (code {self.code}):\n{self.details}" - elif self.code == self.CODE_AD_ACCOUNT_DISABLED: - return f"Ad account is disabled.\n{self.details}" - elif self.code == self.CODE_BUDGET_TOO_LOW: - return f"Budget too low:\n{self.details}" - elif self.code == self.CODE_INSUFFICIENT_PERMISSIONS: - return f"Insufficient permissions.\n{self.details}" - else: - return f"Facebook API Error ({self.code}):\n{self.details}" - - -class FacebookAuthError(FacebookError): - def __init__(self, message: str = "Facebook authentication required"): - super().__init__(message) - - -class FacebookValidationError(FacebookError): - def __init__(self, field: str, message: str): - self.field = field - super().__init__(f"Validation error for '{field}': {message}") - - -class FacebookTimeoutError(FacebookError): - def __init__(self, timeout: float): - super().__init__(f"Request timed out after {timeout} seconds") - - -async def parse_api_error(response: httpx.Response) -> FacebookAPIError: - try: - error_data = response.json() - if "error" in error_data: - err = error_data["error"] - return FacebookAPIError( - code=err.get("code", response.status_code), - message=err.get("message", "Unknown error"), - error_type=err.get("type", ""), - user_title=err.get("error_user_title"), - user_msg=err.get("error_user_msg"), - fbtrace_id=err.get("fbtrace_id"), - ) - else: - return FacebookAPIError( - code=response.status_code, - message=f"HTTP {response.status_code}: {response.text[:500]}", - ) - except (KeyError, ValueError) as e: - logger.warning("Error parsing FB API error response", exc_info=e) - return FacebookAPIError( - code=response.status_code, - message=f"HTTP {response.status_code}: {response.text[:500]}", - ) diff --git a/flexus_client_kit/integrations/facebook/insights.py b/flexus_client_kit/integrations/facebook/insights.py deleted file mode 100644 index 89c4bfab..00000000 --- a/flexus_client_kit/integrations/facebook/insights.py +++ /dev/null @@ -1,211 +0,0 @@ -from __future__ import annotations -import logging -from typing import Any, Dict, List, Optional, TYPE_CHECKING - -from flexus_client_kit.integrations.facebook.models import InsightsBreakdown, InsightsDatePreset - -if TYPE_CHECKING: - from flexus_client_kit.integrations.facebook.client import FacebookAdsClient - -logger = logging.getLogger("facebook.operations.insights") - -DEFAULT_METRICS = "impressions,clicks,spend,reach,frequency,ctr,cpc,cpm,actions,cost_per_action_type,video_avg_time_watched_actions,video_p100_watched_actions" - - -def _format_insights_data(data: Dict[str, Any], label: str) -> str: - result = f"Insights for {label}:\n\n" - items = data.get("data", []) - if not items: - return f"No insights data found for {label}\n" - for item in items: - result += f" Date: {item.get('date_start', 'N/A')} — {item.get('date_stop', 'N/A')}\n" - result += f" Impressions: {item.get('impressions', '0')}\n" - result += f" Clicks: {item.get('clicks', '0')}\n" - result += f" Spend: ${item.get('spend', '0')}\n" - result += f" Reach: {item.get('reach', '0')}\n" - result += f" CTR: {item.get('ctr', '0')}%\n" - result += f" CPC: ${item.get('cpc', '0')}\n" - result += f" CPM: ${item.get('cpm', '0')}\n" - actions = item.get("actions", []) - if actions: - result += " Actions:\n" - for action in actions[:5]: - result += f" - {action.get('action_type', 'N/A')}: {action.get('value', '0')}\n" - breakdowns = item.get("age") or item.get("gender") or item.get("country") or item.get("publisher_platform") - if breakdowns: - result += f" Breakdown: {breakdowns}\n" - result += "\n" - return result - - -async def get_account_insights( - client: "FacebookAdsClient", - ad_account_id: str, - days: int = 30, - breakdowns: Optional[List[str]] = None, - metrics: Optional[str] = None, - date_preset: Optional[str] = None, -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id is required" - if client.is_test_mode: - return f"Account Insights for {ad_account_id} (last {days} days):\n Impressions: 15,000\n Clicks: 450\n Spend: $120.00\n CTR: 3.0%\n" - params: Dict[str, Any] = { - "fields": metrics or DEFAULT_METRICS, - "level": "account", - "limit": 50, - } - if date_preset: - params["date_preset"] = date_preset - else: - params["date_preset"] = _days_to_preset(days) - if breakdowns: - params["breakdowns"] = ",".join(breakdowns) - data = await client.request("GET", f"{ad_account_id}/insights", params=params) - return _format_insights_data(data, ad_account_id) - - -async def get_campaign_insights( - client: "FacebookAdsClient", - campaign_id: str, - days: int = 30, - breakdowns: Optional[List[str]] = None, - metrics: Optional[str] = None, - date_preset: Optional[str] = None, -) -> str: - if not campaign_id: - return "ERROR: campaign_id is required" - if client.is_test_mode: - return f"Campaign Insights for {campaign_id} (last {days} days):\n Impressions: 10,000\n Clicks: 300\n Spend: $80.00\n CTR: 3.0%\n" - params: Dict[str, Any] = { - "fields": metrics or DEFAULT_METRICS, - "limit": 50, - } - if date_preset: - params["date_preset"] = date_preset - else: - params["date_preset"] = _days_to_preset(days) - if breakdowns: - params["breakdowns"] = ",".join(breakdowns) - data = await client.request("GET", f"{campaign_id}/insights", params=params) - return _format_insights_data(data, campaign_id) - - -async def get_adset_insights( - client: "FacebookAdsClient", - adset_id: str, - days: int = 30, - breakdowns: Optional[List[str]] = None, - metrics: Optional[str] = None, - date_preset: Optional[str] = None, -) -> str: - if not adset_id: - return "ERROR: adset_id is required" - if client.is_test_mode: - return f"Ad Set Insights for {adset_id} (last {days} days):\n Impressions: 5,000\n Clicks: 150\n Spend: $40.00\n CTR: 3.0%\n" - params: Dict[str, Any] = { - "fields": metrics or DEFAULT_METRICS, - "limit": 50, - } - if date_preset: - params["date_preset"] = date_preset - else: - params["date_preset"] = _days_to_preset(days) - if breakdowns: - params["breakdowns"] = ",".join(breakdowns) - data = await client.request("GET", f"{adset_id}/insights", params=params) - return _format_insights_data(data, adset_id) - - -async def get_ad_insights( - client: "FacebookAdsClient", - ad_id: str, - days: int = 30, - breakdowns: Optional[List[str]] = None, - metrics: Optional[str] = None, - date_preset: Optional[str] = None, -) -> str: - if not ad_id: - return "ERROR: ad_id is required" - if client.is_test_mode: - return f"Ad Insights for {ad_id} (last {days} days):\n Impressions: 2,000\n Clicks: 60\n Spend: $15.00\n CTR: 3.0%\n" - params: Dict[str, Any] = { - "fields": metrics or DEFAULT_METRICS, - "limit": 50, - } - if date_preset: - params["date_preset"] = date_preset - else: - params["date_preset"] = _days_to_preset(days) - if breakdowns: - params["breakdowns"] = ",".join(breakdowns) - data = await client.request("GET", f"{ad_id}/insights", params=params) - return _format_insights_data(data, ad_id) - - -async def create_async_report( - client: "FacebookAdsClient", - ad_account_id: str, - level: str = "campaign", - fields: Optional[str] = None, - date_preset: str = "last_30d", - breakdowns: Optional[List[str]] = None, -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id is required" - valid_levels = ["account", "campaign", "adset", "ad"] - if level not in valid_levels: - return f"ERROR: level must be one of: {', '.join(valid_levels)}" - if client.is_test_mode: - return f"Async report created:\n Report Run ID: mock_report_run_123\n Status: Job Created\n Use get_async_report_status to check progress.\n" - data: Dict[str, Any] = { - "level": level, - "fields": fields or DEFAULT_METRICS, - "date_preset": date_preset, - } - if breakdowns: - data["breakdowns"] = ",".join(breakdowns) - result = await client.request("POST", f"{ad_account_id}/insights", data=data) - report_run_id = result.get("report_run_id") - if not report_run_id: - return f"Failed to create async report. Response: {result}" - return f"Async report created:\n Report Run ID: {report_run_id}\n Level: {level}\n Date Preset: {date_preset}\n Use get_async_report_status(report_run_id='{report_run_id}') to check progress.\n" - - -async def get_async_report_status( - client: "FacebookAdsClient", - report_run_id: str, -) -> str: - if not report_run_id: - return "ERROR: report_run_id is required" - if client.is_test_mode: - return f"Report {report_run_id} status: Job Completed (100%)\n Use insights endpoint with async_status filter to retrieve results.\n" - data = await client.request( - "GET", report_run_id, - params={"fields": "id,async_status,async_percent_completion,date_start,date_stop"}, - ) - status = data.get("async_status", "Unknown") - pct = data.get("async_percent_completion", 0) - result = f"Report {report_run_id}:\n" - result += f" Status: {status} ({pct}%)\n" - if data.get("date_start"): - result += f" Date Range: {data['date_start']} — {data.get('date_stop', 'N/A')}\n" - if status == "Job Completed": - result += f"\n Report is ready. Retrieve results at:\n GET /{report_run_id}/insights\n" - return result - - -def _days_to_preset(days: int) -> str: - if days <= 1: - return InsightsDatePreset.TODAY.value - if days <= 7: - return InsightsDatePreset.LAST_7D.value - if days <= 14: - return InsightsDatePreset.LAST_14D.value - if days <= 28: - return InsightsDatePreset.LAST_28D.value - if days <= 30: - return InsightsDatePreset.LAST_30D.value - if days <= 90: - return InsightsDatePreset.LAST_90D.value - return InsightsDatePreset.MAXIMUM.value diff --git a/flexus_client_kit/integrations/facebook/models.py b/flexus_client_kit/integrations/facebook/models.py deleted file mode 100644 index 9d4f5ff0..00000000 --- a/flexus_client_kit/integrations/facebook/models.py +++ /dev/null @@ -1,301 +0,0 @@ -from __future__ import annotations -from datetime import datetime -from enum import Enum -from typing import Any, Dict, List, Optional - -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator - - -class CustomAudienceSubtype(str, Enum): - CUSTOM = "CUSTOM" - WEBSITE = "WEBSITE" - APP = "APP" - ENGAGEMENT = "ENGAGEMENT" - LOOKALIKE = "LOOKALIKE" - VIDEO = "VIDEO" - LEAD_GENERATION = "LEAD_GENERATION" - ON_SITE_LEAD = "ON_SITE_LEAD" - - -class InsightsBreakdown(str, Enum): - AGE = "age" - GENDER = "gender" - COUNTRY = "country" - REGION = "region" - PLACEMENT = "publisher_platform" - DEVICE = "device_platform" - IMPRESSION_DEVICE = "impression_device" - PLATFORM_POSITION = "platform_position" - - -class InsightsDatePreset(str, Enum): - TODAY = "today" - YESTERDAY = "yesterday" - LAST_7D = "last_7d" - LAST_14D = "last_14d" - LAST_28D = "last_28d" - LAST_30D = "last_30d" - LAST_90D = "last_90d" - THIS_MONTH = "this_month" - LAST_MONTH = "last_month" - MAXIMUM = "maximum" - - -class AdRuleStatus(str, Enum): - ENABLED = "ENABLED" - DISABLED = "DISABLED" - DELETED = "DELETED" - HAS_ISSUES = "HAS_ISSUES" - - -class CampaignObjective(str, Enum): - TRAFFIC = "OUTCOME_TRAFFIC" - SALES = "OUTCOME_SALES" - ENGAGEMENT = "OUTCOME_ENGAGEMENT" - AWARENESS = "OUTCOME_AWARENESS" - LEADS = "OUTCOME_LEADS" - APP_PROMOTION = "OUTCOME_APP_PROMOTION" - - -class CampaignStatus(str, Enum): - ACTIVE = "ACTIVE" - PAUSED = "PAUSED" - ARCHIVED = "ARCHIVED" - - -class AccountStatus(int, Enum): - ACTIVE = 1 - DISABLED = 2 - UNSETTLED = 3 - PENDING_RISK_REVIEW = 7 - PENDING_SETTLEMENT = 8 - IN_GRACE_PERIOD = 9 - PENDING_CLOSURE = 100 - CLOSED = 101 - TEMPORARILY_UNAVAILABLE = 201 - - -class OptimizationGoal(str, Enum): - LINK_CLICKS = "LINK_CLICKS" - LANDING_PAGE_VIEWS = "LANDING_PAGE_VIEWS" - IMPRESSIONS = "IMPRESSIONS" - REACH = "REACH" - CONVERSIONS = "CONVERSIONS" - VALUE = "VALUE" - LEAD_GENERATION = "LEAD_GENERATION" - APP_INSTALLS = "APP_INSTALLS" - OFFSITE_CONVERSIONS = "OFFSITE_CONVERSIONS" - POST_ENGAGEMENT = "POST_ENGAGEMENT" - VIDEO_VIEWS = "VIDEO_VIEWS" - THRUPLAY = "THRUPLAY" - - -class BillingEvent(str, Enum): - IMPRESSIONS = "IMPRESSIONS" - LINK_CLICKS = "LINK_CLICKS" - APP_INSTALLS = "APP_INSTALLS" - THRUPLAY = "THRUPLAY" - - -class BidStrategy(str, Enum): - LOWEST_COST_WITHOUT_CAP = "LOWEST_COST_WITHOUT_CAP" - LOWEST_COST_WITH_BID_CAP = "LOWEST_COST_WITH_BID_CAP" - COST_CAP = "COST_CAP" - - -class CallToActionType(str, Enum): - LEARN_MORE = "LEARN_MORE" - SHOP_NOW = "SHOP_NOW" - SIGN_UP = "SIGN_UP" - BOOK_NOW = "BOOK_NOW" - DOWNLOAD = "DOWNLOAD" - GET_OFFER = "GET_OFFER" - GET_QUOTE = "GET_QUOTE" - CONTACT_US = "CONTACT_US" - SUBSCRIBE = "SUBSCRIBE" - APPLY_NOW = "APPLY_NOW" - BUY_NOW = "BUY_NOW" - WATCH_MORE = "WATCH_MORE" - - -class AdFormat(str, Enum): - DESKTOP_FEED_STANDARD = "DESKTOP_FEED_STANDARD" - MOBILE_FEED_STANDARD = "MOBILE_FEED_STANDARD" - INSTAGRAM_STANDARD = "INSTAGRAM_STANDARD" - INSTAGRAM_STORY = "INSTAGRAM_STORY" - MOBILE_BANNER = "MOBILE_BANNER" - MOBILE_INTERSTITIAL = "MOBILE_INTERSTITIAL" - MOBILE_NATIVE = "MOBILE_NATIVE" - RIGHT_COLUMN_STANDARD = "RIGHT_COLUMN_STANDARD" - - -class GeoLocation(BaseModel): - countries: List[str] = Field(default_factory=list) - regions: List[Dict[str, Any]] = Field(default_factory=list) - cities: List[Dict[str, Any]] = Field(default_factory=list) - zips: List[Dict[str, Any]] = Field(default_factory=list) - location_types: List[str] = Field(default_factory=lambda: ["home", "recent"]) - model_config = ConfigDict(extra="allow") - - -class TargetingSpec(BaseModel): - geo_locations: GeoLocation - age_min: int = Field(default=18, ge=13, le=65) - age_max: int = Field(default=65, ge=13, le=65) - genders: List[int] = Field(default_factory=list) - interests: List[Dict[str, Any]] = Field(default_factory=list) - behaviors: List[Dict[str, Any]] = Field(default_factory=list) - custom_audiences: List[Dict[str, Any]] = Field(default_factory=list) - excluded_custom_audiences: List[Dict[str, Any]] = Field(default_factory=list) - locales: List[int] = Field(default_factory=list) - publisher_platforms: List[str] = Field(default_factory=list) - device_platforms: List[str] = Field(default_factory=list) - model_config = ConfigDict(extra="allow") - - @model_validator(mode="after") - def validate_geo_locations(self) -> "TargetingSpec": - geo = self.geo_locations - if not geo.countries and not geo.regions and not geo.cities: - raise ValueError("At least one geo_location (country, region, or city) is required") - return self - - @model_validator(mode="after") - def validate_age_range(self) -> "TargetingSpec": - if self.age_min > self.age_max: - raise ValueError("age_min cannot be greater than age_max") - return self - - -class AdAccount(BaseModel): - id: str - account_id: Optional[str] = None - name: str - currency: str = "USD" - timezone_name: str = "America/Los_Angeles" - account_status: AccountStatus = AccountStatus.ACTIVE - balance: int = 0 - amount_spent: int = 0 - spend_cap: int = 0 - min_daily_budget: Optional[int] = None - business: Optional[Dict[str, Any]] = None - created_time: Optional[datetime] = None - model_config = ConfigDict(extra="allow") - - @property - def is_active(self) -> bool: - return self.account_status == AccountStatus.ACTIVE - - @property - def remaining_budget(self) -> int: - if self.spend_cap <= 0: - return 0 - return max(0, self.spend_cap - self.amount_spent) - - -class Campaign(BaseModel): - id: Optional[str] = None - name: str = Field(..., min_length=1, max_length=400) - objective: CampaignObjective - status: CampaignStatus = CampaignStatus.PAUSED - daily_budget: Optional[int] = Field(None, ge=100) - lifetime_budget: Optional[int] = Field(None, ge=100) - special_ad_categories: List[str] = Field(default_factory=list) - created_time: Optional[datetime] = None - updated_time: Optional[datetime] = None - model_config = ConfigDict(extra="allow") - - @field_validator("daily_budget", "lifetime_budget", mode="before") - @classmethod - def coerce_budget(cls, v): - if v is None: - return None - return int(v) - - -class AdSet(BaseModel): - id: Optional[str] = None - campaign_id: str - name: str = Field(..., min_length=1, max_length=400) - status: CampaignStatus = CampaignStatus.PAUSED - optimization_goal: OptimizationGoal = OptimizationGoal.LINK_CLICKS - billing_event: BillingEvent = BillingEvent.IMPRESSIONS - bid_strategy: BidStrategy = BidStrategy.LOWEST_COST_WITHOUT_CAP - bid_amount: Optional[int] = None - daily_budget: Optional[int] = Field(None, ge=100) - lifetime_budget: Optional[int] = Field(None, ge=100) - targeting: TargetingSpec - start_time: Optional[datetime] = None - end_time: Optional[datetime] = None - promoted_object: Optional[Dict[str, Any]] = None - model_config = ConfigDict(extra="allow") - - @field_validator("daily_budget", "lifetime_budget", "bid_amount", mode="before") - @classmethod - def coerce_budget(cls, v): - if v is None: - return None - return int(v) - - -class Creative(BaseModel): - id: Optional[str] = None - name: str = Field(..., min_length=1, max_length=400) - page_id: str - image_hash: Optional[str] = None - image_url: Optional[str] = None - link: str - message: Optional[str] = None - headline: Optional[str] = None - description: Optional[str] = None - call_to_action_type: CallToActionType = CallToActionType.LEARN_MORE - model_config = ConfigDict(extra="allow") - - @model_validator(mode="after") - def validate_image(self) -> "Creative": - if not self.image_hash and not self.image_url: - raise ValueError("Either image_hash or image_url is required") - return self - - -class Ad(BaseModel): - id: Optional[str] = None - name: str = Field(..., min_length=1, max_length=400) - adset_id: str - creative_id: str - status: CampaignStatus = CampaignStatus.PAUSED - model_config = ConfigDict(extra="allow") - - -class ActionBreakdown(BaseModel): - action_type: str - value: int - - @field_validator("value", mode="before") - @classmethod - def coerce_value(cls, v): - return int(v) - - -class Insights(BaseModel): - impressions: int = 0 - clicks: int = 0 - spend: float = 0.0 - reach: int = 0 - frequency: float = 0.0 - ctr: float = 0.0 - cpc: float = 0.0 - cpm: float = 0.0 - actions: List[ActionBreakdown] = Field(default_factory=list) - date_start: Optional[str] = None - date_stop: Optional[str] = None - model_config = ConfigDict(extra="allow") - - @field_validator("impressions", "clicks", "reach", mode="before") - @classmethod - def coerce_int(cls, v): - return int(v) if v else 0 - - @field_validator("spend", "frequency", "ctr", "cpc", "cpm", mode="before") - @classmethod - def coerce_float(cls, v): - return float(v) if v else 0.0 diff --git a/flexus_client_kit/integrations/facebook/pixels.py b/flexus_client_kit/integrations/facebook/pixels.py deleted file mode 100644 index a9a0d9b2..00000000 --- a/flexus_client_kit/integrations/facebook/pixels.py +++ /dev/null @@ -1,90 +0,0 @@ -from __future__ import annotations -import logging -from typing import Any, Dict, Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from flexus_client_kit.integrations.facebook.client import FacebookAdsClient - -logger = logging.getLogger("facebook.operations.pixels") - - -async def list_pixels( - client: "FacebookAdsClient", - ad_account_id: str, -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id is required" - if client.is_test_mode: - return f"Pixels for {ad_account_id}:\n Test Pixel (ID: 111222333) — ACTIVE — 1,250 events last 7 days\n" - data = await client.request( - "GET", f"{ad_account_id}/adspixels", - params={"fields": "id,name,code,creation_time,last_fired_time,owner_business", "limit": 50}, - ) - pixels = data.get("data", []) - if not pixels: - return f"No pixels found for {ad_account_id}" - result = f"Pixels for {ad_account_id} ({len(pixels)} total):\n\n" - for p in pixels: - result += f" **{p.get('name', 'Unnamed')}** (ID: {p['id']})\n" - result += f" Last fired: {p.get('last_fired_time', 'Never')}\n" - result += f" Created: {p.get('creation_time', 'N/A')}\n\n" - return result - - -async def create_pixel( - client: "FacebookAdsClient", - ad_account_id: str, - name: str, -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id is required" - if not name: - return "ERROR: name is required" - if client.is_test_mode: - return f"Pixel created:\n Name: {name}\n ID: mock_pixel_789\n Install the pixel code on your website to start tracking events.\n" - result = await client.request( - "POST", f"{ad_account_id}/adspixels", - data={"name": name}, - ) - pixel_id = result.get("id") - if not pixel_id: - return f"Failed to create pixel. Response: {result}" - return f"Pixel created:\n Name: {name}\n ID: {pixel_id}\n Install the pixel code on your website to start tracking events.\n View stats with: get_pixel_stats(pixel_id='{pixel_id}')\n" - - -async def get_pixel_stats( - client: "FacebookAdsClient", - pixel_id: str, - start_time: Optional[str] = None, - end_time: Optional[str] = None, - aggregation: str = "day", -) -> str: - if not pixel_id: - return "ERROR: pixel_id is required" - valid_aggregations = ["day", "hour", "week", "month"] - if aggregation not in valid_aggregations: - return f"ERROR: aggregation must be one of: {', '.join(valid_aggregations)}" - if client.is_test_mode: - return f"Pixel Stats for {pixel_id}:\n PageView: 3,450 events\n Purchase: 127 events\n AddToCart: 342 events\n Lead: 89 events\n" - params: Dict[str, Any] = { - "aggregation": aggregation, - "fields": "event_name,count,start_time,end_time", - "limit": 200, - } - if start_time: - params["start_time"] = start_time - if end_time: - params["end_time"] = end_time - data = await client.request("GET", f"{pixel_id}/stats", params=params) - stats = data.get("data", []) - if not stats: - return f"No stats found for pixel {pixel_id}" - event_totals: Dict[str, int] = {} - for entry in stats: - event = entry.get("event_name", "Unknown") - count = int(entry.get("count", 0)) - event_totals[event] = event_totals.get(event, 0) + count - result = f"Pixel Stats for {pixel_id}:\n\n" - for event, count in sorted(event_totals.items(), key=lambda x: -x[1]): - result += f" {event}: {count:,} events\n" - return result diff --git a/flexus_client_kit/integrations/facebook/rules.py b/flexus_client_kit/integrations/facebook/rules.py deleted file mode 100644 index 9167f1d6..00000000 --- a/flexus_client_kit/integrations/facebook/rules.py +++ /dev/null @@ -1,129 +0,0 @@ -from __future__ import annotations -import logging -from typing import Any, Dict, List, Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from flexus_client_kit.integrations.facebook.client import FacebookAdsClient - -logger = logging.getLogger("facebook.operations.rules") - -RULE_FIELDS = "id,name,status,evaluation_spec,execution_spec,schedule_spec,created_time,updated_time" - - -async def list_ad_rules( - client: "FacebookAdsClient", - ad_account_id: str, -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id is required" - if client.is_test_mode: - return f"Ad Rules for {ad_account_id}:\n Pause on low CTR (ID: 111222) — ENABLED\n Scale budget on ROAS (ID: 222333) — ENABLED\n" - data = await client.request( - "GET", f"{ad_account_id}/adrules_library", - params={"fields": RULE_FIELDS, "limit": 50}, - ) - rules = data.get("data", []) - if not rules: - return f"No ad rules found for {ad_account_id}" - result = f"Ad Rules for {ad_account_id} ({len(rules)} total):\n\n" - for rule in rules: - result += f" **{rule.get('name', 'Unnamed')}** (ID: {rule['id']})\n" - result += f" Status: {rule.get('status', 'N/A')}\n" - exec_spec = rule.get("execution_spec", {}) - if exec_spec.get("execution_type"): - result += f" Action: {exec_spec['execution_type']}\n" - result += "\n" - return result - - -async def create_ad_rule( - client: "FacebookAdsClient", - ad_account_id: str, - name: str, - evaluation_spec: Dict[str, Any], - execution_spec: Dict[str, Any], - schedule_spec: Optional[Dict[str, Any]] = None, - status: str = "ENABLED", -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id is required" - if not name: - return "ERROR: name is required" - if not evaluation_spec: - return "ERROR: evaluation_spec is required (defines conditions to check)" - if not execution_spec: - return "ERROR: execution_spec is required (defines action to take)" - if client.is_test_mode: - return f"Ad rule created:\n Name: {name}\n ID: mock_rule_123\n Status: {status}\n" - data: Dict[str, Any] = { - "name": name, - "evaluation_spec": evaluation_spec, - "execution_spec": execution_spec, - "status": status, - } - if schedule_spec: - data["schedule_spec"] = schedule_spec - result = await client.request("POST", f"{ad_account_id}/adrules_library", data=data) - rule_id = result.get("id") - if not rule_id: - return f"Failed to create ad rule. Response: {result}" - return f"Ad rule created:\n Name: {name}\n ID: {rule_id}\n Status: {status}\n" - - -async def update_ad_rule( - client: "FacebookAdsClient", - rule_id: str, - name: Optional[str] = None, - status: Optional[str] = None, -) -> str: - if not rule_id: - return "ERROR: rule_id is required" - if not any([name, status]): - return "ERROR: At least one field to update is required (name, status)" - valid_statuses = ["ENABLED", "DISABLED", "DELETED"] - if status and status not in valid_statuses: - return f"ERROR: status must be one of: {', '.join(valid_statuses)}" - if client.is_test_mode: - updates = [] - if name: - updates.append(f"name -> {name}") - if status: - updates.append(f"status -> {status}") - return f"Ad rule {rule_id} updated:\n" + "\n".join(f" - {u}" for u in updates) - data: Dict[str, Any] = {} - if name: - data["name"] = name - if status: - data["status"] = status - result = await client.request("POST", rule_id, data=data) - if result.get("success"): - return f"Ad rule {rule_id} updated successfully." - return f"Failed to update ad rule. Response: {result}" - - -async def delete_ad_rule( - client: "FacebookAdsClient", - rule_id: str, -) -> str: - if not rule_id: - return "ERROR: rule_id is required" - if client.is_test_mode: - return f"Ad rule {rule_id} deleted successfully." - result = await client.request("DELETE", rule_id) - if result.get("success"): - return f"Ad rule {rule_id} deleted successfully." - return f"Failed to delete ad rule. Response: {result}" - - -async def execute_ad_rule( - client: "FacebookAdsClient", - rule_id: str, -) -> str: - if not rule_id: - return "ERROR: rule_id is required" - if client.is_test_mode: - return f"Ad rule {rule_id} executed successfully. Actions applied to matching objects." - result = await client.request("POST", f"{rule_id}/execute", data={}) - if result.get("success"): - return f"Ad rule {rule_id} executed successfully. Actions applied to matching objects." - return f"Failed to execute ad rule. Response: {result}" diff --git a/flexus_client_kit/integrations/facebook/targeting.py b/flexus_client_kit/integrations/facebook/targeting.py deleted file mode 100644 index f4bfcfa1..00000000 --- a/flexus_client_kit/integrations/facebook/targeting.py +++ /dev/null @@ -1,134 +0,0 @@ -from __future__ import annotations -import json -import logging -from typing import Any, Dict, Optional, TYPE_CHECKING - -if TYPE_CHECKING: - from flexus_client_kit.integrations.facebook.client import FacebookAdsClient - -logger = logging.getLogger("facebook.operations.targeting") - - -async def search_interests( - client: "FacebookAdsClient", - q: str, - limit: int = 20, -) -> str: - if not q: - return "ERROR: q (search query) is required" - if client.is_test_mode: - return f"Interests matching '{q}':\n Travel (ID: 6003263) — ~600M people\n Adventure Travel (ID: 6003021) — ~50M people\n" - data = await client.request( - "GET", "search", - params={ - "type": "adinterest", - "q": q, - "limit": min(limit, 50), - "locale": "en_US", - }, - ) - items = data.get("data", []) - if not items: - return f"No interests found matching '{q}'" - result = f"Interests matching '{q}' ({len(items)} results):\n\n" - for item in items: - audience_size = item.get("audience_size", "Unknown") - path = " > ".join(item.get("path", [])) - result += f" **{item.get('name', 'N/A')}** (ID: {item.get('id', 'N/A')})\n" - result += f" Audience: ~{audience_size:,} people\n" if isinstance(audience_size, int) else f" Audience: {audience_size}\n" - if path: - result += f" Category: {path}\n" - result += "\n" - return result - - -async def search_behaviors( - client: "FacebookAdsClient", - q: str, - limit: int = 20, -) -> str: - if not q: - return "ERROR: q (search query) is required" - if client.is_test_mode: - return f"Behaviors matching '{q}':\n Frequent Travelers (ID: 6002714) — ~120M people\n" - data = await client.request( - "GET", "search", - params={ - "type": "adbehavior", - "q": q, - "limit": min(limit, 50), - "locale": "en_US", - }, - ) - items = data.get("data", []) - if not items: - return f"No behaviors found matching '{q}'" - result = f"Behaviors matching '{q}' ({len(items)} results):\n\n" - for item in items: - audience_size = item.get("audience_size", "Unknown") - result += f" **{item.get('name', 'N/A')}** (ID: {item.get('id', 'N/A')})\n" - result += f" Audience: ~{audience_size:,} people\n" if isinstance(audience_size, int) else f" Audience: {audience_size}\n" - result += "\n" - return result - - -async def get_reach_estimate( - client: "FacebookAdsClient", - ad_account_id: str, - targeting: Dict[str, Any], - optimization_goal: str = "LINK_CLICKS", -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id is required" - if not targeting: - return "ERROR: targeting spec is required" - if client.is_test_mode: - return f"Reach Estimate for {ad_account_id}:\n Estimated Audience: 1,200,000 — 1,800,000 people\n Optimization: {optimization_goal}\n" - params: Dict[str, Any] = { - "targeting_spec": json.dumps(targeting), - "optimization_goal": optimization_goal, - } - data = await client.request("GET", f"{ad_account_id}/reachestimate", params=params) - users = data.get("users", "Unknown") - estimate_ready = data.get("estimate_ready", False) - result = f"Reach Estimate for {ad_account_id}:\n" - result += f" Estimated Audience: {users:,} people\n" if isinstance(users, int) else f" Estimated Audience: {users}\n" - result += f" Estimate Ready: {estimate_ready}\n" - result += f" Optimization Goal: {optimization_goal}\n" - return result - - -async def get_delivery_estimate( - client: "FacebookAdsClient", - ad_account_id: str, - targeting: Dict[str, Any], - optimization_goal: str = "LINK_CLICKS", - bid_amount: Optional[int] = None, -) -> str: - if not ad_account_id: - return "ERROR: ad_account_id is required" - if not targeting: - return "ERROR: targeting spec is required" - if client.is_test_mode: - return f"Delivery Estimate for {ad_account_id}:\n Daily Min Spend: $5.00\n Daily Max Spend: $50.00\n Min Reach: 800\n Max Reach: 3,200\n Optimization: {optimization_goal}\n" - params: Dict[str, Any] = { - "targeting_spec": json.dumps(targeting), - "optimization_goal": optimization_goal, - } - if bid_amount: - params["bid_amount"] = bid_amount - data = await client.request("GET", f"{ad_account_id}/delivery_estimate", params=params) - estimates = data.get("data", []) - if not estimates: - return f"No delivery estimates found for {ad_account_id}" - result = f"Delivery Estimate for {ad_account_id}:\n\n" - for est in estimates: - result += f" Optimization: {est.get('optimization_goal', optimization_goal)}\n" - daily = est.get("daily_outcomes_curve", []) - if daily: - first = daily[0] - last = daily[-1] - result += f" Daily Spend Range: ${first.get('spend', 0)/100:.2f} — ${last.get('spend', 0)/100:.2f}\n" - result += f" Daily Reach Range: {first.get('reach', 0):,} — {last.get('reach', 0):,}\n" - result += "\n" - return result diff --git a/flexus_client_kit/integrations/facebook/utils.py b/flexus_client_kit/integrations/facebook/utils.py deleted file mode 100644 index 2b06d3dd..00000000 --- a/flexus_client_kit/integrations/facebook/utils.py +++ /dev/null @@ -1,140 +0,0 @@ -from __future__ import annotations -import hashlib -import logging -from typing import Any, Dict, List, Optional, Tuple - -from flexus_client_kit.integrations.facebook.exceptions import FacebookValidationError -from flexus_client_kit.integrations.facebook.models import Insights - -logger = logging.getLogger("facebook.utils") - - -def validate_ad_account_id(ad_account_id: str) -> str: - if not ad_account_id: - raise FacebookValidationError("ad_account_id", "is required") - ad_account_id = str(ad_account_id).strip() - if not ad_account_id: - raise FacebookValidationError("ad_account_id", "cannot be empty") - if not ad_account_id.startswith("act_"): - return f"act_{ad_account_id}" - return ad_account_id - - -def validate_budget(budget: int, min_budget: int = 100, currency: str = "USD") -> int: - if not isinstance(budget, int): - try: - budget = int(budget) - except (TypeError, ValueError): - raise FacebookValidationError("budget", "must be an integer (cents)") - if budget < min_budget: - raise FacebookValidationError("budget", f"must be at least {format_currency(min_budget, currency)}") - return budget - - -def validate_targeting_spec(spec: Dict[str, Any]) -> Tuple[bool, str]: - try: - if not spec: - return False, "Targeting spec cannot be empty" - if "geo_locations" not in spec: - return False, "geo_locations is required in targeting" - geo = spec["geo_locations"] - if not isinstance(geo, dict): - return False, "geo_locations must be a dictionary" - if not geo.get("countries") and not geo.get("regions") and not geo.get("cities"): - return False, "At least one geo_location (country, region, or city) is required" - if "age_min" in spec: - age_min = spec["age_min"] - if not isinstance(age_min, int) or age_min < 13 or age_min > 65: - return False, "age_min must be between 13 and 65" - if "age_max" in spec: - age_max = spec["age_max"] - if not isinstance(age_max, int) or age_max < 13 or age_max > 65: - return False, "age_max must be between 13 and 65" - if "age_min" in spec and "age_max" in spec: - if spec["age_min"] > spec["age_max"]: - return False, "age_min cannot be greater than age_max" - return True, "" - except (KeyError, TypeError, ValueError) as e: - return False, f"Validation error: {str(e)}" - - -def format_currency(cents: int, currency: str = "USD") -> str: - return f"{cents / 100:.2f} {currency}" - - -def format_account_status(status_code: int) -> str: - status_map = { - 1: "Active", - 2: "Disabled", - 3: "Unsettled", - 7: "Pending Risk Review", - 8: "Pending Settlement", - 9: "In Grace Period", - 100: "Pending Closure", - 101: "Closed", - 201: "Temporarily Unavailable", - } - return status_map.get(status_code, f"Unknown ({status_code})") - - -def normalize_insights_data(raw_data: Dict[str, Any]) -> Insights: - try: - impressions = int(raw_data.get("impressions", 0)) - clicks = int(raw_data.get("clicks", 0)) - spend = float(raw_data.get("spend", 0.0)) - reach = int(raw_data.get("reach", 0)) - frequency = float(raw_data.get("frequency", 0.0)) - ctr = raw_data.get("ctr") - if ctr: - ctr = float(ctr) - elif impressions > 0: - ctr = (clicks / impressions) * 100 - else: - ctr = 0.0 - cpc = raw_data.get("cpc") - if cpc: - cpc = float(cpc) - elif clicks > 0: - cpc = spend / clicks - else: - cpc = 0.0 - cpm = raw_data.get("cpm") - if cpm: - cpm = float(cpm) - elif impressions > 0: - cpm = (spend / impressions) * 1000 - else: - cpm = 0.0 - actions = [] - raw_actions = raw_data.get("actions", []) - if isinstance(raw_actions, list): - for action in raw_actions: - actions.append({ - "action_type": action.get("action_type", "unknown"), - "value": int(action.get("value", 0)), - }) - return Insights( - impressions=impressions, - clicks=clicks, - spend=spend, - reach=reach, - frequency=frequency, - ctr=ctr, - cpc=cpc, - cpm=cpm, - actions=actions, - date_start=raw_data.get("date_start"), - date_stop=raw_data.get("date_stop"), - ) - except (KeyError, TypeError, ValueError) as e: - logger.warning("Error normalizing insights data", exc_info=e) - return Insights() - - -def hash_for_audience(value: str, field_type: str) -> str: - value = value.strip().lower() - if field_type == "PHONE": - value = ''.join(c for c in value if c.isdigit()) - elif field_type in ["FN", "LN", "CT", "ST"]: - value = value.replace(" ", "") - return hashlib.sha256(value.encode()).hexdigest() diff --git a/flexus_client_kit/integrations/fi_meta_marketing_manage.py b/flexus_client_kit/integrations/fi_meta_marketing_manage.py index c961d90f..694f9878 100644 --- a/flexus_client_kit/integrations/fi_meta_marketing_manage.py +++ b/flexus_client_kit/integrations/fi_meta_marketing_manage.py @@ -1,200 +1,987 @@ from __future__ import annotations +import json import logging -from typing import Any, Dict, Optional, TYPE_CHECKING +from pathlib import Path +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +import httpx from flexus_client_kit import ckit_cloudtool -from flexus_client_kit.integrations.facebook.client import FacebookAdsClient -from flexus_client_kit.integrations.facebook.exceptions import ( +from flexus_client_kit.integrations._fi_meta_helpers import ( + FacebookAdsClient, FacebookAPIError, FacebookAuthError, FacebookValidationError, -) -from flexus_client_kit.integrations.facebook.campaigns import ( - list_campaigns, create_campaign, update_campaign, duplicate_campaign, - archive_campaign, bulk_update_campaigns, get_campaign, delete_campaign, -) -from flexus_client_kit.integrations.facebook.adsets import ( - list_adsets, create_adset, update_adset, validate_targeting, - get_adset, delete_adset, list_adsets_for_account, -) -from flexus_client_kit.integrations.facebook.ads import ( - upload_image, create_creative, create_ad, preview_ad, - list_ads, get_ad, update_ad, delete_ad, - list_creatives, get_creative, update_creative, delete_creative, preview_creative, - upload_video, list_videos, -) -from flexus_client_kit.integrations.facebook.audiences import ( - list_custom_audiences, create_custom_audience, create_lookalike_audience, - get_custom_audience, update_custom_audience, delete_custom_audience, add_users_to_audience, -) -from flexus_client_kit.integrations.facebook.pixels import list_pixels, create_pixel, get_pixel_stats -from flexus_client_kit.integrations.facebook.targeting import ( - search_interests, search_behaviors, get_reach_estimate, get_delivery_estimate, -) -from flexus_client_kit.integrations.facebook.rules import ( - list_ad_rules, create_ad_rule, update_ad_rule, delete_ad_rule, execute_ad_rule, + FacebookTimeoutError, + CampaignObjective, + CallToActionType, + AdFormat, + CustomAudienceSubtype, + InsightsDatePreset, + API_BASE, + API_VERSION, + format_currency, + validate_budget, + validate_targeting_spec, + validate_ad_account_id, ) if TYPE_CHECKING: - from flexus_client_kit import ckit_client, ckit_bot_exec + from flexus_client_kit import ckit_bot_exec logger = logging.getLogger("meta_marketing_manage") # Use case: "Create & manage ads with Marketing API" -# Covers campaigns, ad sets, ads, creatives, audiences, pixels, targeting, rules. PROVIDER_NAME = "meta_marketing_manage" _HELP = """meta_marketing_manage: Create & manage ads with Meta Marketing API. op=help | status | list_methods | call(args={method_id, ...}) -Campaign management: - list_campaigns(ad_account_id, status?) - get_campaign(campaign_id) - create_campaign(ad_account_id, name, objective, daily_budget?, lifetime_budget?, status?) - update_campaign(campaign_id, name?, status?, daily_budget?, lifetime_budget?) - delete_campaign(campaign_id) - duplicate_campaign(campaign_id, new_name, ad_account_id?) - archive_campaign(campaign_id) - bulk_update_campaigns(campaigns) - -Ad set management: - list_adsets(campaign_id) - list_adsets_for_account(ad_account_id, status_filter?) - get_adset(adset_id) - create_adset(ad_account_id, campaign_id, name, targeting, optimization_goal?, billing_event?, status?) - update_adset(adset_id, name?, status?, daily_budget?, targeting?) - delete_adset(adset_id) - validate_targeting(targeting_spec, ad_account_id?) - -Ad & creative management: - list_ads(ad_account_id?, adset_id?, status_filter?) - get_ad(ad_id) - create_ad(ad_account_id, adset_id, creative_id, name?, status?) - update_ad(ad_id, name?, status?) - delete_ad(ad_id) - preview_ad(ad_id, ad_format?) - list_creatives(ad_account_id) - get_creative(creative_id) - create_creative(ad_account_id, name, page_id, message?, link?, image_hash?, video_id?, call_to_action_type?) - update_creative(creative_id, name?) - delete_creative(creative_id) - preview_creative(creative_id, ad_format?) - upload_image(image_path?, image_url?, ad_account_id?) - upload_video(video_url, ad_account_id?, title?, description?) - list_videos(ad_account_id) - -Audiences: - list_custom_audiences(ad_account_id) - get_custom_audience(audience_id) - create_custom_audience(ad_account_id, name, subtype?, description?, customer_file_source?) - create_lookalike_audience(ad_account_id, origin_audience_id, country, ratio?, name?) - update_custom_audience(audience_id, name?, description?) - delete_custom_audience(audience_id) - add_users_to_audience(audience_id, emails, phones?) - -Pixels: - list_pixels(ad_account_id) - create_pixel(ad_account_id, name) - get_pixel_stats(pixel_id, start_time?, end_time?, aggregation?) - -Targeting research: - search_interests(q, limit?) - search_behaviors(q, limit?) - get_reach_estimate(ad_account_id, targeting, optimization_goal?) - get_delivery_estimate(ad_account_id, targeting, optimization_goal?, bid_amount?) - -Automation rules: - list_ad_rules(ad_account_id) - create_ad_rule(ad_account_id, name, evaluation_spec, execution_spec, schedule_spec?, status?) - update_ad_rule(rule_id, name?, status?) - delete_ad_rule(rule_id) - execute_ad_rule(rule_id) +Campaigns: list_campaigns, get_campaign, create_campaign, update_campaign, + delete_campaign, duplicate_campaign, archive_campaign, bulk_update_campaigns +Ad Sets: list_adsets, list_adsets_for_account, get_adset, create_adset, + update_adset, delete_adset, validate_targeting +Ads: list_ads, get_ad, create_ad, update_ad, delete_ad, preview_ad +Creatives: list_creatives, get_creative, create_creative, update_creative, + delete_creative, preview_creative, upload_image, upload_video, list_videos +Audiences: list_custom_audiences, get_custom_audience, create_custom_audience, + create_lookalike_audience, update_custom_audience, delete_custom_audience, + add_users_to_audience +Pixels: list_pixels, create_pixel, get_pixel_stats +Targeting: search_interests, search_behaviors, get_reach_estimate, get_delivery_estimate +Rules: list_ad_rules, create_ad_rule, update_ad_rule, delete_ad_rule, execute_ad_rule """ -# Maps op string -> lambda(client, args) for the generic dispatch table. +# ── Campaigns ───────────────────────────────────────────────────────────────── + +_CAMPAIGN_FIELDS = "id,name,status,objective,daily_budget,lifetime_budget" + + +async def _list_campaigns(client: FacebookAdsClient, ad_account_id: Optional[str], status_filter: Optional[str]) -> str: + account_id = ad_account_id or client.ad_account_id + if not account_id: + return "ERROR: ad_account_id required for list_campaigns" + if client.is_test_mode: + return "Found 2 campaigns:\n Test Campaign 1 (ID: 123456789) - ACTIVE, Daily: 50.00 USD\n Test Campaign 2 (ID: 987654321) - PAUSED, Daily: 100.00 USD\n" + params: Dict[str, Any] = {"fields": _CAMPAIGN_FIELDS, "limit": 50} + if status_filter: + params["effective_status"] = f"['{status_filter}']" + data = await client.request("GET", f"{account_id}/campaigns", params=params) + campaigns = data.get("data", []) + if not campaigns: + return "No campaigns found." + result = f"Found {len(campaigns)} campaign{'s' if len(campaigns) != 1 else ''}:\n" + for c in campaigns: + budget_str = "" + if c.get("daily_budget"): + budget_str = f", Daily: {format_currency(int(c['daily_budget']))}" + elif c.get("lifetime_budget"): + budget_str = f", Lifetime: {format_currency(int(c['lifetime_budget']))}" + result += f" {c['name']} (ID: {c['id']}) - {c['status']}{budget_str}\n" + return result + + +async def _get_campaign(client: FacebookAdsClient, campaign_id: str) -> str: + if not campaign_id: + return "ERROR: campaign_id is required" + if client.is_test_mode: + return f"Campaign {campaign_id}:\n Name: Test Campaign\n Status: ACTIVE\n Objective: OUTCOME_TRAFFIC\n Daily Budget: 50.00 USD\n" + data = await client.request("GET", campaign_id, params={"fields": "id,name,status,objective,daily_budget,lifetime_budget,special_ad_categories,created_time,updated_time,start_time,stop_time,budget_remaining"}) + result = f"Campaign {campaign_id}:\n" + result += f" Name: {data.get('name', 'N/A')}\n" + result += f" Status: {data.get('status', 'N/A')}\n" + result += f" Objective: {data.get('objective', 'N/A')}\n" + if data.get("daily_budget"): + result += f" Daily Budget: {format_currency(int(data['daily_budget']))}\n" + if data.get("lifetime_budget"): + result += f" Lifetime Budget: {format_currency(int(data['lifetime_budget']))}\n" + if data.get("budget_remaining"): + result += f" Budget Remaining: {format_currency(int(data['budget_remaining']))}\n" + result += f" Created: {data.get('created_time', 'N/A')}\n" + return result + + +async def _create_campaign(client: FacebookAdsClient, ad_account_id: str, name: str, objective: str, daily_budget: Optional[int], lifetime_budget: Optional[int], status: str) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + if not name: + return "ERROR: name required" + try: + CampaignObjective(objective) + except ValueError: + return f"ERROR: Invalid objective. Must be one of: {', '.join(o.value for o in CampaignObjective)}" + if daily_budget: + try: + daily_budget = validate_budget(daily_budget) + except FacebookValidationError as e: + return f"ERROR: {e.message}" + if lifetime_budget: + try: + lifetime_budget = validate_budget(lifetime_budget) + except FacebookValidationError as e: + return f"ERROR: {e.message}" + if client.is_test_mode: + budget_str = f"\n Daily Budget: {format_currency(daily_budget)}" if daily_budget else "" + return f"Campaign created: {name} (ID: mock_123456789)\n Status: {status}, Objective: {objective}{budget_str}\n" + payload: Dict[str, Any] = {"name": name, "objective": objective, "status": status, "special_ad_categories": []} + if daily_budget: + payload["daily_budget"] = daily_budget + if lifetime_budget: + payload["lifetime_budget"] = lifetime_budget + result = await client.request("POST", f"{ad_account_id}/campaigns", data=payload) + campaign_id = result.get("id") + if not campaign_id: + return f"Failed to create campaign. Response: {result}" + output = f"Campaign created: {name} (ID: {campaign_id})\n Status: {status}, Objective: {objective}\n" + if daily_budget: + output += f" Daily Budget: {format_currency(daily_budget)}\n" + return output + + +async def _update_campaign(client: FacebookAdsClient, campaign_id: str, name: Optional[str], status: Optional[str], daily_budget: Optional[int], lifetime_budget: Optional[int]) -> str: + if not campaign_id: + return "ERROR: campaign_id required" + if not any([name, status, daily_budget, lifetime_budget]): + return "ERROR: At least one field to update required" + if status and status not in ["ACTIVE", "PAUSED"]: + return "ERROR: status must be ACTIVE or PAUSED" + if daily_budget: + try: + daily_budget = validate_budget(daily_budget) + except FacebookValidationError as e: + return f"ERROR: {e.message}" + updates = [f"name -> {name}"] if name else [] + if status: + updates.append(f"status -> {status}") + if daily_budget: + updates.append(f"daily_budget -> {format_currency(daily_budget)}") + if client.is_test_mode: + return f"Campaign {campaign_id} updated:\n" + "\n".join(f" - {u}" for u in updates) + data: Dict[str, Any] = {} + if name: + data["name"] = name + if status: + data["status"] = status + if daily_budget is not None: + data["daily_budget"] = daily_budget + if lifetime_budget is not None: + data["lifetime_budget"] = lifetime_budget + result = await client.request("POST", campaign_id, data=data) + if result.get("success"): + return f"Campaign {campaign_id} updated:\n" + "\n".join(f" - {u}" for u in updates) + return f"Failed to update campaign. Response: {result}" + + +async def _delete_campaign(client: FacebookAdsClient, campaign_id: str) -> str: + if not campaign_id: + return "ERROR: campaign_id required" + if client.is_test_mode: + return f"Campaign {campaign_id} deleted successfully." + result = await client.request("DELETE", campaign_id) + return f"Campaign {campaign_id} deleted successfully." if result.get("success") else f"Failed to delete campaign. Response: {result}" + + +async def _duplicate_campaign(client: FacebookAdsClient, campaign_id: str, new_name: str, ad_account_id: Optional[str]) -> str: + if not campaign_id or not new_name: + return "ERROR: campaign_id and new_name required" + if client.is_test_mode: + return f"Campaign duplicated!\nOriginal: {campaign_id}\nNew ID: {campaign_id}_copy\nNew Name: {new_name}\nStatus: PAUSED\n" + original = await client.request("GET", campaign_id, params={"fields": "name,objective,status,daily_budget,lifetime_budget,special_ad_categories"}) + account_id = ad_account_id or client.ad_account_id + if not account_id: + return "ERROR: ad_account_id required for duplicate_campaign" + create_data: Dict[str, Any] = {"name": new_name, "objective": original["objective"], "status": "PAUSED", "special_ad_categories": original.get("special_ad_categories", [])} + if "daily_budget" in original: + create_data["daily_budget"] = original["daily_budget"] + new_campaign = await client.request("POST", f"{account_id}/campaigns", data=create_data) + return f"Campaign duplicated!\nOriginal: {campaign_id}\nNew ID: {new_campaign.get('id')}\nNew Name: {new_name}\nStatus: PAUSED\n" + + +async def _archive_campaign(client: FacebookAdsClient, campaign_id: str) -> str: + if not campaign_id: + return "ERROR: campaign_id required" + if client.is_test_mode: + return f"Campaign {campaign_id} archived successfully." + result = await client.request("POST", campaign_id, data={"status": "ARCHIVED"}) + return f"Campaign {campaign_id} archived successfully." if result.get("success") else f"Failed to archive campaign. Response: {result}" + + +async def _bulk_update_campaigns(client: FacebookAdsClient, campaigns: List[Dict[str, Any]]) -> str: + if not campaigns or not isinstance(campaigns, list): + return "ERROR: campaigns must be a non-empty list" + if len(campaigns) > 50: + return "ERROR: Maximum 50 campaigns at once" + if client.is_test_mode: + results = [f" {c.get('id', 'unknown')} -> {c.get('status', 'unchanged')}" for c in campaigns] + return f"Bulk update completed for {len(campaigns)} campaigns:\n" + "\n".join(results) + results, errors = [], [] + for camp in campaigns: + campaign_id = camp.get("id") + if not campaign_id: + errors.append("Missing campaign ID") + continue + data: Dict[str, Any] = {} + if "name" in camp: + data["name"] = camp["name"] + if "status" in camp: + if camp["status"] not in ["ACTIVE", "PAUSED", "ARCHIVED"]: + errors.append(f"{campaign_id}: Invalid status") + continue + data["status"] = camp["status"] + if "daily_budget" in camp: + try: + data["daily_budget"] = validate_budget(camp["daily_budget"]) + except FacebookValidationError as e: + errors.append(f"{campaign_id}: {e.message}") + continue + if not data: + errors.append(f"{campaign_id}: No fields to update") + continue + try: + result = await client.request("POST", campaign_id, data=data) + if result.get("success"): + results.append(f" {campaign_id}: {', '.join(f'{k}={v}' for k, v in data.items())}") + else: + errors.append(f"{campaign_id}: Update failed") + except (FacebookAPIError, ValueError) as e: + errors.append(f"{campaign_id}: {e}") + return f"Bulk update: {len(results)} ok, {len(errors)} errors\n" + ("\n".join(results) if results else "") + ("\nErrors:\n" + "\n".join(f" {e}" for e in errors) if errors else "") + + +# ── Ad Sets ─────────────────────────────────────────────────────────────────── + +_ADSET_FIELDS = "id,name,status,optimization_goal,billing_event,daily_budget,lifetime_budget,targeting" + + +async def _list_adsets(client: FacebookAdsClient, campaign_id: str) -> str: + if not campaign_id: + return "ERROR: campaign_id required" + if client.is_test_mode: + return f"Ad Sets for Campaign {campaign_id}:\n Test Ad Set (ID: 234567890) - ACTIVE, Daily: 20.00 USD\n" + data = await client.request("GET", f"{campaign_id}/adsets", params={"fields": _ADSET_FIELDS, "limit": 50}) + adsets = data.get("data", []) + if not adsets: + return f"No ad sets found for campaign {campaign_id}" + result = f"Ad Sets for Campaign {campaign_id} ({len(adsets)}):\n\n" + for a in adsets: + result += f" {a['name']} (ID: {a['id']}) - {a['status']}, Opt: {a.get('optimization_goal', 'N/A')}\n" + return result + + +async def _list_adsets_for_account(client: FacebookAdsClient, ad_account_id: str, status_filter: Optional[str]) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Ad Sets for {ad_account_id}:\n Test Ad Set (ID: 234567890) — ACTIVE\n" + params: Dict[str, Any] = {"fields": _ADSET_FIELDS + ",campaign_id", "limit": 100} + if status_filter: + params["filtering"] = json.dumps([{"field": "adset.delivery_info", "operator": "IN", "value": [status_filter.upper()]}]) + data = await client.request("GET", f"{ad_account_id}/adsets", params=params) + adsets = data.get("data", []) + if not adsets: + return f"No ad sets found for {ad_account_id}" + result = f"Ad Sets for {ad_account_id} ({len(adsets)}):\n\n" + for a in adsets: + result += f" {a.get('name', 'Unnamed')} (ID: {a['id']}) - {a.get('status', 'N/A')}\n" + return result + + +async def _get_adset(client: FacebookAdsClient, adset_id: str) -> str: + if not adset_id: + return "ERROR: adset_id required" + if client.is_test_mode: + return f"Ad Set {adset_id}:\n Name: Test Ad Set\n Status: ACTIVE\n Optimization: LINK_CLICKS\n" + data = await client.request("GET", adset_id, params={"fields": "id,name,status,optimization_goal,billing_event,bid_strategy,bid_amount,daily_budget,lifetime_budget,targeting,campaign_id,created_time"}) + result = f"Ad Set {adset_id}:\n" + result += f" Name: {data.get('name', 'N/A')}\n Status: {data.get('status', 'N/A')}\n Campaign: {data.get('campaign_id', 'N/A')}\n" + result += f" Optimization: {data.get('optimization_goal', 'N/A')}\n" + if data.get("daily_budget"): + result += f" Daily Budget: {format_currency(int(data['daily_budget']))}\n" + return result + + +async def _create_adset(client: FacebookAdsClient, ad_account_id: str, campaign_id: str, name: str, targeting: Dict[str, Any], optimization_goal: str, billing_event: str, daily_budget: Optional[int], lifetime_budget: Optional[int], status: str) -> str: + if not ad_account_id or not campaign_id or not name or not targeting: + return "ERROR: ad_account_id, campaign_id, name, targeting all required" + targeting_valid, targeting_error = validate_targeting_spec(targeting) + if not targeting_valid: + return f"ERROR: Invalid targeting: {targeting_error}" + if daily_budget: + try: + daily_budget = validate_budget(daily_budget) + except FacebookValidationError as e: + return f"ERROR: {e.message}" + if client.is_test_mode: + countries = targeting.get('geo_locations', {}).get('countries', ['Not specified']) + return f"Ad Set created!\nID: mock_adset_123\nName: {name}\nCampaign: {campaign_id}\nStatus: {status}\nOptimization: {optimization_goal}\nTargeting: {', '.join(countries)}\n" + form_data: Dict[str, Any] = { + "name": name, "campaign_id": campaign_id, + "optimization_goal": optimization_goal, "billing_event": billing_event, + "bid_strategy": "LOWEST_COST_WITHOUT_CAP", + "targeting": json.dumps(targeting), "status": status, + "access_token": client.access_token, + } + if daily_budget: + form_data["daily_budget"] = str(daily_budget) + if lifetime_budget: + form_data["lifetime_budget"] = str(lifetime_budget) + result = await client.request("POST", f"{ad_account_id}/adsets", form_data=form_data) + adset_id = result.get("id") + if not adset_id: + return f"Failed to create ad set. Response: {result}" + return f"Ad Set created!\nID: {adset_id}\nName: {name}\nCampaign: {campaign_id}\nStatus: {status}\n" + + +async def _update_adset(client: FacebookAdsClient, adset_id: str, name: Optional[str], status: Optional[str], daily_budget: Optional[int], targeting: Optional[Dict[str, Any]]) -> str: + if not adset_id: + return "ERROR: adset_id required" + if not any([name, status, daily_budget, targeting]): + return "ERROR: At least one field to update required" + if status and status not in ["ACTIVE", "PAUSED"]: + return "ERROR: status must be ACTIVE or PAUSED" + if daily_budget: + try: + daily_budget = validate_budget(daily_budget) + except FacebookValidationError as e: + return f"ERROR: {e.message}" + if client.is_test_mode: + return f"Ad Set {adset_id} updated." + data: Dict[str, Any] = {} + if name: + data["name"] = name + if status: + data["status"] = status + if daily_budget is not None: + data["daily_budget"] = daily_budget + result = await client.request("POST", adset_id, data=data) + return f"Ad Set {adset_id} updated." if result.get("success") else f"Failed to update. Response: {result}" + + +async def _delete_adset(client: FacebookAdsClient, adset_id: str) -> str: + if not adset_id: + return "ERROR: adset_id required" + if client.is_test_mode: + return f"Ad Set {adset_id} deleted." + result = await client.request("DELETE", adset_id) + return f"Ad Set {adset_id} deleted." if result.get("success") else f"Failed to delete. Response: {result}" + + +async def _validate_targeting(client: FacebookAdsClient, targeting_spec: Dict[str, Any], ad_account_id: Optional[str]) -> str: + if not targeting_spec: + return "ERROR: targeting_spec required" + valid, error = validate_targeting_spec(targeting_spec) + if not valid: + return f"Invalid targeting: {error}" + if client.is_test_mode: + countries = targeting_spec.get('geo_locations', {}).get('countries', ['N/A']) + return f"Targeting is valid!\nLocations: {', '.join(countries)}\nAge: {targeting_spec.get('age_min', 18)}-{targeting_spec.get('age_max', 65)}\n" + account_id = ad_account_id or client.ad_account_id + if not account_id: + return "ERROR: ad_account_id required for validate_targeting" + result = await client.request("GET", f"{account_id}/targetingsentencelines", params={"targeting_spec": json.dumps(targeting_spec)}) + output = "Targeting is valid!\n" + for line in result.get("targetingsentencelines", []): + output += f" - {line.get('content', '')}\n" + return output + + +# ── Ads ─────────────────────────────────────────────────────────────────────── + +async def _list_ads(client: FacebookAdsClient, ad_account_id: Optional[str], adset_id: Optional[str], status_filter: Optional[str]) -> str: + if not ad_account_id and not adset_id: + return "ERROR: Either ad_account_id or adset_id required" + parent = adset_id if adset_id else ad_account_id + if client.is_test_mode: + return f"Ads for {parent}:\n Test Ad (ID: 111222333) — PAUSED\n" + params: Dict[str, Any] = {"fields": "id,name,status,adset_id,creative{id},effective_status", "limit": 100} + if status_filter: + params["effective_status"] = f'["{status_filter.upper()}"]' + data = await client.request("GET", f"{parent}/ads", params=params) + ads = data.get("data", []) + if not ads: + return f"No ads found for {parent}" + result = f"Ads for {parent} ({len(ads)}):\n\n" + for ad in ads: + result += f" {ad.get('name', 'Unnamed')} (ID: {ad['id']}) - {ad.get('status', 'N/A')}\n" + return result + + +async def _get_ad(client: FacebookAdsClient, ad_id: str) -> str: + if not ad_id: + return "ERROR: ad_id required" + if client.is_test_mode: + return f"Ad {ad_id}:\n Name: Test Ad\n Status: PAUSED\n" + data = await client.request("GET", ad_id, params={"fields": "id,name,status,adset_id,creative,created_time,effective_status"}) + result = f"Ad {ad_id}:\n Name: {data.get('name', 'N/A')}\n Status: {data.get('status', 'N/A')}\n" + result += f" Adset: {data.get('adset_id', 'N/A')}\n" + if data.get("creative"): + result += f" Creative: {data['creative'].get('id', 'N/A')}\n" + return result + + +async def _create_ad(client: FacebookAdsClient, ad_account_id: str, adset_id: str, creative_id: str, name: Optional[str], status: str) -> str: + if not ad_account_id or not adset_id or not creative_id: + return "ERROR: ad_account_id, adset_id, creative_id all required" + if status not in ["ACTIVE", "PAUSED"]: + return "ERROR: status must be ACTIVE or PAUSED" + if client.is_test_mode: + return f"Ad created!\nID: mock_ad_123\nName: {name}\nAdset: {adset_id}\nStatus: {status}\n" + data = {"name": name or "Ad", "adset_id": adset_id, "creative": {"creative_id": creative_id}, "status": status} + result = await client.request("POST", f"{ad_account_id}/ads", data=data) + ad_id = result.get("id") + return f"Ad created!\nID: {ad_id}\nName: {name}\nAdset: {adset_id}\nStatus: {status}\n" if ad_id else f"Failed to create ad. Response: {result}" + + +async def _update_ad(client: FacebookAdsClient, ad_id: str, name: Optional[str], status: Optional[str]) -> str: + if not ad_id: + return "ERROR: ad_id required" + if not any([name, status]): + return "ERROR: name or status required" + if status and status not in ["ACTIVE", "PAUSED", "ARCHIVED", "DELETED"]: + return "ERROR: status must be ACTIVE, PAUSED, ARCHIVED or DELETED" + if client.is_test_mode: + return f"Ad {ad_id} updated." + data: Dict[str, Any] = {} + if name: + data["name"] = name + if status: + data["status"] = status + result = await client.request("POST", ad_id, data=data) + return f"Ad {ad_id} updated." if result.get("success") else f"Failed to update. Response: {result}" + + +async def _delete_ad(client: FacebookAdsClient, ad_id: str) -> str: + if not ad_id: + return "ERROR: ad_id required" + if client.is_test_mode: + return f"Ad {ad_id} deleted." + result = await client.request("DELETE", ad_id) + return f"Ad {ad_id} deleted." if result.get("success") else f"Failed to delete. Response: {result}" + + +async def _preview_ad(client: FacebookAdsClient, ad_id: str, ad_format: str) -> str: + if not ad_id: + return "ERROR: ad_id required" + try: + AdFormat(ad_format) + except ValueError: + return f"ERROR: Invalid ad_format. Must be one of: {', '.join(f.value for f in AdFormat)}" + if client.is_test_mode: + return f"Ad Preview for {ad_id} ({ad_format}):\n Preview URL: https://facebook.com/ads/preview/mock_{ad_id}\n" + data = await client.request("GET", f"{ad_id}/previews", params={"ad_format": ad_format}) + previews = data.get("data", []) + if not previews: + return "No preview available" + body = previews[0].get("body", "") + return f"Ad Preview for {ad_id} ({ad_format}):\n{body[:500]}...\n" if body else f"Preview available but no body. Response: {previews[0]}" + + +# ── Creatives ───────────────────────────────────────────────────────────────── + +async def _list_creatives(client: FacebookAdsClient, ad_account_id: str) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Creatives for {ad_account_id}:\n Test Creative (ID: 987654321)\n" + data = await client.request("GET", f"{ad_account_id}/adcreatives", params={"fields": "id,name,status,object_story_spec", "limit": 100}) + creatives = data.get("data", []) + if not creatives: + return f"No creatives found for {ad_account_id}" + result = f"Ad Creatives for {ad_account_id} ({len(creatives)}):\n\n" + for c in creatives: + result += f" {c.get('name', 'Unnamed')} (ID: {c['id']})\n" + return result + + +async def _get_creative(client: FacebookAdsClient, creative_id: str) -> str: + if not creative_id: + return "ERROR: creative_id required" + if client.is_test_mode: + return f"Creative {creative_id}:\n Name: Test Creative\n Status: ACTIVE\n" + data = await client.request("GET", creative_id, params={"fields": "id,name,status,object_story_spec,call_to_action_type,thumbnail_url,image_hash,video_id"}) + result = f"Creative {creative_id}:\n Name: {data.get('name', 'N/A')}\n Status: {data.get('status', 'N/A')}\n" + if data.get("call_to_action_type"): + result += f" CTA: {data['call_to_action_type']}\n" + return result + + +async def _create_creative(client: FacebookAdsClient, ad_account_id: str, name: str, page_id: str, message: Optional[str], link: Optional[str], image_hash: Optional[str], video_id: Optional[str], call_to_action_type: Optional[str]) -> str: + if not name or not page_id: + return "ERROR: name and page_id required" + if not image_hash and not video_id: + return "ERROR: image_hash or video_id required" + account_id = ad_account_id or client.ad_account_id + if not account_id: + return "ERROR: ad_account_id required" + cta = call_to_action_type or "LEARN_MORE" + try: + CallToActionType(cta) + except ValueError: + return f"ERROR: Invalid call_to_action_type. Must be one of: {', '.join(c.value for c in CallToActionType)}" + if client.is_test_mode: + return f"Creative created!\nID: mock_creative_123\nName: {name}\nPage: {page_id}\nCTA: {cta}\n" + link_data: Dict[str, Any] = {"call_to_action": {"type": cta}} + if image_hash: + link_data["image_hash"] = image_hash + if link: + link_data["link"] = link + if message: + link_data["message"] = message + data: Dict[str, Any] = {"name": name, "object_story_spec": {"page_id": page_id, "link_data": link_data}} + result = await client.request("POST", f"{account_id}/adcreatives", data=data) + creative_id = result.get("id") + return f"Creative created!\nID: {creative_id}\nName: {name}\nPage: {page_id}\nCTA: {cta}\n" if creative_id else f"Failed to create creative. Response: {result}" + + +async def _update_creative(client: FacebookAdsClient, creative_id: str, name: Optional[str]) -> str: + if not creative_id or not name: + return "ERROR: creative_id and name required" + if client.is_test_mode: + return f"Creative {creative_id} updated: name -> {name}" + result = await client.request("POST", creative_id, data={"name": name}) + return f"Creative {creative_id} updated." if result.get("success") else f"Failed. Response: {result}" + + +async def _delete_creative(client: FacebookAdsClient, creative_id: str) -> str: + if not creative_id: + return "ERROR: creative_id required" + if client.is_test_mode: + return f"Creative {creative_id} deleted." + result = await client.request("DELETE", creative_id) + return f"Creative {creative_id} deleted." if result.get("success") else f"Failed. Response: {result}" + + +async def _preview_creative(client: FacebookAdsClient, creative_id: str, ad_format: str) -> str: + if not creative_id: + return "ERROR: creative_id required" + try: + AdFormat(ad_format) + except ValueError: + return f"ERROR: Invalid ad_format. Must be one of: {', '.join(f.value for f in AdFormat)}" + if client.is_test_mode: + return f"Creative Preview for {creative_id} ({ad_format}):\n Preview URL: https://facebook.com/ads/preview/mock_{creative_id}\n" + data = await client.request("GET", f"{creative_id}/previews", params={"ad_format": ad_format}) + previews = data.get("data", []) + if not previews: + return "No preview available" + body = previews[0].get("body", "") + return f"Creative Preview for {creative_id} ({ad_format}):\n{body[:500]}...\n" if body else "Preview available but no body." + + +async def _upload_image(client: FacebookAdsClient, image_path: Optional[str], image_url: Optional[str], ad_account_id: Optional[str]) -> str: + if not image_path and not image_url: + return "ERROR: image_path or image_url required" + account_id = ad_account_id or client.ad_account_id + if not account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Image uploaded!\nImage Hash: mock_abc123\nAccount: {account_id}\n" + endpoint = f"{account_id}/adimages" + if image_url: + result = await client.request("POST", endpoint, form_data={"url": image_url, "access_token": client.access_token}) + else: + image_file = Path(image_path) + if not image_file.exists(): + return f"ERROR: Image file not found: {image_path}" + await client.ensure_auth() + with open(image_file, 'rb') as f: + image_bytes = f.read() + url = f"{API_BASE}/{API_VERSION}/{endpoint}" + async with httpx.AsyncClient() as http_client: + response = await http_client.post(url, data={"access_token": client.access_token}, files={"filename": (image_file.name, image_bytes, "image/jpeg")}, timeout=60.0) + if response.status_code != 200: + return f"ERROR: Failed to upload image: {response.text}" + result = response.json() + images = result.get("images", {}) + if images: + image_hash = list(images.values())[0].get("hash", "unknown") + return f"Image uploaded!\nImage Hash: {image_hash}\nAccount: {account_id}\n" + return f"Failed to upload image. Response: {result}" + + +async def _upload_video(client: FacebookAdsClient, video_url: str, ad_account_id: Optional[str], title: Optional[str], description: Optional[str]) -> str: + if not video_url: + return "ERROR: video_url required" + account_id = ad_account_id or client.ad_account_id + if not account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Video uploaded!\nVideo ID: mock_video_123\nAccount: {account_id}\n" + form_data: Dict[str, Any] = {"file_url": video_url, "access_token": client.access_token} + if title: + form_data["title"] = title + if description: + form_data["description"] = description + result = await client.request("POST", f"{account_id}/advideos", form_data=form_data) + video_id = result.get("id") + return f"Video uploaded!\nVideo ID: {video_id}\nAccount: {account_id}\n" if video_id else f"Failed to upload video. Response: {result}" + + +async def _list_videos(client: FacebookAdsClient, ad_account_id: str) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Videos for {ad_account_id}:\n Test Video (ID: mock_video_123) — READY\n" + data = await client.request("GET", f"{ad_account_id}/advideos", params={"fields": "id,title,description,length,status", "limit": 50}) + videos = data.get("data", []) + if not videos: + return f"No videos found for {ad_account_id}" + result = f"Ad Videos for {ad_account_id} ({len(videos)}):\n\n" + for v in videos: + result += f" {v.get('title', 'Untitled')} (ID: {v['id']}) — {v.get('status', 'N/A')}\n" + return result + + +# ── Audiences ───────────────────────────────────────────────────────────────── + +_AUDIENCE_FIELDS = "id,name,subtype,description,approximate_count,delivery_status,created_time" + + +async def _list_custom_audiences(client: FacebookAdsClient, ad_account_id: str) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Custom Audiences for {ad_account_id}:\n Test Audience (ID: 111222333) — CUSTOM — ~5,000 people\n" + data = await client.request("GET", f"{ad_account_id}/customaudiences", params={"fields": _AUDIENCE_FIELDS, "limit": 100}) + audiences = data.get("data", []) + if not audiences: + return f"No custom audiences found for {ad_account_id}" + result = f"Custom Audiences for {ad_account_id} ({len(audiences)}):\n\n" + for a in audiences: + result += f" {a.get('name', 'Unnamed')} (ID: {a['id']}) — {a.get('subtype', 'N/A')} — ~{a.get('approximate_count', 'Unknown')} people\n" + return result + + +async def _get_custom_audience(client: FacebookAdsClient, audience_id: str) -> str: + if not audience_id: + return "ERROR: audience_id required" + if client.is_test_mode: + return f"Audience {audience_id}:\n Name: Test Audience\n Subtype: CUSTOM\n Size: ~5,000 people\n" + data = await client.request("GET", audience_id, params={"fields": _AUDIENCE_FIELDS + ",rule,lookalike_spec,pixel_id"}) + return f"Audience {audience_id}:\n Name: {data.get('name', 'N/A')}\n Subtype: {data.get('subtype', 'N/A')}\n Size: ~{data.get('approximate_count', 'Unknown')} people\n" + + +async def _create_custom_audience(client: FacebookAdsClient, ad_account_id: str, name: str, subtype: str, description: Optional[str], customer_file_source: Optional[str]) -> str: + if not ad_account_id or not name: + return "ERROR: ad_account_id and name required" + try: + CustomAudienceSubtype(subtype) + except ValueError: + return f"ERROR: Invalid subtype. Must be one of: {', '.join(s.value for s in CustomAudienceSubtype)}" + if client.is_test_mode: + return f"Custom audience created:\n Name: {name}\n ID: mock_audience_123\n Subtype: {subtype}\n" + data: Dict[str, Any] = {"name": name, "subtype": subtype} + if description: + data["description"] = description + if customer_file_source: + data["customer_file_source"] = customer_file_source + result = await client.request("POST", f"{ad_account_id}/customaudiences", data=data) + audience_id = result.get("id") + return f"Audience created:\n Name: {name}\n ID: {audience_id}\n Subtype: {subtype}\n" if audience_id else f"Failed. Response: {result}" + + +async def _create_lookalike_audience(client: FacebookAdsClient, ad_account_id: str, origin_audience_id: str, country: str, ratio: float, name: Optional[str]) -> str: + if not ad_account_id or not origin_audience_id or not country: + return "ERROR: ad_account_id, origin_audience_id, country required" + if not 0.01 <= ratio <= 0.20: + return "ERROR: ratio must be between 0.01 (1%) and 0.20 (20%)" + audience_name = name or f"Lookalike ({country}, {int(ratio*100)}%) of {origin_audience_id}" + if client.is_test_mode: + return f"Lookalike audience created:\n Name: {audience_name}\n ID: mock_lookalike_456\n Country: {country}\n Ratio: {ratio*100:.0f}%\n" + data: Dict[str, Any] = {"name": audience_name, "subtype": "LOOKALIKE", "origin_audience_id": origin_audience_id, "lookalike_spec": {"country": country, "ratio": ratio, "type": "similarity"}} + result = await client.request("POST", f"{ad_account_id}/customaudiences", data=data) + audience_id = result.get("id") + return f"Lookalike created:\n Name: {audience_name}\n ID: {audience_id}\n Country: {country}\n Ratio: {ratio*100:.0f}%\n" if audience_id else f"Failed. Response: {result}" + + +async def _update_custom_audience(client: FacebookAdsClient, audience_id: str, name: Optional[str], description: Optional[str]) -> str: + if not audience_id or not any([name, description]): + return "ERROR: audience_id and at least one field (name, description) required" + if client.is_test_mode: + return f"Audience {audience_id} updated." + data: Dict[str, Any] = {} + if name: + data["name"] = name + if description: + data["description"] = description + result = await client.request("POST", audience_id, data=data) + return f"Audience {audience_id} updated." if result.get("success") else f"Failed. Response: {result}" + + +async def _delete_custom_audience(client: FacebookAdsClient, audience_id: str) -> str: + if not audience_id: + return "ERROR: audience_id required" + if client.is_test_mode: + return f"Audience {audience_id} deleted." + result = await client.request("DELETE", audience_id) + return f"Audience {audience_id} deleted." if result.get("success") else f"Failed. Response: {result}" + + +async def _add_users_to_audience(client: FacebookAdsClient, audience_id: str, emails: List[str], phones: Optional[List[str]]) -> str: + if not audience_id or not emails: + return "ERROR: audience_id and emails required" + import hashlib + hashed_emails = [hashlib.sha256(e.strip().lower().encode()).hexdigest() for e in emails] + schema = ["EMAIL"] + user_data = [[h] for h in hashed_emails] + if phones: + schema = ["EMAIL", "PHONE"] + hashed_phones = [hashlib.sha256(''.join(c for c in p if c.isdigit()).encode()).hexdigest() for p in phones] + user_data = [[e, p] for e, p in zip(hashed_emails, hashed_phones)] + if client.is_test_mode: + return f"Users added to audience {audience_id}:\n Emails: {len(emails)} (SHA-256 hashed)\n" + payload: Dict[str, Any] = {"payload": {"schema": schema, "data": user_data}} + result = await client.request("POST", f"{audience_id}/users", data=payload) + received = result.get("num_received", 0) + invalid = result.get("num_invalid_entries", 0) + return f"Users added to {audience_id}:\n Received: {received}\n Invalid: {invalid}\n Accepted: {received - invalid}\n" + + +# ── Pixels ──────────────────────────────────────────────────────────────────── + +async def _list_pixels(client: FacebookAdsClient, ad_account_id: str) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Pixels for {ad_account_id}:\n Test Pixel (ID: 111222333) — last fired: recently\n" + data = await client.request("GET", f"{ad_account_id}/adspixels", params={"fields": "id,name,creation_time,last_fired_time", "limit": 50}) + pixels = data.get("data", []) + if not pixels: + return f"No pixels found for {ad_account_id}" + result = f"Pixels for {ad_account_id} ({len(pixels)}):\n\n" + for p in pixels: + result += f" {p.get('name', 'Unnamed')} (ID: {p['id']}) — last fired: {p.get('last_fired_time', 'Never')}\n" + return result + + +async def _create_pixel(client: FacebookAdsClient, ad_account_id: str, name: str) -> str: + if not ad_account_id or not name: + return "ERROR: ad_account_id and name required" + if client.is_test_mode: + return f"Pixel created:\n Name: {name}\n ID: mock_pixel_789\n" + result = await client.request("POST", f"{ad_account_id}/adspixels", data={"name": name}) + pixel_id = result.get("id") + return f"Pixel created:\n Name: {name}\n ID: {pixel_id}\n" if pixel_id else f"Failed. Response: {result}" + + +async def _get_pixel_stats(client: FacebookAdsClient, pixel_id: str, start_time: Optional[str], end_time: Optional[str], aggregation: str) -> str: + if not pixel_id: + return "ERROR: pixel_id required" + if aggregation not in ["day", "hour", "week", "month"]: + return "ERROR: aggregation must be day, hour, week or month" + if client.is_test_mode: + return f"Pixel Stats for {pixel_id}:\n PageView: 3,450\n Purchase: 127\n Lead: 89\n" + params: Dict[str, Any] = {"aggregation": aggregation, "fields": "event_name,count", "limit": 200} + if start_time: + params["start_time"] = start_time + if end_time: + params["end_time"] = end_time + data = await client.request("GET", f"{pixel_id}/stats", params=params) + stats = data.get("data", []) + if not stats: + return f"No stats found for pixel {pixel_id}" + totals: Dict[str, int] = {} + for entry in stats: + event = entry.get("event_name", "Unknown") + totals[event] = totals.get(event, 0) + int(entry.get("count", 0)) + result = f"Pixel Stats for {pixel_id}:\n\n" + for event, count in sorted(totals.items(), key=lambda x: -x[1]): + result += f" {event}: {count:,} events\n" + return result + + +# ── Targeting ───────────────────────────────────────────────────────────────── + +async def _search_interests(client: FacebookAdsClient, q: str, limit: int) -> str: + if not q: + return "ERROR: q (search query) required" + if client.is_test_mode: + return f"Interests matching '{q}':\n Travel (ID: 6003263) — ~600M people\n" + data = await client.request("GET", "search", params={"type": "adinterest", "q": q, "limit": min(limit, 50), "locale": "en_US"}) + items = data.get("data", []) + if not items: + return f"No interests found matching '{q}'" + result = f"Interests matching '{q}' ({len(items)}):\n\n" + for item in items: + audience_size = item.get("audience_size", "Unknown") + result += f" {item.get('name', 'N/A')} (ID: {item.get('id', 'N/A')}) — ~{audience_size:,} people\n" if isinstance(audience_size, int) else f" {item.get('name', 'N/A')} (ID: {item.get('id', 'N/A')})\n" + return result + + +async def _search_behaviors(client: FacebookAdsClient, q: str, limit: int) -> str: + if not q: + return "ERROR: q (search query) required" + if client.is_test_mode: + return f"Behaviors matching '{q}':\n Frequent Travelers (ID: 6002714) — ~120M people\n" + data = await client.request("GET", "search", params={"type": "adbehavior", "q": q, "limit": min(limit, 50), "locale": "en_US"}) + items = data.get("data", []) + if not items: + return f"No behaviors found matching '{q}'" + result = f"Behaviors matching '{q}' ({len(items)}):\n\n" + for item in items: + result += f" {item.get('name', 'N/A')} (ID: {item.get('id', 'N/A')})\n" + return result + + +async def _get_reach_estimate(client: FacebookAdsClient, ad_account_id: str, targeting: Dict[str, Any], optimization_goal: str) -> str: + if not ad_account_id or not targeting: + return "ERROR: ad_account_id and targeting required" + if client.is_test_mode: + return f"Reach Estimate for {ad_account_id}:\n Estimated Audience: 1,200,000 — 1,800,000 people\n" + data = await client.request("GET", f"{ad_account_id}/reachestimate", params={"targeting_spec": json.dumps(targeting), "optimization_goal": optimization_goal}) + users = data.get("users", "Unknown") + return f"Reach Estimate:\n Audience: {users:,} people\n Goal: {optimization_goal}\n" if isinstance(users, int) else f"Reach Estimate:\n Audience: {users}\n Goal: {optimization_goal}\n" + + +async def _get_delivery_estimate(client: FacebookAdsClient, ad_account_id: str, targeting: Dict[str, Any], optimization_goal: str, bid_amount: Optional[int]) -> str: + if not ad_account_id or not targeting: + return "ERROR: ad_account_id and targeting required" + if client.is_test_mode: + return f"Delivery Estimate for {ad_account_id}:\n Daily Min Spend: $5.00\n Daily Max Spend: $50.00\n" + params: Dict[str, Any] = {"targeting_spec": json.dumps(targeting), "optimization_goal": optimization_goal} + if bid_amount: + params["bid_amount"] = bid_amount + data = await client.request("GET", f"{ad_account_id}/delivery_estimate", params=params) + estimates = data.get("data", []) + if not estimates: + return f"No delivery estimates found for {ad_account_id}" + result = f"Delivery Estimate for {ad_account_id}:\n\n" + for est in estimates: + daily = est.get("daily_outcomes_curve", []) + if daily: + first, last = daily[0], daily[-1] + result += f" Daily Spend: ${first.get('spend', 0)/100:.2f} — ${last.get('spend', 0)/100:.2f}\n" + result += f" Daily Reach: {first.get('reach', 0):,} — {last.get('reach', 0):,}\n" + return result + + +# ── Rules ───────────────────────────────────────────────────────────────────── + +async def _list_ad_rules(client: FacebookAdsClient, ad_account_id: str) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Ad Rules for {ad_account_id}:\n Pause on low CTR (ID: 111222) — ENABLED\n" + data = await client.request("GET", f"{ad_account_id}/adrules_library", params={"fields": "id,name,status,execution_spec", "limit": 50}) + rules = data.get("data", []) + if not rules: + return f"No ad rules found for {ad_account_id}" + result = f"Ad Rules for {ad_account_id} ({len(rules)}):\n\n" + for rule in rules: + result += f" {rule.get('name', 'Unnamed')} (ID: {rule['id']}) — {rule.get('status', 'N/A')}\n" + return result + + +async def _create_ad_rule(client: FacebookAdsClient, ad_account_id: str, name: str, evaluation_spec: Dict[str, Any], execution_spec: Dict[str, Any], schedule_spec: Optional[Dict[str, Any]], status: str) -> str: + if not ad_account_id or not name or not evaluation_spec or not execution_spec: + return "ERROR: ad_account_id, name, evaluation_spec, execution_spec all required" + if client.is_test_mode: + return f"Ad rule created:\n Name: {name}\n ID: mock_rule_123\n Status: {status}\n" + data: Dict[str, Any] = {"name": name, "evaluation_spec": evaluation_spec, "execution_spec": execution_spec, "status": status} + if schedule_spec: + data["schedule_spec"] = schedule_spec + result = await client.request("POST", f"{ad_account_id}/adrules_library", data=data) + rule_id = result.get("id") + return f"Ad rule created:\n Name: {name}\n ID: {rule_id}\n Status: {status}\n" if rule_id else f"Failed. Response: {result}" + + +async def _update_ad_rule(client: FacebookAdsClient, rule_id: str, name: Optional[str], status: Optional[str]) -> str: + if not rule_id or not any([name, status]): + return "ERROR: rule_id and at least one field (name, status) required" + if status and status not in ["ENABLED", "DISABLED", "DELETED"]: + return "ERROR: status must be ENABLED, DISABLED or DELETED" + if client.is_test_mode: + return f"Ad rule {rule_id} updated." + data: Dict[str, Any] = {} + if name: + data["name"] = name + if status: + data["status"] = status + result = await client.request("POST", rule_id, data=data) + return f"Ad rule {rule_id} updated." if result.get("success") else f"Failed. Response: {result}" + + +async def _delete_ad_rule(client: FacebookAdsClient, rule_id: str) -> str: + if not rule_id: + return "ERROR: rule_id required" + if client.is_test_mode: + return f"Ad rule {rule_id} deleted." + result = await client.request("DELETE", rule_id) + return f"Ad rule {rule_id} deleted." if result.get("success") else f"Failed. Response: {result}" + + +async def _execute_ad_rule(client: FacebookAdsClient, rule_id: str) -> str: + if not rule_id: + return "ERROR: rule_id required" + if client.is_test_mode: + return f"Ad rule {rule_id} executed." + result = await client.request("POST", f"{rule_id}/execute", data={}) + return f"Ad rule {rule_id} executed." if result.get("success") else f"Failed. Response: {result}" + + +# ── Dispatch table ──────────────────────────────────────────────────────────── + _HANDLERS: Dict[str, Any] = { - "list_campaigns": lambda c, a: list_campaigns(c, a.get("ad_account_id"), a.get("status")), - "get_campaign": lambda c, a: get_campaign(c, a.get("campaign_id", "")), - "create_campaign": lambda c, a: create_campaign( - c, a.get("ad_account_id", ""), a.get("name", ""), - a.get("objective", "OUTCOME_AWARENESS"), - a.get("daily_budget"), a.get("lifetime_budget"), - a.get("status", "PAUSED"), - ), - "update_campaign": lambda c, a: update_campaign( - c, a.get("campaign_id", ""), a.get("name"), a.get("status"), - a.get("daily_budget"), a.get("lifetime_budget"), - ), - "delete_campaign": lambda c, a: delete_campaign(c, a.get("campaign_id", "")), - "duplicate_campaign": lambda c, a: duplicate_campaign(c, a.get("campaign_id", ""), a.get("new_name", ""), a.get("ad_account_id")), - "archive_campaign": lambda c, a: archive_campaign(c, a.get("campaign_id", "")), - "bulk_update_campaigns": lambda c, a: bulk_update_campaigns(c, a.get("campaigns", [])), - "list_adsets": lambda c, a: list_adsets(c, a.get("campaign_id", "")), - "list_adsets_for_account": lambda c, a: list_adsets_for_account(c, a.get("ad_account_id", ""), a.get("status_filter")), - "get_adset": lambda c, a: get_adset(c, a.get("adset_id", "")), - "create_adset": lambda c, a: create_adset( - c, a.get("ad_account_id", ""), a.get("campaign_id", ""), - a.get("name", ""), a.get("targeting", {}), - a.get("optimization_goal"), a.get("billing_event"), - a.get("daily_budget"), a.get("lifetime_budget"), - a.get("status", "PAUSED"), - ), - "update_adset": lambda c, a: update_adset(c, a.get("adset_id", ""), a.get("name"), a.get("status"), a.get("daily_budget"), a.get("targeting")), - "delete_adset": lambda c, a: delete_adset(c, a.get("adset_id", "")), - "validate_targeting": lambda c, a: validate_targeting(c, a.get("targeting_spec", a.get("targeting", {})), a.get("ad_account_id")), - "list_ads": lambda c, a: list_ads(c, a.get("ad_account_id"), a.get("adset_id"), a.get("status_filter")), - "get_ad": lambda c, a: get_ad(c, a.get("ad_id", "")), - "create_ad": lambda c, a: create_ad(c, a.get("ad_account_id", ""), a.get("adset_id", ""), a.get("creative_id", ""), a.get("name"), a.get("status", "PAUSED")), - "update_ad": lambda c, a: update_ad(c, a.get("ad_id", ""), a.get("name"), a.get("status")), - "delete_ad": lambda c, a: delete_ad(c, a.get("ad_id", "")), - "preview_ad": lambda c, a: preview_ad(c, a.get("ad_id", ""), a.get("ad_format", "DESKTOP_FEED_STANDARD")), - "list_creatives": lambda c, a: list_creatives(c, a.get("ad_account_id", "")), - "get_creative": lambda c, a: get_creative(c, a.get("creative_id", "")), - "create_creative": lambda c, a: create_creative( - c, a.get("ad_account_id", ""), a.get("name", ""), - a.get("page_id", ""), a.get("message"), a.get("link"), - a.get("image_hash"), a.get("video_id"), a.get("call_to_action_type"), - ), - "update_creative": lambda c, a: update_creative(c, a.get("creative_id", ""), a.get("name")), - "delete_creative": lambda c, a: delete_creative(c, a.get("creative_id", "")), - "preview_creative": lambda c, a: preview_creative(c, a.get("creative_id", ""), a.get("ad_format", "DESKTOP_FEED_STANDARD")), - "upload_image": lambda c, a: upload_image(c, a.get("image_path"), a.get("image_url"), a.get("ad_account_id")), - "upload_video": lambda c, a: upload_video(c, a.get("video_url", ""), a.get("ad_account_id"), a.get("title"), a.get("description")), - "list_videos": lambda c, a: list_videos(c, a.get("ad_account_id", "")), - "list_custom_audiences": lambda c, a: list_custom_audiences(c, a.get("ad_account_id", "")), - "get_custom_audience": lambda c, a: get_custom_audience(c, a.get("audience_id", "")), - "create_custom_audience": lambda c, a: create_custom_audience(c, a.get("ad_account_id", ""), a.get("name", ""), a.get("subtype", "CUSTOM"), a.get("description"), a.get("customer_file_source")), - "create_lookalike_audience": lambda c, a: create_lookalike_audience(c, a.get("ad_account_id", ""), a.get("origin_audience_id", ""), a.get("country", ""), float(a.get("ratio", 0.01)), a.get("name")), - "update_custom_audience": lambda c, a: update_custom_audience(c, a.get("audience_id", ""), a.get("name"), a.get("description")), - "delete_custom_audience": lambda c, a: delete_custom_audience(c, a.get("audience_id", "")), - "add_users_to_audience": lambda c, a: add_users_to_audience(c, a.get("audience_id", ""), a.get("emails", []), a.get("phones")), - "list_pixels": lambda c, a: list_pixels(c, a.get("ad_account_id", "")), - "create_pixel": lambda c, a: create_pixel(c, a.get("ad_account_id", ""), a.get("name", "")), - "get_pixel_stats": lambda c, a: get_pixel_stats(c, a.get("pixel_id", ""), a.get("start_time"), a.get("end_time"), a.get("aggregation", "day")), - "search_interests": lambda c, a: search_interests(c, a.get("q", ""), int(a.get("limit", 20))), - "search_behaviors": lambda c, a: search_behaviors(c, a.get("q", ""), int(a.get("limit", 20))), - "get_reach_estimate": lambda c, a: get_reach_estimate(c, a.get("ad_account_id", ""), a.get("targeting", {}), a.get("optimization_goal", "LINK_CLICKS")), - "get_delivery_estimate": lambda c, a: get_delivery_estimate(c, a.get("ad_account_id", ""), a.get("targeting", {}), a.get("optimization_goal", "LINK_CLICKS"), a.get("bid_amount")), - "list_ad_rules": lambda c, a: list_ad_rules(c, a.get("ad_account_id", "")), - "create_ad_rule": lambda c, a: create_ad_rule(c, a.get("ad_account_id", ""), a.get("name", ""), a.get("evaluation_spec", {}), a.get("execution_spec", {}), a.get("schedule_spec"), a.get("status", "ENABLED")), - "update_ad_rule": lambda c, a: update_ad_rule(c, a.get("rule_id", ""), a.get("name"), a.get("status")), - "delete_ad_rule": lambda c, a: delete_ad_rule(c, a.get("rule_id", "")), - "execute_ad_rule": lambda c, a: execute_ad_rule(c, a.get("rule_id", "")), + "list_campaigns": lambda c, a: _list_campaigns(c, a.get("ad_account_id"), a.get("status")), + "get_campaign": lambda c, a: _get_campaign(c, a.get("campaign_id", "")), + "create_campaign": lambda c, a: _create_campaign(c, a.get("ad_account_id", ""), a.get("name", ""), a.get("objective", "OUTCOME_AWARENESS"), a.get("daily_budget"), a.get("lifetime_budget"), a.get("status", "PAUSED")), + "update_campaign": lambda c, a: _update_campaign(c, a.get("campaign_id", ""), a.get("name"), a.get("status"), a.get("daily_budget"), a.get("lifetime_budget")), + "delete_campaign": lambda c, a: _delete_campaign(c, a.get("campaign_id", "")), + "duplicate_campaign": lambda c, a: _duplicate_campaign(c, a.get("campaign_id", ""), a.get("new_name", ""), a.get("ad_account_id")), + "archive_campaign": lambda c, a: _archive_campaign(c, a.get("campaign_id", "")), + "bulk_update_campaigns": lambda c, a: _bulk_update_campaigns(c, a.get("campaigns", [])), + "list_adsets": lambda c, a: _list_adsets(c, a.get("campaign_id", "")), + "list_adsets_for_account": lambda c, a: _list_adsets_for_account(c, a.get("ad_account_id", ""), a.get("status_filter")), + "get_adset": lambda c, a: _get_adset(c, a.get("adset_id", "")), + "create_adset": lambda c, a: _create_adset(c, a.get("ad_account_id", ""), a.get("campaign_id", ""), a.get("name", ""), a.get("targeting", {}), a.get("optimization_goal", "LINK_CLICKS"), a.get("billing_event", "IMPRESSIONS"), a.get("daily_budget"), a.get("lifetime_budget"), a.get("status", "PAUSED")), + "update_adset": lambda c, a: _update_adset(c, a.get("adset_id", ""), a.get("name"), a.get("status"), a.get("daily_budget"), a.get("targeting")), + "delete_adset": lambda c, a: _delete_adset(c, a.get("adset_id", "")), + "validate_targeting": lambda c, a: _validate_targeting(c, a.get("targeting_spec", a.get("targeting", {})), a.get("ad_account_id")), + "list_ads": lambda c, a: _list_ads(c, a.get("ad_account_id"), a.get("adset_id"), a.get("status_filter")), + "get_ad": lambda c, a: _get_ad(c, a.get("ad_id", "")), + "create_ad": lambda c, a: _create_ad(c, a.get("ad_account_id", ""), a.get("adset_id", ""), a.get("creative_id", ""), a.get("name"), a.get("status", "PAUSED")), + "update_ad": lambda c, a: _update_ad(c, a.get("ad_id", ""), a.get("name"), a.get("status")), + "delete_ad": lambda c, a: _delete_ad(c, a.get("ad_id", "")), + "preview_ad": lambda c, a: _preview_ad(c, a.get("ad_id", ""), a.get("ad_format", "DESKTOP_FEED_STANDARD")), + "list_creatives": lambda c, a: _list_creatives(c, a.get("ad_account_id", "")), + "get_creative": lambda c, a: _get_creative(c, a.get("creative_id", "")), + "create_creative": lambda c, a: _create_creative(c, a.get("ad_account_id", ""), a.get("name", ""), a.get("page_id", ""), a.get("message"), a.get("link"), a.get("image_hash"), a.get("video_id"), a.get("call_to_action_type")), + "update_creative": lambda c, a: _update_creative(c, a.get("creative_id", ""), a.get("name")), + "delete_creative": lambda c, a: _delete_creative(c, a.get("creative_id", "")), + "preview_creative": lambda c, a: _preview_creative(c, a.get("creative_id", ""), a.get("ad_format", "DESKTOP_FEED_STANDARD")), + "upload_image": lambda c, a: _upload_image(c, a.get("image_path"), a.get("image_url"), a.get("ad_account_id")), + "upload_video": lambda c, a: _upload_video(c, a.get("video_url", ""), a.get("ad_account_id"), a.get("title"), a.get("description")), + "list_videos": lambda c, a: _list_videos(c, a.get("ad_account_id", "")), + "list_custom_audiences": lambda c, a: _list_custom_audiences(c, a.get("ad_account_id", "")), + "get_custom_audience": lambda c, a: _get_custom_audience(c, a.get("audience_id", "")), + "create_custom_audience": lambda c, a: _create_custom_audience(c, a.get("ad_account_id", ""), a.get("name", ""), a.get("subtype", "CUSTOM"), a.get("description"), a.get("customer_file_source")), + "create_lookalike_audience": lambda c, a: _create_lookalike_audience(c, a.get("ad_account_id", ""), a.get("origin_audience_id", ""), a.get("country", ""), float(a.get("ratio", 0.01)), a.get("name")), + "update_custom_audience": lambda c, a: _update_custom_audience(c, a.get("audience_id", ""), a.get("name"), a.get("description")), + "delete_custom_audience": lambda c, a: _delete_custom_audience(c, a.get("audience_id", "")), + "add_users_to_audience": lambda c, a: _add_users_to_audience(c, a.get("audience_id", ""), a.get("emails", []), a.get("phones")), + "list_pixels": lambda c, a: _list_pixels(c, a.get("ad_account_id", "")), + "create_pixel": lambda c, a: _create_pixel(c, a.get("ad_account_id", ""), a.get("name", "")), + "get_pixel_stats": lambda c, a: _get_pixel_stats(c, a.get("pixel_id", ""), a.get("start_time"), a.get("end_time"), a.get("aggregation", "day")), + "search_interests": lambda c, a: _search_interests(c, a.get("q", ""), int(a.get("limit", 20))), + "search_behaviors": lambda c, a: _search_behaviors(c, a.get("q", ""), int(a.get("limit", 20))), + "get_reach_estimate": lambda c, a: _get_reach_estimate(c, a.get("ad_account_id", ""), a.get("targeting", {}), a.get("optimization_goal", "LINK_CLICKS")), + "get_delivery_estimate": lambda c, a: _get_delivery_estimate(c, a.get("ad_account_id", ""), a.get("targeting", {}), a.get("optimization_goal", "LINK_CLICKS"), a.get("bid_amount")), + "list_ad_rules": lambda c, a: _list_ad_rules(c, a.get("ad_account_id", "")), + "create_ad_rule": lambda c, a: _create_ad_rule(c, a.get("ad_account_id", ""), a.get("name", ""), a.get("evaluation_spec", {}), a.get("execution_spec", {}), a.get("schedule_spec"), a.get("status", "ENABLED")), + "update_ad_rule": lambda c, a: _update_ad_rule(c, a.get("rule_id", ""), a.get("name"), a.get("status")), + "delete_ad_rule": lambda c, a: _delete_ad_rule(c, a.get("rule_id", "")), + "execute_ad_rule": lambda c, a: _execute_ad_rule(c, a.get("rule_id", "")), } +# ── Integration class ───────────────────────────────────────────────────────── + class IntegrationMetaMarketingManage: - # Wraps FacebookAdsClient and delegates all manage operations through _HANDLERS. - # Uses OAuth token from rcx (Flexus stores the Meta access token per persona). def __init__(self, rcx: "ckit_bot_exec.RobotContext"): self.client = FacebookAdsClient(rcx.fclient, rcx) - async def called_by_model( - self, - toolcall: ckit_cloudtool.FCloudtoolCall, - model_produced_args: Dict[str, Any], - ) -> str: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: try: args = model_produced_args or {} op = str(args.get("op", "help")).strip() @@ -212,7 +999,7 @@ async def called_by_model( return "Error: args.method_id required for op=call." handler = _HANDLERS.get(method_id) if handler is None: - return f"Error: unknown method_id={method_id!r}. Use op=list_methods to see available methods." + return f"Error: unknown method_id={method_id!r}. Use op=list_methods." return await handler(self.client, call_args) except FacebookAuthError as e: return e.message diff --git a/flexus_client_kit/integrations/fi_meta_marketing_metrics.py b/flexus_client_kit/integrations/fi_meta_marketing_metrics.py index 840500ed..c8b2f97d 100644 --- a/flexus_client_kit/integrations/fi_meta_marketing_metrics.py +++ b/flexus_client_kit/integrations/fi_meta_marketing_metrics.py @@ -1,18 +1,15 @@ from __future__ import annotations import logging -from typing import Any, Dict, TYPE_CHECKING +from typing import Any, Dict, List, Optional, TYPE_CHECKING from flexus_client_kit import ckit_cloudtool -from flexus_client_kit.integrations.facebook.client import FacebookAdsClient -from flexus_client_kit.integrations.facebook.exceptions import ( +from flexus_client_kit.integrations._fi_meta_helpers import ( + FacebookAdsClient, FacebookAPIError, FacebookAuthError, FacebookValidationError, -) -from flexus_client_kit.integrations.facebook.insights import ( - get_account_insights, get_campaign_insights, get_adset_insights, get_ad_insights, - create_async_report, get_async_report_status, + InsightsDatePreset, ) if TYPE_CHECKING: @@ -21,7 +18,6 @@ logger = logging.getLogger("meta_marketing_metrics") # Use case: "Measure ad performance data with Marketing API" -# Covers all insights endpoints: account, campaign, ad set, ad level, async reports. PROVIDER_NAME = "meta_marketing_metrics" _HELP = """meta_marketing_metrics: Measure ad performance with Meta Marketing API. @@ -35,26 +31,145 @@ get_async_report_status(report_run_id) """ +_DEFAULT_METRICS = "impressions,clicks,spend,reach,frequency,ctr,cpc,cpm,actions,cost_per_action_type" + + +def _days_to_preset(days: int) -> str: + if days <= 1: + return InsightsDatePreset.TODAY.value + if days <= 7: + return InsightsDatePreset.LAST_7D.value + if days <= 14: + return InsightsDatePreset.LAST_14D.value + if days <= 28: + return InsightsDatePreset.LAST_28D.value + if days <= 30: + return InsightsDatePreset.LAST_30D.value + if days <= 90: + return InsightsDatePreset.LAST_90D.value + return InsightsDatePreset.MAXIMUM.value + + +def _format_insights(data: Dict[str, Any], label: str) -> str: + result = f"Insights for {label}:\n\n" + items = data.get("data", []) + if not items: + return f"No insights data found for {label}\n" + for item in items: + result += f" Date: {item.get('date_start', 'N/A')} - {item.get('date_stop', 'N/A')}\n" + result += f" Impressions: {item.get('impressions', '0')}\n" + result += f" Clicks: {item.get('clicks', '0')}\n" + result += f" Spend: ${item.get('spend', '0')}\n" + result += f" Reach: {item.get('reach', '0')}\n" + result += f" CTR: {item.get('ctr', '0')}%\n" + result += f" CPC: ${item.get('cpc', '0')}\n" + result += f" CPM: ${item.get('cpm', '0')}\n" + actions = item.get("actions", []) + if actions: + result += " Actions:\n" + for action in actions[:5]: + result += f" - {action.get('action_type', 'N/A')}: {action.get('value', '0')}\n" + result += "\n" + return result + + +async def _get_account_insights(client: FacebookAdsClient, ad_account_id: str, days: int, breakdowns: Optional[List[str]], metrics: Optional[str], date_preset: Optional[str]) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + if client.is_test_mode: + return f"Account Insights for {ad_account_id} (last {days} days):\n Impressions: 15,000\n Clicks: 450\n Spend: $120.00\n CTR: 3.0%\n" + params: Dict[str, Any] = {"fields": metrics or _DEFAULT_METRICS, "level": "account", "limit": 50} + params["date_preset"] = date_preset or _days_to_preset(days) + if breakdowns: + params["breakdowns"] = ",".join(breakdowns) + data = await client.request("GET", f"{ad_account_id}/insights", params=params) + return _format_insights(data, ad_account_id) + + +async def _get_campaign_insights(client: FacebookAdsClient, campaign_id: str, days: int, breakdowns: Optional[List[str]], metrics: Optional[str], date_preset: Optional[str]) -> str: + if not campaign_id: + return "ERROR: campaign_id required" + if client.is_test_mode: + return f"Campaign Insights for {campaign_id} (last {days} days):\n Impressions: 10,000\n Clicks: 300\n Spend: $80.00\n CTR: 3.0%\n" + params: Dict[str, Any] = {"fields": metrics or _DEFAULT_METRICS, "limit": 50} + params["date_preset"] = date_preset or _days_to_preset(days) + if breakdowns: + params["breakdowns"] = ",".join(breakdowns) + data = await client.request("GET", f"{campaign_id}/insights", params=params) + return _format_insights(data, campaign_id) + + +async def _get_adset_insights(client: FacebookAdsClient, adset_id: str, days: int, breakdowns: Optional[List[str]], metrics: Optional[str], date_preset: Optional[str]) -> str: + if not adset_id: + return "ERROR: adset_id required" + if client.is_test_mode: + return f"Ad Set Insights for {adset_id} (last {days} days):\n Impressions: 5,000\n Clicks: 150\n Spend: $40.00\n" + params: Dict[str, Any] = {"fields": metrics or _DEFAULT_METRICS, "limit": 50} + params["date_preset"] = date_preset or _days_to_preset(days) + if breakdowns: + params["breakdowns"] = ",".join(breakdowns) + data = await client.request("GET", f"{adset_id}/insights", params=params) + return _format_insights(data, adset_id) + + +async def _get_ad_insights(client: FacebookAdsClient, ad_id: str, days: int, breakdowns: Optional[List[str]], metrics: Optional[str], date_preset: Optional[str]) -> str: + if not ad_id: + return "ERROR: ad_id required" + if client.is_test_mode: + return f"Ad Insights for {ad_id} (last {days} days):\n Impressions: 2,000\n Clicks: 60\n Spend: $15.00\n" + params: Dict[str, Any] = {"fields": metrics or _DEFAULT_METRICS, "limit": 50} + params["date_preset"] = date_preset or _days_to_preset(days) + if breakdowns: + params["breakdowns"] = ",".join(breakdowns) + data = await client.request("GET", f"{ad_id}/insights", params=params) + return _format_insights(data, ad_id) + + +async def _create_async_report(client: FacebookAdsClient, ad_account_id: str, level: str, fields: Optional[str], date_preset: str, breakdowns: Optional[List[str]]) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + valid_levels = ["account", "campaign", "adset", "ad"] + if level not in valid_levels: + return f"ERROR: level must be one of: {', '.join(valid_levels)}" + if client.is_test_mode: + return f"Async report created:\n Report Run ID: mock_report_run_123\n Level: {level}\n Use get_async_report_status to check progress.\n" + data: Dict[str, Any] = {"level": level, "fields": fields or _DEFAULT_METRICS, "date_preset": date_preset} + if breakdowns: + data["breakdowns"] = ",".join(breakdowns) + result = await client.request("POST", f"{ad_account_id}/insights", data=data) + report_run_id = result.get("report_run_id") + return f"Async report created:\n Report Run ID: {report_run_id}\n Level: {level}\n Date Preset: {date_preset}\n" if report_run_id else f"Failed to create report. Response: {result}" + + +async def _get_async_report_status(client: FacebookAdsClient, report_run_id: str) -> str: + if not report_run_id: + return "ERROR: report_run_id required" + if client.is_test_mode: + return f"Report {report_run_id}: Job Completed (100%)\n" + data = await client.request("GET", report_run_id, params={"fields": "id,async_status,async_percent_completion,date_start,date_stop"}) + status = data.get("async_status", "Unknown") + pct = data.get("async_percent_completion", 0) + result = f"Report {report_run_id}:\n Status: {status} ({pct}%)\n" + if data.get("date_start"): + result += f" Date Range: {data['date_start']} - {data.get('date_stop', 'N/A')}\n" + return result + + _HANDLERS: Dict[str, Any] = { - "get_account_insights": lambda c, a: get_account_insights(c, a.get("ad_account_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), - "get_campaign_insights": lambda c, a: get_campaign_insights(c, a.get("campaign_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), - "get_adset_insights": lambda c, a: get_adset_insights(c, a.get("adset_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), - "get_ad_insights": lambda c, a: get_ad_insights(c, a.get("ad_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), - "create_async_report": lambda c, a: create_async_report(c, a.get("ad_account_id", ""), a.get("level", "campaign"), a.get("fields"), a.get("date_preset", "last_30d"), a.get("breakdowns")), - "get_async_report_status": lambda c, a: get_async_report_status(c, a.get("report_run_id", "")), + "get_account_insights": lambda c, a: _get_account_insights(c, a.get("ad_account_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), + "get_campaign_insights": lambda c, a: _get_campaign_insights(c, a.get("campaign_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), + "get_adset_insights": lambda c, a: _get_adset_insights(c, a.get("adset_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), + "get_ad_insights": lambda c, a: _get_ad_insights(c, a.get("ad_id", ""), int(a.get("days", 30)), a.get("breakdowns"), a.get("metrics"), a.get("date_preset")), + "create_async_report": lambda c, a: _create_async_report(c, a.get("ad_account_id", ""), a.get("level", "campaign"), a.get("fields"), a.get("date_preset", "last_30d"), a.get("breakdowns")), + "get_async_report_status": lambda c, a: _get_async_report_status(c, a.get("report_run_id", "")), } class IntegrationMetaMarketingMetrics: - # Wraps FacebookAdsClient and delegates all insights/metrics operations. def __init__(self, rcx: "ckit_bot_exec.RobotContext"): self.client = FacebookAdsClient(rcx.fclient, rcx) - async def called_by_model( - self, - toolcall: ckit_cloudtool.FCloudtoolCall, - model_produced_args: Dict[str, Any], - ) -> str: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: try: args = model_produced_args or {} op = str(args.get("op", "help")).strip() @@ -72,7 +187,7 @@ async def called_by_model( return "Error: args.method_id required for op=call." handler = _HANDLERS.get(method_id) if handler is None: - return f"Error: unknown method_id={method_id!r}. Use op=list_methods to see available methods." + return f"Error: unknown method_id={method_id!r}. Use op=list_methods." return await handler(self.client, call_args) except FacebookAuthError as e: return e.message diff --git a/flexus_client_kit/integrations/fi_meta_pages.py b/flexus_client_kit/integrations/fi_meta_pages.py index 903e1e0b..96d29542 100644 --- a/flexus_client_kit/integrations/fi_meta_pages.py +++ b/flexus_client_kit/integrations/fi_meta_pages.py @@ -1,18 +1,17 @@ from __future__ import annotations import logging -from typing import Any, Dict, TYPE_CHECKING +from typing import Any, Dict, List, TYPE_CHECKING from flexus_client_kit import ckit_cloudtool -from flexus_client_kit.integrations.facebook.client import FacebookAdsClient -from flexus_client_kit.integrations.facebook.exceptions import ( +from flexus_client_kit.integrations._fi_meta_helpers import ( + FacebookAdsClient, FacebookAPIError, FacebookAuthError, FacebookValidationError, -) -from flexus_client_kit.integrations.facebook.accounts import ( - list_ad_accounts, get_ad_account_info, update_spending_limit, - list_account_users, list_pages, + format_currency, + format_account_status, + validate_ad_account_id, ) if TYPE_CHECKING: @@ -20,39 +19,157 @@ logger = logging.getLogger("meta_pages") -# Use case: "Manage everything on your Page" -# Covers Facebook Pages, ad accounts, users — the account-level layer above campaigns. +# Use case: "Manage Facebook Pages, ad accounts, users" PROVIDER_NAME = "meta_pages" _HELP = """meta_pages: Manage Facebook Pages and ad accounts. op=help | status | list_methods | call(args={method_id, ...}) - list_pages() -- Facebook Pages you manage (needed for ad creatives) - list_ad_accounts() -- All ad accounts accessible with your token + list_ad_accounts() get_ad_account_info(ad_account_id) update_spending_limit(ad_account_id, spending_limit) list_account_users(ad_account_id) + list_pages() """ +_AD_ACCOUNT_FIELDS = "id,account_id,name,currency,timezone_name,account_status,balance,amount_spent,spend_cap,business{id,name}" +_AD_ACCOUNT_DETAIL_FIELDS = "id,account_id,name,currency,timezone_name,account_status,balance,amount_spent,spend_cap,business,funding_source_details,min_daily_budget,created_time" + + +async def _list_ad_accounts(client: FacebookAdsClient) -> str: + if client.is_test_mode: + return "Found 1 ad account:\n Test Ad Account (ID: act_MOCK_TEST_000) — USD — Active\n" + data = await client.request("GET", "me/adaccounts", params={"fields": _AD_ACCOUNT_FIELDS, "limit": 50}) + accounts = data.get("data", []) + if not accounts: + return "No ad accounts found. You may need to create one in Facebook Business Manager." + business_accounts: Dict[str, List[Any]] = {} + personal_accounts: List[Any] = [] + for acc in accounts: + business = acc.get("business") + if business: + biz_name = business.get("name", f"Business {business.get('id', 'Unknown')}") + if biz_name not in business_accounts: + business_accounts[biz_name] = [] + business_accounts[biz_name].append(acc) + else: + personal_accounts.append(acc) + result = f"Found {len(accounts)} ad account{'s' if len(accounts) != 1 else ''}:\n\n" + for biz_name, biz_accounts in business_accounts.items(): + result += f"Business: {biz_name} ({len(biz_accounts)} accounts)\n" + for acc in biz_accounts: + result += _format_account_summary(acc) + if personal_accounts: + result += f"Personal Accounts ({len(personal_accounts)}):\n" + for acc in personal_accounts: + result += _format_account_summary(acc) + return result + + +def _format_account_summary(acc: Dict[str, Any]) -> str: + currency = acc.get("currency", "USD") + status_text = format_account_status(int(acc.get("account_status", 1))) + result = f" {acc.get('name', 'Unnamed')} (ID: {acc['id']})\n" + result += f" Status: {status_text} | Currency: {currency}\n" + if "amount_spent" in acc: + result += f" Spent: {format_currency(int(acc['amount_spent']), currency)}\n" + return result + + +async def _get_ad_account_info(client: FacebookAdsClient, ad_account_id: str) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + try: + ad_account_id = validate_ad_account_id(ad_account_id) + except FacebookValidationError as e: + return f"ERROR: {e.message}" + if client.is_test_mode: + return f"Ad Account:\n {ad_account_id}\n Status: Active\n Currency: USD\n Spent: $1,234.56\n" + acc = await client.request("GET", ad_account_id, params={"fields": _AD_ACCOUNT_DETAIL_FIELDS}) + currency = acc.get("currency", "USD") + status_text = format_account_status(int(acc.get("account_status", 1))) + result = f"Ad Account Details:\n\n {acc.get('name', 'Unnamed')} (ID: {acc['id']})\n" + result += f" Status: {status_text}\n Currency: {currency}\n Timezone: {acc.get('timezone_name', 'N/A')}\n" + result += f" Balance: {format_currency(int(acc.get('balance', 0)), currency)}\n" + result += f" Total Spent: {format_currency(int(acc.get('amount_spent', 0)), currency)}\n" + spend_cap = int(acc.get("spend_cap", 0)) + if spend_cap > 0: + amount_spent = int(acc.get("amount_spent", 0)) + result += f" Spend Cap: {format_currency(spend_cap, currency)}\n" + result += f" Remaining: {format_currency(spend_cap - amount_spent, currency)}\n" + if acc.get("business"): + biz = acc["business"] + result += f" Business: {biz.get('name', 'N/A')} (ID: {biz.get('id', 'N/A')})\n" + return result + + +async def _update_spending_limit(client: FacebookAdsClient, ad_account_id: str, spending_limit: int) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + try: + ad_account_id = validate_ad_account_id(ad_account_id) + except FacebookValidationError as e: + return f"ERROR: {e.message}" + spending_limit = int(spending_limit) + if spending_limit < 0: + return "ERROR: spending_limit must be positive" + if client.is_test_mode: + return f"Spending limit updated to {format_currency(spending_limit)} for {ad_account_id}" + result = await client.request("POST", ad_account_id, data={"spend_cap": spending_limit}) + return f"Spending limit updated to {format_currency(spending_limit)} for {ad_account_id}" if result.get("success") else f"Failed. Response: {result}" + + +async def _list_account_users(client: FacebookAdsClient, ad_account_id: str) -> str: + if not ad_account_id: + return "ERROR: ad_account_id required" + try: + ad_account_id = validate_ad_account_id(ad_account_id) + except FacebookValidationError as e: + return f"ERROR: {e.message}" + if client.is_test_mode: + return f"Users for {ad_account_id}:\n Test User (ID: 123456789) — ADMIN\n" + data = await client.request("GET", f"{ad_account_id}/users", params={"fields": "id,name,role,status", "limit": 50}) + users = data.get("data", []) + if not users: + return f"No users found for {ad_account_id}" + result = f"Users for {ad_account_id} ({len(users)}):\n\n" + for u in users: + result += f" {u.get('name', 'Unknown')} (ID: {u.get('id', 'N/A')}) — {u.get('role', 'N/A')}\n" + return result + + +async def _list_pages(client: FacebookAdsClient) -> str: + if client.is_test_mode: + return "Pages you manage:\n Test Page (ID: 111111111) — ACTIVE\n" + data = await client.request("GET", "me/accounts", params={"fields": "id,name,category,tasks,access_token", "limit": 50}) + pages = data.get("data", []) + if not pages: + return "No pages found. You need to be an admin of at least one Facebook Page to create ads." + result = f"Pages you manage ({len(pages)}):\n\n" + for page in pages: + tasks = ", ".join(page.get("tasks", [])) + result += f" {page.get('name', 'Unnamed')} (ID: {page['id']})\n" + result += f" Category: {page.get('category', 'N/A')}\n" + if tasks: + result += f" Tasks: {tasks}\n" + result += "\n" + return result + + _HANDLERS: Dict[str, Any] = { - "list_pages": lambda c, a: list_pages(c), - "list_ad_accounts": lambda c, a: list_ad_accounts(c), - "get_ad_account_info": lambda c, a: get_ad_account_info(c, a.get("ad_account_id", "")), - "update_spending_limit": lambda c, a: update_spending_limit(c, a.get("ad_account_id", ""), a.get("spending_limit", 0)), - "list_account_users": lambda c, a: list_account_users(c, a.get("ad_account_id", "")), + "list_ad_accounts": lambda c, a: _list_ad_accounts(c), + "get_ad_account_info": lambda c, a: _get_ad_account_info(c, a.get("ad_account_id", "")), + "update_spending_limit": lambda c, a: _update_spending_limit(c, a.get("ad_account_id", ""), int(a.get("spending_limit", 0))), + "list_account_users": lambda c, a: _list_account_users(c, a.get("ad_account_id", "")), + "list_pages": lambda c, a: _list_pages(c), } class IntegrationMetaPages: - # Wraps FacebookAdsClient for page/account-level operations. def __init__(self, rcx: "ckit_bot_exec.RobotContext"): self.client = FacebookAdsClient(rcx.fclient, rcx) - async def called_by_model( - self, - toolcall: ckit_cloudtool.FCloudtoolCall, - model_produced_args: Dict[str, Any], - ) -> str: + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: try: args = model_produced_args or {} op = str(args.get("op", "help")).strip() @@ -70,7 +187,7 @@ async def called_by_model( return "Error: args.method_id required for op=call." handler = _HANDLERS.get(method_id) if handler is None: - return f"Error: unknown method_id={method_id!r}. Use op=list_methods to see available methods." + return f"Error: unknown method_id={method_id!r}. Use op=list_methods." return await handler(self.client, call_args) except FacebookAuthError as e: return e.message