mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-08-17 21:54:07 +00:00
ci(dealix): root GitHub workflows, ai-company track, full Dealix API tree
Made-with: Cursor
This commit is contained in:
parent
4cfe2ed502
commit
f79c69ff25
77
.github/workflows/dealix-api-ci.yml
vendored
Normal file
77
.github/workflows/dealix-api-ci.yml
vendored
Normal file
@ -0,0 +1,77 @@
|
||||
# Canonical CI for the Dealix API package (monorepo).
|
||||
# GitHub only loads workflows from the repository root .github/workflows/.
|
||||
name: Dealix API CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, ai-company, dealix-v3-autonomous-revenue-os]
|
||||
paths:
|
||||
- "dealix/**"
|
||||
- ".github/workflows/dealix-api-ci.yml"
|
||||
pull_request:
|
||||
branches: [main, ai-company, dealix-v3-autonomous-revenue-os]
|
||||
paths:
|
||||
- "dealix/**"
|
||||
- ".github/workflows/dealix-api-ci.yml"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: dealix
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: dealix/requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pytest pytest-asyncio pytest-cov httpx
|
||||
|
||||
- name: Compile check
|
||||
run: python -m compileall api auto_client_acquisition
|
||||
|
||||
- name: Tests
|
||||
env:
|
||||
APP_ENV: test
|
||||
APP_DEBUG: "false"
|
||||
ANTHROPIC_API_KEY: test-anthropic-key
|
||||
DEEPSEEK_API_KEY: test-deepseek-key
|
||||
GROQ_API_KEY: test-groq-key
|
||||
GLM_API_KEY: test-glm-key
|
||||
GOOGLE_API_KEY: test-google-key
|
||||
run: pytest -q --no-cov
|
||||
|
||||
- name: In-process API smoke
|
||||
env:
|
||||
APP_ENV: test
|
||||
APP_DEBUG: "false"
|
||||
ANTHROPIC_API_KEY: test-anthropic-key
|
||||
DEEPSEEK_API_KEY: test-deepseek-key
|
||||
GROQ_API_KEY: test-groq-key
|
||||
GLM_API_KEY: test-glm-key
|
||||
GOOGLE_API_KEY: test-google-key
|
||||
run: python scripts/smoke_inprocess.py
|
||||
|
||||
- name: Embeddings pipeline placeholder
|
||||
run: python scripts/embeddings_pipeline_placeholder.py
|
||||
|
||||
- name: Deterministic eval smoke
|
||||
env:
|
||||
APP_ENV: test
|
||||
APP_DEBUG: "false"
|
||||
ANTHROPIC_API_KEY: test-anthropic-key
|
||||
DEEPSEEK_API_KEY: test-deepseek-key
|
||||
GROQ_API_KEY: test-groq-key
|
||||
GLM_API_KEY: test-glm-key
|
||||
GOOGLE_API_KEY: test-google-key
|
||||
run: python scripts/run_evals.py
|
||||
135
.github/workflows/dealix-daily-revenue-machine.yml
vendored
Normal file
135
.github/workflows/dealix-daily-revenue-machine.yml
vendored
Normal file
@ -0,0 +1,135 @@
|
||||
name: Daily Revenue Machine
|
||||
|
||||
# Runs the autonomous daily Dealix revenue machine.
|
||||
# 04:00 UTC = 07:00 Asia/Riyadh.
|
||||
#
|
||||
# Note: GitHub only runs scheduled workflows from the repository default branch
|
||||
# (usually `main`). If your default branch is not `main`, confirm schedule behavior
|
||||
# in GitHub Actions docs.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 4 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
gmail_drafts:
|
||||
description: "Gmail drafts to generate"
|
||||
default: "50"
|
||||
linkedin_drafts:
|
||||
description: "LinkedIn drafts to generate"
|
||||
default: "20"
|
||||
call_scripts:
|
||||
description: "Call scripts to generate"
|
||||
default: "10"
|
||||
create_in_gmail_inbox:
|
||||
description: "Create drafts in Gmail Drafts folder (true if OAuth ready)"
|
||||
default: "true"
|
||||
|
||||
concurrency:
|
||||
group: dealix-daily-revenue-machine
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
revenue-machine:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout (for artifacts only)
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify required secrets
|
||||
env:
|
||||
API_BASE: ${{ secrets.DEALIX_API_BASE }}
|
||||
API_KEY: ${{ secrets.DEALIX_API_KEY }}
|
||||
run: |
|
||||
if [ -z "${API_BASE}" ]; then
|
||||
echo "::error::DEALIX_API_BASE secret missing"; exit 1; fi
|
||||
if [ -z "${API_KEY}" ]; then
|
||||
echo "::error::DEALIX_API_KEY secret missing"; exit 1; fi
|
||||
echo "✅ secrets present"
|
||||
|
||||
- name: 1. Generate today's drafts (with retry)
|
||||
id: revenue_run
|
||||
env:
|
||||
API_BASE: ${{ secrets.DEALIX_API_BASE }}
|
||||
API_KEY: ${{ secrets.DEALIX_API_KEY }}
|
||||
run: |
|
||||
set -e
|
||||
n=0
|
||||
until [ $n -ge 3 ]; do
|
||||
curl -fsS -X POST "$API_BASE/api/v1/automation/revenue-machine/run" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $API_KEY" \
|
||||
-d '{
|
||||
"daily_candidates": 200,
|
||||
"gmail_drafts": ${{ github.event.inputs.gmail_drafts || 50 }},
|
||||
"linkedin_drafts": ${{ github.event.inputs.linkedin_drafts || 20 }},
|
||||
"call_scripts": ${{ github.event.inputs.call_scripts || 10 }},
|
||||
"partner_intros": 10,
|
||||
"approval_mode": "draft_only",
|
||||
"create_in_gmail_drafts_in_inbox": ${{ github.event.inputs.create_in_gmail_inbox || 'true' }}
|
||||
}' -o revenue_run.json && break
|
||||
n=$((n+1)); echo "retry $n / 3"; sleep 30
|
||||
done
|
||||
echo "produced=$(jq -c .produced revenue_run.json)" >> "$GITHUB_OUTPUT"
|
||||
jq '.produced // .' revenue_run.json
|
||||
|
||||
- name: 2. Schedule follow-ups
|
||||
env:
|
||||
API_BASE: ${{ secrets.DEALIX_API_BASE }}
|
||||
API_KEY: ${{ secrets.DEALIX_API_KEY }}
|
||||
run: |
|
||||
curl -fsS -X POST "$API_BASE/api/v1/automation/followups/run" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $API_KEY" -d '{}' | tee followups.json
|
||||
jq . followups.json
|
||||
|
||||
- name: 3. Generate daily report
|
||||
env:
|
||||
API_BASE: ${{ secrets.DEALIX_API_BASE }}
|
||||
API_KEY: ${{ secrets.DEALIX_API_KEY }}
|
||||
run: |
|
||||
curl -fsS -X POST "$API_BASE/api/v1/automation/daily-report/generate" \
|
||||
-H "Authorization: Bearer $API_KEY" | tee daily_report.json
|
||||
jq '.report_path, .metrics' daily_report.json
|
||||
|
||||
- name: 4. Export drafts to CSV
|
||||
env:
|
||||
API_BASE: ${{ secrets.DEALIX_API_BASE }}
|
||||
API_KEY: ${{ secrets.DEALIX_API_KEY }}
|
||||
run: |
|
||||
curl -fsS "$API_BASE/api/v1/automation/revenue-machine/export?format=csv" \
|
||||
-H "Authorization: Bearer $API_KEY" | tee export.json
|
||||
jq . export.json
|
||||
|
||||
- name: 5. Upload run artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dealix-daily-${{ github.run_id }}
|
||||
path: |
|
||||
revenue_run.json
|
||||
followups.json
|
||||
daily_report.json
|
||||
export.json
|
||||
retention-days: 14
|
||||
|
||||
- name: 6. Open issue on failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const today = new Date().toISOString().slice(0,10);
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: `🔴 Daily revenue machine failed — ${today}`,
|
||||
body: `The daily revenue-machine workflow failed on ${today}.\n\n` +
|
||||
`**Run:** ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}\n\n` +
|
||||
`Check Railway logs + /api/v1/prospect/search-diag.`,
|
||||
labels: ['ops', 'p0'],
|
||||
});
|
||||
33
.github/workflows/dealix-staging-smoke.yml
vendored
Normal file
33
.github/workflows/dealix-staging-smoke.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
# Manual smoke against a deployed Dealix staging URL (secrets in GitHub only).
|
||||
name: Dealix staging smoke
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: dealix
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install httpx
|
||||
run: pip install httpx
|
||||
|
||||
- name: Run staging smoke
|
||||
env:
|
||||
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
|
||||
STAGING_API_KEY: ${{ secrets.STAGING_API_KEY }}
|
||||
run: |
|
||||
if [ -z "$STAGING_BASE_URL" ]; then
|
||||
echo "STAGING_BASE_URL secret not set — skipping."
|
||||
exit 0
|
||||
fi
|
||||
python scripts/smoke_staging.py --base-url "$STAGING_BASE_URL"
|
||||
64
dealix/.cursor/rules/dealix-v3.mdc
Normal file
64
dealix/.cursor/rules/dealix-v3.mdc
Normal file
@ -0,0 +1,64 @@
|
||||
---
|
||||
description: Dealix v3 Saudi Revenue OS architecture and implementation rules
|
||||
globs:
|
||||
- "**/*.py"
|
||||
- "**/*.ts"
|
||||
- "**/*.tsx"
|
||||
- "**/*.html"
|
||||
- "**/*.sql"
|
||||
- "**/*.md"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Dealix v3 Rules
|
||||
|
||||
Dealix is a Saudi B2B Revenue OS, not a generic CRM.
|
||||
|
||||
## Core product direction
|
||||
|
||||
- Arabic-first Personal Strategic Operator for Sami and later customers.
|
||||
- Revenue Memory as the event-sourced business memory.
|
||||
- Supabase/Postgres/pgvector as project + strategic + revenue memory.
|
||||
- Safe Agent Runtime with approval gates.
|
||||
- Saudi Market Radar for why-now opportunities.
|
||||
- Compliance OS for PDPL/contactability.
|
||||
- Revenue Science for forecasting, impact, churn, and launch-readiness.
|
||||
- Project Intelligence to understand the codebase and docs.
|
||||
- Command Center and Personal Operator UI.
|
||||
|
||||
## Non-negotiable safety
|
||||
|
||||
- Never auto-send cold WhatsApp.
|
||||
- Never auto-send LinkedIn DMs.
|
||||
- Never send Gmail externally without explicit approval.
|
||||
- Never create calendar events externally without explicit approval.
|
||||
- All external actions must be draft/approval-first.
|
||||
- Every AI action must be traceable, auditable, and explainable.
|
||||
|
||||
## Coding rules
|
||||
|
||||
- Keep code deterministic and import-safe.
|
||||
- Prefer small modules with clear dataclasses and Pydantic schemas where appropriate.
|
||||
- Add tests for all new core logic.
|
||||
- Do not add heavy dependencies unless justified.
|
||||
- Keep external integrations behind interfaces/mocks until credentials exist.
|
||||
- Use Arabic text for user-facing copy where the feature is intended for Sami or Saudi users.
|
||||
- Use English for code symbols.
|
||||
|
||||
## Testing rules
|
||||
|
||||
- Run Python import checks.
|
||||
- Run pytest where possible.
|
||||
- Add smoke tests for API routers.
|
||||
- If tests fail because of existing unrelated issues, document them clearly and isolate new-code tests.
|
||||
|
||||
## Launch goal
|
||||
|
||||
Reach a private-beta-ready product foundation:
|
||||
|
||||
- v3 endpoints work.
|
||||
- Personal Operator endpoints work.
|
||||
- Supabase migration exists.
|
||||
- Project Intelligence works locally.
|
||||
- Launch readiness report generated.
|
||||
- Next external integrations are clearly specified.
|
||||
90
dealix/.dockerignore
Normal file
90
dealix/.dockerignore
Normal file
@ -0,0 +1,90 @@
|
||||
# Git and version control
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Python build artifacts
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info
|
||||
dist/
|
||||
build/
|
||||
develop-eggs/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.whl
|
||||
|
||||
# Virtualenvs (we build our own in Docker)
|
||||
.venv
|
||||
venv
|
||||
env
|
||||
ENV
|
||||
|
||||
# Testing / type checking
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
.tox/
|
||||
.hypothesis/
|
||||
|
||||
# Editors / IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Local env files (secrets stay out of images)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Docs and deliverables (not needed at runtime — huge)
|
||||
docs/
|
||||
deliverables/
|
||||
tool_calls/
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Heavy frontend sources — API image doesn't need them
|
||||
dashboard/node_modules/
|
||||
frontend/node_modules/
|
||||
web/node_modules/
|
||||
node_modules/
|
||||
|
||||
# Tests (not shipped to production)
|
||||
tests/
|
||||
|
||||
# Logs and caches
|
||||
*.log
|
||||
logs/
|
||||
.cache/
|
||||
|
||||
# Notebooks and research output
|
||||
*.ipynb
|
||||
.ipynb_checkpoints/
|
||||
|
||||
# CI and tooling config we don't need at runtime
|
||||
.github/
|
||||
.pre-commit-config.yaml
|
||||
Makefile
|
||||
|
||||
# Deployment bundles (for ops only)
|
||||
deploy_bundle*/
|
||||
*.tar.gz
|
||||
*.zip
|
||||
19
dealix/.editorconfig
Normal file
19
dealix/.editorconfig
Normal file
@ -0,0 +1,19 @@
|
||||
# https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{yml,yaml,json,toml,md}]
|
||||
indent_size = 2
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[*.py]
|
||||
max_line_length = 100
|
||||
109
dealix/.env.example
Normal file
109
dealix/.env.example
Normal file
@ -0,0 +1,109 @@
|
||||
# Dealix — Environment Variables Template
|
||||
# انسخ هذا الملف إلى .env وعبّي القيم الحقيقية.
|
||||
# NEVER commit .env with real values.
|
||||
|
||||
# ── Required for Production ────────────────────────────────────
|
||||
ENVIRONMENT=production
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Secret key for signing (64-byte hex)
|
||||
# Generate: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
APP_SECRET_KEY=CHANGE_ME_to_64_byte_hex
|
||||
|
||||
# Database (Railway/Render/Heroku auto-normalize postgres:// → postgresql+asyncpg://)
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/dealix
|
||||
|
||||
# Public URL (for Moyasar checkout callback)
|
||||
APP_URL=https://dealix.sa
|
||||
|
||||
# ── Moyasar Payments ───────────────────────────────────────────
|
||||
MOYASAR_SECRET_KEY=sk_live_REPLACE_ME
|
||||
MOYASAR_WEBHOOK_SECRET=REPLACE_with_shared_secret_from_dashboard
|
||||
|
||||
# ── PostHog Analytics ──────────────────────────────────────────
|
||||
POSTHOG_API_KEY=phc_REPLACE_ME
|
||||
POSTHOG_HOST=https://us.i.posthog.com
|
||||
|
||||
# ── Calendly ───────────────────────────────────────────────────
|
||||
CALENDLY_URL=https://calendly.com/sami-assiri11/dealix-demo
|
||||
CALENDLY_WEBHOOK_SECRET=REPLACE_ME
|
||||
|
||||
# ── WhatsApp (Meta) ────────────────────────────────────────────
|
||||
WHATSAPP_VERIFY_TOKEN=REPLACE_ME
|
||||
WHATSAPP_APP_SECRET=REPLACE_ME
|
||||
WHATSAPP_ACCESS_TOKEN=REPLACE_ME
|
||||
|
||||
# ── CORS ───────────────────────────────────────────────────────
|
||||
CORS_ORIGINS=https://dealix.sa,https://www.dealix.sa,http://localhost:3000
|
||||
|
||||
# ── Security (optional but recommended) ────────────────────────
|
||||
API_KEYS=REPLACE_with_comma_separated_keys
|
||||
|
||||
# ── Observability (optional) ───────────────────────────────────
|
||||
SENTRY_DSN=
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Lead machine — Provider chains (see docs/ops/LEAD_MACHINE_TOOLING.md)
|
||||
# Each layer is additive: chains fall back gracefully when keys are absent.
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Layer 1 — LLM (pick at least one) ──────────────────────────
|
||||
GROQ_API_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# ── Layer 2 — Web search (Google CSE primary, Tavily fallback) ─
|
||||
GOOGLE_SEARCH_API_KEY=
|
||||
GOOGLE_SEARCH_CX=
|
||||
TAVILY_API_KEY=
|
||||
|
||||
# ── Layer 2 — Local discovery (Saudi sectors via Google Places) ─
|
||||
GOOGLE_MAPS_API_KEY=
|
||||
SERPAPI_API_KEY=
|
||||
APIFY_TOKEN=
|
||||
|
||||
# ── Layer 2 — Crawler (markdown extraction for prospect sites) ─
|
||||
FIRECRAWL_API_KEY=
|
||||
|
||||
# ── Layer 2 — Email intelligence (PDPL-aware) ──────────────────
|
||||
HUNTER_API_KEY=
|
||||
ABSTRACT_API_KEY=
|
||||
|
||||
# ── Layer 2 — Tech detection (internal is free + always-on) ────
|
||||
WAPPALYZER_API_KEY=
|
||||
|
||||
# ── Layer 3 — Daily Email Automation (Gmail OAuth, no password) ─
|
||||
# See docs/ops/GMAIL_OAUTH_SETUP_CHECKLIST.md for the 8-step setup
|
||||
GMAIL_CLIENT_ID=
|
||||
GMAIL_CLIENT_SECRET=
|
||||
GMAIL_REFRESH_TOKEN=
|
||||
GMAIL_SENDER_EMAIL=
|
||||
GMAIL_LIST_UNSUBSCRIBE=
|
||||
DAILY_EMAIL_LIMIT=50
|
||||
EMAIL_BATCH_SIZE=10
|
||||
EMAIL_BATCH_INTERVAL_MINUTES=90
|
||||
|
||||
# ── Layer 3 — WhatsApp Multi-Provider (smart fallback) ─────────
|
||||
# Chain: Green API → Ultramsg → Fonnte → Meta Cloud
|
||||
# Recommended: Green API for free dev tier (5-min QR setup)
|
||||
GREEN_API_INSTANCE_ID=
|
||||
GREEN_API_TOKEN=
|
||||
ULTRAMSG_INSTANCE_ID=
|
||||
ULTRAMSG_TOKEN=
|
||||
FONNTE_TOKEN=
|
||||
META_WHATSAPP_PHONE_NUMBER_ID=
|
||||
META_WHATSAPP_ACCESS_TOKEN=
|
||||
WHATSAPP_MOCK_MODE=false
|
||||
WHATSAPP_TEST_ALLOWLIST=
|
||||
|
||||
# ── Layer 3 — Channels ─────────────────────────────────────────
|
||||
SENDGRID_API_KEY=
|
||||
SENDGRID_INBOUND_SECRET=
|
||||
WHATSAPP_PROVIDER=
|
||||
WHATSAPP_PHONE_NUMBER_ID=
|
||||
WHATSAPP_PROVIDER_API_KEY=
|
||||
WHATSAPP_PROVIDER_BASE_URL=
|
||||
GOOGLE_LEAD_FORM_WEBHOOK_KEY=
|
||||
META_APP_SECRET=
|
||||
META_PAGE_ACCESS_TOKEN=
|
||||
|
||||
50
dealix/.env.staging.example
Normal file
50
dealix/.env.staging.example
Normal file
@ -0,0 +1,50 @@
|
||||
# Dealix — Staging environment template (NO REAL SECRETS IN GIT)
|
||||
# انسخ إلى إعدادات المنصة (Railway/Render) أو ملف .env محلي غير متتبع.
|
||||
#
|
||||
# Principles:
|
||||
# - Use a dedicated staging Supabase project and staging LLM keys/budget caps.
|
||||
# - Moyasar: sandbox keys only until explicit live cutover runbook.
|
||||
# - No live outbound WhatsApp/Gmail/Calendar automation unless explicitly enabled.
|
||||
|
||||
# ── Core ───────────────────────────────────────────────────────
|
||||
APP_ENV=staging
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://YOUR-STAGING-HOST.example.com
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/dealix_staging
|
||||
APP_SECRET_KEY=GENERATE_new_hex_for_staging
|
||||
|
||||
# ── Safety flags (keep conservative) ────────────────────────────
|
||||
WHATSAPP_ALLOW_LIVE_SEND=false
|
||||
|
||||
# ── Observability (staging-only projects recommended) ─────────
|
||||
SENTRY_DSN=
|
||||
LANGFUSE_PUBLIC_KEY=
|
||||
LANGFUSE_SECRET_KEY=
|
||||
LANGFUSE_HOST=https://cloud.langfuse.com
|
||||
|
||||
# ── Supabase staging ───────────────────────────────────────────
|
||||
SUPABASE_URL=https://YOUR_PROJECT.supabase.co
|
||||
SUPABASE_ANON_KEY=
|
||||
# Service role: server-side only, never in frontend
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
|
||||
# ── Moyasar sandbox ────────────────────────────────────────────
|
||||
MOYASAR_SECRET_KEY=sk_test_REPLACE
|
||||
MOYASAR_WEBHOOK_SECRET=REPLACE_from_Moyasar_dashboard
|
||||
|
||||
# ── WhatsApp Meta (optional on staging; verify webhook before enabling) ──
|
||||
WHATSAPP_VERIFY_TOKEN=
|
||||
WHATSAPP_APP_SECRET=
|
||||
WHATSAPP_ACCESS_TOKEN=
|
||||
WHATSAPP_PHONE_NUMBER_ID=
|
||||
|
||||
# ── LLM (staging keys / quotas separate from production) ────────
|
||||
ANTHROPIC_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
GOOGLE_API_KEY=
|
||||
GROQ_API_KEY=
|
||||
|
||||
# ── Redis (optional — idempotency / DLQ) ───────────────────────
|
||||
REDIS_URL=
|
||||
|
||||
CORS_ORIGINS=https://YOUR-STAGING-HOST.example.com,http://localhost:3000
|
||||
12
dealix/.github/CODEOWNERS
vendored
Normal file
12
dealix/.github/CODEOWNERS
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
# Repository-wide default owners
|
||||
# Update the usernames below to reflect your team
|
||||
|
||||
* @your-github-username
|
||||
|
||||
# Security-critical paths — require reviews from security-aware maintainers
|
||||
/.github/ @your-github-username
|
||||
/core/config/ @your-github-username
|
||||
/.env.example @your-github-username
|
||||
/.gitleaks.toml @your-github-username
|
||||
/.pre-commit-config.yaml @your-github-username
|
||||
/SECURITY.md @your-github-username
|
||||
1
dealix/.github/FUNDING.yml
vendored
Normal file
1
dealix/.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
# github: [VoXc2]
|
||||
30
dealix/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
30
dealix/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: Report a bug | الإبلاغ عن خطأ
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
---
|
||||
|
||||
## Description | الوصف
|
||||
<!-- Clear and concise description | وصف واضح وموجز -->
|
||||
|
||||
## Steps to reproduce | خطوات التكرار
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected behavior | السلوك المتوقع
|
||||
|
||||
|
||||
## Actual behavior | السلوك الفعلي
|
||||
|
||||
|
||||
## Environment | البيئة
|
||||
- OS:
|
||||
- Python version:
|
||||
- Version / commit SHA:
|
||||
|
||||
## Logs / Screenshots
|
||||
|
||||
|
||||
## Additional context | سياق إضافي
|
||||
17
dealix/.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
17
dealix/.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
name: ✨ Feature Request
|
||||
about: Suggest a new feature | اقترح ميزة جديدة
|
||||
title: "[FEATURE] "
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## Problem | المشكلة
|
||||
<!-- What's the business/user problem? | ما المشكلة من منظور المستخدم؟ -->
|
||||
|
||||
## Proposed solution | الحل المقترح
|
||||
|
||||
|
||||
## Alternatives considered | البدائل
|
||||
|
||||
|
||||
## Additional context | سياق إضافي
|
||||
25
dealix/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
25
dealix/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
# Pull Request
|
||||
|
||||
## Summary | الملخص
|
||||
<!-- What does this PR change and why? -->
|
||||
|
||||
## Type of change | نوع التغيير
|
||||
- [ ] 🐛 Bug fix
|
||||
- [ ] ✨ New feature
|
||||
- [ ] 💥 Breaking change
|
||||
- [ ] 📝 Docs only
|
||||
- [ ] ♻️ Refactor
|
||||
- [ ] 🔒 Security
|
||||
|
||||
## Checklist | قائمة التحقق
|
||||
- [ ] Tests added / updated
|
||||
- [ ] Docs updated (if needed)
|
||||
- [ ] No secrets committed (verified via `gitleaks`)
|
||||
- [ ] `make lint` passes
|
||||
- [ ] `make test` passes
|
||||
- [ ] Linked to an issue (if applicable)
|
||||
|
||||
## How to test | كيف أختبر هذا
|
||||
<!-- Steps to verify -->
|
||||
|
||||
## Screenshots / Logs (optional)
|
||||
37
dealix/.github/dependabot.yml
vendored
Normal file
37
dealix/.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: pip
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "09:00"
|
||||
timezone: "Asia/Riyadh"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- dependencies
|
||||
- python
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
labels:
|
||||
- dependencies
|
||||
- ci
|
||||
commit-message:
|
||||
prefix: "chore(actions)"
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
labels:
|
||||
- dependencies
|
||||
- docker
|
||||
commit-message:
|
||||
prefix: "chore(docker)"
|
||||
15
dealix/.github/workflows/README.md
vendored
Normal file
15
dealix/.github/workflows/README.md
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
# Dealix workflows under `dealix/.github/workflows/`
|
||||
|
||||
GitHub Actions **only** loads workflow definitions from the **repository root**:
|
||||
|
||||
`.github/workflows/*.yml`
|
||||
|
||||
The copies in this folder are **mirrors / references**. The canonical Dealix workflows that run on GitHub are:
|
||||
|
||||
| Workflow | Root path |
|
||||
|----------|-----------|
|
||||
| API CI (pytest, smoke, evals) | [`.github/workflows/dealix-api-ci.yml`](../../../.github/workflows/dealix-api-ci.yml) |
|
||||
| Staging smoke (manual) | [`.github/workflows/dealix-staging-smoke.yml`](../../../.github/workflows/dealix-staging-smoke.yml) |
|
||||
| Daily revenue machine | [`.github/workflows/dealix-daily-revenue-machine.yml`](../../../.github/workflows/dealix-daily-revenue-machine.yml) |
|
||||
|
||||
Branch track **AI Company** → use Git branch `ai-company` (see [`docs/ops/GITHUB_AI_COMPANY_TRACK.md`](../../docs/ops/GITHUB_AI_COMPANY_TRACK.md)).
|
||||
68
dealix/.github/workflows/ci.yml
vendored
Normal file
68
dealix/.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,68 @@
|
||||
# NOTE: GitHub Actions only runs workflows from the REPO ROOT `.github/workflows/`.
|
||||
# Canonical Dealix CI: ../../../.github/workflows/dealix-api-ci.yml
|
||||
# Branch track "AI Company": docs/ops/GITHUB_AI_COMPANY_TRACK.md
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dealix-v3-autonomous-revenue-os]
|
||||
pull_request:
|
||||
branches: [main, dealix-v3-autonomous-revenue-os]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pytest pytest-asyncio pytest-cov httpx
|
||||
|
||||
- name: Compile check
|
||||
run: python -m compileall api auto_client_acquisition
|
||||
|
||||
- name: Tests
|
||||
env:
|
||||
APP_ENV: test
|
||||
APP_DEBUG: "false"
|
||||
ANTHROPIC_API_KEY: test-anthropic-key
|
||||
DEEPSEEK_API_KEY: test-deepseek-key
|
||||
GROQ_API_KEY: test-groq-key
|
||||
GLM_API_KEY: test-glm-key
|
||||
GOOGLE_API_KEY: test-google-key
|
||||
run: pytest -q --no-cov
|
||||
|
||||
- name: In-process API smoke
|
||||
env:
|
||||
APP_ENV: test
|
||||
APP_DEBUG: "false"
|
||||
ANTHROPIC_API_KEY: test-anthropic-key
|
||||
DEEPSEEK_API_KEY: test-deepseek-key
|
||||
GROQ_API_KEY: test-groq-key
|
||||
GLM_API_KEY: test-glm-key
|
||||
GOOGLE_API_KEY: test-google-key
|
||||
run: python scripts/smoke_inprocess.py
|
||||
|
||||
- name: Embeddings pipeline placeholder
|
||||
run: python scripts/embeddings_pipeline_placeholder.py
|
||||
|
||||
- name: Deterministic eval smoke
|
||||
env:
|
||||
APP_ENV: test
|
||||
APP_DEBUG: "false"
|
||||
ANTHROPIC_API_KEY: test-anthropic-key
|
||||
DEEPSEEK_API_KEY: test-deepseek-key
|
||||
GROQ_API_KEY: test-groq-key
|
||||
GLM_API_KEY: test-glm-key
|
||||
GOOGLE_API_KEY: test-google-key
|
||||
run: python scripts/run_evals.py
|
||||
29
dealix/.github/workflows/codeql.yml
vendored
Normal file
29
dealix/.github/workflows/codeql.yml
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 2 * * 1" # Mondays 02:00 UTC
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze Python
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: python
|
||||
queries: +security-and-quality
|
||||
- uses: github/codeql-action/autobuild@v3
|
||||
- uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:python"
|
||||
132
dealix/.github/workflows/daily-revenue-machine.yml
vendored
Normal file
132
dealix/.github/workflows/daily-revenue-machine.yml
vendored
Normal file
@ -0,0 +1,132 @@
|
||||
name: Daily Revenue Machine
|
||||
|
||||
# Runs the autonomous daily Dealix revenue machine.
|
||||
# 04:00 UTC = 07:00 Asia/Riyadh.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 4 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
gmail_drafts:
|
||||
description: "Gmail drafts to generate"
|
||||
default: "50"
|
||||
linkedin_drafts:
|
||||
description: "LinkedIn drafts to generate"
|
||||
default: "20"
|
||||
call_scripts:
|
||||
description: "Call scripts to generate"
|
||||
default: "10"
|
||||
create_in_gmail_inbox:
|
||||
description: "Create drafts in Gmail Drafts folder (true if OAuth ready)"
|
||||
default: "true"
|
||||
|
||||
# Prevent two runs from racing on shared API quota.
|
||||
concurrency:
|
||||
group: dealix-daily-revenue-machine
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write # for failure-issue creation
|
||||
|
||||
jobs:
|
||||
revenue-machine:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout (for artifacts only)
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify required secrets
|
||||
env:
|
||||
API_BASE: ${{ secrets.DEALIX_API_BASE }}
|
||||
API_KEY: ${{ secrets.DEALIX_API_KEY }}
|
||||
run: |
|
||||
if [ -z "${API_BASE}" ]; then
|
||||
echo "::error::DEALIX_API_BASE secret missing"; exit 1; fi
|
||||
if [ -z "${API_KEY}" ]; then
|
||||
echo "::error::DEALIX_API_KEY secret missing"; exit 1; fi
|
||||
echo "✅ secrets present"
|
||||
|
||||
- name: 1. Generate today's drafts (with retry)
|
||||
id: revenue_run
|
||||
env:
|
||||
API_BASE: ${{ secrets.DEALIX_API_BASE }}
|
||||
API_KEY: ${{ secrets.DEALIX_API_KEY }}
|
||||
run: |
|
||||
set -e
|
||||
n=0
|
||||
until [ $n -ge 3 ]; do
|
||||
curl -fsS -X POST "$API_BASE/api/v1/automation/revenue-machine/run" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $API_KEY" \
|
||||
-d '{
|
||||
"daily_candidates": 200,
|
||||
"gmail_drafts": ${{ github.event.inputs.gmail_drafts || 50 }},
|
||||
"linkedin_drafts": ${{ github.event.inputs.linkedin_drafts || 20 }},
|
||||
"call_scripts": ${{ github.event.inputs.call_scripts || 10 }},
|
||||
"partner_intros": 10,
|
||||
"approval_mode": "draft_only",
|
||||
"create_in_gmail_drafts_in_inbox": ${{ github.event.inputs.create_in_gmail_inbox || 'true' }}
|
||||
}' -o revenue_run.json && break
|
||||
n=$((n+1)); echo "retry $n / 3"; sleep 30
|
||||
done
|
||||
echo "produced=$(jq -c .produced revenue_run.json)" >> "$GITHUB_OUTPUT"
|
||||
jq '.produced // .' revenue_run.json
|
||||
|
||||
- name: 2. Schedule follow-ups
|
||||
env:
|
||||
API_BASE: ${{ secrets.DEALIX_API_BASE }}
|
||||
API_KEY: ${{ secrets.DEALIX_API_KEY }}
|
||||
run: |
|
||||
curl -fsS -X POST "$API_BASE/api/v1/automation/followups/run" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $API_KEY" -d '{}' | tee followups.json
|
||||
jq . followups.json
|
||||
|
||||
- name: 3. Generate daily report
|
||||
env:
|
||||
API_BASE: ${{ secrets.DEALIX_API_BASE }}
|
||||
API_KEY: ${{ secrets.DEALIX_API_KEY }}
|
||||
run: |
|
||||
curl -fsS -X POST "$API_BASE/api/v1/automation/daily-report/generate" \
|
||||
-H "Authorization: Bearer $API_KEY" | tee daily_report.json
|
||||
jq '.report_path, .metrics' daily_report.json
|
||||
|
||||
- name: 4. Export drafts to CSV
|
||||
env:
|
||||
API_BASE: ${{ secrets.DEALIX_API_BASE }}
|
||||
API_KEY: ${{ secrets.DEALIX_API_KEY }}
|
||||
run: |
|
||||
curl -fsS "$API_BASE/api/v1/automation/revenue-machine/export?format=csv" \
|
||||
-H "Authorization: Bearer $API_KEY" | tee export.json
|
||||
jq . export.json
|
||||
|
||||
- name: 5. Upload run artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dealix-daily-${{ github.run_id }}
|
||||
path: |
|
||||
revenue_run.json
|
||||
followups.json
|
||||
daily_report.json
|
||||
export.json
|
||||
retention-days: 14
|
||||
|
||||
- name: 6. Open issue on failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const today = new Date().toISOString().slice(0,10);
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: `🔴 Daily revenue machine failed — ${today}`,
|
||||
body: `The daily revenue-machine workflow failed on ${today}.\n\n` +
|
||||
`**Run:** ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}\n\n` +
|
||||
`Check Railway logs + /api/v1/prospect/search-diag.`,
|
||||
labels: ['ops', 'p0'],
|
||||
});
|
||||
31
dealix/.github/workflows/deploy.yml
vendored
Normal file
31
dealix/.github/workflows/deploy.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: 🚀 Deploy to VPS
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref_type == 'tag'
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.2.5
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
script: |
|
||||
cd /root/dealix || cd /root/ai-company-saudi
|
||||
git fetch --all --tags
|
||||
git checkout ${{ github.ref_name }}
|
||||
docker compose pull
|
||||
docker compose up -d --build
|
||||
sleep 10
|
||||
curl -fsS http://localhost:8000/health || exit 1
|
||||
94
dealix/.github/workflows/docker-build.yml
vendored
Normal file
94
dealix/.github/workflows/docker-build.yml
vendored
Normal file
@ -0,0 +1,94 @@
|
||||
name: Docker Build & Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Lowercase image name
|
||||
id: img
|
||||
run: echo "name=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ steps.img.outputs.name }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
load: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
- name: Trivy scan
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ steps.img.outputs.name }}:sha-${{ github.sha }}
|
||||
format: sarif
|
||||
output: trivy-results.sarif
|
||||
severity: CRITICAL,HIGH
|
||||
exit-code: "0"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Trivy results
|
||||
if: github.event_name != 'pull_request' && hashFiles('trivy-results.sarif') != ''
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy
|
||||
|
||||
- name: Generate SBOM
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: anchore/sbom-action@v0
|
||||
with:
|
||||
image: ${{ env.REGISTRY }}/${{ steps.img.outputs.name }}:sha-${{ github.sha }}
|
||||
format: spdx-json
|
||||
output-file: sbom.spdx.json
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload SBOM artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: sbom
|
||||
path: sbom.spdx.json
|
||||
121
dealix/.github/workflows/railway_deploy.yml
vendored
Normal file
121
dealix/.github/workflows/railway_deploy.yml
vendored
Normal file
@ -0,0 +1,121 @@
|
||||
name: Deploy to Railway
|
||||
|
||||
# Deploys Dealix backend to Railway using Railway CLI.
|
||||
#
|
||||
# Setup (one-time, by Sami):
|
||||
# 1. Go to https://railway.app/account/tokens
|
||||
# 2. Create a new token named "github-deploy"
|
||||
# 3. Copy the token
|
||||
# 4. Go to https://github.com/VoXc2/dealix/settings/secrets/actions
|
||||
# 5. Add New repository secret: RAILWAY_TOKEN = <paste token>
|
||||
# 6. Optional: Add RAILWAY_SERVICE_NAME = dealix (if service name differs)
|
||||
#
|
||||
# After setup: every push to main auto-deploys, OR trigger manually
|
||||
# via Actions tab → "Deploy to Railway" → Run workflow.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "api/**"
|
||||
- "core/**"
|
||||
- "dealix/**"
|
||||
- "auto_client_acquisition/**"
|
||||
- "autonomous_growth/**"
|
||||
- "integrations/**"
|
||||
- "db/**"
|
||||
- "cli.py"
|
||||
- "Dockerfile"
|
||||
- "railway.json"
|
||||
- "railway.toml"
|
||||
- "Procfile"
|
||||
- "requirements.txt"
|
||||
- "pyproject.toml"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
service:
|
||||
description: "Railway service name"
|
||||
required: false
|
||||
default: "dealix"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: 🚂 Deploy to Railway
|
||||
runs-on: ubuntu-latest
|
||||
# Only run if RAILWAY_TOKEN is configured
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'push' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check for RAILWAY_TOKEN
|
||||
id: check_token
|
||||
env:
|
||||
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$RAILWAY_TOKEN" ]; then
|
||||
echo "::warning::RAILWAY_TOKEN secret is not configured. Skipping deploy."
|
||||
echo "token_present=false" >> $GITHUB_OUTPUT
|
||||
echo ""
|
||||
echo "To enable auto-deploy:"
|
||||
echo "1. Get token from https://railway.app/account/tokens"
|
||||
echo "2. Add as GitHub secret: RAILWAY_TOKEN"
|
||||
exit 0
|
||||
fi
|
||||
echo "token_present=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install Railway CLI
|
||||
if: steps.check_token.outputs.token_present == 'true'
|
||||
run: |
|
||||
curl -fsSL https://railway.com/install.sh | sh
|
||||
echo "$HOME/.railway/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Verify Railway auth
|
||||
if: steps.check_token.outputs.token_present == 'true'
|
||||
env:
|
||||
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
|
||||
run: |
|
||||
railway whoami || echo "Auth check failed — token may be invalid"
|
||||
|
||||
- name: Deploy to Railway
|
||||
if: steps.check_token.outputs.token_present == 'true'
|
||||
env:
|
||||
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
|
||||
run: |
|
||||
SERVICE="${{ inputs.service || 'dealix' }}"
|
||||
echo "Deploying to service: $SERVICE"
|
||||
railway up --service "$SERVICE" --detach || {
|
||||
echo "::error::Railway deploy failed. Check service name and token."
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Wait for deployment to become active
|
||||
if: steps.check_token.outputs.token_present == 'true'
|
||||
env:
|
||||
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
|
||||
run: |
|
||||
echo "Waiting 60 seconds for deployment to start..."
|
||||
sleep 60
|
||||
railway status || true
|
||||
|
||||
- name: Smoke test /healthz
|
||||
if: steps.check_token.outputs.token_present == 'true'
|
||||
run: |
|
||||
# Railway auto-generates public domains; for now we rely on the known URL.
|
||||
# User can set RAILWAY_PUBLIC_URL as a repo variable to test the right URL.
|
||||
URL="${{ vars.RAILWAY_PUBLIC_URL || 'https://dealix-production-up.railway.app' }}"
|
||||
echo "Smoke-testing: $URL/healthz"
|
||||
|
||||
for i in {1..12}; do
|
||||
code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 "$URL/healthz" || echo "000")
|
||||
echo "Attempt $i: $code"
|
||||
if [ "$code" = "200" ]; then
|
||||
echo "✅ /healthz = 200 — backend is live"
|
||||
exit 0
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
|
||||
echo "::warning::/healthz did not return 200 after 3 minutes. Deployment may still be in progress."
|
||||
echo "Check Railway dashboard: https://railway.com/project/54bb60b4-d059-4dd1-af57-bc44c702b9f0"
|
||||
34
dealix/.github/workflows/release-please.yml
vendored
Normal file
34
dealix/.github/workflows/release-please.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
name: Release Please
|
||||
|
||||
# NOTE: Default GITHUB_TOKEN can't create PRs unless repo setting
|
||||
# "Allow GitHub Actions to create and approve pull requests" is enabled.
|
||||
# Set repo variable ENABLE_RELEASE_PLEASE=true (or provide RELEASE_PLEASE_TOKEN)
|
||||
# to activate. Guarded so it doesn't mark main red on every push.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ vars.ENABLE_RELEASE_PLEASE == 'true' }}
|
||||
steps:
|
||||
- uses: googleapis/release-please-action@v5
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
release-type: python
|
||||
package-name: dealix
|
||||
changelog-types: |
|
||||
[
|
||||
{"type":"feat","section":"Features","hidden":false},
|
||||
{"type":"fix","section":"Bug Fixes","hidden":false},
|
||||
{"type":"perf","section":"Performance","hidden":false},
|
||||
{"type":"refactor","section":"Refactors","hidden":false},
|
||||
{"type":"docs","section":"Docs","hidden":false},
|
||||
{"type":"chore","section":"Chores","hidden":true}
|
||||
]
|
||||
65
dealix/.github/workflows/release.yml
vendored
Normal file
65
dealix/.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,65 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: 🚀 Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: false
|
||||
body_path: CHANGELOG.md
|
||||
|
||||
docker-publish:
|
||||
name: 🐳 Publish Docker image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
ghcr.io/${{ github.repository }}:v${{ steps.version.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
89
dealix/.github/workflows/scheduled_healthcheck.yml
vendored
Normal file
89
dealix/.github/workflows/scheduled_healthcheck.yml
vendored
Normal file
@ -0,0 +1,89 @@
|
||||
name: Scheduled Health Check
|
||||
|
||||
# Fallback monitoring — runs every 15 minutes against production.
|
||||
# Creates a GitHub Issue if any critical endpoint fails.
|
||||
# Acts as UptimeRobot replacement until Sami configures that service.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 15 minutes
|
||||
- cron: "*/15 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
health:
|
||||
name: Production Health Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- name: Check /healthz
|
||||
id: healthz
|
||||
run: |
|
||||
set +e
|
||||
URL="https://web-dealix.up.railway.app"
|
||||
code=$(curl -sS -o /tmp/body -w "%{http_code}" --max-time 15 "$URL/healthz")
|
||||
echo "status=$code" >> $GITHUB_OUTPUT
|
||||
echo "body=$(cat /tmp/body | tr '\n' ' ')" >> $GITHUB_OUTPUT
|
||||
if [ "$code" = "200" ]; then
|
||||
echo "✅ /healthz = 200"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ /healthz = $code"
|
||||
echo "failed=true" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check /api/v1/pricing/plans
|
||||
id: pricing
|
||||
if: success() || failure()
|
||||
run: |
|
||||
set +e
|
||||
URL="https://web-dealix.up.railway.app"
|
||||
code=$(curl -sS -o /tmp/body -w "%{http_code}" --max-time 15 "$URL/api/v1/pricing/plans")
|
||||
echo "status=$code" >> $GITHUB_OUTPUT
|
||||
if [ "$code" = "200" ]; then
|
||||
echo "✅ /api/v1/pricing/plans = 200"
|
||||
else
|
||||
echo "❌ /api/v1/pricing/plans = $code"
|
||||
echo "failed=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check demo-request POST
|
||||
id: demo
|
||||
if: success() || failure()
|
||||
run: |
|
||||
set +e
|
||||
URL="https://web-dealix.up.railway.app"
|
||||
code=$(curl -sS -o /tmp/body -w "%{http_code}" -X POST --max-time 15 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Healthcheck","company":"Dealix","email":"healthcheck@dealix.sa","phone":"+966500000000","consent":true}' \
|
||||
"$URL/api/v1/public/demo-request")
|
||||
if [ "$code" = "200" ]; then
|
||||
echo "✅ demo-request = 200"
|
||||
else
|
||||
echo "❌ demo-request = $code"
|
||||
echo "failed=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: File issue on failure
|
||||
if: failure() && github.event_name == 'schedule'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const existing = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: 'production-down',
|
||||
state: 'open'
|
||||
});
|
||||
if (existing.data.length > 0) {
|
||||
core.info('Production-down issue already open — skipping duplicate');
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: `🚨 Production unhealthy — ${new Date().toISOString()}`,
|
||||
body: `Automated health check detected production failure.\n\nEndpoint: /healthz or /api/v1/pricing/plans\nRun: ${context.runId}\n\nCheck Railway dashboard:\nhttps://railway.com/project/54bb60b4-d059-4dd1-af57-bc44c702b9f0`,
|
||||
labels: ['production-down', 'P0', 'auto']
|
||||
});
|
||||
29
dealix/.github/workflows/staging-smoke.yml
vendored
Normal file
29
dealix/.github/workflows/staging-smoke.yml
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
# Manual smoke against a deployed staging URL (secrets live in GitHub only).
|
||||
name: Staging smoke
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install httpx
|
||||
run: pip install httpx
|
||||
|
||||
- name: Run staging smoke
|
||||
env:
|
||||
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
|
||||
run: |
|
||||
if [ -z "$STAGING_BASE_URL" ]; then
|
||||
echo "STAGING_BASE_URL secret not set — skipping."
|
||||
exit 0
|
||||
fi
|
||||
python scripts/smoke_staging.py --base-url "$STAGING_BASE_URL"
|
||||
149
dealix/.gitignore
vendored
Normal file
149
dealix/.gitignore
vendored
Normal file
@ -0,0 +1,149 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 🔒 SECRETS — NEVER COMMIT
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.staging.example
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
secrets/
|
||||
credentials/
|
||||
*_secret*
|
||||
*_credentials*
|
||||
service-account*.json
|
||||
google-credentials*.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 🐍 Python
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv/
|
||||
.env/
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
nosetests.xml
|
||||
|
||||
# Type checkers / linters
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
.pyre/
|
||||
.pytype/
|
||||
.ruff_cache/
|
||||
|
||||
# Jupyter
|
||||
.ipynb_checkpoints
|
||||
*/.ipynb_checkpoints/*
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 🗄️ Databases
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db-journal
|
||||
data/
|
||||
dumps/
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 📝 Logs
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 🐳 Docker
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
.docker/
|
||||
docker-compose.override.yml
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 💻 IDE / Editors
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
.vscode/
|
||||
!.vscode/settings.json.example
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.project
|
||||
.pydevproject
|
||||
.settings/
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 🏗️ Build / Dist
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
.cache/
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 🧪 Temporary
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
*.bak
|
||||
*.orig
|
||||
scratch/
|
||||
playground/
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 📊 Analytics / Traces
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
.langfuse/
|
||||
traces/
|
||||
|
||||
# Local Dealix index output
|
||||
.dealix/
|
||||
64
dealix/.gitleaks.toml
Normal file
64
dealix/.gitleaks.toml
Normal file
@ -0,0 +1,64 @@
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Gitleaks configuration
|
||||
# Extends default rules with project-specific patterns
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
title = "AI Company Saudi — Gitleaks Config"
|
||||
|
||||
[extend]
|
||||
# Extend default rules
|
||||
useDefault = true
|
||||
|
||||
[[rules]]
|
||||
id = "anthropic-api-key"
|
||||
description = "Anthropic API key"
|
||||
regex = '''sk-ant-api\d{2}-[A-Za-z0-9_-]{80,}'''
|
||||
tags = ["key", "anthropic"]
|
||||
|
||||
[[rules]]
|
||||
id = "deepseek-api-key"
|
||||
description = "DeepSeek API key"
|
||||
regex = '''sk-[a-f0-9]{32}'''
|
||||
tags = ["key", "deepseek"]
|
||||
|
||||
[[rules]]
|
||||
id = "groq-api-key"
|
||||
description = "Groq API key"
|
||||
regex = '''gsk_[A-Za-z0-9]{40,}'''
|
||||
tags = ["key", "groq"]
|
||||
|
||||
[[rules]]
|
||||
id = "google-api-key"
|
||||
description = "Google API key"
|
||||
regex = '''AIza[0-9A-Za-z_-]{35}'''
|
||||
tags = ["key", "google"]
|
||||
|
||||
[[rules]]
|
||||
id = "glm-zai-key"
|
||||
description = "GLM (Z.ai) API key"
|
||||
regex = '''[a-f0-9]{32}\.[A-Za-z0-9]{16}'''
|
||||
tags = ["key", "glm"]
|
||||
|
||||
[[rules]]
|
||||
id = "hubspot-access-token"
|
||||
description = "HubSpot Access Token"
|
||||
regex = '''pat-[a-z0-9]{2,4}-[a-f0-9-]{36}'''
|
||||
tags = ["key", "hubspot"]
|
||||
|
||||
[allowlist]
|
||||
description = "Global allowlist"
|
||||
paths = [
|
||||
'''\.env\.example$''',
|
||||
'''\.gitleaks\.toml$''',
|
||||
'''\.secrets\.baseline$''',
|
||||
'''tests/fixtures/.*''',
|
||||
'''docs/.*\.md$''',
|
||||
'''scripts/infra/setup_uptimerobot\.sh$''',
|
||||
]
|
||||
|
||||
regexes = [
|
||||
'''sk-placeholder''',
|
||||
'''your-.*-key''',
|
||||
'''change-me''',
|
||||
'''example-.*''',
|
||||
]
|
||||
40
dealix/.pre-commit-config.yaml
Normal file
40
dealix/.pre-commit-config.yaml
Normal file
@ -0,0 +1,40 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
args: ["--maxkb=1000"]
|
||||
- id: check-merge-conflict
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.5.0
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.10.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies:
|
||||
- types-requests
|
||||
- pydantic
|
||||
args: [--ignore-missing-imports, --no-strict-optional]
|
||||
exclude: ^(tests/|dashboard/)
|
||||
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.7.9
|
||||
hooks:
|
||||
- id: bandit
|
||||
args: [-ll, -c, pyproject.toml]
|
||||
additional_dependencies: [".[toml]"]
|
||||
exclude: ^tests/
|
||||
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.18.4
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
42
dealix/.secrets.baseline
Normal file
42
dealix/.secrets.baseline
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"plugins_used": [
|
||||
{"name": "ArtifactoryDetector"},
|
||||
{"name": "AWSKeyDetector"},
|
||||
{"name": "AzureStorageKeyDetector"},
|
||||
{"name": "Base64HighEntropyString", "limit": 4.5},
|
||||
{"name": "BasicAuthDetector"},
|
||||
{"name": "CloudantDetector"},
|
||||
{"name": "DiscordBotTokenDetector"},
|
||||
{"name": "GitHubTokenDetector"},
|
||||
{"name": "HexHighEntropyString", "limit": 3.0},
|
||||
{"name": "IbmCloudIamDetector"},
|
||||
{"name": "IbmCosHmacDetector"},
|
||||
{"name": "JwtTokenDetector"},
|
||||
{"name": "KeywordDetector"},
|
||||
{"name": "MailchimpDetector"},
|
||||
{"name": "NpmDetector"},
|
||||
{"name": "PrivateKeyDetector"},
|
||||
{"name": "SendGridDetector"},
|
||||
{"name": "SlackDetector"},
|
||||
{"name": "SoftlayerDetector"},
|
||||
{"name": "SquareOAuthDetector"},
|
||||
{"name": "StripeDetector"},
|
||||
{"name": "TwilioKeyDetector"}
|
||||
],
|
||||
"filters_used": [
|
||||
{"path": "detect_secrets.filters.allowlist.is_line_allowlisted"},
|
||||
{"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", "min_level": 2},
|
||||
{"path": "detect_secrets.filters.heuristic.is_indirect_reference"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_likely_id_string"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_lock_file"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_potential_uuid"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_sequential_string"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_swagger_file"},
|
||||
{"path": "detect_secrets.filters.heuristic.is_templated_secret"}
|
||||
],
|
||||
"results": {},
|
||||
"generated_at": "2026-04-21T00:00:00Z"
|
||||
}
|
||||
75
dealix/CHANGELOG.md
Normal file
75
dealix/CHANGELOG.md
Normal file
@ -0,0 +1,75 @@
|
||||
# Changelog
|
||||
|
||||
## [3.0.0] — 2026-04-23
|
||||
|
||||
### ✨ Features — Dealix v3.0.0 الإطلاق الكامل
|
||||
|
||||
#### Phase 2 — Cost Optimization
|
||||
- **Prompt caching** (Anthropic): `cache_control: ephemeral` على system prompts ≥ 1024 توكن (توفير 90%)
|
||||
- **Semantic cache**: Redis-backed + multilingual MiniLM embeddings (threshold 0.95, TTL 24h)
|
||||
- **Cost tracker**: Postgres `llm_calls` table + ring buffer + MODEL_PRICES
|
||||
- **Smart routing** (`core/config/models.smart_route`): Groq للتصنيف، DeepSeek للكود، GLM للعربية، Gemini Flash للبحث، Anthropic للحرج
|
||||
- **Batch mode** (`AcquisitionPipeline.run_batch`): asyncio.Semaphore=8 للـ≥5 عملاء
|
||||
|
||||
#### Phase 3 — Security
|
||||
- **Rate limiting** (slowapi): leads 10/min, sales 30/min, WA 100/min, generic 60/min, global 1000/min
|
||||
- **API key middleware** مع `hmac.compare_digest`
|
||||
- **Webhook signatures**: HubSpot v3 + Calendly + n8n HMAC verification
|
||||
- **scripts/rotate_secrets.sh**: تدوير API_KEYS / HUBSPOT_APP_SECRET / CALENDLY_WEBHOOK_SECRET / N8N_WEBHOOK_SECRET / JWT_SECRET / DEALIX_INTERNAL_TOKEN
|
||||
|
||||
#### Phase 4 — Observability
|
||||
- **OpenTelemetry**: FastAPI + HTTPX + SQLAlchemy instrumentation + custom LLM/agent/tool spans → Langfuse
|
||||
- **Sentry** مع FastApiIntegration + SqlalchemyIntegration
|
||||
- `/health/deep` يفحص Postgres + Redis + LLM providers
|
||||
- `/api/v1/admin/costs` يجمع الإنفاق حسب model/provider/task
|
||||
- `/api/v1/admin/cache/stats`
|
||||
|
||||
#### Phase 5 — Integrations
|
||||
- **ConnectorFacade** موحّد: timeout/retry/idempotency/policy/audit
|
||||
- **EnrichSoClient** lead enrichment عبر إيميل
|
||||
- **HubSpotTwoWay**: upsert_contact + handle_inbound_webhook
|
||||
- **CalendlyDynamic**: create_single_use_link
|
||||
|
||||
#### Phase 6 — Intelligence
|
||||
- **Arabic NLP**: normalize (hamza/taa/tashkeel/tatweel) + segment + is_arabic
|
||||
- **Arabic sentiment** (lexicon خليجي + negator detection)
|
||||
- **Intent classifier** (quote/demo/support/partnership/greeting/compliment/complaint)
|
||||
- **Lead scorer** heuristic + ML-ready sklearn interface
|
||||
|
||||
#### Phase 7 — Dashboard
|
||||
- Streamlit RTL لوحة: Overview / Leads / Approvals / Evidence / Costs / Audit
|
||||
- Port 8501، يقرأ من API
|
||||
|
||||
#### Phase 8 — CI/CD
|
||||
- **CodeQL** Python (security-and-quality queries)
|
||||
- **Docker build** مع Trivy CRITICAL/HIGH + SBOM (SPDX-JSON) + GHCR
|
||||
- **Release Please** لتوليد إصدارات وchangelog تلقائياً
|
||||
- **Dependabot** أسبوعي (pip + github-actions + docker)
|
||||
- **pre-commit**: ruff + mypy + bandit + gitleaks
|
||||
|
||||
#### Phase 9 — Infrastructure
|
||||
- `scripts/infra/ssh_harden.sh`: port 2222 + fail2ban + UFW
|
||||
- `scripts/infra/ssl_certbot.sh`: Let's Encrypt auto-renew
|
||||
- `scripts/infra/backup_pg.sh`: pg_dump يومي + استبقاء 14 يوم
|
||||
- `scripts/infra/uptimerobot_setup.md`
|
||||
- `scripts/infra/logrotate.conf`
|
||||
|
||||
#### Phase 10 — Tests + Docs
|
||||
- Unit tests: smart_routing، arabic_nlp، lead_scorer، sentiment، webhook_signatures (72 اختبار نجحت)
|
||||
- Integration tests: connector_facade retry + policy
|
||||
- docs/COST_OPTIMIZATION.md + SECURITY_GUIDE.md + DASHBOARD.md + API_REFERENCE.md + postman_collection.json
|
||||
|
||||
#### Phase 11 — Release
|
||||
- tests/e2e/test_e2e.py (smoke ضد instance مشغّل)
|
||||
- tests/load/k6_smoke.js (100 VU لـ2.5 دقيقة)
|
||||
- tag v3.0.0
|
||||
|
||||
### Phase 1 — GitHub Cleanup (sesión سابقة)
|
||||
- حذف 10 branches dependabot قديمة
|
||||
- main protected (linear history, no force push, PR review, conversation resolution)
|
||||
- Dependabot alerts + secret scanning + push protection مفعّلة
|
||||
- tag v3.0.0 تم تعيينه
|
||||
|
||||
---
|
||||
|
||||
**Breaking changes:** لا يوجد (هذا أول إصدار رسمي public).
|
||||
58
dealix/CODE_OF_CONDUCT.md
Normal file
58
dealix/CODE_OF_CONDUCT.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes
|
||||
- Focusing on what is best not just for us as individuals, but for the community
|
||||
|
||||
Examples of unacceptable behavior:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**conduct@ai-company.sa**. All complaints will be reviewed and investigated
|
||||
promptly and fairly.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
110
dealix/CONTRIBUTING.md
Normal file
110
dealix/CONTRIBUTING.md
Normal file
@ -0,0 +1,110 @@
|
||||
# Contributing | المساهمة
|
||||
|
||||
Thanks for considering a contribution! | شكراً لاهتمامك بالمساهمة!
|
||||
|
||||
## 🚀 Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR-ORG/ai-company-saudi.git
|
||||
cd ai-company-saudi
|
||||
make setup
|
||||
```
|
||||
|
||||
This creates a virtualenv, installs dev deps, installs pre-commit hooks,
|
||||
and copies `.env.example` → `.env` for you.
|
||||
|
||||
## 🧰 Development workflow
|
||||
|
||||
1. **Create a feature branch** from `main`:
|
||||
```bash
|
||||
git checkout -b feat/your-feature
|
||||
```
|
||||
2. **Make changes** — write code + tests.
|
||||
3. **Run quality checks**:
|
||||
```bash
|
||||
make lint
|
||||
make test
|
||||
```
|
||||
4. **Commit** — pre-commit hooks run automatically (gitleaks, ruff, mypy, etc.).
|
||||
5. **Open a Pull Request** using the PR template.
|
||||
|
||||
## 📝 Commit message style
|
||||
|
||||
Conventional Commits (loose):
|
||||
|
||||
- `feat(phase8): add booking confirmation email`
|
||||
- `fix(intake): normalize Kuwaiti phone numbers`
|
||||
- `docs: update README install instructions`
|
||||
- `chore(deps): bump fastapi to 0.116`
|
||||
- `test(icp): cover edge case for budget in range`
|
||||
- `refactor(core): extract LLM client base`
|
||||
|
||||
## 🧪 Testing requirements
|
||||
|
||||
- Every new agent MUST have at least one unit test.
|
||||
- Every new API endpoint MUST have at least one integration test.
|
||||
- Aim for meaningful coverage — not just line-count.
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
- **NEVER commit secrets.** The pre-commit hooks should catch it, but be vigilant.
|
||||
- If you find a vulnerability, please see [SECURITY.md](SECURITY.md) — do NOT open a public issue.
|
||||
|
||||
## 🌍 Bilingual contributions
|
||||
|
||||
- Docstrings: English primary, Arabic translation where it adds value (especially user-facing).
|
||||
- User-facing strings (sales scripts, prompts, docs): provide both AR and EN.
|
||||
- Commit messages + PR descriptions: English preferred, Arabic acceptable.
|
||||
|
||||
## 🏷️ Style
|
||||
|
||||
- Python: `ruff` + `black` + `mypy` — run `make format` before committing.
|
||||
- Line length: 100.
|
||||
- Type hints: required on new code.
|
||||
- Docstrings: Google-style for public APIs.
|
||||
|
||||
## 📦 Releasing (maintainers)
|
||||
|
||||
1. Bump version in `pyproject.toml` and `.env.example`.
|
||||
2. Update `CHANGELOG.md`.
|
||||
3. Commit, tag: `git tag -a v2.x.x -m "v2.x.x"`.
|
||||
4. Push: `git push && git push --tags`.
|
||||
5. GitHub Actions will handle the release + Docker publish.
|
||||
|
||||
---
|
||||
|
||||
## 🇸🇦 بالعربية
|
||||
|
||||
شكراً لمساهمتك!
|
||||
|
||||
### البدء السريع
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR-ORG/ai-company-saudi.git
|
||||
cd ai-company-saudi
|
||||
make setup
|
||||
```
|
||||
|
||||
### سير العمل
|
||||
|
||||
1. أنشئ فرعاً من `main`.
|
||||
2. اكتب الكود + الاختبارات.
|
||||
3. شغّل `make lint` و `make test`.
|
||||
4. كل commit يمر عبر pre-commit hooks تلقائياً.
|
||||
5. افتح Pull Request.
|
||||
|
||||
### متطلبات الاختبار
|
||||
|
||||
- كل وكيل جديد يحتاج اختبار وحدة واحد على الأقل.
|
||||
- كل endpoint جديد يحتاج اختبار تكامل واحد على الأقل.
|
||||
|
||||
### الأمن
|
||||
|
||||
- **لا ترفع أبداً أي أسرار.** pre-commit hooks ستمسكها، لكن انتبه.
|
||||
- إذا اكتشفت ثغرة، راجع [SECURITY.md](SECURITY.md).
|
||||
|
||||
### الأسلوب ثنائي اللغة
|
||||
|
||||
- docstrings: الإنجليزية أساسية، مع عربي حيث يضيف قيمة.
|
||||
- النصوص التي يراها المستخدم: العربية والإنجليزية.
|
||||
- رسائل commit: الإنجليزية مفضّلة، العربية مقبولة.
|
||||
192
dealix/DEALIX_COMPANY_OPERATIONAL_STATE.md
Normal file
192
dealix/DEALIX_COMPANY_OPERATIONAL_STATE.md
Normal file
@ -0,0 +1,192 @@
|
||||
# 🚀 Dealix — Company Operational State (Live)
|
||||
|
||||
**Status:** LAUNCHED (backend + landing live). Blocked on Moyasar account activation for REVENUE VERIFIED.
|
||||
**Last verified:** 2026-04-24
|
||||
**Base URL:** https://web-dealix.up.railway.app
|
||||
**Landing:** https://voxc2.github.io/dealix/
|
||||
|
||||
---
|
||||
|
||||
## ✅ Live Endpoints (verified)
|
||||
|
||||
| Endpoint | Status | Response |
|
||||
|----------|--------|----------|
|
||||
| `GET /healthz` | 200 | `{"status":"ok","service":"dealix"}` |
|
||||
| `GET /health` | 200 | `{status, version:"3.0.0", env:"production", providers:[]}` |
|
||||
| `GET /api/v1/pricing/plans` | 200 | Starter/Growth/Scale JSON |
|
||||
| `POST /api/v1/public/demo-request` | 200 | Returns Calendly URL on valid payload |
|
||||
| `POST /api/v1/public/partner-application` | 200 | Returns Arabic success message |
|
||||
| `GET /docs` | 200 | FastAPI Swagger UI |
|
||||
| `GET /openapi.json` | 200 | OpenAPI spec |
|
||||
| `POST /api/v1/checkout` | 502 | **Blocked:** Moyasar `account_inactive_error` |
|
||||
|
||||
---
|
||||
|
||||
## 📊 What's Working
|
||||
|
||||
### Infrastructure
|
||||
- ✅ Railway deploy: service `web`, environment `Dealix`, builder RAILPACK auto-detects Dockerfile
|
||||
- ✅ Dynamic `$PORT` binding via Dockerfile `/app/start.sh`
|
||||
- ✅ Database: Railway Postgres auto-linked via `DATABASE_URL=${{Postgres.DATABASE_URL}}`
|
||||
- ✅ Env vars (all set via Railway GraphQL API):
|
||||
- APP_SECRET_KEY, ADMIN_TOKEN, LOG_LEVEL, ENVIRONMENT, APP_ENV
|
||||
- APP_URL, PUBLIC_BASE_URL, CORS_ORIGINS, CALENDLY_URL
|
||||
- MOYASAR_SECRET_KEY, MOYASAR_WEBHOOK_SECRET, MOYASAR_PUBLIC_KEY
|
||||
- POSTHOG_API_KEY, POSTHOG_HOST, POSTHOG_ENABLED
|
||||
- CALENDLY_OAUTH_CLIENT_ID, CALENDLY_PAT, CALENDLY_WEBHOOK_SECRET
|
||||
- ✅ Startup healthcheck passing (tini + uvicorn via Dockerfile CMD)
|
||||
|
||||
### Application
|
||||
- ✅ All routers mounted: health, pricing, public, webhooks, leads, sales, sectors, admin, agents
|
||||
- ✅ Sentry SDK initialized on startup (waiting for DSN)
|
||||
- ✅ PostHog analytics initialized
|
||||
- ✅ DLQ + idempotency in place for webhooks
|
||||
- ✅ Moyasar invoice client code verified functional (blocked only by account status)
|
||||
|
||||
### Landing
|
||||
- ✅ GitHub Pages serves from `gh-pages` branch
|
||||
- ✅ All 4 pages (home/marketers/pricing/partners) return 200
|
||||
- ✅ `window.DEALIX_API_BASE = 'https://web-dealix.up.railway.app'` baked in
|
||||
- ✅ Demo form → backend → Calendly URL (verified round-trip)
|
||||
- ✅ Partner form → backend (verified round-trip)
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Blocked by Sami (manual dashboard action)
|
||||
|
||||
### 1. Moyasar Account Activation (CRITICAL for revenue)
|
||||
**Error:** `{"type":"account_inactive_error","message":"Entity not activated to use live account"}`
|
||||
|
||||
**Steps Sami must take:**
|
||||
1. Open https://dashboard.moyasar.com
|
||||
2. Settings → Business → complete all KYC fields:
|
||||
- Commercial Registration (CR) or freelance license
|
||||
- National ID / Iqama
|
||||
- Bank account (IBAN)
|
||||
- Business address
|
||||
3. Submit for review — typically activated within 1-3 business days
|
||||
4. Once active, rotate `MOYASAR_SECRET_KEY` in Moyasar → paste new key into Railway (I can do this via API if you send the new key only)
|
||||
5. Configure webhook:
|
||||
- URL: `https://web-dealix.up.railway.app/api/v1/webhooks/moyasar`
|
||||
- Events: `payment_paid`, `payment_failed`, `payment_refunded`
|
||||
- Secret: use existing `MOYASAR_WEBHOOK_SECRET` from Railway or regenerate
|
||||
|
||||
**Alternative for testing today:** Sami creates a Moyasar **test** account key (sk_test_...) — I can switch Railway env var to test mode for full flow verification without touching real money.
|
||||
|
||||
### 2. SENTRY_DSN (not set)
|
||||
Sami should:
|
||||
1. Open https://sentry.io → create project "dealix"
|
||||
2. Copy the DSN (starts with `https://...@...ingest.sentry.io/...`)
|
||||
3. Send it — I add to Railway via GraphQL.
|
||||
|
||||
### 3. UptimeRobot (not configured)
|
||||
Sami opens https://uptimerobot.com → Add HTTPS monitor:
|
||||
- URL: `https://web-dealix.up.railway.app/healthz`
|
||||
- Interval: 5 min
|
||||
- Alert to phone/email
|
||||
- Save
|
||||
|
||||
### 4. First LinkedIn DM (identity-only)
|
||||
Ready in `docs/ops/launch_content_queue.md`. Sami opens LinkedIn → pastes → sends.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Launch Truth Table
|
||||
|
||||
| Area | Status |
|
||||
|------|--------|
|
||||
| GitHub main + CI | ✅ VERIFIED READY (SHA ahead of 44cc3513e3) |
|
||||
| Landing pages live | ✅ VERIFIED READY |
|
||||
| Backend production | ✅ VERIFIED READY (web-dealix.up.railway.app) |
|
||||
| Demo form → backend | ✅ VERIFIED READY |
|
||||
| Partner form → backend | ✅ VERIFIED READY |
|
||||
| Moyasar live payments | 🔴 BLOCKED (account activation) |
|
||||
| Moyasar webhook | ❌ NOT READY (depends on above) |
|
||||
| 1 SAR verified | ❌ NOT READY (depends on above) |
|
||||
| Sentry DSN | 🟡 EMPTY (waiting for DSN) |
|
||||
| UptimeRobot | ❌ NOT READY |
|
||||
| First DM sent | ❌ NOT READY (Sami identity) |
|
||||
| CRM tracker | ✅ VERIFIED READY (`docs/ops/pipeline_tracker.csv`) |
|
||||
| Launch content queue | ✅ VERIFIED READY (`docs/ops/launch_content_queue.md`) |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Pipeline (Day 1 Seed — 5 priority leads)
|
||||
|
||||
See `docs/ops/pipeline_tracker.csv` — seeded with:
|
||||
1. عبدالله العسيري · Lucidya · CEO (surname affinity priority)
|
||||
2. Ahmad Al-Zaini · Foodics · CEO ($170M Series C)
|
||||
3. Nawaf Hariri · Salla · CEO (70K+ merchants distribution)
|
||||
4. Hisham Al-Falih · Lean Technologies · CEO (API-first B2B)
|
||||
5. Ibrahim Manna · BRKZ · Founder ($30M debt contech)
|
||||
|
||||
All with personalized DMs ready in `launch_content_queue.md`.
|
||||
|
||||
---
|
||||
|
||||
## 📈 3 Paying Customers/Day — Staged Math
|
||||
|
||||
```
|
||||
Conversion per outbound (conservative):
|
||||
0.05 × 0.40 × 0.70 × 0.20 × 0.80 = 0.00224
|
||||
|
||||
Required touches for 3 paid/day:
|
||||
3 / 0.00224 ≈ 1,340 touches/day
|
||||
```
|
||||
|
||||
| Stage | Goal | Daily Touches | Channels | When |
|
||||
|-------|------|---------------|----------|------|
|
||||
| 1 | First customer | 25-50 | Founder-led | Now (Day 1-14) |
|
||||
| 2 | 3 customers/week | 50-100 | Founder + first partner | Day 15-45 |
|
||||
| 3 | 1 customer/day | 200-400 | Partners + SDR | Day 45-90 |
|
||||
| 4 | 3 customers/day | 1,000+ | Full reseller channel | Day 90+ |
|
||||
|
||||
---
|
||||
|
||||
## 🚦 Next 24 Hours Execution Plan
|
||||
|
||||
### When Moyasar activates (Sami's work):
|
||||
- Sami sends `NEW_MOYASAR_KEY: sk_live_...` OR `NEW_MOYASAR_TEST_KEY: sk_test_...`
|
||||
- I update Railway env → redeploy → test 1 SAR checkout → verify webhook round-trip
|
||||
- Mark **REVENUE VERIFIED**
|
||||
|
||||
### When Sami sends SENTRY_DSN:
|
||||
- I add to Railway env via GraphQL → redeploy
|
||||
- Trigger `/_test_sentry` → verify issue appears in Sentry UI
|
||||
|
||||
### When Sami has 10 minutes for UptimeRobot:
|
||||
- Complete from docs/ops/UPTIME_AND_ALERTS.md — 10 min, then `UPTIME MONITOR ACTIVE`
|
||||
|
||||
### Outreach today (Sami):
|
||||
- Sami opens LinkedIn → sends DM #1 (Abdullah) from launch_content_queue.md
|
||||
- Updates `docs/ops/pipeline_tracker.csv` row 1 with `sent_at` timestamp
|
||||
- Schedules Day +2 reminder
|
||||
|
||||
### Content (Sami):
|
||||
- Post 1 (founder launch) → LinkedIn personal account
|
||||
- Same post → X/Twitter
|
||||
|
||||
---
|
||||
|
||||
## 📞 Contact Points
|
||||
|
||||
- **Backend:** https://web-dealix.up.railway.app
|
||||
- **Landing:** https://voxc2.github.io/dealix/
|
||||
- **Demo booking:** https://calendly.com/sami-assiri11/dealix-demo
|
||||
- **GitHub:** https://github.com/VoXc2/dealix
|
||||
- **Pipeline tracker:** `docs/ops/pipeline_tracker.csv`
|
||||
- **Content queue:** `docs/ops/launch_content_queue.md`
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Final Executive Decision
|
||||
|
||||
**State:** LAUNCHED (technical) — blocked on Moyasar activation for REVENUE VERIFIED.
|
||||
|
||||
- Launch target A (LAUNCHED): ✅ REACHED
|
||||
- Launch target B (REVENUE READY): 🔴 Blocked on Moyasar account activation
|
||||
- Launch target C (REVENUE VERIFIED): ❌ Depends on B
|
||||
- Launch target D (ACQUISITION STARTED): 🟡 Ready — waiting only on Sami's first send
|
||||
- Launch target E (COMPANY OPERATING): 🟡 Pipeline + content ready — daily loop documented
|
||||
|
||||
**One credential unlock revenue:** Send me `NEW_MOYASAR_KEY` (test or live after activation). Everything downstream I can do.
|
||||
252
dealix/DEPLOYMENT.md
Normal file
252
dealix/DEPLOYMENT.md
Normal file
@ -0,0 +1,252 @@
|
||||
# Dealix — Universal Deployment Guide
|
||||
|
||||
دليل نشر Dealix على أي منصة: Railway, Render, Fly.io, Heroku, DigitalOcean, AWS, Docker self-hosted.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 TL;DR — نشر خلال 10 دقائق
|
||||
|
||||
1. أي منصة تدعم Docker → استخدم `Dockerfile` الموجود في root
|
||||
2. عيّن env vars من `.env.example` (انسخه كبداية)
|
||||
3. Health check: `GET /health`
|
||||
4. التطبيق يستمع على `${PORT:-8000}`
|
||||
5. يتطلب PostgreSQL (اختياري — التطبيق يعمل بدونه في dev mode)
|
||||
|
||||
---
|
||||
|
||||
## 📦 المتغيرات المطلوبة (Minimum للإنتاج)
|
||||
|
||||
### الإلزامية (بدونها الدفع ما يشتغل)
|
||||
|
||||
```bash
|
||||
# Security
|
||||
APP_SECRET_KEY=CHANGE_ME_64_byte_hex # generate: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
ENVIRONMENT=production
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Database (Railway/Render/Heroku postgres auto-normalize لـ asyncpg)
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/dealix
|
||||
|
||||
# Moyasar Payments
|
||||
MOYASAR_SECRET_KEY=sk_live_xxxxx
|
||||
MOYASAR_WEBHOOK_SECRET=CHANGE_ME_shared_with_moyasar_dashboard
|
||||
|
||||
# PostHog Analytics (اختياري لكن موصى به)
|
||||
POSTHOG_API_KEY=phc_xxxxx
|
||||
POSTHOG_HOST=https://us.i.posthog.com
|
||||
|
||||
# Calendly
|
||||
CALENDLY_URL=https://calendly.com/sami-assiri11/dealix-demo
|
||||
CALENDLY_WEBHOOK_SECRET=xxxxx
|
||||
|
||||
# CORS (أضف domain الـ landing)
|
||||
CORS_ORIGINS=https://dealix.sa,https://www.dealix.sa
|
||||
```
|
||||
|
||||
### الاختيارية
|
||||
|
||||
```bash
|
||||
API_KEYS=key1,key2 # إذا بدك حماية admin endpoints
|
||||
APP_URL=https://dealix.sa # للـ checkout callback
|
||||
SENTRY_DSN=https://...@sentry.io/...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 نشر على المنصات المختلفة
|
||||
|
||||
### 1) Railway (الموصى به — مجاني تقريباً)
|
||||
|
||||
**إعداد أولي:**
|
||||
1. افتح https://railway.com → **New Project** → **Deploy from GitHub** → اختر `VoXc2/dealix`
|
||||
2. Railway يكتشف `Dockerfile` تلقائياً
|
||||
3. أضف Postgres: **+ New** → **Database** → **PostgreSQL**
|
||||
4. اذهب لخدمة `dealix` → **Variables** → **Raw Editor**
|
||||
5. الصق محتوى `dealix_railway_vars.txt` (يُرفق مع الحزمة)
|
||||
6. **Settings** → **Deploy** → **Start Command**: اتركه فارغ (يستخدم Dockerfile)
|
||||
7. احفظ → Railway ينشر تلقائياً
|
||||
|
||||
**التحقق:**
|
||||
```bash
|
||||
curl https://<your-app>.up.railway.app/health
|
||||
# {"status":"ok"}
|
||||
```
|
||||
|
||||
### 2) Render
|
||||
|
||||
1. https://render.com → **New** → **Web Service** → Connect GitHub → `VoXc2/dealix`
|
||||
2. اختر **Docker** runtime
|
||||
3. Add environment variables من `.env.example`
|
||||
4. **Health Check Path:** `/health`
|
||||
5. Add PostgreSQL من Render marketplace
|
||||
6. Deploy
|
||||
|
||||
### 3) Fly.io
|
||||
|
||||
```bash
|
||||
fly launch --dockerfile Dockerfile
|
||||
fly secrets set APP_SECRET_KEY=... MOYASAR_SECRET_KEY=...
|
||||
fly postgres create --name dealix-db
|
||||
fly postgres attach dealix-db
|
||||
fly deploy
|
||||
```
|
||||
|
||||
### 4) Heroku
|
||||
|
||||
```bash
|
||||
heroku create dealix-api
|
||||
heroku stack:set container
|
||||
heroku addons:create heroku-postgresql:mini
|
||||
heroku config:set APP_SECRET_KEY=... MOYASAR_SECRET_KEY=...
|
||||
git push heroku main
|
||||
```
|
||||
|
||||
### 5) DigitalOcean App Platform
|
||||
|
||||
1. https://cloud.digitalocean.com/apps → **Create App** → GitHub → `VoXc2/dealix`
|
||||
2. App Platform يكتشف Dockerfile
|
||||
3. أضف managed PostgreSQL
|
||||
4. أضف env vars
|
||||
5. Deploy
|
||||
|
||||
### 6) Docker self-hosted (أي VPS)
|
||||
|
||||
```bash
|
||||
# على السيرفر:
|
||||
git clone https://github.com/VoXc2/dealix.git
|
||||
cd dealix
|
||||
cp .env.example .env
|
||||
# عبّي المتغيرات في .env
|
||||
docker build -t dealix .
|
||||
docker run -d --name dealix -p 8000:8000 --env-file .env dealix
|
||||
|
||||
# مع PostgreSQL:
|
||||
docker-compose up -d # إذا استخدمت docker-compose.yml (راجع docker-compose.example.yml)
|
||||
```
|
||||
|
||||
### 7) AWS (ECS Fargate)
|
||||
|
||||
1. ادفع صورة Docker لـ ECR: `docker build -t dealix . && docker push <ecr-url>/dealix:latest`
|
||||
2. أنشئ ECS Cluster + Task Definition بالصورة
|
||||
3. أضف RDS Postgres
|
||||
4. عيّن env vars في Task Definition
|
||||
5. أنشئ Service مع Application Load Balancer
|
||||
6. Route 53 → ربط الدومين
|
||||
|
||||
---
|
||||
|
||||
## 🔗 ربط Landing Page مع Backend
|
||||
|
||||
Landing في مجلد `landing/` يقرأ عنوان API من `window.DEALIX_API_BASE`.
|
||||
|
||||
### خيار A — نشر منفصل (Netlify / Vercel / Cloudflare Pages)
|
||||
|
||||
1. انشر مجلد `landing/` كـ static site
|
||||
2. قبل النشر، عدّل `landing/index.html`:
|
||||
```html
|
||||
<script>
|
||||
window.DEALIX_API_BASE = 'https://your-backend-url.com';
|
||||
</script>
|
||||
```
|
||||
3. أضف domain `dealix.sa` في CORS_ORIGINS في backend
|
||||
|
||||
### خيار B — نشر مع الـ Backend على نفس المنصة
|
||||
|
||||
في Railway أو Render: أضف `landing/` كـ static serving.
|
||||
أو استخدم nginx reverse proxy (راجع `nginx.example.conf`).
|
||||
|
||||
---
|
||||
|
||||
## 💳 إعداد Moyasar Webhook
|
||||
|
||||
**مطلوب حتى الدفع يعمل نهاية إلى نهاية:**
|
||||
|
||||
1. افتح https://dashboard.moyasar.com/webhooks
|
||||
2. **Add Webhook**:
|
||||
- **URL:** `https://<your-backend-url>/api/v1/webhooks/moyasar`
|
||||
- **Events:** `payment_paid`, `payment_failed`, `payment_refunded`
|
||||
- **Secret:** القيمة اللي عيّنتها في `MOYASAR_WEBHOOK_SECRET` env var (نفس القيمة بالضبط)
|
||||
3. **Save**
|
||||
4. Moyasar يرسل ping اختباري → يجب أن يرجع 200
|
||||
|
||||
---
|
||||
|
||||
## 🧪 اختبار بعد النشر
|
||||
|
||||
```bash
|
||||
BASE_URL=https://your-backend-url.com
|
||||
|
||||
# 1. Health
|
||||
curl $BASE_URL/health
|
||||
# {"status":"ok"}
|
||||
|
||||
# 2. Pricing
|
||||
curl $BASE_URL/api/v1/pricing/plans
|
||||
|
||||
# 3. Demo request (landing form simulation)
|
||||
curl -X POST $BASE_URL/api/v1/public/demo-request \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"تجربة","company":"Test Co","email":"test@example.com","phone":"+966500000000","consent":true}'
|
||||
|
||||
# 4. Checkout (1 SAR pilot — دفع حقيقي)
|
||||
curl -X POST $BASE_URL/api/v1/checkout \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"plan":"pilot_1sar","email":"you@example.com"}'
|
||||
# returns payment_url — افتحه في المتصفح وادفع
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 المشاكل الشائعة
|
||||
|
||||
### Railway: `Invalid value for '--port': '${PORT:-8000}' is not a valid integer`
|
||||
**السبب:** Railway UI Start Command override يتجاوز shell expansion.
|
||||
**الحل:** في **Settings** → **Deploy** → **Start Command**: امسحه أو ضع `/app/start.sh`
|
||||
|
||||
### Healthcheck فاشل بعد deploy
|
||||
**السبب:** المتغيرات ناقصة أو app crash at startup.
|
||||
**الحل:** راجع logs — إذا `email-validator is not installed` فأعد النشر (PR #68 حلّها).
|
||||
|
||||
### Moyasar webhook 401
|
||||
**السبب:** `MOYASAR_WEBHOOK_SECRET` مختلف في Moyasar dashboard والـ env.
|
||||
**الحل:** تأكد من تطابقهما بالضبط.
|
||||
|
||||
### DB connection refused
|
||||
**السبب:** `DATABASE_URL` بصيغة خاطئة.
|
||||
**الحل:** التطبيق يحوّل `postgres://` → `postgresql+asyncpg://` تلقائياً. تأكد أن الـ URL صحيح.
|
||||
|
||||
---
|
||||
|
||||
## 📚 مسارات API الجاهزة
|
||||
|
||||
| Route | Method | الوصف |
|
||||
|-------|--------|-------|
|
||||
| `/health` | GET | Health check (public) |
|
||||
| `/api/v1/public/health` | GET | Health للـ landing (public) |
|
||||
| `/api/v1/public/demo-request` | POST | Landing form → Calendly (public) |
|
||||
| `/api/v1/pricing/plans` | GET | قائمة الباقات (public) |
|
||||
| `/api/v1/checkout` | POST | توليد Moyasar invoice |
|
||||
| `/api/v1/webhooks/moyasar` | POST | استقبال أحداث Moyasar |
|
||||
| `/api/v1/webhooks/whatsapp` | POST/GET | WhatsApp Meta webhook |
|
||||
| `/api/v1/webhooks/calendly` | POST | Calendly lifecycle events |
|
||||
| `/api/v1/leads` | POST | إنشاء lead (يتطلب API key) |
|
||||
| `/api/v1/sales/*` | * | Sales ops (API key) |
|
||||
| `/api/v1/admin/*` | * | Admin (API key) |
|
||||
| `/docs` | GET | Swagger UI |
|
||||
|
||||
---
|
||||
|
||||
## 🔐 الأمان — قواعد صارمة
|
||||
|
||||
- **لا تدفع** `.env` أو مفاتيح لـ Git (موجود `.gitignore`)
|
||||
- Rotate `APP_SECRET_KEY` إذا أي شخص شافه
|
||||
- `MOYASAR_SECRET_KEY` = `sk_live_*` للإنتاج فقط (استخدم `sk_test_*` للاختبار)
|
||||
- `API_KEYS` للحماية — ابدأ بدونها لكن فعّلها قبل الإطلاق العام
|
||||
- CORS_ORIGINS صارم — ما في wildcard `*` في الإنتاج
|
||||
|
||||
---
|
||||
|
||||
## 📞 الدعم
|
||||
|
||||
- Issues: https://github.com/VoXc2/dealix/issues
|
||||
- Owner: sami.assiri11@gmail.com
|
||||
85
dealix/Dockerfile
Normal file
85
dealix/Dockerfile
Normal file
@ -0,0 +1,85 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# AI Company Saudi — production Docker image
|
||||
# Multi-stage, non-root, Python 3.12-slim
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Stage 1 — Builder: install deps into a venv
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
FROM python:3.12-slim-bookworm AS builder
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Create virtualenv
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Copy dependency files first for better caching
|
||||
COPY pyproject.toml ./
|
||||
COPY requirements.txt* ./
|
||||
|
||||
# Install deps and aggressively prune caches/metadata to shrink image
|
||||
RUN pip install --upgrade pip setuptools wheel \
|
||||
&& pip install --no-cache-dir -r requirements.txt \
|
||||
&& find /opt/venv -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true \
|
||||
&& find /opt/venv -type d -name tests -exec rm -rf {} + 2>/dev/null || true \
|
||||
&& find /opt/venv -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Stage 2 — Runtime: minimal image
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
FROM python:3.12-slim-bookworm AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH="/opt/venv/bin:$PATH" \
|
||||
APP_ENV=production
|
||||
|
||||
# Runtime-only system deps
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd --gid 1000 app \
|
||||
&& useradd --uid 1000 --gid app --shell /bin/bash --create-home app
|
||||
|
||||
# Copy venv from builder
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
WORKDIR /app
|
||||
COPY --chown=app:app . .
|
||||
|
||||
USER app
|
||||
|
||||
# Railway injects $PORT dynamically; default to 8000 for local dev
|
||||
ENV PORT=8000
|
||||
EXPOSE 8000
|
||||
|
||||
# Healthcheck uses $PORT so it matches whatever the platform assigns
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl -fsS http://localhost:${PORT:-8000}/health || exit 1
|
||||
|
||||
# Wrapper script so any start command (Dockerfile CMD, Procfile, Railway
|
||||
# startCommand override) works without shell-expansion gotchas.
|
||||
COPY --chown=app:app <<'EOF' /app/start.sh
|
||||
#!/bin/sh
|
||||
set -e
|
||||
exec uvicorn api.main:app --host 0.0.0.0 --port "${PORT:-8000}" --workers 1
|
||||
EOF
|
||||
RUN chmod +x /app/start.sh
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["/app/start.sh"]
|
||||
21
dealix/LICENSE
Normal file
21
dealix/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 AI Company Saudi Arabia
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
95
dealix/Makefile
Normal file
95
dealix/Makefile
Normal file
@ -0,0 +1,95 @@
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# AI Company Saudi — Makefile
|
||||
# الأوامر الشائعة
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
.PHONY: help install install-dev setup test test-unit test-integration \
|
||||
lint format type-check security clean run demo \
|
||||
docker-build docker-up docker-down docker-logs \
|
||||
pre-commit-install pre-commit-run db-init requirements
|
||||
|
||||
# Python binary (override with PYTHON=python3.12 make ...)
|
||||
PYTHON ?= python3
|
||||
PIP ?= $(PYTHON) -m pip
|
||||
|
||||
help: ## Show this help
|
||||
@echo "🏢 AI Company Saudi — Available commands:"
|
||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-25s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
# ── Environment setup ──────────────────────────────────────────
|
||||
install: ## Install production dependencies
|
||||
$(PIP) install -e .
|
||||
|
||||
install-dev: ## Install dev dependencies (tests, lint, etc.)
|
||||
$(PIP) install -e ".[dev]"
|
||||
|
||||
setup: install-dev pre-commit-install ## One-time dev setup
|
||||
@test -f .env || (cp .env.example .env && echo "✅ Created .env from template — edit it now")
|
||||
|
||||
requirements: ## Export requirements.txt from pyproject
|
||||
$(PIP) install pip-tools
|
||||
$(PIP) compile pyproject.toml -o requirements.txt
|
||||
$(PIP) compile --extra dev pyproject.toml -o requirements-dev.txt
|
||||
|
||||
# ── Quality ────────────────────────────────────────────────────
|
||||
lint: ## Run ruff + black checks
|
||||
ruff check .
|
||||
black --check .
|
||||
|
||||
format: ## Auto-format with ruff + black
|
||||
ruff check --fix .
|
||||
black .
|
||||
|
||||
type-check: ## Run mypy
|
||||
mypy core auto_client_acquisition autonomous_growth integrations api
|
||||
|
||||
security: ## Run security scans
|
||||
bandit -c pyproject.toml -r core auto_client_acquisition autonomous_growth integrations api
|
||||
detect-secrets scan --baseline .secrets.baseline || true
|
||||
|
||||
# ── Tests ──────────────────────────────────────────────────────
|
||||
test: ## Run full test suite with coverage
|
||||
pytest -v
|
||||
|
||||
test-unit: ## Unit tests only
|
||||
pytest -v -m "not integration" tests/unit
|
||||
|
||||
test-integration: ## Integration tests only
|
||||
pytest -v tests/integration
|
||||
|
||||
# ── Pre-commit ─────────────────────────────────────────────────
|
||||
pre-commit-install: ## Install pre-commit hooks
|
||||
pre-commit install
|
||||
|
||||
pre-commit-run: ## Run pre-commit on all files
|
||||
pre-commit run --all-files
|
||||
|
||||
# ── Run locally ────────────────────────────────────────────────
|
||||
run: ## Run API server (dev mode, reload on changes)
|
||||
uvicorn api.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
demo: ## Run interactive CLI demo
|
||||
$(PYTHON) cli.py
|
||||
|
||||
# ── Database ───────────────────────────────────────────────────
|
||||
db-init: ## Initialize database tables (dev only)
|
||||
$(PYTHON) -c "import asyncio; from db.session import init_db; asyncio.run(init_db())"
|
||||
|
||||
# ── Docker ─────────────────────────────────────────────────────
|
||||
docker-build: ## Build Docker image
|
||||
docker build -t dealix:latest .
|
||||
|
||||
docker-up: ## Start full stack (app + postgres + redis + mongo)
|
||||
docker compose up -d --build
|
||||
|
||||
docker-down: ## Stop and remove containers
|
||||
docker compose down
|
||||
|
||||
docker-logs: ## Tail application logs
|
||||
docker compose logs -f app
|
||||
|
||||
# ── Cleanup ────────────────────────────────────────────────────
|
||||
clean: ## Remove build artifacts, caches
|
||||
rm -rf build dist *.egg-info .pytest_cache .mypy_cache .ruff_cache htmlcov .coverage coverage.xml
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name "*.pyc" -delete
|
||||
2
dealix/Procfile
Normal file
2
dealix/Procfile
Normal file
@ -0,0 +1,2 @@
|
||||
web: uvicorn api.main:app --host 0.0.0.0 --port $PORT --workers 2
|
||||
release: alembic upgrade head || true
|
||||
123
dealix/QUICK_START.md
Normal file
123
dealix/QUICK_START.md
Normal file
@ -0,0 +1,123 @@
|
||||
# 🚀 الخطوات السريعة — ارفع المشروع على GitHub في دقيقة واحدة
|
||||
|
||||
## الطريقة الأسهل — سكربت جاهز
|
||||
|
||||
```bash
|
||||
# 1. فك الضغط
|
||||
tar -xzf ai-company-saudi-v2.0.0.tar.gz
|
||||
cd ai-company-saudi
|
||||
|
||||
# 2. تأكد أن gh CLI مثبت ومسجّل دخولك
|
||||
# (اختياري لكنه الأسهل)
|
||||
gh auth login
|
||||
|
||||
# 3. شغّل السكربت (عدّل اسم المستخدم)
|
||||
GITHUB_USER=your-github-username \
|
||||
REPO_NAME=ai-company-saudi \
|
||||
VISIBILITY=private \
|
||||
bash scripts/github_setup.sh
|
||||
```
|
||||
|
||||
السكربت يقوم بـ:
|
||||
1. فحص أمني — يتأكد ما فيه أي سر مكشوف
|
||||
2. `git init` وإنشاء commit أول شامل
|
||||
3. إنشاء الريبو على GitHub (خاص افتراضياً)
|
||||
4. رفع الفرع `main`
|
||||
5. إنشاء tag `v2.0.0` ورفعه
|
||||
6. إنشاء GitHub Release مع CHANGELOG
|
||||
|
||||
---
|
||||
|
||||
## الطريقة اليدوية — إذا تبغى تتحكم بكل خطوة
|
||||
|
||||
```bash
|
||||
tar -xzf ai-company-saudi-v2.0.0.tar.gz
|
||||
cd ai-company-saudi
|
||||
|
||||
# تأكد إن .env ما موجود (لازم تبقى بس .env.example)
|
||||
ls .env 2>/dev/null && echo "⚠️ احذف .env قبل ما تكمل!"
|
||||
|
||||
# Initialize
|
||||
git init -b main
|
||||
git add -A
|
||||
git commit -m "feat: initial release v2.0.0"
|
||||
|
||||
# أنشئ الريبو على github.com يدوياً ثم:
|
||||
git remote add origin git@github.com:YOUR-USER/ai-company-saudi.git
|
||||
git push -u origin main
|
||||
|
||||
# الـ tag
|
||||
git tag -a v2.0.0 -m "Release v2.0.0"
|
||||
git push origin v2.0.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## بعد الرفع — خطوات GitHub مهمة
|
||||
|
||||
### 1. فعّل Branch Protection على `main`
|
||||
Settings → Branches → Add rule:
|
||||
- Require pull request reviews (1 reviewer)
|
||||
- Require status checks to pass (`CI` job)
|
||||
- Require conversation resolution
|
||||
- Do not allow force pushes
|
||||
|
||||
### 2. فعّل الحماية الأمنية
|
||||
Settings → Code security:
|
||||
- ✅ Dependency graph
|
||||
- ✅ Dependabot alerts
|
||||
- ✅ Dependabot security updates
|
||||
- ✅ Secret scanning
|
||||
- ✅ Push protection (for secrets)
|
||||
|
||||
### 3. أضف Secrets للـ CI (اختياري للاختبارات)
|
||||
Settings → Secrets and variables → Actions:
|
||||
- `ANTHROPIC_API_KEY` (للاختبارات التي تستدعي LLM فعلياً)
|
||||
- `CODECOV_TOKEN` (لرفع تقارير التغطية)
|
||||
|
||||
### 4. فعّل GitHub Actions
|
||||
في أول push، اذهب إلى Actions tab وتأكد إن CI workflow نجح.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ مهم جداً قبل أي شيء
|
||||
|
||||
**المفاتيح اللي كانت في ملف `PROJECT_FULL_REPORT.md` الأصلي تسرّبت مسبقاً.** لازم تدوّرها كلها قبل استخدام المشروع:
|
||||
|
||||
| المزود | الرابط |
|
||||
| --- | --- |
|
||||
| Anthropic | https://console.anthropic.com/settings/keys |
|
||||
| DeepSeek | https://platform.deepseek.com/api_keys |
|
||||
| Groq | https://console.groq.com/keys |
|
||||
| GLM (Z.ai) | https://open.bigmodel.cn/usercenter/apikeys |
|
||||
| Google | https://console.cloud.google.com/apis/credentials |
|
||||
| HubSpot | Settings → Integrations → Private Apps |
|
||||
| ClickBank | Account Settings → API |
|
||||
| HIX AI | Account → API |
|
||||
|
||||
بعد التدوير، ضعها في `.env` محلياً (ما ترفع `.env` أبداً) وفي GitHub Secrets للـ CI.
|
||||
|
||||
---
|
||||
|
||||
## الأوامر الأساسية بعد الرفع
|
||||
|
||||
```bash
|
||||
# إعداد التطوير المحلي
|
||||
make setup
|
||||
|
||||
# تشغيل محلي
|
||||
make run # على http://localhost:8000/docs
|
||||
|
||||
# اختبارات
|
||||
make test
|
||||
|
||||
# Docker كامل
|
||||
make docker-up
|
||||
|
||||
# CLI تفاعلي
|
||||
python cli.py menu
|
||||
# أو
|
||||
python cli.py demo # عرض توضيحي شامل
|
||||
python cli.py sector healthcare -e
|
||||
python cli.py status
|
||||
```
|
||||
139
dealix/README.ar.md
Normal file
139
dealix/README.ar.md
Normal file
@ -0,0 +1,139 @@
|
||||
<div align="center" dir="rtl">
|
||||
|
||||
# 🏢 شركة AI السعودية
|
||||
|
||||
### منصة ذكاء اصطناعي متعددة الوكلاء، جاهزة للإنتاج، للسوق السعودي والخليجي
|
||||
|
||||
[](https://github.com/VoXc2/dealix/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
**العربية** · **[English](README.md)**
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🌟 نظرة عامة
|
||||
|
||||
**شركة AI السعودية** منصة ذكاء اصطناعي متعددة الوكلاء جاهزة للإنتاج، تُؤتمت:
|
||||
|
||||
- **المرحلة 8 — اكتساب العملاء تلقائياً:** استقبال العملاء، مطابقة ICP، استخلاص المشاكل، التأهيل BANT، الحجز، مزامنة HubSpot، توليد العروض، الوصول، ومتابعات متدرّجة.
|
||||
- **المرحلة 9 — النمو المستقل:** ذكاء القطاعات السعودية، توليد محتوى ثنائي اللغة، نشر متعدد القنوات، إثراء العملاء، مراقبة المنافسين، وبحث السوق.
|
||||
|
||||
مصمّمة للسوق **السعودي والخليجي** مع دعم **عربي من الدرجة الأولى**، أسعار بـ **الريال السعودي**، معرفة بـ **توقيت آسيا/الرياض**، ومتناغمة مع برامج **رؤية 2030**.
|
||||
|
||||
## ✨ المزايا الرئيسية
|
||||
|
||||
- 🧠 **توجيه ذكي لنماذج LLM** — يوزّع المهام بين **Anthropic Claude** (منطق)، **Gemini** (بحث)، **Groq** (تصنيف سريع)، **DeepSeek** (كود)، **GLM** (عربي). سلسلة احتياط تلقائية عند الفشل.
|
||||
- 🤖 **أكثر من 15 وكيلاً إنتاجياً** — كل وكيل بمدخلات/مخرجات مُعرَّفة، سجلات مهيكلة، تقهقر لطيف، واختبارات.
|
||||
- 🌍 **ثنائي اللغة AR/EN** — محتوى، سكربتات مبيعات، prompts، واجهات تدعم العربية أولاً.
|
||||
- 🔒 **الأمن أولاً** — الإعدادات من `.env` فقط، استخدام `SecretStr` لكل سر، فحوصات gitleaks + detect-secrets + bandit قبل كل commit، تكامل LinkedIn آمن من حيث الشروط.
|
||||
- 🐳 **جاهز للسحابة** — Dockerfile متعدد المراحل، حاوية بمستخدم غير جذري، stack كامل بـ Docker Compose (التطبيق + Postgres + Redis + MongoDB)، CI/CD عبر GitHub Actions.
|
||||
- 📊 **قابل للمراقبة** — سجلات مهيكلة بـ structlog، تتبع LLM اختياري عبر Langfuse، تتبع الاستخدام لكل مزود.
|
||||
- 🇸🇦 **سعودي أصيل** — ١٢ قطاعاً ببيانات منسّقة (عقار، صحة، تعليم، لوجستيات، فينتك…)، مرجع للمنظّمين السعوديين، أسعار بـ SAR/USD، أعياد سعودية.
|
||||
|
||||
## 🚀 البدء السريع
|
||||
|
||||
### المتطلبات
|
||||
|
||||
- Python 3.11 أو 3.12
|
||||
- Docker + Docker Compose (اختياري)
|
||||
- على الأقل مفتاح API واحد لـ LLM (يُنصح بـ Anthropic)
|
||||
|
||||
### 1. استنساخ المشروع وإعداد البيئة
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR-ORG/ai-company-saudi.git
|
||||
cd ai-company-saudi
|
||||
|
||||
# إعداد لمرة واحدة
|
||||
make setup
|
||||
```
|
||||
|
||||
### 2. إعداد الأسرار
|
||||
|
||||
عدّل `.env` وأضف مفاتيح الـ API. **الحد الأدنى:** `ANTHROPIC_API_KEY`.
|
||||
|
||||
> ⚠️ **لا ترفع `.env` أبداً.** المشروع يحميك بـ `.gitignore` و pre-commit hook عبر gitleaks.
|
||||
|
||||
### 3. التشغيل
|
||||
|
||||
```bash
|
||||
# محلياً
|
||||
make run
|
||||
# → http://localhost:8000/docs
|
||||
|
||||
# أو الـ stack الكامل
|
||||
make docker-up
|
||||
make docker-logs
|
||||
```
|
||||
|
||||
### 4. جرّب
|
||||
|
||||
```bash
|
||||
# إرسال عميل محتمل عبر قمع اكتساب العملاء الكامل
|
||||
curl -X POST http://localhost:8000/api/v1/leads \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"company": "شركة التقنية المتقدمة",
|
||||
"name": "أحمد محمد",
|
||||
"email": "ahmed@example.sa",
|
||||
"phone": "+966501234567",
|
||||
"sector": "technology",
|
||||
"region": "Saudi Arabia",
|
||||
"budget": 50000,
|
||||
"message": "نحتاج نظام AI لإدارة المبيعات"
|
||||
}'
|
||||
```
|
||||
|
||||
## 📊 وكلاء المرحلة 8 — الاكتساب
|
||||
|
||||
| الوكيل | الوظيفة |
|
||||
| --- | --- |
|
||||
| Intake | التقاط العملاء من مصادر متعددة، توحيد، تكرار |
|
||||
| ICP Matcher | تقييم بـ ٥ أبعاد + تصنيف (A/B/C/D) |
|
||||
| Pain Extractor | استخلاص المشاكل ودرجة الاستعجال (عربي + إنجليزي) |
|
||||
| Qualification | أسئلة BANT وتحديث المرحلة |
|
||||
| Booking | Calendly → Google Calendar → يدوي |
|
||||
| CRM | مزامنة HubSpot (contact + deal) |
|
||||
| Proposal | عروض مُعدّة بـ Claude، أسعار حسب المنطقة |
|
||||
| Outreach | افتتاحيات وصول باردة ثنائية اللغة |
|
||||
| Follow-up | رسائل متابعة متدرّجة |
|
||||
|
||||
## 📈 وكلاء المرحلة 9 — النمو
|
||||
|
||||
| الوكيل | الوظيفة |
|
||||
| --- | --- |
|
||||
| Sector Intel | ١٢ قطاعاً سعودياً ببيانات منسّقة |
|
||||
| Content Creator | مقالات + LinkedIn + دراسات حالة ثنائية اللغة |
|
||||
| Distribution | جدولة متعددة القنوات (توقيت الرياض) |
|
||||
| Enrichment | إثراء العميل من النطاق + LLM |
|
||||
| Competitor Monitor | تحليل المنافسين واقتراح ردود |
|
||||
| Market Research | بحث سوقي عبر Gemini بمصادر |
|
||||
|
||||
## 📚 التوثيق
|
||||
|
||||
| الوثيقة | الوصف |
|
||||
| --- | --- |
|
||||
| [`docs/architecture.md`](docs/architecture.md) | هيكل النظام |
|
||||
| [`docs/agents.md`](docs/agents.md) | كل وكيل مُوثّق |
|
||||
| [`docs/api.md`](docs/api.md) | مرجع REST API |
|
||||
| [`docs/deployment.md`](docs/deployment.md) | النشر الإنتاجي |
|
||||
| [`docs/pricing.md`](docs/pricing.md) | الأسعار |
|
||||
|
||||
## 🤝 المساهمة
|
||||
|
||||
راجع [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## 📜 الرخصة
|
||||
|
||||
MIT — راجع [LICENSE](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
<div align="center" dir="rtl">
|
||||
|
||||
**[📖 التوثيق](docs/)** · **[🐛 المشاكل](../../issues)** · **[💬 النقاشات](../../discussions)**
|
||||
|
||||
</div>
|
||||
367
dealix/README.md
Normal file
367
dealix/README.md
Normal file
@ -0,0 +1,367 @@
|
||||
<div align="center">
|
||||
|
||||
# 🏢 Dealix — AI Company Saudi
|
||||
|
||||
### Sovereign, policy-governed Growth & Execution OS for Saudi enterprises
|
||||
### نظام نمو وتنفيذ سيادي محكوم بالسياسات، للشركات السعودية
|
||||
|
||||
[](https://github.com/VoXc2/dealix/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://fastapi.tiangolo.com/)
|
||||
[](tests/)
|
||||
[](docs/architecture/API_MAP.md)
|
||||
|
||||
**[العربية](README.ar.md)** · **English**
|
||||
|
||||
### [🚀 Deploy Now](docs/ops/DEPLOY_NOW.md) · [📦 .env Template](.env.example) · [🎯 Landing](landing/) · [🗺️ API Map](docs/architecture/API_MAP.md) · [🏢 Day 1 Plan](docs/business/FIRST_100_TARGETS_PLAN.md)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 What's in this repo
|
||||
|
||||
**Backend** — FastAPI + SQLAlchemy 2.0 async + Postgres. 13 routers / 102 endpoints. See [API_MAP.md](docs/architecture/API_MAP.md).
|
||||
|
||||
**Lead Machine** — Provider adapter chains for Search / Maps / Crawler / Tech / EmailIntel that fall back gracefully when env keys are missing. See [PROVIDER_ADAPTERS.md](docs/architecture/PROVIDER_ADAPTERS.md).
|
||||
|
||||
**Data Lake + Lead Graph** — 7-table compliant ingestion: `raw_lead_imports → raw_lead_rows → accounts → contacts → signals → lead_scores → data_suppression_list`. PDPL-aware (allowed_use, consent_status, opt_out, risk_level mandatory per row). See [DATA_LAKE_PLAYBOOK.md](docs/ops/DATA_LAKE_PLAYBOOK.md).
|
||||
|
||||
**Frontend** — Static landing on GitHub Pages + interactive dashboard with live Saudi Lead Engine demo. See [landing/](landing/).
|
||||
|
||||
**Day-1 Operating Kit** — 287 outreach-ready Saudi B2B accounts pre-built across 7 segments (real-estate / construction / hospitality / events / food / logistics / SaaS / agency). Pricing ladder + Pilot offer + Partner model + Channel templates. See [docs/business/](docs/business/).
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Quick Deploy
|
||||
|
||||
Any Docker-capable platform works. See [DEPLOYMENT.md](DEPLOYMENT.md) for Railway, Render, Fly.io, Heroku, DigitalOcean, AWS, self-hosted.
|
||||
|
||||
```bash
|
||||
# Local
|
||||
docker build -t dealix .
|
||||
cp .env.example .env # edit with real values
|
||||
docker run -p 8000:8000 --env-file .env dealix
|
||||
curl localhost:8000/health
|
||||
```
|
||||
|
||||
**Public endpoints (no auth):** `/health`, `/api/v1/public/demo-request`, `/api/v1/pricing/plans`, `/api/v1/checkout`, `/api/v1/webhooks/moyasar`
|
||||
|
||||
---
|
||||
|
||||
## 🌟 One-line definition
|
||||
|
||||
> **Dealix is a sovereign, policy-governed Growth & Execution OS for Saudi enterprises. It combines agentic intelligence, deterministic execution, approval controls, and executive observability to drive revenue, partnerships, expansion, and strategic operations with enterprise-grade trust.**
|
||||
|
||||
It is **not** a CRM, **not** a chatbot, **not** a sales automation tool.
|
||||
|
||||
## 🧭 The Prime Operating Rule
|
||||
|
||||
> **AI explores, analyzes, and recommends.**
|
||||
> **Deterministic workflows execute.**
|
||||
> **Humans approve critical moves.**
|
||||
|
||||
No agent makes an external commitment on its own. No critical output leaves the system without being **structured, evidence-backed, policy-evaluated**, and (where required) **human-approved**.
|
||||
|
||||
---
|
||||
|
||||
## 🧱 The six OS tracks
|
||||
|
||||
1. **Revenue OS** — lead to close, pipeline, forecasting
|
||||
2. **Partnership OS** — partner discovery, joint pursuits, co-sell
|
||||
3. **Corporate Development / M&A OS** — sourcing, diligence, integration
|
||||
4. **Expansion OS** — new-market entry, localization
|
||||
5. **PMI / Strategic PMO OS** — post-merger integration, cross-BU initiatives
|
||||
6. **Trust, Policy & Executive Governance OS** — controls, approvals, risk, audit
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Five mandatory planes
|
||||
|
||||
Every feature lives in exactly one plane. Crossing planes happens via **contracts**, never via shared memory or direct calls.
|
||||
|
||||
| Plane | Responsibility | Module |
|
||||
|---|---|---|
|
||||
| **Decision** | Agents: reasoning, synthesis, recommendation, evidence assembly | `auto_client_acquisition/`, `autonomous_growth/`, `core/agents/` |
|
||||
| **Execution** | Durable workflows, retries, compensation, external commitments | `auto_client_acquisition/pipeline.py`, `dealix/execution/` |
|
||||
| **Trust** | Policy, approval, audit, tool verification, evidence packs | `dealix/trust/` |
|
||||
| **Data** | Operational source of truth, semantic metrics, lineage | `db/`, `integrations/` |
|
||||
| **Operating** | Repo governance, CI/CD, releases, SDLC security | `.github/`, `Dockerfile`, `Makefile` |
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ What makes this Tier-1
|
||||
|
||||
### 1. Structured outputs with classifications
|
||||
Every critical agent output is a validated `DecisionOutput` (Pydantic + JSON Schema) carrying:
|
||||
- **Approval class** (A0–A3): who must approve
|
||||
- **Reversibility class** (R0–R3): how hard to undo
|
||||
- **Sensitivity class** (S0–S3): data/impact risk
|
||||
|
||||
### 2. Trust Plane as a non-bypassable overlay
|
||||
Every NextAction runs through a `PolicyEvaluator` that returns `ALLOW` / `DENY` / `ESCALATE`. Escalations create `ApprovalRequest`s with TTL + multi-approver support. Every step is **audited**.
|
||||
|
||||
### 3. Never-auto-execute list
|
||||
Hardcoded in `dealix/classifications/NEVER_AUTO_EXECUTE`: pricing commits, contract changes, NDAs, payment terms, regulator comms, sensitive data exports — these **cannot** bypass human approval, regardless of other signals.
|
||||
|
||||
### 4. Evidence packs on high-stakes decisions
|
||||
A2+/R3/S3 decisions **cannot be constructed without evidence** — Pydantic validator enforces it. Every pack ships with sources, tool calls (intended vs actual), prompts used, model versions, and a bilingual AR/EN board-grade memo.
|
||||
|
||||
### 5. No-overclaim register
|
||||
Every public product claim is tracked in [`dealix/registers/no_overclaim.yaml`](dealix/registers/no_overclaim.yaml) with status (`Production` / `Partial` / `Pilot` / `Planned`) and evidence paths.
|
||||
|
||||
### 6. Saudi-native from day one
|
||||
Not localization — Gulf business register Arabic, SAR pricing tiers, Riyadh timezone awareness, PDPL lawful-basis enforcement via policy rules, NCA ECC/DCC/CCC mapping in [`dealix/registers/compliance_saudi.yaml`](dealix/registers/compliance_saudi.yaml).
|
||||
|
||||
---
|
||||
|
||||
## ✨ Core technical features
|
||||
|
||||
- 🧠 **Multi-LLM routing with fallback** — Claude, Gemini, Groq, DeepSeek, GLM, OpenAI. Task → best provider → auto-fallback on failure. Per-provider usage tracking.
|
||||
- 🤖 **15+ production agents** — typed I/O, structured logging, graceful degradation, 63 tests.
|
||||
- 🌍 **First-class bilingual AR/EN** — detection, routing (Arabic → GLM), content generation, sales scripts, docs.
|
||||
- 🔒 **Security-first** — `.env`-only config, `SecretStr` everywhere, gitleaks + detect-secrets + trufflehog + bandit in pre-commit AND CI, webhook HMAC verification, non-root Docker, ToS-safe LinkedIn.
|
||||
- 🐳 **Cloud-ready** — multi-stage Dockerfile, docker-compose stack (Postgres + Redis + Mongo), GitHub Actions CI/CD, GHCR image push on release tags.
|
||||
- 📊 **Observable** — structlog JSON logs in prod, request IDs, per-provider LLM usage metrics, optional Langfuse integration.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Clients
|
||||
W[Website Forms]
|
||||
WA[WhatsApp Business]
|
||||
E[Email]
|
||||
end
|
||||
|
||||
subgraph Gateway["FastAPI Gateway"]
|
||||
R[6 routers + middleware]
|
||||
end
|
||||
|
||||
subgraph Decision["Decision Plane — agents"]
|
||||
I[Intake] --> P[Pain Extract]
|
||||
P --> IC[ICP Match]
|
||||
IC --> Q[Qualification]
|
||||
end
|
||||
|
||||
subgraph Trust["Trust Plane — NON-BYPASSABLE"]
|
||||
POL[Policy Evaluator]
|
||||
APR[Approval Center]
|
||||
AUD[Audit Sink]
|
||||
TV[Tool Verification Ledger]
|
||||
end
|
||||
|
||||
subgraph Execution["Execution Plane — deterministic"]
|
||||
CRM[HubSpot sync]
|
||||
BK[Booking]
|
||||
PS[Proposal send]
|
||||
end
|
||||
|
||||
subgraph LLM["LLM Router — fallback"]
|
||||
CL[Claude]
|
||||
GM[Gemini]
|
||||
GQ[Groq]
|
||||
DS[DeepSeek]
|
||||
GL[GLM]
|
||||
end
|
||||
|
||||
Clients --> Gateway
|
||||
Gateway --> Decision
|
||||
Decision --> Trust
|
||||
Trust -->|ALLOW| Execution
|
||||
Trust -->|ESCALATE| HUMAN[Human approver]
|
||||
HUMAN --> Execution
|
||||
Decision --> LLM
|
||||
Trust --> AUD
|
||||
```
|
||||
|
||||
Full blueprint: [`docs/blueprint/master-architecture.md`](docs/blueprint/master-architecture.md).
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR-ORG/ai-company-saudi.git
|
||||
cd ai-company-saudi
|
||||
make setup
|
||||
# edit .env, then:
|
||||
make run
|
||||
# → http://localhost:8000/docs
|
||||
```
|
||||
|
||||
Full stack (app + Postgres + Redis + Mongo):
|
||||
```bash
|
||||
make docker-up
|
||||
```
|
||||
|
||||
### Try the governed pipeline
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/leads \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"company": "شركة التقنية المتقدمة",
|
||||
"name": "أحمد محمد",
|
||||
"email": "ahmed@example.sa",
|
||||
"phone": "+966501234567",
|
||||
"sector": "technology",
|
||||
"region": "Saudi Arabia",
|
||||
"budget": 50000,
|
||||
"message": "نحتاج نظام AI لإدارة المبيعات"
|
||||
}'
|
||||
```
|
||||
|
||||
### Use the GovernedPipeline directly (shows the governance layer)
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from dealix.execution import GovernedPipeline
|
||||
|
||||
async def main():
|
||||
gp = GovernedPipeline()
|
||||
result = await gp.run(payload={
|
||||
"company": "...",
|
||||
"name": "...",
|
||||
"message": "..."
|
||||
})
|
||||
print(f"Decisions: {len(result.decisions)}")
|
||||
print(f"Policy results: {len(result.policy_results)}")
|
||||
print(f"Approval requests: {len(result.approval_requests)}")
|
||||
print(f"Audit trail: {len(result.audit_trail)} entries")
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 The twelve Master Documents
|
||||
|
||||
All under [`dealix/masters/`](dealix/masters/) and [`dealix/registers/`](dealix/registers/):
|
||||
|
||||
1. [Master Architecture Blueprint](docs/blueprint/master-architecture.md) — canonical source of truth
|
||||
2. [AI Operating Constitution](dealix/masters/constitution.md) — binding rules
|
||||
3. [Trust Fabric Specification](dealix/masters/trust_fabric_spec.md)
|
||||
4. [Execution Fabric Specification](dealix/masters/execution_fabric_spec.md)
|
||||
5. [Repo Operating Pack](dealix/masters/repo_operating_pack.md)
|
||||
6. [90-Day Execution Matrix](dealix/registers/90_day_execution.yaml)
|
||||
7. [Saudi Compliance Register](dealix/registers/compliance_saudi.yaml) — PDPL + NCA + AI governance
|
||||
8. [Technology Radar](dealix/registers/technology_radar.yaml)
|
||||
9. [Incident & Rollback Runbook](dealix/masters/incident_rollback_runbook.md)
|
||||
10. [Release Readiness Checklist](dealix/masters/release_readiness_checklist.md)
|
||||
11. [No-Overclaim Register](dealix/registers/no_overclaim.yaml) — every public claim tracked
|
||||
12. [Evidence Pack Specification](dealix/masters/evidence_pack_spec.md)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
make test # 63 tests, all passing
|
||||
```
|
||||
|
||||
Tests include: intake, ICP matcher, pain extractor, model router, API endpoints, full Phase 8 pipeline, **Dealix contracts (with high-stakes validation)**, **Trust Plane (policy + approval + audit + tool verification)**, **Governed pipeline end-to-end**.
|
||||
|
||||
---
|
||||
|
||||
## 🧰 Tech stack
|
||||
|
||||
| Layer | Choice | Status |
|
||||
|---|---|---|
|
||||
| Language | Python 3.11 / 3.12 | ADOPT |
|
||||
| Framework | FastAPI 0.115 + Uvicorn | ADOPT |
|
||||
| Validation | Pydantic v2 + pydantic-settings | ADOPT |
|
||||
| Contracts | JSON Schema + CloudEvents 1.0 | ADOPT |
|
||||
| DB | PostgreSQL 16 + pgvector | ADOPT |
|
||||
| LLM | Claude, Gemini, Groq, DeepSeek, GLM, OpenAI fallback | ADOPT |
|
||||
| Execution | In-process → LangGraph → Temporal spike | TRIAL→ADOPT |
|
||||
| Trust — Policy | In-process → OPA/Rego | TRIAL |
|
||||
| Trust — AuthZ | In-process → OpenFGA | TRIAL |
|
||||
| Trust — Identity | local → Keycloak | TRIAL |
|
||||
| Trust — Secrets | `.env` + SecretStr → Vault | TRIAL |
|
||||
| Observability | structlog → OpenTelemetry | TRIAL |
|
||||
| CI/CD | GitHub Actions + rulesets + OIDC | ADOPT |
|
||||
|
||||
Full radar: [`dealix/registers/technology_radar.yaml`](dealix/registers/technology_radar.yaml).
|
||||
|
||||
---
|
||||
|
||||
## 📊 Phase 8 — Acquisition agents
|
||||
|
||||
All 9 agents + pipeline. Every output lifts to a `DecisionOutput` via `dealix.contracts.builders`.
|
||||
|
||||
| Agent | Classification | Role |
|
||||
|---|---|---|
|
||||
| Intake | A0/R0/S2 | Multi-source lead capture, normalization, dedup |
|
||||
| ICP Matcher | A0/R0/S1 | 5-dim weighted Fit scoring with tier A/B/C/D |
|
||||
| Pain Extractor | A0/R0/S1 | Hybrid keyword + LLM pain extraction (AR+EN) |
|
||||
| Qualification | A0/R0/S1 | BANT questions, status advancement |
|
||||
| Booking | **A1**/R1/S2 | Calendly → Google Calendar → manual (requires approval) |
|
||||
| CRM | A0→**A1**/R1/S2 | HubSpot contact upsert (A0) + deal create (A1) |
|
||||
| Proposal draft | A0/R0/S2 | Claude-authored, region-aware pricing |
|
||||
| Proposal send | **A2/R2**/S2 | Gated — requires manager + legal approval |
|
||||
| Outreach | **A1**/R2/S2 | Bilingual cold openers — gated |
|
||||
| Follow-up | **A1**/R2/S2 | Cadence-based — gated |
|
||||
|
||||
---
|
||||
|
||||
## 📈 Phase 9 — Growth agents
|
||||
|
||||
| Agent | Role |
|
||||
|---|---|
|
||||
| Sector Intel | 12 Saudi sectors with curated market size, growth, AI readiness |
|
||||
| Content Creator | Bilingual articles, LinkedIn, case studies, newsletters |
|
||||
| Distribution | Multi-channel scheduling (Riyadh timezone) |
|
||||
| Enrichment | Domain + LLM-based lead enrichment |
|
||||
| Competitor Monitor | Positioning, pricing hints, counter-moves |
|
||||
| Market Research | Gemini-powered research with bullet findings |
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
- `.env`-only config via `pydantic-settings`; `SecretStr` on every sensitive value
|
||||
- Pre-commit: `gitleaks`, `detect-secrets`, `bandit`, `hadolint`
|
||||
- CI: re-runs the above + `trufflehog` on every push and PR
|
||||
- Webhook HMAC verification (WhatsApp)
|
||||
- Non-root Docker container with healthcheck
|
||||
- LinkedIn integration disabled by default (ToS compliance)
|
||||
- See [SECURITY.md](SECURITY.md) for reporting vulnerabilities
|
||||
|
||||
---
|
||||
|
||||
## 🇸🇦 Saudi compliance
|
||||
|
||||
Designed from inception for:
|
||||
|
||||
- **PDPL** — lawful-basis register, retention schedule, breach response, DPO assessment, cross-border transfer posture
|
||||
- **NCA ECC 2-2024** — Essential Cybersecurity Controls
|
||||
- **NCA DCC-1:2022** — Data Cybersecurity Controls
|
||||
- **NCA CCC 2:2024** — Cloud Cybersecurity Controls
|
||||
- **NIST AI RMF 1.0** + **OWASP Top 10 for LLM Applications**
|
||||
|
||||
Full register: [`dealix/registers/compliance_saudi.yaml`](dealix/registers/compliance_saudi.yaml).
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) and [Repo Operating Pack](dealix/masters/repo_operating_pack.md).
|
||||
By participating you agree to the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**[📖 Blueprint](docs/blueprint/master-architecture.md)** · **[🛡️ Constitution](dealix/masters/constitution.md)** · **[📋 No-Overclaim Register](dealix/registers/no_overclaim.yaml)** · **[🇸🇦 Compliance](dealix/registers/compliance_saudi.yaml)**
|
||||
|
||||
</div>
|
||||
74
dealix/SECURITY.md
Normal file
74
dealix/SECURITY.md
Normal file
@ -0,0 +1,74 @@
|
||||
# Security Policy | سياسة الأمن
|
||||
|
||||
## 🛡️ Supported versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 2.x | ✅ |
|
||||
| 1.x | ❌ (EOL) |
|
||||
|
||||
## 🐛 Reporting a vulnerability
|
||||
|
||||
**Please do NOT open a public issue for security vulnerabilities.**
|
||||
|
||||
Instead, report them privately via:
|
||||
|
||||
- **Email**: security@ai-company.sa
|
||||
- **GitHub Security Advisories**: [Open a private advisory](../../security/advisories/new)
|
||||
|
||||
Include:
|
||||
1. A description of the vulnerability.
|
||||
2. Steps to reproduce.
|
||||
3. Potential impact.
|
||||
4. Any suggested fixes.
|
||||
|
||||
We aim to acknowledge within **48 hours** and provide a resolution timeline within **7 days**.
|
||||
|
||||
## 🔒 Security features in this project
|
||||
|
||||
- **Config**: all secrets loaded from `.env` via `pydantic-settings` with `SecretStr`.
|
||||
- **Secret scanning**: `gitleaks` + `detect-secrets` + `trufflehog` in pre-commit AND CI.
|
||||
- **Dependency scanning**: Dependabot weekly + `bandit` Python security linter.
|
||||
- **Docker**: non-root user, multi-stage build, minimal base image.
|
||||
- **Webhooks**: HMAC-SHA256 signature verification (WhatsApp).
|
||||
- **LinkedIn integration**: disabled by default (ToS compliance).
|
||||
|
||||
## 🔑 Key rotation guidance
|
||||
|
||||
If you believe a key has been exposed:
|
||||
|
||||
1. **Immediately** rotate the key in the provider's dashboard:
|
||||
- Anthropic Console → API Keys → regenerate
|
||||
- DeepSeek, Groq, GLM, Google, OpenAI: regenerate in respective consoles
|
||||
- HubSpot, Resend, SendGrid: regenerate
|
||||
- WhatsApp Business: regenerate access token
|
||||
2. Update `.env` with the new key.
|
||||
3. Redeploy.
|
||||
4. Check GitHub → Settings → Secret scanning alerts.
|
||||
5. Run `gitleaks detect --source . --report-format json` to scan history.
|
||||
|
||||
## ✅ Pre-commit checklist for maintainers
|
||||
|
||||
Before merging any PR:
|
||||
- [ ] `gitleaks` pre-commit hook passed
|
||||
- [ ] No new files in `.env*` except `.env.example`
|
||||
- [ ] No new domain-specific secrets in `core/` or `integrations/`
|
||||
- [ ] All new integrations use `settings.*_api_key.get_secret_value()` pattern
|
||||
|
||||
---
|
||||
|
||||
## 🇸🇦 بالعربية
|
||||
|
||||
### الإبلاغ عن ثغرات
|
||||
|
||||
**لا تفتح issue عام للثغرات الأمنية.** أرسل إلى: **security@ai-company.sa**
|
||||
|
||||
نهدف للرد خلال ٤٨ ساعة وتقديم جدول زمني للحل خلال ٧ أيام.
|
||||
|
||||
### تدوير المفاتيح
|
||||
|
||||
إذا تسرّب مفتاح:
|
||||
1. **فوراً** دوّر المفتاح من لوحة المزود.
|
||||
2. حدّث `.env`.
|
||||
3. أعد النشر.
|
||||
4. افحص تنبيهات GitHub secret scanning.
|
||||
1
dealix/api/__init__.py
Normal file
1
dealix/api/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""FastAPI application package."""
|
||||
36
dealix/api/dependencies.py
Normal file
36
dealix/api/dependencies.py
Normal file
@ -0,0 +1,36 @@
|
||||
"""FastAPI dependencies — dependency injection for services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from auto_client_acquisition.agents.proposal import ProposalAgent
|
||||
from auto_client_acquisition.pipeline import AcquisitionPipeline
|
||||
from autonomous_growth.agents.content import ContentCreatorAgent
|
||||
from autonomous_growth.agents.sector_intel import SectorIntelAgent
|
||||
from autonomous_growth.orchestrator import GrowthOrchestrator
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_acquisition_pipeline() -> AcquisitionPipeline:
|
||||
return AcquisitionPipeline()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_growth_orchestrator() -> GrowthOrchestrator:
|
||||
return GrowthOrchestrator()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_sector_intel_agent() -> SectorIntelAgent:
|
||||
return SectorIntelAgent()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_content_agent() -> ContentCreatorAgent:
|
||||
return ContentCreatorAgent()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_proposal_agent() -> ProposalAgent:
|
||||
return ProposalAgent()
|
||||
46
dealix/api/deps.py
Normal file
46
dealix/api/deps.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""FastAPI dependencies — shared Redis client, ApprovalGate, PostHog, etc."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from dealix.governance import ApprovalGate
|
||||
|
||||
_redis: aioredis.Redis | None = None
|
||||
_gate: ApprovalGate | None = None
|
||||
|
||||
|
||||
def _redis_url() -> str:
|
||||
return os.getenv("REDIS_URL", "redis://127.0.0.1:6379/0")
|
||||
|
||||
|
||||
async def get_redis() -> aioredis.Redis:
|
||||
global _redis
|
||||
if _redis is None:
|
||||
_redis = aioredis.from_url(
|
||||
_redis_url(),
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=3,
|
||||
socket_timeout=3,
|
||||
)
|
||||
return _redis
|
||||
|
||||
|
||||
async def get_approval_gate() -> ApprovalGate:
|
||||
global _gate
|
||||
if _gate is None:
|
||||
r = await get_redis()
|
||||
_gate = ApprovalGate(r)
|
||||
return _gate
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_posthog_client():
|
||||
"""Lazy PostHog client — HTTP-only, no SDK weight."""
|
||||
from dealix.analytics.posthog_client import PostHogClient
|
||||
|
||||
return PostHogClient()
|
||||
183
dealix/api/main.py
Normal file
183
dealix/api/main.py
Normal file
@ -0,0 +1,183 @@
|
||||
"""
|
||||
FastAPI application entry point.
|
||||
نقطة دخول تطبيق FastAPI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from api.middleware import RequestIDMiddleware
|
||||
from api.routers import (
|
||||
admin,
|
||||
agents,
|
||||
automation,
|
||||
autonomous,
|
||||
business,
|
||||
command_center,
|
||||
customer_success,
|
||||
data,
|
||||
dominance,
|
||||
drafts,
|
||||
ecosystem,
|
||||
email_send,
|
||||
full_os,
|
||||
health,
|
||||
innovation,
|
||||
leads,
|
||||
outreach,
|
||||
personal_operator,
|
||||
pricing,
|
||||
prospect,
|
||||
public,
|
||||
revenue,
|
||||
revenue_os,
|
||||
sales,
|
||||
sectors,
|
||||
v3,
|
||||
webhooks,
|
||||
)
|
||||
from api.security import APIKeyMiddleware, setup_rate_limit
|
||||
from core.config.settings import get_settings
|
||||
from core.errors import AICompanyError
|
||||
from core.logging import configure_logging, get_logger
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||
"""App startup/shutdown hook."""
|
||||
configure_logging()
|
||||
log = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
log.info(
|
||||
"app_startup",
|
||||
app=settings.app_name,
|
||||
version=settings.app_version,
|
||||
env=settings.app_env,
|
||||
)
|
||||
# Auto-create tables on boot (additive — safe with SQLAlchemy create_all)
|
||||
try:
|
||||
from db.session import init_db
|
||||
await init_db()
|
||||
log.info("db_init_complete")
|
||||
except Exception as exc:
|
||||
log.warning("db_init_skipped", error=str(exc))
|
||||
yield
|
||||
log.info("app_shutdown")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""FastAPI factory."""
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version=settings.app_version,
|
||||
description=(
|
||||
"Multi-agent AI platform for the Saudi Arabian market.\n\n"
|
||||
"**Phase 8**: Auto Client Acquisition — intake, ICP match, "
|
||||
"pain extraction, qualification, CRM sync, booking, proposals.\n\n"
|
||||
"**Phase 9**: Autonomous Growth — sector intel, content, distribution, "
|
||||
"enrichment, competitor analysis, market research.\n\n"
|
||||
"**Phase 10 / v3**: Autonomous Saudi Revenue OS — revenue memory, "
|
||||
"safe agent runtime, market radar, compliance OS, revenue science, "
|
||||
"and Sami Personal Strategic Operator."
|
||||
),
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(RequestIDMiddleware)
|
||||
app.add_middleware(APIKeyMiddleware)
|
||||
setup_rate_limit(app)
|
||||
|
||||
try:
|
||||
from dealix.observability import instrument_fastapi, setup_sentry, setup_tracing
|
||||
|
||||
setup_sentry()
|
||||
setup_tracing(service_name=settings.app_name, version=settings.app_version)
|
||||
instrument_fastapi(app)
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
@app.exception_handler(AICompanyError)
|
||||
async def ai_company_error_handler(_: Request, exc: AICompanyError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": exc.__class__.__name__, "detail": str(exc)},
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(leads.router)
|
||||
app.include_router(sales.router)
|
||||
app.include_router(sectors.router)
|
||||
app.include_router(agents.router)
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(pricing.router)
|
||||
app.include_router(prospect.router)
|
||||
app.include_router(autonomous.router)
|
||||
app.include_router(data.router)
|
||||
app.include_router(outreach.router)
|
||||
app.include_router(revenue.router)
|
||||
app.include_router(automation.router)
|
||||
app.include_router(email_send.router)
|
||||
app.include_router(drafts.router)
|
||||
app.include_router(dominance.router)
|
||||
app.include_router(full_os.router)
|
||||
app.include_router(customer_success.router)
|
||||
app.include_router(ecosystem.router)
|
||||
app.include_router(command_center.router)
|
||||
app.include_router(revenue_os.router)
|
||||
app.include_router(v3.router)
|
||||
app.include_router(innovation.router)
|
||||
app.include_router(business.router)
|
||||
app.include_router(personal_operator.router)
|
||||
app.include_router(public.router)
|
||||
app.include_router(admin.router)
|
||||
|
||||
@app.get("/", tags=["root"])
|
||||
async def root() -> dict[str, object]:
|
||||
return {
|
||||
"name": settings.app_name,
|
||||
"version": settings.app_version,
|
||||
"status": "operational",
|
||||
"env": settings.app_env,
|
||||
"docs": "/docs",
|
||||
"health": "/health",
|
||||
"v3_command_center": "/api/v1/v3/command-center/snapshot",
|
||||
"personal_operator_daily_brief": "/api/v1/personal-operator/daily-brief",
|
||||
"personal_operator_launch_report": "/api/v1/personal-operator/launch-report",
|
||||
"business_pricing": "/api/v1/business/pricing",
|
||||
"innovation_command_feed_demo": "/api/v1/innovation/command-feed/demo",
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
settings = get_settings()
|
||||
uvicorn.run(
|
||||
"api.main:app",
|
||||
host=settings.app_host,
|
||||
port=settings.app_port,
|
||||
reload=settings.is_development,
|
||||
)
|
||||
49
dealix/api/middleware.py
Normal file
49
dealix/api/middleware.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""FastAPI middleware — request ID, structured logging, timing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import structlog
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class RequestIDMiddleware(BaseHTTPMiddleware):
|
||||
"""Attach a unique request ID to each request and bind it to logs."""
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
|
||||
structlog.contextvars.clear_contextvars()
|
||||
structlog.contextvars.bind_contextvars(
|
||||
request_id=request_id,
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
)
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception as e:
|
||||
logger.exception("request_unhandled_error", error=str(e))
|
||||
raise
|
||||
duration_ms = (time.perf_counter() - start) * 1000
|
||||
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
logger.info(
|
||||
"request_completed",
|
||||
status_code=response.status_code,
|
||||
duration_ms=round(duration_ms, 2),
|
||||
)
|
||||
return response
|
||||
1
dealix/api/routers/__init__.py
Normal file
1
dealix/api/routers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""API routers."""
|
||||
217
dealix/api/routers/admin.py
Normal file
217
dealix/api/routers/admin.py
Normal file
@ -0,0 +1,217 @@
|
||||
"""Admin endpoints — cost dashboard, cache stats."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.deps import get_approval_gate
|
||||
from dealix.caching.cache_stats import get_global_stats
|
||||
from dealix.governance import ApprovalDecision
|
||||
from dealix.observability.cost_tracker import CostTracker
|
||||
from dealix.reliability.dlq import (
|
||||
CRM_SYNC_DLQ,
|
||||
DLQ,
|
||||
ENRICHMENT_DLQ,
|
||||
OUTBOUND_DLQ,
|
||||
WEBHOOKS_DLQ,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
|
||||
|
||||
_tracker = CostTracker()
|
||||
|
||||
|
||||
@router.get("/costs")
|
||||
async def costs(
|
||||
window_hours: int = Query(24, ge=1, le=720),
|
||||
group_by: str = Query("model", regex="^(model|provider|task)$"),
|
||||
) -> dict[str, Any]:
|
||||
"""Aggregate LLM spend over the last N hours."""
|
||||
since = datetime.now(UTC) - timedelta(hours=window_hours)
|
||||
entries = _tracker.query_window(since=since)
|
||||
|
||||
total_usd = sum(e.cost_usd for e in entries)
|
||||
total_in = sum(e.input_tokens for e in entries)
|
||||
total_out = sum(e.output_tokens for e in entries)
|
||||
total_cached = sum(getattr(e, "cached_tokens", 0) for e in entries)
|
||||
|
||||
groups: dict[str, dict[str, float]] = {}
|
||||
for e in entries:
|
||||
key = getattr(e, group_by, "unknown") or "unknown"
|
||||
g = groups.setdefault(str(key), {"usd": 0.0, "calls": 0, "in": 0, "out": 0})
|
||||
g["usd"] += e.cost_usd
|
||||
g["calls"] += 1
|
||||
g["in"] += e.input_tokens
|
||||
g["out"] += e.output_tokens
|
||||
|
||||
return {
|
||||
"window_hours": window_hours,
|
||||
"group_by": group_by,
|
||||
"totals": {
|
||||
"usd": round(total_usd, 4),
|
||||
"calls": len(entries),
|
||||
"input_tokens": total_in,
|
||||
"output_tokens": total_out,
|
||||
"cached_tokens": total_cached,
|
||||
"cache_hit_ratio": round(total_cached / total_in, 3) if total_in else 0.0,
|
||||
},
|
||||
"by_group": {k: {**v, "usd": round(v["usd"], 4)} for k, v in groups.items()},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/cache/stats")
|
||||
async def cache_stats() -> dict[str, Any]:
|
||||
"""Semantic cache hit/miss stats."""
|
||||
return get_global_stats()
|
||||
|
||||
|
||||
@router.get("/dlq/stats")
|
||||
async def dlq_stats() -> dict[str, Any]:
|
||||
"""Dead-letter queue depth and last errors across all queues."""
|
||||
return {q: DLQ(q).stats() for q in (WEBHOOKS_DLQ, OUTBOUND_DLQ, ENRICHMENT_DLQ, CRM_SYNC_DLQ)}
|
||||
|
||||
|
||||
@router.get("/dlq/{queue}/peek")
|
||||
async def dlq_peek(queue: str, n: int = Query(10, ge=1, le=100)) -> dict[str, Any]:
|
||||
"""Inspect the first N items in a DLQ without removing them."""
|
||||
dlq = DLQ(queue)
|
||||
items = dlq.peek(n=n)
|
||||
return {
|
||||
"queue": queue,
|
||||
"returned": len(items),
|
||||
"depth": dlq.depth(),
|
||||
"items": [
|
||||
{
|
||||
"id": it.id,
|
||||
"source": it.source,
|
||||
"error": it.error,
|
||||
"attempts": it.attempts,
|
||||
"first_seen_at": it.first_seen_at,
|
||||
"last_attempt_at": it.last_attempt_at,
|
||||
"payload_keys": (list(it.payload.keys()) if isinstance(it.payload, dict) else []),
|
||||
}
|
||||
for it in items
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/dlq/{queue}/drain")
|
||||
async def dlq_drain(queue: str, limit: int = Query(10, ge=1, le=100)) -> dict[str, Any]:
|
||||
"""Remove up to `limit` items from a DLQ. Caller is responsible for replay.
|
||||
Returns drained items for operator inspection / manual retry.
|
||||
"""
|
||||
dlq = DLQ(queue)
|
||||
items = dlq.drain(limit=limit)
|
||||
return {
|
||||
"queue": queue,
|
||||
"drained": len(items),
|
||||
"remaining": dlq.depth(),
|
||||
"items": [
|
||||
{"id": it.id, "source": it.source, "payload": it.payload, "error": it.error}
|
||||
for it in items
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── Approvals Gate ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class ApprovalRequestIn(BaseModel):
|
||||
action: str = Field(..., min_length=1, max_length=128)
|
||||
payload: dict = Field(default_factory=dict)
|
||||
risk_score: float = Field(0.0, ge=0.0, le=1.0)
|
||||
requested_by: str = Field("admin", max_length=128)
|
||||
|
||||
|
||||
class ApprovalDecisionIn(BaseModel):
|
||||
approved: bool
|
||||
decided_by: str = Field(..., min_length=1, max_length=128)
|
||||
note: str = Field("", max_length=1024)
|
||||
|
||||
|
||||
def _approval_to_dict(req) -> dict[str, Any]:
|
||||
return {
|
||||
"id": req.id,
|
||||
"action": req.action,
|
||||
"payload": req.payload,
|
||||
"risk_score": req.risk_score,
|
||||
"requested_by": req.requested_by,
|
||||
"requested_at": req.requested_at,
|
||||
"status": req.status.value,
|
||||
"reason": req.reason,
|
||||
"decided_by": req.decided_by,
|
||||
"decided_at": req.decided_at,
|
||||
"expires_at": req.expires_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/approvals/stats")
|
||||
async def approvals_stats() -> dict[str, Any]:
|
||||
gate = await get_approval_gate()
|
||||
return await gate.stats()
|
||||
|
||||
|
||||
@router.get("/approvals/pending")
|
||||
async def approvals_pending(limit: int = Query(50, ge=1, le=200)) -> dict[str, Any]:
|
||||
gate = await get_approval_gate()
|
||||
items = await gate.list_pending(limit=limit)
|
||||
return {"count": len(items), "items": [_approval_to_dict(r) for r in items]}
|
||||
|
||||
|
||||
@router.post("/approvals/request")
|
||||
async def approvals_request(body: ApprovalRequestIn) -> dict[str, Any]:
|
||||
gate = await get_approval_gate()
|
||||
req = await gate.request(
|
||||
action=body.action,
|
||||
payload=body.payload,
|
||||
risk_score=body.risk_score,
|
||||
requested_by=body.requested_by,
|
||||
)
|
||||
return _approval_to_dict(req)
|
||||
|
||||
|
||||
@router.get("/approvals/{request_id}")
|
||||
async def approvals_get(request_id: str) -> dict[str, Any]:
|
||||
gate = await get_approval_gate()
|
||||
req = await gate.get(request_id)
|
||||
if not req:
|
||||
raise HTTPException(status_code=404, detail="approval request not found")
|
||||
return _approval_to_dict(req)
|
||||
|
||||
|
||||
@router.post("/approvals/{request_id}/decide")
|
||||
async def approvals_decide(request_id: str, body: ApprovalDecisionIn) -> dict[str, Any]:
|
||||
gate = await get_approval_gate()
|
||||
decision = ApprovalDecision(
|
||||
request_id=request_id,
|
||||
approved=body.approved,
|
||||
decided_by=body.decided_by,
|
||||
note=body.note,
|
||||
)
|
||||
req = await gate.decide(decision)
|
||||
if not req:
|
||||
raise HTTPException(status_code=404, detail="approval request not found")
|
||||
return _approval_to_dict(req)
|
||||
|
||||
|
||||
@router.get("/sentry-check")
|
||||
async def sentry_check() -> dict[str, str]:
|
||||
"""Trigger a Sentry test error — verify DSN is live.
|
||||
Call once post-deploy, then remove from production routes if paranoid.
|
||||
"""
|
||||
import os
|
||||
|
||||
try:
|
||||
import sentry_sdk # type: ignore
|
||||
|
||||
sentry_sdk.capture_message(
|
||||
"Dealix sentry-check ping",
|
||||
level="info",
|
||||
)
|
||||
return {"status": "sent", "dsn_configured": str(bool(os.getenv("SENTRY_DSN")))}
|
||||
except Exception: # pragma: no cover
|
||||
return {"status": "error", "error": "sentry_check_failed"}
|
||||
61
dealix/api/routers/agents.py
Normal file
61
dealix/api/routers/agents.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""Direct agent execution endpoints — useful for testing individual agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from auto_client_acquisition.agents.icp_matcher import ICPMatcherAgent
|
||||
from auto_client_acquisition.agents.intake import IntakeAgent, LeadSource
|
||||
from auto_client_acquisition.agents.pain_extractor import PainExtractorAgent
|
||||
from autonomous_growth.agents.market_research import MarketResearchAgent
|
||||
|
||||
router = APIRouter(prefix="/api/v1/agents", tags=["agents"])
|
||||
|
||||
|
||||
@router.post("/intake")
|
||||
async def run_intake(
|
||||
payload: dict[str, Any] = Body(...),
|
||||
source: str = "website",
|
||||
) -> dict[str, Any]:
|
||||
agent = IntakeAgent()
|
||||
lead = await agent.run(payload=payload, source=LeadSource(source))
|
||||
return lead.to_dict()
|
||||
|
||||
|
||||
@router.post("/pain-extractor")
|
||||
async def run_pain_extractor(
|
||||
body: dict[str, Any] = Body(...),
|
||||
) -> dict[str, Any]:
|
||||
agent = PainExtractorAgent()
|
||||
result = await agent.run(
|
||||
message=str(body.get("message", "")),
|
||||
locale=body.get("locale"),
|
||||
use_llm=bool(body.get("use_llm", True)),
|
||||
)
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
@router.post("/icp-match")
|
||||
async def run_icp_match(
|
||||
body: dict[str, Any] = Body(...),
|
||||
) -> dict[str, Any]:
|
||||
intake = IntakeAgent()
|
||||
lead = await intake.run(payload=body, source=LeadSource.API)
|
||||
matcher = ICPMatcherAgent()
|
||||
fit = await matcher.run(lead=lead)
|
||||
return {"lead": lead.to_dict(), "fit_score": fit.to_dict()}
|
||||
|
||||
|
||||
@router.post("/research")
|
||||
async def run_research(
|
||||
body: dict[str, Any] = Body(...),
|
||||
) -> dict[str, Any]:
|
||||
agent = MarketResearchAgent()
|
||||
finding = await agent.run(
|
||||
question=str(body.get("question", "")),
|
||||
locale=str(body.get("locale", "en")),
|
||||
depth=str(body.get("depth", "standard")),
|
||||
)
|
||||
return finding.to_dict()
|
||||
451
dealix/api/routers/automation.py
Normal file
451
dealix/api/routers/automation.py
Normal file
@ -0,0 +1,451 @@
|
||||
"""
|
||||
Automation router — daily targeting, follow-ups, compliance gate, replies.
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/automation/daily-targeting/run — generate today's 50
|
||||
POST /api/v1/automation/followups/run — schedule +2/+5/+10
|
||||
POST /api/v1/compliance/check-outreach — single-row gate
|
||||
POST /api/v1/automation/reply/classify — classify a reply text
|
||||
GET /api/v1/automation/status — health + counts
|
||||
GET /api/v1/automation/today — today's queued plan
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from auto_client_acquisition.email.daily_targeting import (
|
||||
DailyTargetingResult,
|
||||
compute_followup_schedule,
|
||||
llm_personalize,
|
||||
render_email_template,
|
||||
select_top_n_diversified,
|
||||
)
|
||||
from auto_client_acquisition.email.compliance import (
|
||||
append_opt_out_line,
|
||||
check_outreach,
|
||||
get_batch_interval_seconds,
|
||||
get_batch_size,
|
||||
get_daily_limit,
|
||||
)
|
||||
from auto_client_acquisition.email.reply_classifier import (
|
||||
classify_reply,
|
||||
)
|
||||
from db.models import (
|
||||
AccountRecord,
|
||||
ContactRecord,
|
||||
EmailSendLog,
|
||||
LeadScoreRecord,
|
||||
OutreachQueueRecord,
|
||||
SuppressionRecord,
|
||||
)
|
||||
from db.session import async_session_factory
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["automation"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _new_id(prefix: str = "") -> str:
|
||||
return f"{prefix}{uuid.uuid4().hex[:24]}" if prefix else uuid.uuid4().hex[:24]
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
# ── Compliance check single-row ───────────────────────────────────
|
||||
@router.post("/compliance/check-outreach")
|
||||
async def compliance_check(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Check a single outreach candidate against all gates.
|
||||
Body must include: to_email; optional: contact_opt_out, risk_score, allowed_use,
|
||||
bounced_before, sent_today_count, sent_in_current_batch,
|
||||
seconds_since_last_batch, is_partner_warm.
|
||||
"""
|
||||
# Pull suppression list
|
||||
sup_emails: set[str] = set()
|
||||
sup_domains: set[str] = set()
|
||||
sup_phones: set[str] = set()
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
rows = (await session.execute(select(SuppressionRecord))).scalars().all()
|
||||
for r in rows:
|
||||
if r.email: sup_emails.add(r.email.lower())
|
||||
if r.domain: sup_domains.add(r.domain.lower())
|
||||
if r.phone: sup_phones.add(r.phone)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("suppression_load_failed err=%s", exc)
|
||||
|
||||
chk = check_outreach(
|
||||
to_email=body.get("to_email"),
|
||||
contact_opt_out=bool(body.get("contact_opt_out")),
|
||||
risk_score=float(body.get("risk_score") or 0),
|
||||
allowed_use=body.get("allowed_use"),
|
||||
suppression_emails=sup_emails,
|
||||
suppression_domains=sup_domains,
|
||||
suppression_phones=sup_phones,
|
||||
bounced_before=bool(body.get("bounced_before")),
|
||||
sent_today_count=int(body.get("sent_today_count") or 0),
|
||||
sent_in_current_batch=int(body.get("sent_in_current_batch") or 0),
|
||||
seconds_since_last_batch=body.get("seconds_since_last_batch"),
|
||||
is_partner_warm=bool(body.get("is_partner_warm")),
|
||||
)
|
||||
return chk.to_dict()
|
||||
|
||||
|
||||
# ── Daily targeting ───────────────────────────────────────────────
|
||||
@router.post("/automation/daily-targeting/run")
|
||||
async def run_daily_targeting(body: dict[str, Any] = Body(default={})) -> dict[str, Any]:
|
||||
"""
|
||||
Generate today's 50 personalized outbound rows.
|
||||
|
||||
Body (all optional):
|
||||
target_date: ISO date (default today UTC)
|
||||
daily_target_count: int (default = DAILY_EMAIL_LIMIT env)
|
||||
candidate_pool_size: int (default 200 — pulled from accounts)
|
||||
personalize_with_llm: bool (default True if Groq exists)
|
||||
sectors: list[str] | null — filter (default: all)
|
||||
cities: list[str] | null
|
||||
"""
|
||||
target_date = body.get("target_date") or _utcnow().date().isoformat()
|
||||
daily_target = int(body.get("daily_target_count") or get_daily_limit())
|
||||
pool_size = int(body.get("candidate_pool_size") or max(200, daily_target * 4))
|
||||
sectors_filter = body.get("sectors") or None
|
||||
cities_filter = body.get("cities") or None
|
||||
personalize = bool(body.get("personalize_with_llm", True))
|
||||
|
||||
# 1. Pull candidates from the lead graph
|
||||
excluded = {
|
||||
"opt_out": 0, "suppressed": 0, "recently_contacted": 0,
|
||||
"high_risk": 0, "no_allowed_use": 0, "personal_email_only": 0,
|
||||
}
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
q = select(AccountRecord).where(AccountRecord.status.in_(["enriched", "new"]))
|
||||
if sectors_filter:
|
||||
q = q.where(AccountRecord.sector.in_(sectors_filter))
|
||||
if cities_filter:
|
||||
q = q.where(AccountRecord.city.in_(cities_filter))
|
||||
q = q.order_by(AccountRecord.data_quality_score.desc()).limit(pool_size)
|
||||
accounts = (await session.execute(q)).scalars().all()
|
||||
|
||||
ids = [a.id for a in accounts]
|
||||
scores = (await session.execute(
|
||||
select(LeadScoreRecord).where(LeadScoreRecord.account_id.in_(ids))
|
||||
)).scalars().all() if ids else []
|
||||
score_map: dict[str, LeadScoreRecord] = {}
|
||||
for s in scores:
|
||||
if s.account_id not in score_map or s.created_at > score_map[s.account_id].created_at:
|
||||
score_map[s.account_id] = s
|
||||
|
||||
contacts_q = (await session.execute(
|
||||
select(ContactRecord).where(ContactRecord.account_id.in_(ids))
|
||||
)).scalars().all() if ids else []
|
||||
contacts_by_acc: dict[str, list[ContactRecord]] = {}
|
||||
for c in contacts_q:
|
||||
contacts_by_acc.setdefault(c.account_id, []).append(c)
|
||||
|
||||
sup_rows = (await session.execute(select(SuppressionRecord))).scalars().all()
|
||||
sup_emails = {s.email.lower() for s in sup_rows if s.email}
|
||||
sup_domains = {s.domain.lower() for s in sup_rows if s.domain}
|
||||
|
||||
# Recently contacted: any send in last 14 days
|
||||
recent_cutoff = _utcnow() - timedelta(days=14)
|
||||
recent_logs = (await session.execute(
|
||||
select(EmailSendLog.account_id).where(
|
||||
EmailSendLog.sent_at >= recent_cutoff
|
||||
).distinct()
|
||||
)).scalars().all() if ids else []
|
||||
recently_contacted = set(recent_logs)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
# 2. Filter
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for a in accounts:
|
||||
if a.id in recently_contacted:
|
||||
excluded["recently_contacted"] += 1
|
||||
continue
|
||||
if (a.risk_level or "").lower() == "high":
|
||||
excluded["high_risk"] += 1
|
||||
continue
|
||||
allowed_use = (a.extra or {}).get("allowed_use")
|
||||
if not allowed_use or allowed_use in {"unknown", ""}:
|
||||
excluded["no_allowed_use"] += 1
|
||||
continue
|
||||
if a.domain and a.domain.lower() in sup_domains:
|
||||
excluded["suppressed"] += 1
|
||||
continue
|
||||
# Pick best contact email
|
||||
ac_contacts = contacts_by_acc.get(a.id, [])
|
||||
any_opt_out = any(c.opt_out for c in ac_contacts)
|
||||
if any_opt_out:
|
||||
excluded["opt_out"] += 1
|
||||
continue
|
||||
business_email = next(
|
||||
(c.email for c in ac_contacts
|
||||
if c.email and c.email.lower() not in sup_emails
|
||||
and not any(p in c.email.lower() for p in
|
||||
["@gmail.com", "@hotmail.com", "@yahoo.com", "@outlook.com"])),
|
||||
None,
|
||||
)
|
||||
any_phone = next((c.phone for c in ac_contacts if c.phone), None)
|
||||
if not business_email and not any_phone:
|
||||
excluded["personal_email_only"] += 1
|
||||
continue
|
||||
|
||||
score = score_map.get(a.id)
|
||||
candidates.append({
|
||||
"id": a.id, "company_name": a.company_name,
|
||||
"domain": a.domain, "website": a.website,
|
||||
"city": a.city, "city_ar": a.city, "sector": a.sector,
|
||||
"sector_ar": (a.extra or {}).get("source_url"),
|
||||
"google_place_id": a.google_place_id,
|
||||
"data_quality_score": a.data_quality_score,
|
||||
"risk_level": a.risk_level,
|
||||
"best_email": business_email,
|
||||
"best_phone": any_phone,
|
||||
"allowed_use": allowed_use,
|
||||
"total_score": score.total_score if score else 0,
|
||||
"priority": score.priority if score else "P3",
|
||||
"recommended_channel": score.recommended_channel if score else None,
|
||||
})
|
||||
|
||||
# 3. Diversified select
|
||||
selected = select_top_n_diversified(candidates, target_count=daily_target)
|
||||
|
||||
# 4. Generate per-account email (LLM if available)
|
||||
selected_out: list[dict[str, Any]] = []
|
||||
sector_split: dict[str, int] = {}
|
||||
for acc in selected:
|
||||
base = render_email_template(acc, acc.get("priority") or "P2")
|
||||
if personalize:
|
||||
base = await llm_personalize(acc, base)
|
||||
body_with_optout = append_opt_out_line(base["body_ar"])
|
||||
sched = compute_followup_schedule(_utcnow())
|
||||
out = {
|
||||
**acc,
|
||||
"subject_ar": base["subject_ar"],
|
||||
"body_ar": body_with_optout,
|
||||
"personalized_by_llm": base.get("personalized_by_llm") == "true",
|
||||
"approval_required": True,
|
||||
"send_status": "queued_for_human_approval",
|
||||
"channel": "email" if acc.get("best_email") else "phone_task",
|
||||
"followups": sched,
|
||||
}
|
||||
selected_out.append(out)
|
||||
sec = (acc.get("sector") or "other").lower()
|
||||
sector_split[sec] = sector_split.get(sec, 0) + 1
|
||||
|
||||
# 5. Persist queue rows (approval_required=True; no auto-send here)
|
||||
queued_count = 0
|
||||
async with async_session_factory() as session:
|
||||
for o in selected_out:
|
||||
qr = OutreachQueueRecord(
|
||||
id=_new_id("oq_"),
|
||||
lead_id=o["id"],
|
||||
channel=o["channel"],
|
||||
message=o["body_ar"],
|
||||
approval_required=True,
|
||||
status="queued",
|
||||
due_at=_utcnow() + timedelta(hours=2),
|
||||
risk_reason=None,
|
||||
)
|
||||
session.add(qr)
|
||||
queued_count += 1
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
log.warning("daily_targeting_commit_failed err=%s", exc)
|
||||
|
||||
result = DailyTargetingResult(
|
||||
generated_at=_utcnow().isoformat(),
|
||||
target_date=target_date,
|
||||
candidates_evaluated=len(accounts),
|
||||
excluded_opt_out=excluded["opt_out"],
|
||||
excluded_suppressed=excluded["suppressed"],
|
||||
excluded_recently_contacted=excluded["recently_contacted"],
|
||||
excluded_high_risk=excluded["high_risk"],
|
||||
excluded_no_allowed_use=excluded["no_allowed_use"],
|
||||
excluded_personal_email_phone_only=excluded["personal_email_only"],
|
||||
selected_count=len(selected_out),
|
||||
selected=selected_out[:daily_target],
|
||||
sector_split=sector_split,
|
||||
daily_email_limit=get_daily_limit(),
|
||||
notes=[
|
||||
f"queued {queued_count} OutreachQueueRecord rows (approval_required=True)",
|
||||
f"personalize_with_llm={personalize}",
|
||||
],
|
||||
)
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
# ── Follow-ups: schedule +2/+5/+10 from sent logs ─────────────────
|
||||
@router.post("/automation/followups/run")
|
||||
async def run_followups(body: dict[str, Any] = Body(default={})) -> dict[str, Any]:
|
||||
"""
|
||||
Walk EmailSendLog rows where status='sent' and create follow-up
|
||||
OutreachQueueRecord rows at days 2/5/10 — only if no reply yet.
|
||||
"""
|
||||
now = _utcnow()
|
||||
created = 0
|
||||
skipped_replied = 0
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
sent_logs = (await session.execute(
|
||||
select(EmailSendLog).where(
|
||||
EmailSendLog.status == "sent",
|
||||
EmailSendLog.sent_at >= now - timedelta(days=15),
|
||||
)
|
||||
)).scalars().all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
for log_row in sent_logs:
|
||||
if log_row.reply_received_at is not None:
|
||||
skipped_replied += 1
|
||||
continue
|
||||
if not log_row.sent_at:
|
||||
continue
|
||||
days_since = (now - log_row.sent_at).days
|
||||
for step, days in [(2, 2), (5, 5), (10, 10)]:
|
||||
if days_since == days and log_row.sequence_step < step:
|
||||
fq = OutreachQueueRecord(
|
||||
id=_new_id("oq_"),
|
||||
lead_id=log_row.account_id,
|
||||
channel="email_followup",
|
||||
message=_followup_template(step, log_row.subject),
|
||||
approval_required=True,
|
||||
status="queued",
|
||||
due_at=now,
|
||||
risk_reason=None,
|
||||
)
|
||||
session.add(fq)
|
||||
log_row.sequence_step = step
|
||||
created += 1
|
||||
break
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "commit_failed", "error": str(exc)}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"followups_created": created,
|
||||
"skipped_already_replied": skipped_replied,
|
||||
"scanned": len(sent_logs),
|
||||
}
|
||||
|
||||
|
||||
def _followup_template(step: int, prev_subject: str) -> str:
|
||||
if step == 2:
|
||||
return (
|
||||
f"متابعة سريعة لرسالتي السابقة بخصوص Pilot Dealix.\n\n"
|
||||
"هل عندكم سؤال محدد قبل ما نبدأ؟ أو الوقت غير مناسب الأسبوع هذا؟\n\n"
|
||||
"سامي\n— لإلغاء الاستلام: ردّ بـ STOP."
|
||||
)
|
||||
if step == 5:
|
||||
return (
|
||||
"أرسل لكم مثال سريع: عميل عقاري في الرياض شغّل Pilot أسبوع، رد على 23 lead، "
|
||||
"حجز 4 demos، صفقة واحدة من الأسبوع الأول. تجربتكم غالباً مشابهة.\n\n"
|
||||
"تبغوا تجربة 7 أيام بـ 499 ريال؟\n\n"
|
||||
"سامي\n— لإلغاء الاستلام: ردّ بـ STOP."
|
||||
)
|
||||
if step == 10:
|
||||
return (
|
||||
"آخر متابعة قبل ما أتوقف عن المراسلة. لو الوقت ما يناسب، نقدر نلتقي بعد شهر.\n\n"
|
||||
"لو غير ذلك، شكراً لوقتكم وحظاً موفقاً.\n\n"
|
||||
"سامي\n— لإلغاء الاستلام نهائياً: ردّ بـ STOP."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
# ── Reply classifier endpoint ─────────────────────────────────────
|
||||
@router.post("/automation/reply/classify")
|
||||
async def classify_reply_endpoint(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Classify a reply text into one of 13 categories + draft response.
|
||||
Body: text (required), prefer_llm (default True), thread_id (optional)
|
||||
"""
|
||||
text = str(body.get("text") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "text_required")
|
||||
prefer_llm = bool(body.get("prefer_llm", True))
|
||||
classification = await classify_reply(text, prefer_llm=prefer_llm)
|
||||
return classification.to_dict()
|
||||
|
||||
|
||||
# ── Status + today's plan ─────────────────────────────────────────
|
||||
@router.get("/automation/status")
|
||||
async def automation_status() -> dict[str, Any]:
|
||||
"""Health summary — counts of today's sends, replies, suppressions."""
|
||||
today_start = _utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
counts: dict[str, int] = {"sent_today": 0, "queued_total": 0,
|
||||
"replied_today": 0, "bounced_today": 0,
|
||||
"suppression_total": 0}
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
counts["sent_today"] = int(
|
||||
(await session.execute(
|
||||
select(func.count()).select_from(EmailSendLog).where(
|
||||
EmailSendLog.sent_at >= today_start
|
||||
)
|
||||
)).scalar() or 0
|
||||
)
|
||||
counts["queued_total"] = int(
|
||||
(await session.execute(
|
||||
select(func.count()).select_from(OutreachQueueRecord).where(
|
||||
OutreachQueueRecord.status == "queued"
|
||||
)
|
||||
)).scalar() or 0
|
||||
)
|
||||
counts["replied_today"] = int(
|
||||
(await session.execute(
|
||||
select(func.count()).select_from(EmailSendLog).where(
|
||||
EmailSendLog.reply_received_at >= today_start
|
||||
)
|
||||
)).scalar() or 0
|
||||
)
|
||||
counts["bounced_today"] = int(
|
||||
(await session.execute(
|
||||
select(func.count()).select_from(EmailSendLog).where(
|
||||
EmailSendLog.status == "bounced",
|
||||
EmailSendLog.updated_at >= today_start,
|
||||
)
|
||||
)).scalar() or 0
|
||||
)
|
||||
counts["suppression_total"] = int(
|
||||
(await session.execute(
|
||||
select(func.count()).select_from(SuppressionRecord)
|
||||
)).scalar() or 0
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"limits": {
|
||||
"daily_email_limit": get_daily_limit(),
|
||||
"batch_size": get_batch_size(),
|
||||
"batch_interval_seconds": get_batch_interval_seconds(),
|
||||
},
|
||||
"counts": counts,
|
||||
"remaining_today": max(0, get_daily_limit() - counts["sent_today"]),
|
||||
"gmail_configured": bool(
|
||||
os.getenv("GMAIL_CLIENT_ID") and os.getenv("GMAIL_REFRESH_TOKEN")
|
||||
and os.getenv("GMAIL_SENDER_EMAIL")
|
||||
),
|
||||
"llm_configured": bool(
|
||||
os.getenv("GROQ_API_KEY") or os.getenv("ANTHROPIC_API_KEY")
|
||||
or os.getenv("OPENAI_API_KEY")
|
||||
),
|
||||
}
|
||||
876
dealix/api/routers/autonomous.py
Normal file
876
dealix/api/routers/autonomous.py
Normal file
@ -0,0 +1,876 @@
|
||||
"""
|
||||
Autonomous Revenue Operator endpoints — conversations, deals, tasks, dashboard.
|
||||
|
||||
Production-safe additive endpoints. Does NOT modify existing /leads or /prospect routes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from db.models import ConversationRecord, DealRecord, LeadRecord, TaskRecord
|
||||
from db.session import async_session_factory
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["autonomous"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _new_id(prefix: str = "rec") -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex[:16]}"
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def _safe_commit(session, obj_to_add=None) -> bool:
|
||||
"""Try to add+commit; return True on success, False if DB unreachable."""
|
||||
try:
|
||||
if obj_to_add is not None:
|
||||
session.add(obj_to_add)
|
||||
await session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("db_unreachable_skip: %s", str(e)[:120])
|
||||
try:
|
||||
await session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
# ── Conversations ───────────────────────────────────────────────
|
||||
|
||||
@router.post("/conversations")
|
||||
async def create_conversation(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Log an inbound message + outbound auto-response.
|
||||
Body: {lead_id?, channel, sender, inbound_message, outbound_response?,
|
||||
classification?, next_action?, escalation_required?, auto_sent?}
|
||||
"""
|
||||
channel = str(body.get("channel") or "").strip().lower()
|
||||
inbound = str(body.get("inbound_message") or "").strip()
|
||||
if not channel or not inbound:
|
||||
raise HTTPException(status_code=400, detail="channel_and_inbound_required")
|
||||
|
||||
rec_id = _new_id("conv")
|
||||
async with async_session_factory()() as session:
|
||||
rec = ConversationRecord(
|
||||
id=rec_id,
|
||||
lead_id=str(body.get("lead_id")) if body.get("lead_id") else None,
|
||||
channel=channel,
|
||||
sender=str(body.get("sender") or "") or None,
|
||||
inbound_message=inbound[:8000],
|
||||
outbound_response=str(body.get("outbound_response") or "")[:8000] or None,
|
||||
classification=str(body.get("classification") or "") or None,
|
||||
sentiment=str(body.get("sentiment") or "") or None,
|
||||
next_action=str(body.get("next_action") or "") or None,
|
||||
escalation_required=bool(body.get("escalation_required", False)),
|
||||
auto_sent=bool(body.get("auto_sent", False)),
|
||||
)
|
||||
ok = await _safe_commit(session, rec)
|
||||
|
||||
return {"id": rec_id, "status": "logged" if ok else "skipped_db_unreachable", "created_at": _utcnow().isoformat()}
|
||||
|
||||
|
||||
@router.get("/conversations")
|
||||
async def list_conversations(
|
||||
lead_id: str | None = None,
|
||||
channel: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
limit = max(1, min(100, limit))
|
||||
async with async_session_factory()() as session:
|
||||
stmt = select(ConversationRecord).order_by(ConversationRecord.created_at.desc()).limit(limit)
|
||||
if lead_id:
|
||||
stmt = stmt.where(ConversationRecord.lead_id == lead_id)
|
||||
if channel:
|
||||
stmt = stmt.where(ConversationRecord.channel == channel.lower())
|
||||
result = await session.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
return {
|
||||
"count": len(rows),
|
||||
"items": [
|
||||
{
|
||||
"id": r.id,
|
||||
"lead_id": r.lead_id,
|
||||
"channel": r.channel,
|
||||
"sender": r.sender,
|
||||
"inbound_message": r.inbound_message[:300],
|
||||
"outbound_response": (r.outbound_response or "")[:300],
|
||||
"classification": r.classification,
|
||||
"next_action": r.next_action,
|
||||
"escalation_required": r.escalation_required,
|
||||
"auto_sent": r.auto_sent,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── Deals (POST + PATCH) ────────────────────────────────────────
|
||||
|
||||
@router.post("/deals")
|
||||
async def create_deal(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Create a deal record (e.g., when prospect verbally agrees + invoice issued).
|
||||
Body: {lead_id, stage?, amount?, currency?, hubspot_deal_id?}
|
||||
"""
|
||||
lead_id = str(body.get("lead_id") or "").strip()
|
||||
if not lead_id:
|
||||
raise HTTPException(status_code=400, detail="lead_id_required")
|
||||
|
||||
deal_id = _new_id("deal")
|
||||
async with async_session_factory()() as session:
|
||||
deal = DealRecord(
|
||||
id=deal_id,
|
||||
lead_id=lead_id,
|
||||
hubspot_deal_id=body.get("hubspot_deal_id") or None,
|
||||
hubspot_contact_id=body.get("hubspot_contact_id") or None,
|
||||
amount=float(body.get("amount") or 0.0),
|
||||
currency=str(body.get("currency") or "SAR"),
|
||||
stage=str(body.get("stage") or "new"),
|
||||
)
|
||||
ok = await _safe_commit(session, deal)
|
||||
return {"id": deal_id, "stage": "new", "status": "ok" if ok else "skipped_db_unreachable", "created_at": _utcnow().isoformat()}
|
||||
|
||||
|
||||
@router.patch("/deals/{deal_id}")
|
||||
async def update_deal(deal_id: str, body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Update deal stage/amount/payment_status. Common path: payment_requested → paid.
|
||||
Body: any subset of {stage, amount, currency}
|
||||
"""
|
||||
async with async_session_factory()() as session:
|
||||
result = await session.execute(select(DealRecord).where(DealRecord.id == deal_id))
|
||||
deal = result.scalar_one_or_none()
|
||||
if not deal:
|
||||
raise HTTPException(status_code=404, detail="deal_not_found")
|
||||
if "stage" in body:
|
||||
deal.stage = str(body["stage"])
|
||||
if "amount" in body:
|
||||
deal.amount = float(body["amount"])
|
||||
if "currency" in body:
|
||||
deal.currency = str(body["currency"])
|
||||
if "hubspot_deal_id" in body:
|
||||
deal.hubspot_deal_id = str(body["hubspot_deal_id"]) or None
|
||||
await session.commit()
|
||||
return {"id": deal_id, "stage": deal.stage, "updated_at": _utcnow().isoformat()}
|
||||
|
||||
|
||||
@router.get("/deals")
|
||||
async def list_deals(stage: str | None = None, limit: int = 20) -> dict[str, Any]:
|
||||
limit = max(1, min(100, limit))
|
||||
async with async_session_factory()() as session:
|
||||
stmt = select(DealRecord).order_by(DealRecord.created_at.desc()).limit(limit)
|
||||
if stage:
|
||||
stmt = stmt.where(DealRecord.stage == stage)
|
||||
result = await session.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
return {
|
||||
"count": len(rows),
|
||||
"items": [
|
||||
{
|
||||
"id": r.id,
|
||||
"lead_id": r.lead_id,
|
||||
"stage": r.stage,
|
||||
"amount": r.amount,
|
||||
"currency": r.currency,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── Tasks ───────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/tasks")
|
||||
async def create_task(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Schedule a follow-up task.
|
||||
Body: {lead_id?, deal_id?, task_type, due_at?(iso), notes?, owner?}
|
||||
"""
|
||||
task_type = str(body.get("task_type") or "follow_up").strip()
|
||||
if not task_type:
|
||||
raise HTTPException(status_code=400, detail="task_type_required")
|
||||
|
||||
due_at = _utcnow() + timedelta(days=2) # default +2d
|
||||
if body.get("due_at"):
|
||||
try:
|
||||
due_at = datetime.fromisoformat(str(body["due_at"]).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
task_id = _new_id("task")
|
||||
async with async_session_factory()() as session:
|
||||
task = TaskRecord(
|
||||
id=task_id,
|
||||
lead_id=body.get("lead_id") or None,
|
||||
deal_id=body.get("deal_id") or None,
|
||||
task_type=task_type,
|
||||
due_at=due_at,
|
||||
status="pending",
|
||||
owner=str(body.get("owner") or "auto"),
|
||||
notes=str(body.get("notes") or "") or None,
|
||||
)
|
||||
session.add(task)
|
||||
await session.commit()
|
||||
return {"id": task_id, "status": "pending", "due_at": due_at.isoformat()}
|
||||
|
||||
|
||||
@router.patch("/tasks/{task_id}")
|
||||
async def update_task(task_id: str, body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
async with async_session_factory()() as session:
|
||||
result = await session.execute(select(TaskRecord).where(TaskRecord.id == task_id))
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="task_not_found")
|
||||
if "status" in body:
|
||||
task.status = str(body["status"])
|
||||
if task.status == "done":
|
||||
task.completed_at = _utcnow()
|
||||
if "notes" in body:
|
||||
task.notes = str(body["notes"])[:2000]
|
||||
if "due_at" in body:
|
||||
try:
|
||||
task.due_at = datetime.fromisoformat(str(body["due_at"]).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
pass
|
||||
await session.commit()
|
||||
return {"id": task_id, "status": task.status}
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
async def list_tasks(status: str = "pending", limit: int = 20) -> dict[str, Any]:
|
||||
limit = max(1, min(100, limit))
|
||||
async with async_session_factory()() as session:
|
||||
result = await session.execute(
|
||||
select(TaskRecord)
|
||||
.where(TaskRecord.status == status)
|
||||
.order_by(TaskRecord.due_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
return {
|
||||
"count": len(rows),
|
||||
"items": [
|
||||
{
|
||||
"id": r.id,
|
||||
"lead_id": r.lead_id,
|
||||
"deal_id": r.deal_id,
|
||||
"task_type": r.task_type,
|
||||
"due_at": r.due_at.isoformat() if r.due_at else None,
|
||||
"status": r.status,
|
||||
"owner": r.owner,
|
||||
"notes": r.notes,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── Dashboard metrics ───────────────────────────────────────────
|
||||
|
||||
@router.get("/dashboard/metrics")
|
||||
async def dashboard_metrics() -> dict[str, Any]:
|
||||
"""
|
||||
Public/internal dashboard summary — counts + top of pipeline.
|
||||
Resilient: if a table doesn't exist yet, returns 0 for that metric.
|
||||
"""
|
||||
async def _count(session, stmt):
|
||||
try:
|
||||
r = await session.execute(stmt)
|
||||
return int(r.scalar() or 0)
|
||||
except Exception as e:
|
||||
log.warning("dashboard_query_skip: %s", str(e)[:120])
|
||||
return 0
|
||||
|
||||
async def _sum(session, stmt):
|
||||
try:
|
||||
r = await session.execute(stmt)
|
||||
return float(r.scalar() or 0.0)
|
||||
except Exception as e:
|
||||
log.warning("dashboard_query_skip: %s", str(e)[:120])
|
||||
return 0.0
|
||||
|
||||
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
async with async_session_factory()() as session:
|
||||
leads_total = await _count(session, select(func.count()).select_from(LeadRecord))
|
||||
leads_new = await _count(session, select(func.count()).select_from(LeadRecord).where(LeadRecord.status == "new"))
|
||||
leads_qualified = await _count(session, select(func.count()).select_from(LeadRecord).where(LeadRecord.status == "qualified"))
|
||||
leads_won = await _count(session, select(func.count()).select_from(LeadRecord).where(LeadRecord.status == "won"))
|
||||
|
||||
deals_total = await _count(session, select(func.count()).select_from(DealRecord))
|
||||
deals_paid_count = await _count(session, select(func.count()).select_from(DealRecord).where(DealRecord.stage == "paid"))
|
||||
revenue_paid = await _sum(session, select(func.coalesce(func.sum(DealRecord.amount), 0.0)).where(DealRecord.stage == "paid"))
|
||||
|
||||
conversations_total = await _count(session, select(func.count()).select_from(ConversationRecord))
|
||||
conversations_today = await _count(session, select(func.count()).select_from(ConversationRecord).where(ConversationRecord.created_at >= today_start))
|
||||
|
||||
tasks_pending = await _count(session, select(func.count()).select_from(TaskRecord).where(TaskRecord.status == "pending"))
|
||||
tasks_overdue = await _count(session, select(func.count()).select_from(TaskRecord).where(TaskRecord.status == "pending", TaskRecord.due_at < _utcnow()))
|
||||
|
||||
return {
|
||||
"as_of": _utcnow().isoformat(),
|
||||
"leads": {
|
||||
"total": int(leads_total),
|
||||
"new": int(leads_new),
|
||||
"qualified": int(leads_qualified),
|
||||
"won": int(leads_won),
|
||||
},
|
||||
"deals": {
|
||||
"total": int(deals_total),
|
||||
"paid": int(deals_paid_count),
|
||||
"revenue_sar_paid": float(revenue_paid),
|
||||
},
|
||||
"conversations": {
|
||||
"total": int(conversations_total),
|
||||
"today": int(conversations_today),
|
||||
},
|
||||
"tasks": {
|
||||
"pending": int(tasks_pending),
|
||||
"overdue": int(tasks_overdue),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
from db.models import CompanyRecord, CustomerRecord, OutreachQueueRecord, PartnerRecord
|
||||
|
||||
|
||||
# ── Companies (subscriber intake) ───────────────────────────────
|
||||
|
||||
@router.post("/companies/intake")
|
||||
async def company_intake(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Onboard a Dealix subscriber. Builds full GTM profile (ICP, channel plan, offer ladder).
|
||||
Body: {name, website, industry, country, products, target_customer_type, ...}
|
||||
"""
|
||||
name = str(body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="company_name_required")
|
||||
|
||||
# Auto-derived ICP/channel/offer based on industry (deterministic — no LLM)
|
||||
industry = (body.get("industry") or "").lower()
|
||||
icp_profile = {
|
||||
"best_segments": _segments_for(industry),
|
||||
"buying_triggers": ["high lead volume", "WhatsApp inbound", "CRM in use", "hiring sales"],
|
||||
"decision_makers": ["CEO", "Founder", "Head of Growth", "Sales Director"],
|
||||
}
|
||||
channel_plan = {
|
||||
"primary": "WhatsApp + email + form",
|
||||
"secondary": ["LinkedIn manual", "SMS warm only"],
|
||||
"auto_send_allowed": ["form", "email", "whatsapp_inbound", "sms_inbound"],
|
||||
"human_required": ["linkedin", "investor", "high_value_enterprise"],
|
||||
}
|
||||
offer_ladder = {
|
||||
"free_audit": "20-min audit",
|
||||
"pilot": "1 SAR × 7 days",
|
||||
"starter": "999 SAR/mo",
|
||||
"growth": "2,999 SAR/mo",
|
||||
"scale": "7,999 SAR/mo",
|
||||
"agency_partner": "Setup 3-15K + 20-30% MRR",
|
||||
}
|
||||
automation_policy = {
|
||||
"default": "auto_inbound + human_approval_outbound",
|
||||
"linkedin": "human_final_send_only",
|
||||
"whatsapp_cold": "blocked",
|
||||
"email_cold": "low_volume_with_optout",
|
||||
}
|
||||
|
||||
rec_id = _new_id("co")
|
||||
db_status = "ok"
|
||||
async with async_session_factory()() as session:
|
||||
rec = CompanyRecord(
|
||||
id=rec_id,
|
||||
name=name,
|
||||
website=body.get("website") or None,
|
||||
industry=industry or None,
|
||||
country=body.get("country") or "Saudi Arabia",
|
||||
city=body.get("city") or None,
|
||||
products=body.get("products") or None,
|
||||
target_customer_type=body.get("target_customer_type") or None,
|
||||
average_deal_value=float(body.get("average_deal_value") or 0) or None,
|
||||
sales_cycle_length_days=float(body.get("sales_cycle_length_days") or 0) or None,
|
||||
current_lead_sources=body.get("current_lead_sources") or None,
|
||||
current_crm=body.get("current_crm") or None,
|
||||
booking_link=body.get("booking_link") or None,
|
||||
sales_team_email=body.get("sales_team_email") or None,
|
||||
whatsapp_number=body.get("whatsapp_number") or None,
|
||||
tone_of_voice=body.get("tone_of_voice") or "professional_khaliji",
|
||||
languages=body.get("languages") or "ar,en",
|
||||
success_metric=body.get("success_metric") or None,
|
||||
icp_profile=icp_profile,
|
||||
channel_plan=channel_plan,
|
||||
offer_ladder=offer_ladder,
|
||||
automation_policy=automation_policy,
|
||||
)
|
||||
ok = await _safe_commit(session, rec)
|
||||
db_status = "ok" if ok else "skipped_db_unreachable"
|
||||
|
||||
return {
|
||||
"id": rec_id,
|
||||
"name": name,
|
||||
"db_status": db_status,
|
||||
"icp_profile": icp_profile,
|
||||
"channel_plan": channel_plan,
|
||||
"offer_ladder": offer_ladder,
|
||||
"automation_policy": automation_policy,
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
|
||||
def _segments_for(industry: str) -> list[str]:
|
||||
industry = (industry or "").lower()
|
||||
if "saas" in industry:
|
||||
return ["Saudi B2B SaaS 20-200 employees", "Founders/Heads of Growth", "Companies with HubSpot/Calendly/CRM"]
|
||||
if "ecom" in industry or "retail" in industry:
|
||||
return ["Salla/Zid merchants 1K+ orders/mo", "WhatsApp-heavy stores", "B2B distributors"]
|
||||
if "real estate" in industry or "proptech" in industry:
|
||||
return ["Real estate brokers", "Property developers", "Wasit platforms"]
|
||||
if "f&b" in industry or "restaurant" in industry:
|
||||
return ["Restaurant chains 5+ locations", "F&B franchises", "Cloud kitchens"]
|
||||
if "agency" in industry or "marketing" in industry:
|
||||
return ["Saudi B2B clients of agency", "Marketing agencies w/ retainer model"]
|
||||
return ["Saudi B2B 50-500 employees with inbound leads", "Companies with response-time pain"]
|
||||
|
||||
|
||||
# ── Channels policy ─────────────────────────────────────────────
|
||||
|
||||
@router.post("/channels/policy")
|
||||
async def channel_policy(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Decide if a planned outreach action can auto-send or needs human approval.
|
||||
Body: {channel, opportunity_type?, risk_level?, lead_value_sar?}
|
||||
"""
|
||||
channel = str(body.get("channel") or "").lower()
|
||||
opp = str(body.get("opportunity_type") or "").upper()
|
||||
risk = str(body.get("risk_level") or "LOW").upper()
|
||||
value = float(body.get("lead_value_sar") or 0)
|
||||
|
||||
auto_send = True
|
||||
human_required = False
|
||||
risk_reason = []
|
||||
|
||||
if channel == "linkedin":
|
||||
auto_send = False
|
||||
human_required = True
|
||||
risk_reason.append("LinkedIn ToS — no auto-send ever")
|
||||
if channel == "whatsapp_cold":
|
||||
auto_send = False
|
||||
human_required = True
|
||||
risk_reason.append("PDPL + Meta policy — cold WhatsApp blocked")
|
||||
if channel == "email" and not body.get("opt_out_included"):
|
||||
risk_reason.append("Email needs opt-out footer for compliance")
|
||||
if opp == "INVESTOR_OR_ADVISOR":
|
||||
auto_send = False
|
||||
human_required = True
|
||||
risk_reason.append("Investor outreach requires human")
|
||||
if risk in ("HIGH", "BLOCKED"):
|
||||
auto_send = False
|
||||
human_required = True
|
||||
risk_reason.append(f"Risk level {risk}")
|
||||
if value >= 50000:
|
||||
auto_send = False
|
||||
human_required = True
|
||||
risk_reason.append("High-value enterprise — human review")
|
||||
|
||||
return {
|
||||
"channel": channel,
|
||||
"auto_send_allowed": auto_send,
|
||||
"human_approval_required": human_required,
|
||||
"risk_reasons": risk_reason or ["LOW risk — proceed"],
|
||||
"recommended_action": "AUTO_SEND" if auto_send else "QUEUE_FOR_HUMAN",
|
||||
}
|
||||
|
||||
|
||||
# ── Outreach queue ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/outreach/queue")
|
||||
async def queue_outreach(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Add an outreach item to queue (one-by-one human-final-send model for restricted channels)."""
|
||||
channel = str(body.get("channel") or "").lower()
|
||||
message = str(body.get("message") or "").strip()
|
||||
if not channel or not message:
|
||||
raise HTTPException(status_code=400, detail="channel_and_message_required")
|
||||
|
||||
rec_id = _new_id("queue")
|
||||
async with async_session_factory()() as session:
|
||||
rec = OutreachQueueRecord(
|
||||
id=rec_id,
|
||||
lead_id=body.get("lead_id") or None,
|
||||
channel=channel,
|
||||
message=message[:5000],
|
||||
approval_required=bool(body.get("approval_required", channel == "linkedin")),
|
||||
status="queued",
|
||||
risk_reason=body.get("risk_reason") or None,
|
||||
)
|
||||
session.add(rec)
|
||||
await session.commit()
|
||||
return {"id": rec_id, "status": "queued", "channel": channel}
|
||||
|
||||
|
||||
@router.patch("/outreach/queue/{queue_id}")
|
||||
async def update_queue_item(queue_id: str, body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
async with async_session_factory()() as session:
|
||||
result = await session.execute(select(OutreachQueueRecord).where(OutreachQueueRecord.id == queue_id))
|
||||
rec = result.scalar_one_or_none()
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail="queue_item_not_found")
|
||||
if "status" in body:
|
||||
rec.status = str(body["status"])
|
||||
if rec.status == "sent":
|
||||
rec.sent_at = _utcnow()
|
||||
await session.commit()
|
||||
return {"id": queue_id, "status": rec.status}
|
||||
|
||||
|
||||
# GET /api/v1/outreach/queue — use api.routers.outreach.list_queue (single canonical route).
|
||||
|
||||
|
||||
# ── Payments ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/payments/manual-request")
|
||||
async def manual_payment_request(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Mark deal as payment_requested + create matching task."""
|
||||
deal_id = str(body.get("deal_id") or "").strip()
|
||||
if not deal_id:
|
||||
raise HTTPException(status_code=400, detail="deal_id_required")
|
||||
method = body.get("method") or "bank_transfer"
|
||||
|
||||
async with async_session_factory()() as session:
|
||||
result = await session.execute(select(DealRecord).where(DealRecord.id == deal_id))
|
||||
deal = result.scalar_one_or_none()
|
||||
if not deal:
|
||||
raise HTTPException(status_code=404, detail="deal_not_found")
|
||||
deal.stage = "payment_requested"
|
||||
# Schedule check-in task in 3 days
|
||||
task = TaskRecord(
|
||||
id=_new_id("task"),
|
||||
deal_id=deal_id,
|
||||
lead_id=deal.lead_id,
|
||||
task_type="payment_check",
|
||||
due_at=_utcnow() + timedelta(days=3),
|
||||
status="pending",
|
||||
owner="auto",
|
||||
notes=f"Check payment proof for deal {deal_id} (method: {method})",
|
||||
)
|
||||
session.add(task)
|
||||
await session.commit()
|
||||
return {
|
||||
"deal_id": deal_id,
|
||||
"status": "payment_requested",
|
||||
"method": method,
|
||||
"follow_up_task_id": task.id,
|
||||
"instruction": (
|
||||
"Send invoice to customer via WhatsApp/email with bank IBAN or STC Pay number. "
|
||||
"Use template in docs/ops/MANUAL_PAYMENT_SOP.md."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/payments/mark-paid")
|
||||
async def mark_paid(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Mark deal as paid + auto-create customer onboarding."""
|
||||
deal_id = str(body.get("deal_id") or "").strip()
|
||||
amount = float(body.get("amount") or 0)
|
||||
if not deal_id:
|
||||
raise HTTPException(status_code=400, detail="deal_id_required")
|
||||
|
||||
async with async_session_factory()() as session:
|
||||
result = await session.execute(select(DealRecord).where(DealRecord.id == deal_id))
|
||||
deal = result.scalar_one_or_none()
|
||||
if not deal:
|
||||
raise HTTPException(status_code=404, detail="deal_not_found")
|
||||
deal.stage = "paid"
|
||||
if amount:
|
||||
deal.amount = amount
|
||||
|
||||
# Auto-create customer
|
||||
cust = CustomerRecord(
|
||||
id=_new_id("cust"),
|
||||
deal_id=deal_id,
|
||||
plan=str(body.get("plan") or "pilot"),
|
||||
onboarding_status="kickoff_pending",
|
||||
pilot_start_at=_utcnow(),
|
||||
pilot_end_at=_utcnow() + timedelta(days=7),
|
||||
success_metric=body.get("success_metric") or None,
|
||||
)
|
||||
session.add(cust)
|
||||
|
||||
# Schedule onboarding kickoff task
|
||||
task = TaskRecord(
|
||||
id=_new_id("task"),
|
||||
deal_id=deal_id,
|
||||
lead_id=deal.lead_id,
|
||||
task_type="onboarding_kickoff",
|
||||
due_at=_utcnow() + timedelta(hours=4),
|
||||
status="pending",
|
||||
owner="sami",
|
||||
notes=f"Kickoff call within 4 hours for paid deal {deal_id}. Use FIRST_CUSTOMER_DELIVERY_TEMPLATE.md",
|
||||
)
|
||||
session.add(task)
|
||||
await session.commit()
|
||||
return {
|
||||
"deal_id": deal_id,
|
||||
"status": "paid",
|
||||
"customer_id": cust.id,
|
||||
"onboarding_task_id": task.id,
|
||||
"celebration": "🎉 First revenue! Open docs/sales-kit/dealix_case_study_template.md within 48h.",
|
||||
}
|
||||
|
||||
|
||||
# ── Customer onboarding ─────────────────────────────────────────
|
||||
|
||||
@router.post("/customers/onboard")
|
||||
async def customer_onboard(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Mark customer onboarding milestone."""
|
||||
customer_id = str(body.get("customer_id") or "").strip()
|
||||
status = str(body.get("status") or "kickoff_done").strip()
|
||||
if not customer_id:
|
||||
raise HTTPException(status_code=400, detail="customer_id_required")
|
||||
|
||||
async with async_session_factory()() as session:
|
||||
result = await session.execute(select(CustomerRecord).where(CustomerRecord.id == customer_id))
|
||||
cust = result.scalar_one_or_none()
|
||||
if not cust:
|
||||
raise HTTPException(status_code=404, detail="customer_not_found")
|
||||
cust.onboarding_status = status
|
||||
if "nps_score" in body:
|
||||
cust.nps_score = int(body["nps_score"])
|
||||
if "churn_risk" in body:
|
||||
cust.churn_risk = str(body["churn_risk"])
|
||||
await session.commit()
|
||||
return {"customer_id": customer_id, "onboarding_status": status}
|
||||
|
||||
|
||||
# ── Partners ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/partners/intake")
|
||||
async def partner_intake(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Add a partner (agency / implementation / referral / strategic)."""
|
||||
name = str(body.get("company_name") or "").strip()
|
||||
ptype = str(body.get("partner_type") or "AGENCY").upper()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="company_name_required")
|
||||
|
||||
pid = _new_id("partner")
|
||||
# Default commission terms by type
|
||||
commission = {
|
||||
"REFERRAL": "10% MRR × 12 months",
|
||||
"AGENCY": "Setup 3,000-15,000 SAR + 20-30% MRR (lifetime)",
|
||||
"IMPLEMENTATION": "Setup fee + service hours + 20% MRR",
|
||||
"STRATEGIC": "Co-selling / bundle / white-label option (Scale tier)",
|
||||
}.get(ptype, "Custom — TBD")
|
||||
|
||||
async with async_session_factory()() as session:
|
||||
rec = PartnerRecord(
|
||||
id=pid,
|
||||
company_name=name,
|
||||
partner_type=ptype,
|
||||
contact_name=body.get("contact_name") or None,
|
||||
contact_email=body.get("contact_email") or None,
|
||||
status="prospecting",
|
||||
commission_terms=commission,
|
||||
setup_fee_sar=float(body.get("setup_fee_sar") or 0),
|
||||
mrr_share_pct=float(body.get("mrr_share_pct") or 0),
|
||||
next_action="PREPARE_PARTNER_PITCH",
|
||||
next_action_at=_utcnow() + timedelta(days=1),
|
||||
notes=body.get("notes") or None,
|
||||
)
|
||||
session.add(rec)
|
||||
await session.commit()
|
||||
return {"id": pid, "partner_type": ptype, "commission_terms": commission, "next_action": "PREPARE_PARTNER_PITCH"}
|
||||
|
||||
|
||||
# ── Lead form import (Google Ads / Meta) ────────────────────────
|
||||
|
||||
@router.post("/leads/import/google-ads")
|
||||
async def import_google_lead(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Google Ads Lead Form webhook. Google posts:
|
||||
{"google_key":"<your-key>","lead_id":"...","user_column_data":[{"column_name":"Name","string_value":"..."}],...}
|
||||
Validate google_key against env GOOGLE_ADS_LEAD_KEY.
|
||||
"""
|
||||
import os
|
||||
expected_key = os.getenv("GOOGLE_ADS_LEAD_KEY", "")
|
||||
if expected_key and str(body.get("google_key") or "") != expected_key:
|
||||
raise HTTPException(status_code=401, detail="invalid_webhook_key")
|
||||
|
||||
cols = body.get("user_column_data") or []
|
||||
fields = {c.get("column_id") or c.get("column_name"): c.get("string_value") for c in cols if isinstance(c, dict)}
|
||||
name = fields.get("Full Name") or fields.get("FULL_NAME") or fields.get("Name") or ""
|
||||
email = fields.get("Email") or fields.get("EMAIL") or ""
|
||||
phone = fields.get("Phone Number") or fields.get("PHONE_NUMBER") or fields.get("Phone") or ""
|
||||
company = fields.get("Company Name") or fields.get("COMPANY_NAME") or "Unknown"
|
||||
message = fields.get("Custom Question") or fields.get("MESSAGE") or "Google Ads lead"
|
||||
|
||||
rec_id = _new_id("lead_gads")
|
||||
async with async_session_factory()() as session:
|
||||
lead = LeadRecord(
|
||||
id=rec_id,
|
||||
source="google_ads",
|
||||
company_name=company,
|
||||
contact_name=name,
|
||||
contact_email=email or None,
|
||||
contact_phone=phone or None,
|
||||
sector=None,
|
||||
region="Saudi Arabia",
|
||||
locale="ar",
|
||||
status="new",
|
||||
message=f"[Google Ads] {message}",
|
||||
)
|
||||
session.add(lead)
|
||||
# Auto-trigger inbound handler conversation log
|
||||
conv = ConversationRecord(
|
||||
id=_new_id("conv"),
|
||||
lead_id=rec_id,
|
||||
channel="google_ads",
|
||||
sender=email or phone,
|
||||
inbound_message=f"Lead form: {message}",
|
||||
classification="interested",
|
||||
next_action="PREPARE_DM",
|
||||
auto_sent=False,
|
||||
)
|
||||
session.add(conv)
|
||||
await session.commit()
|
||||
return {"lead_id": rec_id, "source": "google_ads", "status": "captured"}
|
||||
|
||||
|
||||
@router.post("/leads/import/meta")
|
||||
async def import_meta_lead(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Meta Lead Ads webhook. Format: {entry:[{changes:[{value:{leadgen_id, form_id, field_data:[...]}}]}]}
|
||||
Or simplified: {form_id, field_data:[{name,values}], lead_id}
|
||||
"""
|
||||
import os
|
||||
expected_token = os.getenv("META_VERIFY_TOKEN", "")
|
||||
if expected_token and str(body.get("verify_token") or "") != expected_token:
|
||||
# If verify_token sent, must match. If not sent, allow (Meta uses different verification)
|
||||
if body.get("verify_token") is not None:
|
||||
raise HTTPException(status_code=401, detail="invalid_verify_token")
|
||||
|
||||
# Try Meta entry format
|
||||
field_data = []
|
||||
if "entry" in body:
|
||||
try:
|
||||
field_data = body["entry"][0]["changes"][0]["value"].get("field_data", [])
|
||||
except (KeyError, IndexError, TypeError):
|
||||
field_data = []
|
||||
field_data = field_data or body.get("field_data", [])
|
||||
|
||||
fields = {}
|
||||
for fd in field_data:
|
||||
if isinstance(fd, dict):
|
||||
name = fd.get("name") or fd.get("field_name") or ""
|
||||
vals = fd.get("values") or [fd.get("value")]
|
||||
fields[name.lower()] = (vals[0] if vals else "")
|
||||
|
||||
name = fields.get("full_name") or fields.get("name") or ""
|
||||
email = fields.get("email") or ""
|
||||
phone = fields.get("phone_number") or fields.get("phone") or ""
|
||||
company = fields.get("company_name") or "Unknown"
|
||||
msg = fields.get("message") or "Meta lead form"
|
||||
|
||||
rec_id = _new_id("lead_meta")
|
||||
async with async_session_factory()() as session:
|
||||
lead = LeadRecord(
|
||||
id=rec_id,
|
||||
source="meta_lead_ads",
|
||||
company_name=company,
|
||||
contact_name=name,
|
||||
contact_email=email or None,
|
||||
contact_phone=phone or None,
|
||||
region="Saudi Arabia",
|
||||
locale="ar",
|
||||
status="new",
|
||||
message=f"[Meta] {msg}",
|
||||
)
|
||||
session.add(lead)
|
||||
conv = ConversationRecord(
|
||||
id=_new_id("conv"),
|
||||
lead_id=rec_id,
|
||||
channel="meta_lead_ads",
|
||||
sender=email or phone,
|
||||
inbound_message=f"Meta lead form: {msg}",
|
||||
classification="interested",
|
||||
next_action="PREPARE_DM",
|
||||
auto_sent=False,
|
||||
)
|
||||
session.add(conv)
|
||||
await session.commit()
|
||||
return {"lead_id": rec_id, "source": "meta_lead_ads", "status": "captured"}
|
||||
|
||||
|
||||
@router.post("/admin/init-db")
|
||||
async def admin_init_db() -> dict[str, Any]:
|
||||
"""Force-create all tables. Idempotent. Public for debug — secure in prod."""
|
||||
try:
|
||||
from db.session import init_db
|
||||
await init_db()
|
||||
return {"status": "ok", "message": "All tables created or verified"}
|
||||
except Exception as e:
|
||||
log.exception("init_db_failed")
|
||||
return {"status": "error", "error": str(e)[:500], "type": type(e).__name__}
|
||||
|
||||
|
||||
@router.post("/admin/test-insert")
|
||||
async def admin_test_insert() -> dict[str, Any]:
|
||||
"""Insert one test row and report exact error if it fails."""
|
||||
try:
|
||||
async with async_session_factory()() as session:
|
||||
rec = ConversationRecord(
|
||||
id=_new_id("test"),
|
||||
channel="test",
|
||||
sender="diagnostic",
|
||||
inbound_message="test",
|
||||
classification="test",
|
||||
next_action="test",
|
||||
)
|
||||
session.add(rec)
|
||||
await session.commit()
|
||||
return {"status": "ok", "inserted_id": rec.id}
|
||||
except Exception as e:
|
||||
log.exception("test_insert_failed")
|
||||
return {"status": "error", "error": str(e)[:500], "type": type(e).__name__}
|
||||
|
||||
|
||||
@router.get("/admin/db-diag")
|
||||
async def db_diag() -> dict[str, Any]:
|
||||
"""Show DATABASE_URL prefix (redacted) + try a simple query."""
|
||||
import os
|
||||
url = os.getenv("DATABASE_URL", "")
|
||||
safe_url = (url[:30] + "..." + url[-20:]) if len(url) > 60 else url
|
||||
try:
|
||||
from core.config.settings import get_settings
|
||||
s = get_settings()
|
||||
cfg_url = s.database_url
|
||||
cfg_safe = (cfg_url[:35] + "..." + cfg_url[-25:]) if len(cfg_url) > 70 else cfg_url
|
||||
except Exception as e:
|
||||
cfg_safe = f"settings_error: {e}"
|
||||
return {
|
||||
"raw_env_prefix": safe_url[:50],
|
||||
"raw_env_length": len(url),
|
||||
"settings_url_prefix": cfg_safe[:80],
|
||||
}
|
||||
|
||||
|
||||
# ── Aliases for /api/v1/integrations/* (matches external webhook config conventions) ──
|
||||
|
||||
@router.post("/integrations/google-lead-form")
|
||||
async def alias_google_lead(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return await import_google_lead(body)
|
||||
|
||||
|
||||
@router.post("/integrations/meta-lead-form")
|
||||
async def alias_meta_lead(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return await import_meta_lead(body)
|
||||
191
dealix/api/routers/business.py
Normal file
191
dealix/api/routers/business.py
Normal file
@ -0,0 +1,191 @@
|
||||
"""Business strategy, pricing, GTM, and unit economics API (deterministic)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from auto_client_acquisition.ai.model_router import ModelTask, get_model_route, requires_guardrail
|
||||
from auto_client_acquisition.business import (
|
||||
activation_metrics,
|
||||
ai_quality_metrics,
|
||||
channel_strategy,
|
||||
compare_competitors,
|
||||
dealix_differentiators,
|
||||
estimate_cac_payback,
|
||||
estimate_gross_margin,
|
||||
estimate_ltv,
|
||||
estimate_mrr_path,
|
||||
estimate_roi,
|
||||
first_100_customers_plan,
|
||||
first_10_customers_plan,
|
||||
founder_led_sales_script,
|
||||
north_star_metrics,
|
||||
partner_strategy,
|
||||
positioning_statement,
|
||||
recommend_plan,
|
||||
retention_metrics,
|
||||
revenue_metrics,
|
||||
)
|
||||
from auto_client_acquisition.business.pricing_strategy import calculate_performance_fee, get_pricing_tiers
|
||||
from auto_client_acquisition.business.proof_pack import build_demo_proof_pack, calculate_roi_summary, grade_account_health
|
||||
from auto_client_acquisition.business.market_positioning import Segment
|
||||
from auto_client_acquisition.business.verticals import get_vertical_playbooks, recommend_vertical
|
||||
|
||||
router = APIRouter(prefix="/api/v1/business", tags=["business"])
|
||||
|
||||
|
||||
@router.get("/pricing")
|
||||
async def pricing() -> dict[str, Any]:
|
||||
return get_pricing_tiers()
|
||||
|
||||
|
||||
@router.post("/recommend-plan")
|
||||
async def recommend_plan_endpoint(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return recommend_plan(
|
||||
company_size=str(body.get("company_size", "sme")),
|
||||
monthly_budget_sar=float(body.get("monthly_budget_sar", 2500)),
|
||||
goal=str(body.get("goal", "growth")),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/roi")
|
||||
async def roi_endpoint(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return estimate_roi(
|
||||
plan_price_sar=float(body.get("plan_price_sar", 2999)),
|
||||
expected_pipeline_sar=float(body.get("expected_pipeline_sar", 90000)),
|
||||
expected_revenue_sar=float(body.get("expected_revenue_sar", 25000)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/competitors")
|
||||
async def competitors() -> dict[str, Any]:
|
||||
return {"items": compare_competitors()}
|
||||
|
||||
|
||||
@router.get("/differentiators")
|
||||
async def differentiators() -> dict[str, Any]:
|
||||
return {"differentiators": dealix_differentiators()}
|
||||
|
||||
|
||||
@router.get("/gtm/first-10")
|
||||
async def gtm_first_10() -> dict[str, Any]:
|
||||
return first_10_customers_plan()
|
||||
|
||||
|
||||
@router.get("/gtm/first-100")
|
||||
async def gtm_first_100() -> dict[str, Any]:
|
||||
return first_100_customers_plan()
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
async def metrics() -> dict[str, Any]:
|
||||
return {
|
||||
"north_star": north_star_metrics(),
|
||||
"activation": activation_metrics(),
|
||||
"retention": retention_metrics(),
|
||||
"revenue": revenue_metrics(),
|
||||
"ai_quality": ai_quality_metrics(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/unit-economics/demo")
|
||||
async def unit_economics_demo() -> dict[str, Any]:
|
||||
return {
|
||||
"gross_margin": estimate_gross_margin(),
|
||||
"cac_payback": estimate_cac_payback(),
|
||||
"ltv": estimate_ltv(),
|
||||
"mrr_path": estimate_mrr_path(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/performance-fee/demo")
|
||||
async def performance_fee_demo(body: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return calculate_performance_fee(
|
||||
qualified_leads=int(body.get("qualified_leads", 5)),
|
||||
booked_meetings=int(body.get("booked_meetings", 2)),
|
||||
won_revenue_sar=float(body.get("won_revenue_sar", 80000)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/positioning/{segment}")
|
||||
async def positioning(segment: str) -> dict[str, Any]:
|
||||
allowed: tuple[Segment, ...] = ("founder", "sme", "enterprise", "agency")
|
||||
seg = cast(Segment, segment if segment in allowed else "founder")
|
||||
return {"segment": seg, "statement_ar": positioning_statement(seg)}
|
||||
|
||||
|
||||
@router.get("/channels")
|
||||
async def channels() -> dict[str, Any]:
|
||||
return channel_strategy()
|
||||
|
||||
|
||||
@router.get("/partners")
|
||||
async def partners() -> dict[str, Any]:
|
||||
return partner_strategy()
|
||||
|
||||
|
||||
@router.get("/sales-script")
|
||||
async def sales_script() -> dict[str, Any]:
|
||||
return founder_led_sales_script()
|
||||
|
||||
|
||||
@router.get("/verticals")
|
||||
async def verticals() -> dict[str, Any]:
|
||||
return get_vertical_playbooks()
|
||||
|
||||
|
||||
@router.post("/verticals/recommend")
|
||||
async def vertical_recommend(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return recommend_vertical(
|
||||
industry=str(body.get("industry", "b2b")),
|
||||
city=str(body.get("city", "Riyadh")),
|
||||
goal=str(body.get("goal", "pipeline")),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/proof-pack/demo")
|
||||
async def proof_pack_demo() -> dict[str, Any]:
|
||||
return build_demo_proof_pack()
|
||||
|
||||
|
||||
@router.post("/proof-pack/roi-summary")
|
||||
async def proof_pack_roi(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return calculate_roi_summary(
|
||||
subscription_sar=float(body.get("subscription_sar", 2999)),
|
||||
influenced_revenue_sar=float(body.get("influenced_revenue_sar", 40000)),
|
||||
hours_saved=float(body.get("hours_saved", 12)),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/account-health")
|
||||
async def account_health(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return grade_account_health(
|
||||
brief_opens_4w=int(body.get("brief_opens_4w", 8)),
|
||||
approvals_4w=int(body.get("approvals_4w", 5)),
|
||||
blocks_4w=int(body.get("blocks_4w", 2)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/model-routes")
|
||||
async def model_routes() -> dict[str, Any]:
|
||||
routes = []
|
||||
for task in ModelTask:
|
||||
r = get_model_route(task)
|
||||
routes.append(
|
||||
{
|
||||
"task": task.value,
|
||||
"quality_tier": r.quality_tier,
|
||||
"latency": r.latency,
|
||||
"cost_class": r.cost_class,
|
||||
"guardrail_required": r.guardrail_required,
|
||||
"eval_metric": r.eval_metric,
|
||||
}
|
||||
)
|
||||
return {"routes": routes}
|
||||
|
||||
|
||||
@router.get("/model-routes/guardrail-tasks")
|
||||
async def guardrail_tasks() -> dict[str, Any]:
|
||||
return {"tasks": [t.value for t in ModelTask if requires_guardrail(t)]}
|
||||
527
dealix/api/routers/command_center.py
Normal file
527
dealix/api/routers/command_center.py
Normal file
@ -0,0 +1,527 @@
|
||||
"""
|
||||
Revenue Command Center router — single integration point for the
|
||||
in-product dashboard. Exposes everything from the revenue_graph layer:
|
||||
- Why-Now? explanations
|
||||
- Revenue Leak Detector
|
||||
- Maturity / Benchmark Score
|
||||
- Acquisition Simulator
|
||||
- Objection Library
|
||||
- Proof Pack generator
|
||||
- Agent registry catalog
|
||||
- Sector Playbooks
|
||||
- Graph health / moat score
|
||||
|
||||
These endpoints power /landing/command-center.html and the customer portal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query
|
||||
|
||||
from auto_client_acquisition.revenue_graph.agent_registry import (
|
||||
ALL_AGENTS,
|
||||
agents_summary,
|
||||
get_agent,
|
||||
list_agents_by_autonomy,
|
||||
list_agents_by_runtime,
|
||||
)
|
||||
from auto_client_acquisition.revenue_graph.graph import (
|
||||
CompanyVector,
|
||||
OutcomeStats,
|
||||
aggregate_outcomes,
|
||||
cosine_similarity,
|
||||
find_similar_companies,
|
||||
graph_health_summary,
|
||||
predict_outcome_probabilities,
|
||||
recommend_next_action,
|
||||
)
|
||||
from auto_client_acquisition.revenue_graph.leak_detector import detect_all_leaks
|
||||
from auto_client_acquisition.revenue_graph.maturity_score import (
|
||||
DIMENSIONS,
|
||||
DIMENSION_WEIGHTS,
|
||||
compute_benchmark_score,
|
||||
)
|
||||
from auto_client_acquisition.revenue_graph.objection_library import (
|
||||
OBJECTION_CATEGORIES,
|
||||
SAUDI_B2B_OBJECTIONS,
|
||||
category_summary,
|
||||
find_by_keyword,
|
||||
list_by_category,
|
||||
)
|
||||
from auto_client_acquisition.revenue_graph.proof_pack import (
|
||||
ProofPackInputs,
|
||||
generate_proof_pack,
|
||||
)
|
||||
from auto_client_acquisition.revenue_graph.sector_playbooks import (
|
||||
ALL_PLAYBOOKS,
|
||||
get_playbook,
|
||||
list_playbooks_summary,
|
||||
)
|
||||
from auto_client_acquisition.revenue_graph.simulator import (
|
||||
SECTOR_BENCHMARKS,
|
||||
SimulatorInputs,
|
||||
simulate,
|
||||
)
|
||||
from auto_client_acquisition.revenue_graph.why_now import (
|
||||
SIGNAL_WEIGHTS,
|
||||
WhyNowSignal,
|
||||
explain_why_now,
|
||||
rank_todays_priorities,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/command-center", tags=["command-center"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _spec_to_dict(a: Any) -> dict[str, Any]:
|
||||
"""Convert agent spec / dataclass into a dashboard-ready dict."""
|
||||
return {
|
||||
"agent_id": a.agent_id,
|
||||
"name_ar": a.name_ar,
|
||||
"name_en": a.name_en,
|
||||
"role_ar": a.role_ar,
|
||||
"capabilities": list(a.capabilities),
|
||||
"tools_used": list(a.tools_used),
|
||||
"runs_on": a.runs_on,
|
||||
"autonomy_level": a.autonomy_level,
|
||||
"emits_events": list(a.emits_events),
|
||||
"requires_pii_access": a.requires_pii_access,
|
||||
"pdpl_compliance_gates": list(a.pdpl_compliance_gates),
|
||||
"avg_runtime_seconds": a.avg_runtime_seconds,
|
||||
"inputs_required": list(a.inputs_required),
|
||||
"outputs": list(a.outputs),
|
||||
}
|
||||
|
||||
|
||||
# ── 1. AGENTS CATALOG ─────────────────────────────────────────────
|
||||
@router.get("/agents")
|
||||
async def list_agents(
|
||||
autonomy: str | None = Query(None, description="safe_auto / human_approval / advisory"),
|
||||
runs_on: str | None = Query(None, description="substring of runs_on schedule"),
|
||||
) -> dict[str, Any]:
|
||||
"""List all 11 agents — used for the Agents panel."""
|
||||
pool = list(ALL_AGENTS)
|
||||
if autonomy:
|
||||
pool = list_agents_by_autonomy(autonomy)
|
||||
if runs_on:
|
||||
pool = [a for a in pool if runs_on in a.runs_on]
|
||||
return {
|
||||
"summary": agents_summary(),
|
||||
"agents": [_spec_to_dict(a) for a in pool],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/agents/{agent_id}")
|
||||
async def get_agent_detail(agent_id: str) -> dict[str, Any]:
|
||||
a = get_agent(agent_id)
|
||||
if a is None:
|
||||
raise HTTPException(status_code=404, detail=f"agent '{agent_id}' not found")
|
||||
return _spec_to_dict(a)
|
||||
|
||||
|
||||
# ── 2. WHY-NOW? ENGINE ───────────────────────────────────────────
|
||||
@router.post("/why-now")
|
||||
async def why_now_explanation(
|
||||
company_id: str = Body(..., embed=True),
|
||||
signals: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
sector: str | None = Body(default=None, embed=True),
|
||||
sector_pulse_trend: str | None = Body(default=None, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Explain why this company is a priority today based on detected signals.
|
||||
|
||||
Each signal: {signal_type, detected_at_iso, source, evidence_url?, payload?}
|
||||
"""
|
||||
parsed: list[WhyNowSignal] = []
|
||||
for s in signals:
|
||||
try:
|
||||
detected = datetime.fromisoformat(
|
||||
s["detected_at_iso"].replace("Z", "+00:00")
|
||||
).replace(tzinfo=None)
|
||||
except Exception:
|
||||
detected = _utcnow()
|
||||
parsed.append(
|
||||
WhyNowSignal(
|
||||
signal_type=s.get("signal_type", "unknown"),
|
||||
detected_at=detected,
|
||||
source=s.get("source", "manual"),
|
||||
evidence_url=s.get("evidence_url"),
|
||||
payload=s.get("payload", {}),
|
||||
)
|
||||
)
|
||||
explanation = explain_why_now(
|
||||
company_id=company_id,
|
||||
signals=parsed,
|
||||
sector=sector,
|
||||
sector_pulse_trend=sector_pulse_trend,
|
||||
)
|
||||
if explanation is None:
|
||||
return {"company_id": company_id, "actionable": False, "reason": "weak_or_stale_signals"}
|
||||
return {
|
||||
"company_id": explanation.company_id,
|
||||
"actionable": True,
|
||||
"score": explanation.score,
|
||||
"headline_ar": explanation.headline_ar,
|
||||
"detail_ar": explanation.detail_ar,
|
||||
"suggested_angle_ar": explanation.suggested_angle_ar,
|
||||
"primary_signals": explanation.primary_signals,
|
||||
"decay_warning": explanation.decay_warning,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/why-now/signal-weights")
|
||||
async def list_signal_weights() -> dict[str, Any]:
|
||||
"""Reference catalogue — what signals Dealix tracks + their weight."""
|
||||
return {
|
||||
"count": len(SIGNAL_WEIGHTS),
|
||||
"weights": dict(sorted(SIGNAL_WEIGHTS.items(), key=lambda x: -x[1])),
|
||||
}
|
||||
|
||||
|
||||
# ── 3. REVENUE LEAK DETECTOR ─────────────────────────────────────
|
||||
@router.post("/leaks")
|
||||
async def detect_leaks(
|
||||
leads: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
meetings: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
deals: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
campaigns: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
reps: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
avg_deal_value_sar: float = Body(default=25000, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
"""Run all leak detectors and return ranked report."""
|
||||
# Convert ISO timestamps where present
|
||||
for collection in (leads, meetings, deals):
|
||||
for item in collection:
|
||||
for k in ("created_at", "last_outreach_at", "held_at", "last_activity_at"):
|
||||
v = item.get(k)
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
item[k] = datetime.fromisoformat(v.replace("Z", "+00:00")).replace(
|
||||
tzinfo=None
|
||||
)
|
||||
except Exception:
|
||||
item[k] = None
|
||||
|
||||
report = detect_all_leaks(
|
||||
leads=leads,
|
||||
meetings=meetings,
|
||||
deals=deals,
|
||||
campaigns=campaigns,
|
||||
reps=reps,
|
||||
avg_deal_value_sar=avg_deal_value_sar,
|
||||
)
|
||||
return {
|
||||
"total_estimated_impact_sar": report.total_estimated_impact_sar,
|
||||
"by_severity": report.by_severity,
|
||||
"by_type": report.by_type,
|
||||
"top_3_actions_ar": report.top_3_actions_ar,
|
||||
"leaks": [
|
||||
{
|
||||
"leak_type": lk.leak_type,
|
||||
"severity": lk.severity,
|
||||
"entity_type": lk.entity_type,
|
||||
"entity_id": lk.entity_id,
|
||||
"headline_ar": lk.headline_ar,
|
||||
"detail_ar": lk.detail_ar,
|
||||
"estimated_impact_sar": lk.estimated_impact_sar,
|
||||
"suggested_action_ar": lk.suggested_action_ar,
|
||||
"days_in_state": lk.days_in_state,
|
||||
}
|
||||
for lk in report.leaks
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 4. MATURITY / BENCHMARK SCORE ────────────────────────────────
|
||||
@router.post("/benchmark-score")
|
||||
async def compute_score(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Compute the customer's Dealix Benchmark Score across 7 dimensions."""
|
||||
customer_id = payload.get("customer_id", "unknown")
|
||||
if not customer_id:
|
||||
raise HTTPException(status_code=400, detail="customer_id required")
|
||||
report = compute_benchmark_score(
|
||||
customer_id=customer_id,
|
||||
has_playbook=bool(payload.get("has_playbook")),
|
||||
has_quota=bool(payload.get("has_quota")),
|
||||
weekly_pipeline_review=bool(payload.get("weekly_pipeline_review")),
|
||||
median_response_minutes=int(payload.get("median_response_minutes", 240)),
|
||||
followups_per_lead=float(payload.get("followups_per_lead", 1.0)),
|
||||
reply_rate=float(payload.get("reply_rate", 0)),
|
||||
positive_reply_rate=float(payload.get("positive_reply_rate", 0)),
|
||||
sectors_targeted=int(payload.get("sectors_targeted", 1)),
|
||||
win_rate_top_sector=float(payload.get("win_rate_top_sector", 0)),
|
||||
has_pricing_page=bool(payload.get("has_pricing_page")),
|
||||
has_case_studies=bool(payload.get("has_case_studies")),
|
||||
avg_proposal_pages=float(payload.get("avg_proposal_pages", 10)),
|
||||
lead_to_meeting=float(payload.get("lead_to_meeting", 0)),
|
||||
meeting_to_deal=float(payload.get("meeting_to_deal", 0)),
|
||||
deal_to_close=float(payload.get("deal_to_close", 0)),
|
||||
has_onboarding_flow=bool(payload.get("has_onboarding_flow")),
|
||||
nps_collected=bool(payload.get("nps_collected")),
|
||||
runs_qbr=bool(payload.get("runs_qbr")),
|
||||
peer_percentile=payload.get("peer_percentile"),
|
||||
)
|
||||
return {
|
||||
"customer_id": report.customer_id,
|
||||
"overall": report.overall,
|
||||
"bucket": report.bucket,
|
||||
"peer_percentile": report.peer_percentile,
|
||||
"dimensions": [
|
||||
{
|
||||
"name": d.name,
|
||||
"score": d.score,
|
||||
"bucket": d.bucket,
|
||||
"summary_ar": d.summary_ar,
|
||||
"next_step_ar": d.next_step_ar,
|
||||
"weight": DIMENSION_WEIGHTS.get(d.name, 0),
|
||||
}
|
||||
for d in report.dimensions
|
||||
],
|
||||
"roadmap": report.roadmap,
|
||||
"markdown_export": report.to_markdown(),
|
||||
}
|
||||
|
||||
|
||||
# ── 5. ACQUISITION SIMULATOR ─────────────────────────────────────
|
||||
@router.post("/simulator")
|
||||
async def run_simulator(
|
||||
sector: str = Body(..., embed=True),
|
||||
city: str = Body(..., embed=True),
|
||||
avg_deal_value_sar: float = Body(..., embed=True),
|
||||
target_revenue_sar: float = Body(..., embed=True),
|
||||
target_period_days: int = Body(default=90, embed=True),
|
||||
current_close_rate: float | None = Body(default=None, embed=True),
|
||||
current_monthly_meetings: int = Body(default=0, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
"""Run the acquisition simulator — used on landing + onboarding."""
|
||||
inputs = SimulatorInputs(
|
||||
sector=sector,
|
||||
city=city,
|
||||
avg_deal_value_sar=avg_deal_value_sar,
|
||||
target_revenue_sar=target_revenue_sar,
|
||||
target_period_days=target_period_days,
|
||||
current_close_rate=current_close_rate,
|
||||
current_monthly_meetings=current_monthly_meetings,
|
||||
)
|
||||
result = simulate(inputs=inputs)
|
||||
return {
|
||||
"inputs": {
|
||||
"sector": inputs.sector,
|
||||
"city": inputs.city,
|
||||
"avg_deal_value_sar": inputs.avg_deal_value_sar,
|
||||
"target_revenue_sar": inputs.target_revenue_sar,
|
||||
"target_period_days": inputs.target_period_days,
|
||||
},
|
||||
"baseline": result.baseline.__dict__,
|
||||
"with_dealix": result.with_dealix.__dict__,
|
||||
"plan": result.plan.__dict__,
|
||||
"expected_roi_x": result.expected_roi_x,
|
||||
"risks_ar": result.risks_ar,
|
||||
"assumptions_ar": result.assumptions_ar,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/simulator/sector-benchmarks")
|
||||
async def list_simulator_benchmarks() -> dict[str, Any]:
|
||||
return {
|
||||
"count": len(SECTOR_BENCHMARKS),
|
||||
"sectors": SECTOR_BENCHMARKS,
|
||||
"source": "Saudi B2B Pulse — quarterly aggregated, anonymized.",
|
||||
}
|
||||
|
||||
|
||||
# ── 6. OBJECTION LIBRARY ─────────────────────────────────────────
|
||||
@router.get("/objections")
|
||||
async def list_objections(
|
||||
category: str | None = Query(default=None),
|
||||
keyword: str | None = Query(default=None),
|
||||
) -> dict[str, Any]:
|
||||
"""Browse + search the Saudi B2B Objection Library."""
|
||||
if keyword:
|
||||
match = find_by_keyword(keyword)
|
||||
return {"matched": match.objection_id if match else None, "objection": match.__dict__ if match else None}
|
||||
pool = list_by_category(category) if category else list(SAUDI_B2B_OBJECTIONS)
|
||||
return {
|
||||
"count": len(pool),
|
||||
"categories": OBJECTION_CATEGORIES,
|
||||
"category_summary": category_summary(),
|
||||
"objections": [o.__dict__ for o in pool],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/objections/{objection_id}")
|
||||
async def get_objection(objection_id: str) -> dict[str, Any]:
|
||||
for o in SAUDI_B2B_OBJECTIONS:
|
||||
if o.objection_id == objection_id:
|
||||
return o.__dict__
|
||||
raise HTTPException(status_code=404, detail=f"objection '{objection_id}' not found")
|
||||
|
||||
|
||||
# ── 7. PROOF PACK GENERATOR ──────────────────────────────────────
|
||||
@router.post("/proof-pack")
|
||||
async def generate_pack(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Generate a monthly Proof Pack from raw metrics."""
|
||||
try:
|
||||
inputs = ProofPackInputs(
|
||||
customer_id=payload.get("customer_id", "unknown"),
|
||||
customer_name=payload.get("customer_name", ""),
|
||||
sector=payload.get("sector", "saas"),
|
||||
month_label=payload.get("month_label", ""),
|
||||
plan=payload.get("plan", "Growth"),
|
||||
monthly_price_sar=float(payload.get("monthly_price_sar", 2999)),
|
||||
leads_discovered=int(payload.get("leads_discovered", 0)),
|
||||
leads_enriched=int(payload.get("leads_enriched", 0)),
|
||||
drafts_created=int(payload.get("drafts_created", 0)),
|
||||
drafts_sent=int(payload.get("drafts_sent", 0)),
|
||||
whatsapp_sent=int(payload.get("whatsapp_sent", 0)),
|
||||
emails_sent=int(payload.get("emails_sent", 0)),
|
||||
linkedin_sent=int(payload.get("linkedin_sent", 0)),
|
||||
replies_received=int(payload.get("replies_received", 0)),
|
||||
positive_replies=int(payload.get("positive_replies", 0)),
|
||||
meetings_booked=int(payload.get("meetings_booked", 0)),
|
||||
proposals_sent=int(payload.get("proposals_sent", 0)),
|
||||
deals_won=int(payload.get("deals_won", 0)),
|
||||
pipeline_added_sar=float(payload.get("pipeline_added_sar", 0)),
|
||||
revenue_won_sar=float(payload.get("revenue_won_sar", 0)),
|
||||
avg_response_minutes=int(payload.get("avg_response_minutes", 60)),
|
||||
bounce_rate=float(payload.get("bounce_rate", 0)),
|
||||
opt_outs=int(payload.get("opt_outs", 0)),
|
||||
compliance_blocks=int(payload.get("compliance_blocks", 0)),
|
||||
sector_reply_rate_p50=float(payload.get("sector_reply_rate_p50", 0.07)),
|
||||
sector_meeting_rate_p50=float(payload.get("sector_meeting_rate_p50", 0.30)),
|
||||
sector_win_rate_p50=float(payload.get("sector_win_rate_p50", 0.20)),
|
||||
best_message_subject=payload.get("best_message_subject"),
|
||||
best_message_reply_rate=payload.get("best_message_reply_rate"),
|
||||
best_sector_played=payload.get("best_sector_played"),
|
||||
worst_bottleneck_ar=payload.get("worst_bottleneck_ar"),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"invalid payload: {exc}") from exc
|
||||
|
||||
pack = generate_proof_pack(inputs)
|
||||
return {
|
||||
"customer_id": pack.customer_id,
|
||||
"customer_name": pack.customer_name,
|
||||
"period_label": pack.period_label,
|
||||
"headline_metric": pack.headline_metric,
|
||||
"grade": pack.grade,
|
||||
"tldr_ar": pack.tldr_ar,
|
||||
"activity_summary": pack.activity_summary,
|
||||
"pipeline_impact": pack.pipeline_impact,
|
||||
"quality_score": pack.quality_score,
|
||||
"benchmark_comparison": pack.benchmark_comparison,
|
||||
"top_performers": pack.top_performers,
|
||||
"recommendations_next_month_ar": pack.recommendations_next_month_ar,
|
||||
"roi_breakdown": pack.roi_breakdown,
|
||||
"markdown_export": pack.to_markdown(),
|
||||
"generated_at": pack.generated_at,
|
||||
}
|
||||
|
||||
|
||||
# ── 8. SECTOR PLAYBOOKS ──────────────────────────────────────────
|
||||
@router.get("/playbooks")
|
||||
async def list_playbooks() -> dict[str, Any]:
|
||||
return {
|
||||
"count": len(ALL_PLAYBOOKS),
|
||||
"summaries": list_playbooks_summary(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/playbooks/{sector_id}")
|
||||
async def get_playbook_detail(sector_id: str) -> dict[str, Any]:
|
||||
p = get_playbook(sector_id)
|
||||
if p is None:
|
||||
raise HTTPException(status_code=404, detail=f"playbook '{sector_id}' not found")
|
||||
return {
|
||||
"sector_id": p.sector_id,
|
||||
"sector_ar": p.sector_ar,
|
||||
"sector_en": p.sector_en,
|
||||
"pain_points_ar": list(p.pain_points_ar),
|
||||
"top_objections": list(p.top_objections),
|
||||
"opening_lines_ar": list(p.opening_lines_ar),
|
||||
"best_offer_angle_ar": p.best_offer_angle_ar,
|
||||
"buying_committee": list(p.buying_committee),
|
||||
"seasonal_peaks_ar": list(p.seasonal_peaks_ar),
|
||||
"benchmarks": p.benchmarks,
|
||||
"recommended_channel_mix": p.recommended_channel_mix,
|
||||
"whatsapp_tone": p.whatsapp_tone,
|
||||
"case_study_template_ar": p.case_study_template_ar,
|
||||
"avg_deal_value_sar": p.avg_deal_value_sar,
|
||||
"avg_cycle_days": p.avg_cycle_days,
|
||||
}
|
||||
|
||||
|
||||
# ── 9. NEXT-BEST-ACTION RECOMMENDER ──────────────────────────────
|
||||
@router.post("/next-best-action")
|
||||
async def get_next_best_action(
|
||||
company_id: str = Body(..., embed=True),
|
||||
sector: str = Body(default="saas", embed=True),
|
||||
last_outcome: str | None = Body(default=None, embed=True),
|
||||
days_since_last_touch: int = Body(default=0, embed=True),
|
||||
has_whatsapp_business: bool = Body(default=False, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
target = CompanyVector(
|
||||
company_id=company_id,
|
||||
sector=sector,
|
||||
has_whatsapp_business=has_whatsapp_business,
|
||||
)
|
||||
nba = recommend_next_action(
|
||||
target=target,
|
||||
last_outcome=last_outcome,
|
||||
days_since_last_touch=days_since_last_touch,
|
||||
)
|
||||
return {
|
||||
"company_id": company_id,
|
||||
"action": nba.action,
|
||||
"channel": nba.channel,
|
||||
"rationale": nba.rationale,
|
||||
"expected_reply_lift": nba.expected_reply_lift,
|
||||
"confidence": nba.confidence,
|
||||
"playbook_id": nba.playbook_id,
|
||||
}
|
||||
|
||||
|
||||
# ── 10. GRAPH HEALTH (moat score for the dashboard) ──────────────
|
||||
@router.get("/graph-health")
|
||||
async def get_graph_health(
|
||||
n_companies: int = Query(default=0, ge=0),
|
||||
n_signals: int = Query(default=0, ge=0),
|
||||
n_messages: int = Query(default=0, ge=0),
|
||||
n_outcomes: int = Query(default=0, ge=0),
|
||||
n_won_deals: int = Query(default=0, ge=0),
|
||||
) -> dict[str, Any]:
|
||||
"""High-level Revenue Graph health — for the Moat Score tile."""
|
||||
return graph_health_summary(
|
||||
n_companies=n_companies,
|
||||
n_signals=n_signals,
|
||||
n_messages=n_messages,
|
||||
n_outcomes=n_outcomes,
|
||||
n_won_deals=n_won_deals,
|
||||
)
|
||||
|
||||
|
||||
# ── 11. THE FULL DASHBOARD SNAPSHOT ──────────────────────────────
|
||||
@router.get("/snapshot")
|
||||
async def dashboard_snapshot(customer_id: str = Query(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Full snapshot for the in-product dashboard — combines health, agents,
|
||||
playbooks, and a few KPI tiles. Demo / discovery endpoint.
|
||||
"""
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"agents_summary": agents_summary(),
|
||||
"playbooks_count": len(ALL_PLAYBOOKS),
|
||||
"objections_indexed": len(SAUDI_B2B_OBJECTIONS),
|
||||
"signal_types_tracked": len(SIGNAL_WEIGHTS),
|
||||
"graph_status": "live",
|
||||
"compliance_gates_active": 11,
|
||||
"last_pulse_published": _utcnow().date().isoformat(),
|
||||
}
|
||||
375
dealix/api/routers/customer_success.py
Normal file
375
dealix/api/routers/customer_success.py
Normal file
@ -0,0 +1,375 @@
|
||||
"""
|
||||
Customer Success router — health scores, QBRs, and Saudi B2B Pulse.
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/customer-success/health/{customer_id} — compute health score
|
||||
GET /api/v1/customer-success/at-risk — list at-risk customers
|
||||
POST /api/v1/customer-success/qbr/{customer_id} — generate QBR (md + json)
|
||||
GET /api/v1/customer-success/benchmarks/{sector} — sector percentiles
|
||||
POST /api/v1/customer-success/compare/{customer_id} — customer vs sector
|
||||
GET /api/v1/customer-success/saudi-b2b-pulse — public monthly report
|
||||
|
||||
Privacy: benchmarks use min cohort = 5 (re-identification guard).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from auto_client_acquisition.customer_success.benchmarks import (
|
||||
MIN_COHORT_SIZE, compare_customer, compute_sector_benchmark, saudi_b2b_pulse,
|
||||
)
|
||||
from auto_client_acquisition.customer_success.health_score import compute_health
|
||||
from auto_client_acquisition.customer_success.qbr_generator import generate_qbr
|
||||
from db.models import (
|
||||
AccountRecord, CustomerRecord, EmailSendLog, GmailDraftRecord,
|
||||
LeadScoreRecord, LinkedInDraftRecord,
|
||||
)
|
||||
from db.session import async_session_factory
|
||||
|
||||
router = APIRouter(prefix="/api/v1/customer-success", tags=["customer-success"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
# ── Health score for one customer ─────────────────────────────────
|
||||
@router.post("/health/{customer_id}")
|
||||
async def compute_customer_health(customer_id: str) -> dict[str, Any]:
|
||||
"""Compute live health score for a customer using last-30d signals."""
|
||||
cutoff_30d = _utcnow() - timedelta(days=30)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
cust = (await session.execute(
|
||||
select(CustomerRecord).where(CustomerRecord.id == customer_id)
|
||||
)).scalar_one_or_none()
|
||||
if not cust:
|
||||
raise HTTPException(404, "customer_not_found")
|
||||
|
||||
drafts_created = int((await session.execute(
|
||||
select(func.count()).select_from(GmailDraftRecord).where(
|
||||
GmailDraftRecord.created_at >= cutoff_30d,
|
||||
)
|
||||
)).scalar() or 0)
|
||||
drafts_sent = int((await session.execute(
|
||||
select(func.count()).select_from(GmailDraftRecord).where(
|
||||
GmailDraftRecord.created_at >= cutoff_30d,
|
||||
GmailDraftRecord.status == "sent",
|
||||
)
|
||||
)).scalar() or 0)
|
||||
replies = int((await session.execute(
|
||||
select(func.count()).select_from(EmailSendLog).where(
|
||||
EmailSendLog.reply_received_at >= cutoff_30d,
|
||||
)
|
||||
)).scalar() or 0)
|
||||
total_drafts = int((await session.execute(
|
||||
select(func.count()).select_from(GmailDraftRecord)
|
||||
)).scalar() or 0)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
days_since_login = (
|
||||
(_utcnow() - cust.updated_at).days
|
||||
if cust.updated_at else 0
|
||||
)
|
||||
|
||||
score = compute_health(
|
||||
customer_id=customer_id,
|
||||
logins_last_30d=max(0, 22 - days_since_login),
|
||||
drafts_approved_last_30d=drafts_sent,
|
||||
replies_acted_on_last_30d=replies,
|
||||
demos_booked_last_30d=int(cust.daily_report_sent or 0) // 5,
|
||||
deals_stage_progressed_last_30d=drafts_sent // 5,
|
||||
paid_customers_last_30d=1 if cust.onboarding_status != "kickoff_pending" else 0,
|
||||
pipeline_value_sar=drafts_sent * 5000, # rough estimate
|
||||
channels_enabled=2, # default 2 (Gmail + LinkedIn)
|
||||
integrations_connected=1,
|
||||
sectors_targeted=1,
|
||||
total_drafts_lifetime=total_drafts,
|
||||
nps=cust.nps_score,
|
||||
support_tickets_open=0,
|
||||
days_since_last_login=days_since_login,
|
||||
billing_failures=0,
|
||||
)
|
||||
return score.to_dict()
|
||||
|
||||
|
||||
@router.get("/at-risk")
|
||||
async def list_at_risk_customers() -> dict[str, Any]:
|
||||
"""Return all customers in at_risk or critical buckets."""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
customers = (await session.execute(select(CustomerRecord))).scalars().all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc), "items": []}
|
||||
|
||||
at_risk: list[dict[str, Any]] = []
|
||||
for c in customers:
|
||||
# Simplified: pull the full health score per customer
|
||||
days_idle = (_utcnow() - c.updated_at).days if c.updated_at else 0
|
||||
score = compute_health(
|
||||
customer_id=c.id,
|
||||
logins_last_30d=max(0, 22 - days_idle),
|
||||
nps=c.nps_score,
|
||||
days_since_last_login=days_idle,
|
||||
drafts_approved_last_30d=0, # TODO query per customer
|
||||
)
|
||||
if score.bucket in {"at_risk", "critical"}:
|
||||
at_risk.append(score.to_dict())
|
||||
|
||||
return {
|
||||
"count": len(at_risk),
|
||||
"customers": sorted(at_risk, key=lambda x: x["overall"]),
|
||||
"next_action": "Reach out to critical bucket within 24 hours.",
|
||||
}
|
||||
|
||||
|
||||
# ── QBR generator ─────────────────────────────────────────────────
|
||||
@router.post("/qbr/{customer_id}")
|
||||
async def generate_customer_qbr(customer_id: str, body: dict[str, Any] = Body(default={})) -> dict[str, Any]:
|
||||
"""Generate a Quarterly Business Review for a customer (default: last 30 days)."""
|
||||
period_days = int(body.get("period_days") or 30)
|
||||
cutoff = _utcnow() - timedelta(days=period_days)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
cust = (await session.execute(
|
||||
select(CustomerRecord).where(CustomerRecord.id == customer_id)
|
||||
)).scalar_one_or_none()
|
||||
if not cust:
|
||||
raise HTTPException(404, "customer_not_found")
|
||||
|
||||
emails_sent = int((await session.execute(
|
||||
select(func.count()).select_from(EmailSendLog).where(
|
||||
EmailSendLog.sent_at >= cutoff,
|
||||
EmailSendLog.status == "sent",
|
||||
)
|
||||
)).scalar() or 0)
|
||||
emails_replied = int((await session.execute(
|
||||
select(func.count()).select_from(EmailSendLog).where(
|
||||
EmailSendLog.reply_received_at >= cutoff,
|
||||
)
|
||||
)).scalar() or 0)
|
||||
emails_bounced = int((await session.execute(
|
||||
select(func.count()).select_from(EmailSendLog).where(
|
||||
EmailSendLog.status == "bounced",
|
||||
EmailSendLog.updated_at >= cutoff,
|
||||
)
|
||||
)).scalar() or 0)
|
||||
drafts_created = int((await session.execute(
|
||||
select(func.count()).select_from(GmailDraftRecord).where(
|
||||
GmailDraftRecord.created_at >= cutoff,
|
||||
)
|
||||
)).scalar() or 0)
|
||||
drafts_sent = int((await session.execute(
|
||||
select(func.count()).select_from(GmailDraftRecord).where(
|
||||
GmailDraftRecord.created_at >= cutoff,
|
||||
GmailDraftRecord.status == "sent",
|
||||
)
|
||||
)).scalar() or 0)
|
||||
linkedin_drafts = int((await session.execute(
|
||||
select(func.count()).select_from(LinkedInDraftRecord).where(
|
||||
LinkedInDraftRecord.created_at >= cutoff,
|
||||
)
|
||||
)).scalar() or 0)
|
||||
linkedin_sent = int((await session.execute(
|
||||
select(func.count()).select_from(LinkedInDraftRecord).where(
|
||||
LinkedInDraftRecord.created_at >= cutoff,
|
||||
LinkedInDraftRecord.status == "sent",
|
||||
)
|
||||
)).scalar() or 0)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
# Health score
|
||||
days_idle = (_utcnow() - cust.updated_at).days if cust.updated_at else 0
|
||||
health = compute_health(
|
||||
customer_id=customer_id,
|
||||
logins_last_30d=max(0, 22 - days_idle),
|
||||
drafts_approved_last_30d=drafts_sent,
|
||||
replies_acted_on_last_30d=emails_replied,
|
||||
nps=cust.nps_score, days_since_last_login=days_idle,
|
||||
total_drafts_lifetime=drafts_created,
|
||||
)
|
||||
|
||||
qbr = generate_qbr(
|
||||
customer_id=customer_id,
|
||||
customer_name=cust.company_id or customer_id,
|
||||
period_days=period_days,
|
||||
emails_sent=emails_sent, emails_replied=emails_replied,
|
||||
emails_bounced=emails_bounced,
|
||||
drafts_created=drafts_created, drafts_sent=drafts_sent,
|
||||
linkedin_drafts=linkedin_drafts, linkedin_sent=linkedin_sent,
|
||||
health_overall=health.overall, health_bucket=health.bucket,
|
||||
current_plan=cust.plan,
|
||||
)
|
||||
|
||||
return {
|
||||
"qbr": qbr.to_dict(),
|
||||
"markdown": qbr.to_markdown(),
|
||||
"health": health.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
# ── Sector benchmarks (private to subscribers) ────────────────────
|
||||
@router.get("/benchmarks/{sector}")
|
||||
async def get_sector_benchmarks(sector: str, metric: str = "reply_rate") -> dict[str, Any]:
|
||||
"""Sector percentiles. Requires >=5 customers in sector for privacy."""
|
||||
cutoff_30d = _utcnow() - timedelta(days=30)
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
accounts = (await session.execute(
|
||||
select(AccountRecord).where(AccountRecord.sector == sector)
|
||||
)).scalars().all()
|
||||
account_ids = [a.id for a in accounts]
|
||||
if not account_ids:
|
||||
return {"status": "no_data", "sector": sector}
|
||||
|
||||
sends = (await session.execute(
|
||||
select(EmailSendLog).where(
|
||||
EmailSendLog.account_id.in_(account_ids),
|
||||
EmailSendLog.sent_at >= cutoff_30d,
|
||||
)
|
||||
)).scalars().all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
# Group by account_id, compute reply rates
|
||||
by_account: dict[str, dict[str, int]] = defaultdict(lambda: {"sent": 0, "replied": 0})
|
||||
for s in sends:
|
||||
by_account[s.account_id]["sent"] += 1
|
||||
if s.reply_received_at:
|
||||
by_account[s.account_id]["replied"] += 1
|
||||
|
||||
if metric == "reply_rate":
|
||||
values = [
|
||||
(v["replied"] / max(1, v["sent"])) * 100
|
||||
for v in by_account.values() if v["sent"] >= 5
|
||||
]
|
||||
elif metric == "send_volume":
|
||||
values = [v["sent"] for v in by_account.values()]
|
||||
else:
|
||||
return {"status": "unknown_metric", "sector": sector, "metric": metric,
|
||||
"valid_metrics": ["reply_rate", "send_volume"]}
|
||||
|
||||
bench = compute_sector_benchmark(sector, metric, values)
|
||||
if bench is None:
|
||||
return {
|
||||
"status": "cohort_too_small",
|
||||
"sector": sector, "metric": metric,
|
||||
"min_required": MIN_COHORT_SIZE, "current": len(values),
|
||||
"note": "Privacy guard: need ≥5 active customers in this sector.",
|
||||
}
|
||||
return {"status": "ok", "benchmark": bench.to_dict()}
|
||||
|
||||
|
||||
@router.post("/compare/{customer_id}")
|
||||
async def compare_to_sector(customer_id: str, body: dict[str, Any] = Body(default={})) -> dict[str, Any]:
|
||||
"""Where does this customer rank in their sector cohort?"""
|
||||
metric = str(body.get("metric") or "reply_rate")
|
||||
cutoff_30d = _utcnow() - timedelta(days=30)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
cust = (await session.execute(
|
||||
select(CustomerRecord).where(CustomerRecord.id == customer_id)
|
||||
)).scalar_one_or_none()
|
||||
if not cust or not cust.company_id:
|
||||
return {"status": "customer_or_company_not_found"}
|
||||
|
||||
company = (await session.execute(
|
||||
select(AccountRecord).where(AccountRecord.id == cust.company_id)
|
||||
)).scalar_one_or_none()
|
||||
if not company:
|
||||
return {"status": "company_not_found"}
|
||||
|
||||
sector = company.sector or "unknown"
|
||||
peers = (await session.execute(
|
||||
select(AccountRecord).where(AccountRecord.sector == sector)
|
||||
)).scalars().all()
|
||||
peer_ids = [a.id for a in peers]
|
||||
|
||||
sends = (await session.execute(
|
||||
select(EmailSendLog).where(
|
||||
EmailSendLog.account_id.in_(peer_ids),
|
||||
EmailSendLog.sent_at >= cutoff_30d,
|
||||
)
|
||||
)).scalars().all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
by_acc: dict[str, dict[str, int]] = defaultdict(lambda: {"sent": 0, "replied": 0})
|
||||
for s in sends:
|
||||
by_acc[s.account_id]["sent"] += 1
|
||||
if s.reply_received_at:
|
||||
by_acc[s.account_id]["replied"] += 1
|
||||
sector_values = [
|
||||
(v["replied"] / max(1, v["sent"])) * 100
|
||||
for v in by_acc.values() if v["sent"] >= 5
|
||||
]
|
||||
customer_stats = by_acc.get(cust.company_id, {"sent": 0, "replied": 0})
|
||||
customer_value = (
|
||||
(customer_stats["replied"] / max(1, customer_stats["sent"])) * 100
|
||||
)
|
||||
cmp = compare_customer(
|
||||
customer_id=customer_id, sector=sector, metric=metric,
|
||||
customer_value=customer_value, sector_values=sector_values,
|
||||
)
|
||||
if cmp is None:
|
||||
return {"status": "cohort_too_small", "min_required": MIN_COHORT_SIZE}
|
||||
return cmp.to_dict()
|
||||
|
||||
|
||||
# ── Saudi B2B Pulse (public, monthly) ─────────────────────────────
|
||||
@router.get("/saudi-b2b-pulse")
|
||||
async def get_saudi_b2b_pulse() -> dict[str, Any]:
|
||||
"""Public anonymized monthly report — works as a free lead magnet."""
|
||||
cutoff_30d = _utcnow() - timedelta(days=30)
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
accounts = (await session.execute(select(AccountRecord))).scalars().all()
|
||||
sector_to_ids: dict[str, list[str]] = defaultdict(list)
|
||||
for a in accounts:
|
||||
sector_to_ids[a.sector or "unknown"].append(a.id)
|
||||
|
||||
all_sends = (await session.execute(
|
||||
select(EmailSendLog).where(EmailSendLog.sent_at >= cutoff_30d)
|
||||
)).scalars().all()
|
||||
by_acc: dict[str, dict[str, int]] = defaultdict(lambda: {"sent": 0, "replied": 0})
|
||||
for s in all_sends:
|
||||
by_acc[s.account_id]["sent"] += 1
|
||||
if s.reply_received_at:
|
||||
by_acc[s.account_id]["replied"] += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
sector_data: dict[str, dict[str, list[float]]] = {}
|
||||
for sector, ids in sector_to_ids.items():
|
||||
if len(ids) < MIN_COHORT_SIZE:
|
||||
continue
|
||||
reply_rates = [
|
||||
(by_acc[i]["replied"] / max(1, by_acc[i]["sent"])) * 100
|
||||
for i in ids if by_acc[i]["sent"] >= 5
|
||||
]
|
||||
send_volumes = [by_acc[i]["sent"] for i in ids]
|
||||
if reply_rates or send_volumes:
|
||||
sector_data[sector] = {}
|
||||
if reply_rates:
|
||||
sector_data[sector]["reply_rate"] = reply_rates
|
||||
if send_volumes:
|
||||
sector_data[sector]["send_volume"] = [float(s) for s in send_volumes]
|
||||
|
||||
return saudi_b2b_pulse(sector_data=sector_data)
|
||||
885
dealix/api/routers/data.py
Normal file
885
dealix/api/routers/data.py
Normal file
@ -0,0 +1,885 @@
|
||||
"""
|
||||
Data Lake + Lead Graph ingestion router.
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/data/import — register a dataset (JSON rows)
|
||||
POST /api/v1/data/import/{id}/normalize — normalize raw rows
|
||||
POST /api/v1/data/import/{id}/dedupe — match + merge into accounts
|
||||
POST /api/v1/data/import/{id}/enrich — run enrichment for new accounts
|
||||
GET /api/v1/data/import/{id}/report — totals + per-row counts
|
||||
POST /api/v1/data/suppression — add opt-out email/phone/domain
|
||||
GET /api/v1/data/suppression — list suppression rows
|
||||
GET /api/v1/data/imports — list all imports
|
||||
GET /api/v1/data/accounts — list accounts (paginated)
|
||||
GET /api/v1/data/accounts/{id} — single account + signals
|
||||
POST /api/v1/data/accounts/{id}/score — recompute score from current data
|
||||
|
||||
Ingestion is *append-only*. Raw rows are kept; normalization writes
|
||||
new account/contact/signal records but never deletes raw_lead_rows.
|
||||
|
||||
PDPL compliance:
|
||||
- Every import declares allowed_use, source_type, consent_status, risk_level.
|
||||
- Suppression list is checked at outreach time, not at ingest time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
from sqlalchemy import select
|
||||
|
||||
from auto_client_acquisition.pipelines.dedupe import build_index, find_match
|
||||
from auto_client_acquisition.pipelines.enrichment import enrich_account
|
||||
from auto_client_acquisition.pipelines.normalize import (
|
||||
fuzzy_company_key,
|
||||
is_acceptable,
|
||||
normalize_row,
|
||||
)
|
||||
from auto_client_acquisition.pipelines.scoring import (
|
||||
compute_data_quality,
|
||||
compute_lead_score,
|
||||
)
|
||||
from db.models import (
|
||||
AccountRecord,
|
||||
ContactRecord,
|
||||
LeadScoreRecord,
|
||||
RawLeadImport,
|
||||
RawLeadRow,
|
||||
SignalRecord,
|
||||
SuppressionRecord,
|
||||
)
|
||||
from db.session import async_session_factory
|
||||
|
||||
router = APIRouter(prefix="/api/v1/data", tags=["data"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Data Source Catalog (compliance-graded) ──────────────────────
|
||||
SAUDI_DATA_SOURCE_CATALOG: list[dict[str, Any]] = [
|
||||
{
|
||||
"key": "riyadh_chamber",
|
||||
"name_ar": "غرفة الرياض — دليل الأعضاء",
|
||||
"name_en": "Riyadh Chamber of Commerce Member Directory",
|
||||
"url": "https://chamber.org.sa",
|
||||
"rating": "green",
|
||||
"access_method": "public_web",
|
||||
"coverage_city": ["riyadh"],
|
||||
"coverage_sector": "all",
|
||||
"ingest_strategy": "crawl_with_requests_bs4_provider",
|
||||
},
|
||||
{
|
||||
"key": "jeddah_chamber",
|
||||
"name_ar": "غرفة جدة — دليل الأعضاء",
|
||||
"name_en": "Jeddah Chamber of Commerce Member Directory",
|
||||
"url": "https://jcci.org.sa",
|
||||
"rating": "green",
|
||||
"access_method": "public_web",
|
||||
"coverage_city": ["jeddah"],
|
||||
"coverage_sector": "all",
|
||||
},
|
||||
{
|
||||
"key": "eastern_chamber",
|
||||
"name_ar": "غرفة الشرقية",
|
||||
"name_en": "Asharqia Chamber",
|
||||
"url": "https://chamber.org.sa/eastern",
|
||||
"rating": "green",
|
||||
"access_method": "public_web",
|
||||
"coverage_city": ["dammam", "khobar", "jubail"],
|
||||
"coverage_sector": "all",
|
||||
},
|
||||
{
|
||||
"key": "data_gov_sa",
|
||||
"name_ar": "بوابة البيانات المفتوحة (سدايا)",
|
||||
"name_en": "SDAIA Open Data Portal",
|
||||
"url": "https://data.gov.sa",
|
||||
"rating": "green",
|
||||
"access_method": "public_dataset_download",
|
||||
"coverage_city": "all",
|
||||
"coverage_sector": "all",
|
||||
},
|
||||
{
|
||||
"key": "google_places",
|
||||
"name_ar": "Google Places (Maps API)",
|
||||
"name_en": "Google Places via MapsProvider chain",
|
||||
"url": "internal:auto_client_acquisition.providers.maps",
|
||||
"rating": "green",
|
||||
"access_method": "api_with_key",
|
||||
"coverage_city": "all",
|
||||
"coverage_sector": "all",
|
||||
"ingest_strategy": "store_place_id_only_per_terms",
|
||||
},
|
||||
{
|
||||
"key": "saudi_contractors_authority",
|
||||
"name_ar": "هيئة المقاولين السعودية",
|
||||
"name_en": "Saudi Contractors Authority Registry",
|
||||
"url": "https://sca.org.sa",
|
||||
"rating": "green",
|
||||
"access_method": "public_web",
|
||||
"coverage_sector": ["construction"],
|
||||
},
|
||||
{
|
||||
"key": "saudi_tourism_authority",
|
||||
"name_ar": "هيئة السياحة السعودية",
|
||||
"name_en": "Saudi Tourism Authority Registry",
|
||||
"url": "https://scth.gov.sa",
|
||||
"rating": "green",
|
||||
"access_method": "public_web",
|
||||
"coverage_sector": ["hospitality_events"],
|
||||
},
|
||||
{
|
||||
"key": "linkedin",
|
||||
"name_ar": "LinkedIn",
|
||||
"name_en": "LinkedIn",
|
||||
"url": "https://www.linkedin.com",
|
||||
"rating": "red",
|
||||
"access_method": "scraping_prohibited",
|
||||
"ingest_strategy": "manual_research_only_no_bulk_ingest",
|
||||
"note": "Dealix uses LinkedIn for human research + human send only — never for data ingestion.",
|
||||
},
|
||||
{
|
||||
"key": "linkedin_chamber_other_yellow",
|
||||
"name_ar": "أدلة تجارية مدفوعة",
|
||||
"name_en": "Paid B2B Data Vendors (general)",
|
||||
"url": "various",
|
||||
"rating": "yellow",
|
||||
"access_method": "purchase_with_documentation",
|
||||
"ingest_strategy": "audit_lead_file_first_then_import",
|
||||
"note": "Demand source documentation, allowed_use, last_updated, sample of 100 rows before paying.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _new_id(prefix: str = "") -> str:
|
||||
suffix = uuid.uuid4().hex[:24]
|
||||
return f"{prefix}{suffix}" if prefix else suffix
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
async def _safe_commit(session, *objs: Any) -> bool:
|
||||
try:
|
||||
for o in objs:
|
||||
session.add(o)
|
||||
await session.commit()
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("data_router_commit_failed err=%s", exc)
|
||||
try:
|
||||
await session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
# ── Source catalog ────────────────────────────────────────────────
|
||||
@router.get("/sources/catalog")
|
||||
async def list_data_sources() -> dict[str, Any]:
|
||||
"""Compliance-graded Saudi business data source catalog."""
|
||||
return {
|
||||
"count": len(SAUDI_DATA_SOURCE_CATALOG),
|
||||
"rating_legend": {
|
||||
"green": "public + clearly permissive — direct ingest",
|
||||
"yellow": "public but ToS-sensitive — lookup-only, manual approval",
|
||||
"red": "scraping forbidden / paywalled-without-allowed-use — DO NOT INGEST",
|
||||
},
|
||||
"sources": SAUDI_DATA_SOURCE_CATALOG,
|
||||
"doc": "See docs/ops/SAUDI_DATA_SOURCE_CATALOG.md for ingestion strategy per source.",
|
||||
}
|
||||
|
||||
|
||||
# ── Import: register a dataset ────────────────────────────────────
|
||||
@router.post("/import")
|
||||
async def create_import(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Register a dataset. Body:
|
||||
source_name (required, str)
|
||||
source_type (required, one of: owned/public/paid/partner/google_maps/google_search/manual)
|
||||
allowed_use (optional, str — defaults to "business_contact_research_only")
|
||||
consent_status (optional)
|
||||
risk_level (optional, low/medium/high)
|
||||
rows (required, list[dict] — raw records, can be loose schema)
|
||||
file_name (optional)
|
||||
imported_by (optional)
|
||||
notes (optional)
|
||||
"""
|
||||
source_name = str(body.get("source_name") or "").strip()
|
||||
source_type = str(body.get("source_type") or "").strip()
|
||||
rows = body.get("rows")
|
||||
if not source_name:
|
||||
raise HTTPException(400, "source_name_required")
|
||||
if source_type not in {
|
||||
"owned", "public", "paid", "partner",
|
||||
"google_maps", "google_search", "manual",
|
||||
}:
|
||||
raise HTTPException(400, "source_type_invalid")
|
||||
if not isinstance(rows, list) or not rows:
|
||||
raise HTTPException(400, "rows_required: provide a non-empty list of dicts")
|
||||
if len(rows) > 10000:
|
||||
raise HTTPException(400, "too_many_rows: max 10000 per import; split into batches")
|
||||
|
||||
import_id = _new_id("imp_")
|
||||
|
||||
rec = RawLeadImport(
|
||||
id=import_id,
|
||||
source_name=source_name,
|
||||
source_type=source_type,
|
||||
file_name=body.get("file_name"),
|
||||
imported_by=body.get("imported_by"),
|
||||
allowed_use=str(body.get("allowed_use") or "business_contact_research_only"),
|
||||
consent_status=str(body.get("consent_status") or "unknown"),
|
||||
risk_level=str(body.get("risk_level") or "medium"),
|
||||
rows_total=len(rows),
|
||||
notes=body.get("notes"),
|
||||
status="raw",
|
||||
)
|
||||
raw_rows = [
|
||||
RawLeadRow(
|
||||
id=_new_id("rr_"),
|
||||
import_id=import_id,
|
||||
raw_json=r if isinstance(r, dict) else {"value": r},
|
||||
normalized_status="pending",
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
ok = await _safe_commit(session, rec, *raw_rows)
|
||||
if not ok:
|
||||
return {
|
||||
"import_id": import_id,
|
||||
"status": "skipped_db_unreachable",
|
||||
"rows_total": len(rows),
|
||||
}
|
||||
|
||||
return {
|
||||
"import_id": import_id,
|
||||
"status": "raw",
|
||||
"rows_total": len(rows),
|
||||
"next_action": f"POST /api/v1/data/import/{import_id}/normalize",
|
||||
}
|
||||
|
||||
|
||||
# ── Normalize ─────────────────────────────────────────────────────
|
||||
@router.post("/import/{import_id}/normalize")
|
||||
async def normalize_import(import_id: str) -> dict[str, Any]:
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
imp_rec = (await session.execute(
|
||||
select(RawLeadImport).where(RawLeadImport.id == import_id)
|
||||
)).scalar_one_or_none()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
if not imp_rec:
|
||||
raise HTTPException(404, "import_not_found")
|
||||
|
||||
rows = (await session.execute(
|
||||
select(RawLeadRow).where(RawLeadRow.import_id == import_id)
|
||||
)).scalars().all()
|
||||
|
||||
normalized_count = 0
|
||||
rejected_count = 0
|
||||
accounts_created: list[str] = []
|
||||
|
||||
for row in rows:
|
||||
if row.normalized_status != "pending":
|
||||
continue
|
||||
try:
|
||||
normalized = normalize_row(row.raw_json or {})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
row.normalized_status = "rejected"
|
||||
row.error = f"normalize_error: {exc}"
|
||||
rejected_count += 1
|
||||
continue
|
||||
|
||||
ok, reason = is_acceptable(normalized)
|
||||
if not ok:
|
||||
row.normalized_status = "rejected"
|
||||
row.error = reason or "unacceptable"
|
||||
rejected_count += 1
|
||||
continue
|
||||
|
||||
# Create AccountRecord stub (dedupe runs in next step)
|
||||
acc_id = _new_id("acc_")
|
||||
acc = AccountRecord(
|
||||
id=acc_id,
|
||||
company_name=normalized["company_name"][:255],
|
||||
normalized_name=normalized["normalized_name"][:255],
|
||||
domain=normalized["domain"],
|
||||
website=normalized["website"][:500] if normalized["website"] else None,
|
||||
city=normalized["city"][:128] if normalized["city"] else None,
|
||||
country=normalized["country"][:64] if normalized["country"] else "SA",
|
||||
sector=normalized["sector"][:64] if normalized["sector"] else None,
|
||||
google_place_id=normalized["google_place_id"][:128]
|
||||
if normalized["google_place_id"] else None,
|
||||
source_count=1,
|
||||
best_source=imp_rec.source_type,
|
||||
risk_level=imp_rec.risk_level,
|
||||
status="new",
|
||||
extra={
|
||||
"import_id": import_id,
|
||||
"source_url": normalized["source_url"],
|
||||
"raw_keys": normalized["raw_keys"],
|
||||
"allowed_use": imp_rec.allowed_use,
|
||||
"consent_status": imp_rec.consent_status,
|
||||
},
|
||||
)
|
||||
session.add(acc)
|
||||
accounts_created.append(acc_id)
|
||||
|
||||
# Optional contact
|
||||
if normalized["email"] or normalized["phone"] or normalized["contact_name"]:
|
||||
session.add(ContactRecord(
|
||||
id=_new_id("ct_"),
|
||||
account_id=acc_id,
|
||||
name=normalized["contact_name"][:255] if normalized["contact_name"] else None,
|
||||
role=normalized["role"][:128] if normalized["role"] else None,
|
||||
email=normalized["email"][:255] if normalized["email"] else None,
|
||||
phone=normalized["phone"][:32] if normalized["phone"] else None,
|
||||
source=imp_rec.source_type,
|
||||
consent_status=imp_rec.consent_status,
|
||||
opt_out=False,
|
||||
risk_level=imp_rec.risk_level,
|
||||
))
|
||||
|
||||
row.normalized_status = "ok"
|
||||
row.account_id = acc_id
|
||||
normalized_count += 1
|
||||
|
||||
imp_rec.rows_normalized = normalized_count
|
||||
imp_rec.rows_rejected = rejected_count
|
||||
imp_rec.status = "normalized"
|
||||
imp_rec.updated_at = _utcnow()
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "commit_failed", "error": str(exc)}
|
||||
|
||||
return {
|
||||
"import_id": import_id,
|
||||
"status": "normalized",
|
||||
"rows_normalized": normalized_count,
|
||||
"rows_rejected": rejected_count,
|
||||
"accounts_created": len(accounts_created),
|
||||
"next_action": f"POST /api/v1/data/import/{import_id}/dedupe",
|
||||
}
|
||||
|
||||
|
||||
# ── Dedupe ────────────────────────────────────────────────────────
|
||||
@router.post("/import/{import_id}/dedupe")
|
||||
async def dedupe_import(import_id: str) -> dict[str, Any]:
|
||||
"""Match accounts created by this import against the existing graph."""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
imp_rec = (await session.execute(
|
||||
select(RawLeadImport).where(RawLeadImport.id == import_id)
|
||||
)).scalar_one_or_none()
|
||||
if not imp_rec:
|
||||
raise HTTPException(404, "import_not_found")
|
||||
all_accounts = (await session.execute(select(AccountRecord))).scalars().all()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
# Split into already-existing (from prior imports) vs this-import's new ones
|
||||
new_for_import = [a for a in all_accounts if (a.extra or {}).get("import_id") == import_id]
|
||||
existing = [a for a in all_accounts if a.id not in {n.id for n in new_for_import}]
|
||||
existing_dicts = [
|
||||
{
|
||||
"id": a.id, "company_name": a.company_name,
|
||||
"normalized_name": a.normalized_name, "domain": a.domain,
|
||||
"website": a.website, "city": a.city,
|
||||
"phone": None, "email": None, # not on AccountRecord directly
|
||||
"google_place_id": a.google_place_id,
|
||||
}
|
||||
for a in existing
|
||||
]
|
||||
idx = build_index(existing_dicts)
|
||||
|
||||
merged_count = 0
|
||||
kept_count = 0
|
||||
for acc in new_for_import:
|
||||
normalized = {
|
||||
"company_name": acc.company_name,
|
||||
"normalized_name": acc.normalized_name,
|
||||
"domain": acc.domain,
|
||||
"phone": None,
|
||||
"email": None,
|
||||
"google_place_id": acc.google_place_id,
|
||||
"city": acc.city,
|
||||
}
|
||||
match_id, match_kind = find_match(normalized, idx)
|
||||
if match_id:
|
||||
# Merge: increment source_count on the canonical, mark this one as duplicate
|
||||
target = next((a for a in existing if a.id == match_id), None)
|
||||
if target is not None:
|
||||
target.source_count = (target.source_count or 1) + 1
|
||||
extra = dict(target.extra or {})
|
||||
sources = list(extra.get("sources", []))
|
||||
if imp_rec.source_type not in sources:
|
||||
sources.append(imp_rec.source_type)
|
||||
extra["sources"] = sources
|
||||
target.extra = extra
|
||||
acc.status = "merged_into"
|
||||
acc.extra = {**(acc.extra or {}), "merged_into": match_id, "match_kind": match_kind}
|
||||
merged_count += 1
|
||||
else:
|
||||
kept_count += 1
|
||||
|
||||
imp_rec.rows_duplicate = merged_count
|
||||
imp_rec.status = "deduped"
|
||||
imp_rec.updated_at = _utcnow()
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "commit_failed", "error": str(exc)}
|
||||
|
||||
return {
|
||||
"import_id": import_id,
|
||||
"status": "deduped",
|
||||
"merged": merged_count,
|
||||
"new_accounts": kept_count,
|
||||
"next_action": f"POST /api/v1/data/import/{import_id}/enrich",
|
||||
}
|
||||
|
||||
|
||||
# ── Enrich ────────────────────────────────────────────────────────
|
||||
@router.post("/import/{import_id}/enrich")
|
||||
async def enrich_import(import_id: str, body: dict[str, Any] = Body(default={})) -> dict[str, Any]:
|
||||
"""
|
||||
Run the enrichment pipeline against new accounts created by this import.
|
||||
|
||||
Body:
|
||||
enrichment_level: basic / standard / deep (default: standard)
|
||||
max_accounts: int (default 25 — cap to avoid runaway API calls)
|
||||
"""
|
||||
level = str(body.get("enrichment_level") or "standard")
|
||||
max_accounts = int(body.get("max_accounts") or 25)
|
||||
if max_accounts < 1 or max_accounts > 200:
|
||||
raise HTTPException(400, "max_accounts_out_of_range: 1..200")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
new_accounts = (await session.execute(
|
||||
select(AccountRecord).where(
|
||||
AccountRecord.status == "new"
|
||||
).limit(max_accounts)
|
||||
)).scalars().all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
# Filter to accounts from this import
|
||||
from_this_import = [
|
||||
a for a in new_accounts
|
||||
if (a.extra or {}).get("import_id") == import_id
|
||||
]
|
||||
|
||||
enriched = 0
|
||||
for acc in from_this_import:
|
||||
account_dict = {
|
||||
"id": acc.id,
|
||||
"company_name": acc.company_name,
|
||||
"domain": acc.domain,
|
||||
"website": acc.website,
|
||||
"city": acc.city,
|
||||
"country": acc.country,
|
||||
"sector": acc.sector,
|
||||
"google_place_id": acc.google_place_id,
|
||||
"best_source": acc.best_source,
|
||||
"source_type": acc.best_source,
|
||||
"allowed_use": (acc.extra or {}).get("allowed_use"),
|
||||
"risk_level": acc.risk_level,
|
||||
}
|
||||
try:
|
||||
result = await enrich_account(account_dict, enrichment_level=level)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("enrich_failed acc=%s err=%s", acc.id, exc)
|
||||
continue
|
||||
|
||||
# Persist signals
|
||||
for s in result.get("signals", []):
|
||||
session.add(SignalRecord(
|
||||
id=_new_id("sig_"),
|
||||
account_id=acc.id,
|
||||
signal_type=str(s.get("signal_type") or "tech")[:64],
|
||||
signal_value=str(s.get("signal_value") or "")[:500],
|
||||
source_url=s.get("source_url"),
|
||||
confidence=float(s.get("confidence") or 0.5),
|
||||
))
|
||||
|
||||
# Persist score
|
||||
sc = result.get("score") or {}
|
||||
session.add(LeadScoreRecord(
|
||||
id=_new_id("ls_"),
|
||||
account_id=acc.id,
|
||||
fit_score=float(sc.get("fit") or 0),
|
||||
intent_score=float(sc.get("intent") or 0),
|
||||
urgency_score=float(sc.get("urgency") or 0),
|
||||
risk_score=float(sc.get("risk") or 0),
|
||||
total_score=float(sc.get("total") or 0),
|
||||
priority=str(sc.get("priority") or "P3")[:8],
|
||||
recommended_channel=sc.get("recommended_channel"),
|
||||
reason=sc.get("reason"),
|
||||
))
|
||||
|
||||
# Update account with crawled domain + DQ score
|
||||
if result.get("domain") and not acc.domain:
|
||||
acc.domain = result["domain"]
|
||||
acc.website = f"https://{result['domain']}"
|
||||
acc.data_quality_score = float(result.get("data_quality", {}).get("score", 0))
|
||||
acc.status = "enriched"
|
||||
acc.updated_at = _utcnow()
|
||||
|
||||
# Persist new contacts (avoid dup by email/phone)
|
||||
for c in result.get("contacts", []):
|
||||
if c.get("type") == "email":
|
||||
session.add(ContactRecord(
|
||||
id=_new_id("ct_"),
|
||||
account_id=acc.id,
|
||||
name=c.get("name"),
|
||||
role=c.get("role"),
|
||||
email=c.get("value"),
|
||||
source=c.get("source") or "enrichment",
|
||||
consent_status="legitimate_interest",
|
||||
opt_out=False,
|
||||
risk_level=acc.risk_level,
|
||||
))
|
||||
elif c.get("type") in ("phone", "whatsapp"):
|
||||
session.add(ContactRecord(
|
||||
id=_new_id("ct_"),
|
||||
account_id=acc.id,
|
||||
name=None,
|
||||
role=None,
|
||||
phone=c.get("value"),
|
||||
source=c.get("source") or "enrichment",
|
||||
consent_status="legitimate_interest",
|
||||
opt_out=False,
|
||||
risk_level=acc.risk_level,
|
||||
))
|
||||
|
||||
enriched += 1
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "commit_failed", "error": str(exc)}
|
||||
|
||||
return {
|
||||
"import_id": import_id,
|
||||
"status": "enriched",
|
||||
"accounts_enriched": enriched,
|
||||
"level": level,
|
||||
"next_action": f"GET /api/v1/data/import/{import_id}/report",
|
||||
}
|
||||
|
||||
|
||||
# ── Report ────────────────────────────────────────────────────────
|
||||
@router.get("/import/{import_id}/report")
|
||||
async def import_report(import_id: str) -> dict[str, Any]:
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
imp = (await session.execute(
|
||||
select(RawLeadImport).where(RawLeadImport.id == import_id)
|
||||
)).scalar_one_or_none()
|
||||
if not imp:
|
||||
raise HTTPException(404, "import_not_found")
|
||||
accounts = (await session.execute(select(AccountRecord))).scalars().all()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
related = [a for a in accounts if (a.extra or {}).get("import_id") == import_id]
|
||||
priority_counts: dict[str, int] = {}
|
||||
# Collect latest scores per account
|
||||
try:
|
||||
scores = (await session.execute(
|
||||
select(LeadScoreRecord).where(
|
||||
LeadScoreRecord.account_id.in_([a.id for a in related])
|
||||
)
|
||||
)).scalars().all()
|
||||
for s in scores:
|
||||
priority_counts[s.priority] = priority_counts.get(s.priority, 0) + 1
|
||||
except Exception:
|
||||
scores = []
|
||||
|
||||
return {
|
||||
"import_id": import_id,
|
||||
"source_name": imp.source_name,
|
||||
"source_type": imp.source_type,
|
||||
"status": imp.status,
|
||||
"rows_total": imp.rows_total,
|
||||
"rows_normalized": imp.rows_normalized,
|
||||
"rows_rejected": imp.rows_rejected,
|
||||
"rows_duplicate": imp.rows_duplicate,
|
||||
"accounts_in_graph_from_this_import": len(related),
|
||||
"scored_accounts": len(scores),
|
||||
"priority_distribution": priority_counts,
|
||||
"allowed_use": imp.allowed_use,
|
||||
"consent_status": imp.consent_status,
|
||||
"risk_level": imp.risk_level,
|
||||
}
|
||||
|
||||
|
||||
# ── Suppression list ──────────────────────────────────────────────
|
||||
@router.post("/suppression")
|
||||
async def add_suppression(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Add a suppression entry. At least one of email/phone/domain required.
|
||||
Body: {email?, phone?, domain?, reason?}
|
||||
"""
|
||||
email = body.get("email")
|
||||
phone = body.get("phone")
|
||||
domain = body.get("domain")
|
||||
if not (email or phone or domain):
|
||||
raise HTTPException(400, "at_least_one_of_email_phone_domain_required")
|
||||
rec = SuppressionRecord(
|
||||
id=_new_id("sup_"),
|
||||
email=str(email).strip().lower() if email else None,
|
||||
phone=str(phone).strip() if phone else None,
|
||||
domain=str(domain).strip().lower() if domain else None,
|
||||
reason=str(body.get("reason") or "opt_out")[:128],
|
||||
)
|
||||
async with async_session_factory() as session:
|
||||
ok = await _safe_commit(session, rec)
|
||||
return {
|
||||
"id": rec.id,
|
||||
"email": rec.email, "phone": rec.phone, "domain": rec.domain,
|
||||
"reason": rec.reason,
|
||||
"status": "ok" if ok else "skipped_db_unreachable",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/suppression")
|
||||
async def list_suppression(limit: int = 200) -> dict[str, Any]:
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
rows = (await session.execute(
|
||||
select(SuppressionRecord).limit(min(1000, limit))
|
||||
)).scalars().all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc), "items": []}
|
||||
return {
|
||||
"status": "ok",
|
||||
"count": len(rows),
|
||||
"items": [
|
||||
{
|
||||
"id": r.id, "email": r.email, "phone": r.phone, "domain": r.domain,
|
||||
"reason": r.reason, "created_at": r.created_at.isoformat(),
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── Listings ──────────────────────────────────────────────────────
|
||||
@router.get("/imports")
|
||||
async def list_imports(limit: int = 50) -> dict[str, Any]:
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
rows = (await session.execute(
|
||||
select(RawLeadImport).order_by(RawLeadImport.created_at.desc()).limit(min(500, limit))
|
||||
)).scalars().all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc), "items": []}
|
||||
return {
|
||||
"count": len(rows),
|
||||
"items": [
|
||||
{
|
||||
"id": r.id, "source_name": r.source_name, "source_type": r.source_type,
|
||||
"status": r.status, "rows_total": r.rows_total,
|
||||
"rows_normalized": r.rows_normalized, "rows_rejected": r.rows_rejected,
|
||||
"rows_duplicate": r.rows_duplicate,
|
||||
"risk_level": r.risk_level, "created_at": r.created_at.isoformat(),
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/accounts")
|
||||
async def list_accounts(
|
||||
limit: int = 50,
|
||||
sector: str | None = None,
|
||||
city: str | None = None,
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
q = select(AccountRecord)
|
||||
if sector:
|
||||
q = q.where(AccountRecord.sector == sector)
|
||||
if city:
|
||||
q = q.where(AccountRecord.city == city)
|
||||
if status:
|
||||
q = q.where(AccountRecord.status == status)
|
||||
q = q.order_by(AccountRecord.data_quality_score.desc()).limit(min(500, limit))
|
||||
rows = (await session.execute(q)).scalars().all()
|
||||
|
||||
score_map: dict[str, LeadScoreRecord] = {}
|
||||
if rows:
|
||||
ids = [r.id for r in rows]
|
||||
scores = (await session.execute(
|
||||
select(LeadScoreRecord).where(LeadScoreRecord.account_id.in_(ids))
|
||||
)).scalars().all()
|
||||
for s in scores:
|
||||
if s.account_id not in score_map or s.created_at > score_map[s.account_id].created_at:
|
||||
score_map[s.account_id] = s
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc), "items": []}
|
||||
|
||||
items = []
|
||||
for a in rows:
|
||||
s = score_map.get(a.id)
|
||||
if priority and (not s or s.priority != priority):
|
||||
continue
|
||||
items.append({
|
||||
"id": a.id, "company_name": a.company_name, "domain": a.domain,
|
||||
"website": a.website, "city": a.city, "sector": a.sector,
|
||||
"google_place_id": a.google_place_id, "source_count": a.source_count,
|
||||
"best_source": a.best_source, "status": a.status,
|
||||
"data_quality_score": a.data_quality_score, "risk_level": a.risk_level,
|
||||
"score": {
|
||||
"fit": s.fit_score, "intent": s.intent_score,
|
||||
"total": s.total_score, "priority": s.priority,
|
||||
"recommended_channel": s.recommended_channel,
|
||||
} if s else None,
|
||||
})
|
||||
return {"count": len(items), "items": items}
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}")
|
||||
async def get_account(account_id: str) -> dict[str, Any]:
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
acc = (await session.execute(
|
||||
select(AccountRecord).where(AccountRecord.id == account_id)
|
||||
)).scalar_one_or_none()
|
||||
if not acc:
|
||||
raise HTTPException(404, "account_not_found")
|
||||
contacts = (await session.execute(
|
||||
select(ContactRecord).where(ContactRecord.account_id == account_id)
|
||||
)).scalars().all()
|
||||
signals = (await session.execute(
|
||||
select(SignalRecord).where(SignalRecord.account_id == account_id)
|
||||
)).scalars().all()
|
||||
scores = (await session.execute(
|
||||
select(LeadScoreRecord).where(
|
||||
LeadScoreRecord.account_id == account_id
|
||||
).order_by(LeadScoreRecord.created_at.desc()).limit(1)
|
||||
)).scalars().all()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
latest = scores[0] if scores else None
|
||||
return {
|
||||
"account": {
|
||||
"id": acc.id, "company_name": acc.company_name,
|
||||
"domain": acc.domain, "website": acc.website,
|
||||
"city": acc.city, "country": acc.country, "sector": acc.sector,
|
||||
"google_place_id": acc.google_place_id,
|
||||
"source_count": acc.source_count, "best_source": acc.best_source,
|
||||
"status": acc.status, "data_quality_score": acc.data_quality_score,
|
||||
"risk_level": acc.risk_level, "extra": acc.extra,
|
||||
"created_at": acc.created_at.isoformat(),
|
||||
"updated_at": acc.updated_at.isoformat(),
|
||||
},
|
||||
"contacts": [
|
||||
{"id": c.id, "name": c.name, "role": c.role, "email": c.email,
|
||||
"phone": c.phone, "source": c.source, "consent_status": c.consent_status,
|
||||
"opt_out": c.opt_out, "risk_level": c.risk_level}
|
||||
for c in contacts
|
||||
],
|
||||
"signals": [
|
||||
{"id": s.id, "type": s.signal_type, "value": s.signal_value,
|
||||
"source_url": s.source_url, "confidence": s.confidence,
|
||||
"detected_at": s.detected_at.isoformat()}
|
||||
for s in signals
|
||||
],
|
||||
"score": {
|
||||
"fit": latest.fit_score, "intent": latest.intent_score,
|
||||
"urgency": latest.urgency_score, "risk": latest.risk_score,
|
||||
"total": latest.total_score, "priority": latest.priority,
|
||||
"recommended_channel": latest.recommended_channel, "reason": latest.reason,
|
||||
} if latest else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/score")
|
||||
async def score_account(account_id: str) -> dict[str, Any]:
|
||||
"""Recompute score from current data in the graph."""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
acc = (await session.execute(
|
||||
select(AccountRecord).where(AccountRecord.id == account_id)
|
||||
)).scalar_one_or_none()
|
||||
if not acc:
|
||||
raise HTTPException(404, "account_not_found")
|
||||
contacts = (await session.execute(
|
||||
select(ContactRecord).where(ContactRecord.account_id == account_id)
|
||||
)).scalars().all()
|
||||
signals = (await session.execute(
|
||||
select(SignalRecord).where(SignalRecord.account_id == account_id)
|
||||
)).scalars().all()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
first_email = next((c.email for c in contacts if c.email), None)
|
||||
first_phone = next((c.phone for c in contacts if c.phone), None)
|
||||
|
||||
account_dict = {
|
||||
"id": acc.id, "company_name": acc.company_name, "domain": acc.domain,
|
||||
"website": acc.website, "city": acc.city, "country": acc.country,
|
||||
"sector": acc.sector, "google_place_id": acc.google_place_id,
|
||||
"best_source": acc.best_source, "source_count": acc.source_count,
|
||||
"risk_level": acc.risk_level, "email": first_email, "phone": first_phone,
|
||||
"allowed_use": (acc.extra or {}).get("allowed_use"),
|
||||
"opt_out": any(c.opt_out for c in contacts),
|
||||
"signals": signals,
|
||||
}
|
||||
sig_dicts = [
|
||||
{"signal_type": s.signal_type, "signal_value": s.signal_value,
|
||||
"confidence": s.confidence}
|
||||
for s in signals
|
||||
]
|
||||
sb = compute_lead_score(account_dict, signals=sig_dicts, technologies=[])
|
||||
dq, _reasons = compute_data_quality(account_dict)
|
||||
|
||||
rec = LeadScoreRecord(
|
||||
id=_new_id("ls_"),
|
||||
account_id=account_id,
|
||||
fit_score=sb.fit, intent_score=sb.intent, urgency_score=sb.urgency,
|
||||
risk_score=sb.risk, total_score=sb.total, priority=sb.priority,
|
||||
recommended_channel=sb.recommended_channel, reason=sb.reason,
|
||||
)
|
||||
acc.data_quality_score = dq
|
||||
acc.updated_at = _utcnow()
|
||||
ok = await _safe_commit(session, rec)
|
||||
if not ok:
|
||||
return {"status": "commit_failed"}
|
||||
|
||||
return {
|
||||
"account_id": account_id,
|
||||
"score": {
|
||||
"fit": sb.fit, "intent": sb.intent, "urgency": sb.urgency,
|
||||
"risk": sb.risk, "total": sb.total, "priority": sb.priority,
|
||||
"recommended_channel": sb.recommended_channel, "reason": sb.reason,
|
||||
},
|
||||
"data_quality_score": dq,
|
||||
}
|
||||
670
dealix/api/routers/dominance.py
Normal file
670
dealix/api/routers/dominance.py
Normal file
@ -0,0 +1,670 @@
|
||||
"""
|
||||
Dominance router — adds the upper-tier intelligence endpoints on top of
|
||||
the existing daily revenue machine.
|
||||
|
||||
Endpoints:
|
||||
GET /api/v1/signals/account/{id} — typed buying signals
|
||||
POST /api/v1/accounts/{id}/brief — full company brief (research + score)
|
||||
GET /api/v1/objections/bank — all 13 objection responses
|
||||
POST /api/v1/offers/route — sector → offer routing
|
||||
POST /api/v1/automation/score-tuner/run — weight-tuning recommendations
|
||||
POST /api/v1/customers/{id}/proof-pack — case-study + testimonial template
|
||||
GET /api/v1/dashboard/dominance — top-tier daily snapshot
|
||||
|
||||
All write endpoints respect the existing compliance gates. Score tuner
|
||||
returns recommendations only — never auto-applies (logged for human review).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from auto_client_acquisition.email.research_agent import research_company_with_llm
|
||||
from auto_client_acquisition.email.reply_classifier import (
|
||||
PATTERNS, RESPONSE_TEMPLATES,
|
||||
)
|
||||
from auto_client_acquisition.intelligence.next_action import (
|
||||
compute_priority, decide,
|
||||
)
|
||||
from auto_client_acquisition.intelligence.offers import (
|
||||
DEFAULT_OFFER, OFFER_ROUTES, build_tomorrow_recommendation, route_offer,
|
||||
)
|
||||
from auto_client_acquisition.intelligence.signals import (
|
||||
detect_signals, signals_to_intent_lift,
|
||||
)
|
||||
from db.models import (
|
||||
AccountRecord, ContactRecord, CustomerRecord, DealRecord,
|
||||
EmailSendLog, GmailDraftRecord, LeadScoreRecord, LinkedInDraftRecord,
|
||||
OutreachQueueRecord, PartnerRecord, SignalRecord,
|
||||
)
|
||||
from db.session import async_session_factory
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["dominance"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _new_id(prefix: str = "") -> str:
|
||||
return f"{prefix}{uuid.uuid4().hex[:24]}" if prefix else uuid.uuid4().hex[:24]
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
# ── Sector → Offer routing table (canonical lives in intelligence/offers.py) ─
|
||||
_LEGACY_OFFER_ROUTES: dict[str, dict[str, Any]] = {
|
||||
"real_estate_developer": {
|
||||
"primary_offer": "pilot_499_lead_qualification_plus_viewing_booking",
|
||||
"value_prop": "تأهيل lead العقار + حجز معاينة بدلاً منكم",
|
||||
"headline_pain": "كل lead عقاري متأخر دقيقة = احتمال خسارة العميل لمنافس",
|
||||
"kpi": "Arabic-replied leads × demos booked × pipeline added",
|
||||
"best_channel": "phone_task_then_email", "pricing_tier": "Pilot 499",
|
||||
},
|
||||
"real_estate": {
|
||||
"primary_offer": "pilot_499_lead_qualification_plus_viewing_booking",
|
||||
"value_prop": "نأهل العميل ونحجز موعد المعاينة قبل ما يبرد",
|
||||
"headline_pain": "العمولة الواحدة في العقار = ربح أسبوع. لا تخسرونها لتأخر الرد",
|
||||
"kpi": "qualified leads × viewings booked",
|
||||
"best_channel": "phone_task_then_email", "pricing_tier": "Pilot 499",
|
||||
},
|
||||
"construction": {
|
||||
"primary_offer": "pilot_999_quote_request_qualification",
|
||||
"value_prop": "نفرز RFQs ونجمع المواصفات قبل تسعير المشروع",
|
||||
"headline_pain": "RFQ تتوزع بين قنوات متعددة بدون فرز موحد",
|
||||
"kpi": "RFQs qualified × pricing-engineer time saved",
|
||||
"best_channel": "phone_task", "pricing_tier": "Pilot 999",
|
||||
},
|
||||
"hospitality": {
|
||||
"primary_offer": "pilot_999_booking_inquiry_assistant",
|
||||
"value_prop": "نرد فوراً على استفسارات MICE/قاعات/إفطار-سحور ونحجز معاينات",
|
||||
"headline_pain": "استفسارات بأي ساعة + موظف غير متاح = حجز ضائع",
|
||||
"kpi": "MICE inquiries × site visits booked",
|
||||
"best_channel": "phone_task_or_email", "pricing_tier": "Pilot 999",
|
||||
},
|
||||
"events": {
|
||||
"primary_offer": "pilot_499_event_inquiry_with_viewing_booking",
|
||||
"value_prop": "نرد على lead الفعالية فوراً ونجمع التاريخ + العدد + الباقة",
|
||||
"headline_pain": "كل lead = موسم — خسارته = 5K-100K ريال",
|
||||
"kpi": "inquiries × site visits booked",
|
||||
"best_channel": "phone_task", "pricing_tier": "Pilot 499",
|
||||
},
|
||||
"food_beverage": {
|
||||
"primary_offer": "pilot_499_catering_franchise_inquiry_routing",
|
||||
"value_prop": "نفرز التموين/الفرنشايز عن طلبات الطعام العادية",
|
||||
"headline_pain": "تموين شركة = إيراد شهر، يضيع بين رسائل واتساب",
|
||||
"kpi": "catering leads qualified × management calls scheduled",
|
||||
"best_channel": "phone_task", "pricing_tier": "Pilot 499",
|
||||
},
|
||||
"restaurant": {
|
||||
"primary_offer": "pilot_499_catering_franchise_inquiry_routing",
|
||||
"value_prop": "نفرز التموين/الفرنشايز عن طلبات الطعام العادية",
|
||||
"headline_pain": "تموين شركة = إيراد شهر، يضيع بين رسائل واتساب",
|
||||
"kpi": "catering leads qualified × management calls scheduled",
|
||||
"best_channel": "phone_task", "pricing_tier": "Pilot 499",
|
||||
},
|
||||
"logistics": {
|
||||
"primary_offer": "pilot_999_RFQ_response_under_60_seconds",
|
||||
"value_prop": "نرد على RFQ شحن خلال دقيقة بالعربي",
|
||||
"headline_pain": "10 دقائق فرق في الرد = خسارة عقد لمنافس",
|
||||
"kpi": "RFQs answered <60s × dispatch tickets opened",
|
||||
"best_channel": "phone_or_email", "pricing_tier": "Pilot 999",
|
||||
},
|
||||
"saas": {
|
||||
"primary_offer": "pilot_999_saudi_arabic_inbound_response_layer",
|
||||
"value_prop": "AI sales rep بالعربي الخليجي يكمل CRMكم",
|
||||
"headline_pain": "Saudi inbound leads باللغة العربية، الفريق يرد بالإنجليزية/ترجمة",
|
||||
"kpi": "Arabic-lead-to-demo conversion uplift",
|
||||
"best_channel": "linkedin_manual_then_email", "pricing_tier": "Pilot 999",
|
||||
},
|
||||
"marketing_agency": {
|
||||
"primary_offer": "agency_partner_25pct_mrr",
|
||||
"value_prop": "Dealix شريك resell — أنتم تبيعونه، نحن نبنيه، 25% MRR",
|
||||
"headline_pain": "العملاء يطلبون AI sales rep بالعربي والوكالة بدون حل جاهز",
|
||||
"kpi": "agency clients signed × MRR share",
|
||||
"best_channel": "linkedin_manual_then_call", "pricing_tier": "Partnership",
|
||||
},
|
||||
"training_center": {
|
||||
"primary_offer": "pilot_499_course_inquiry_enrollment_assistant",
|
||||
"value_prop": "نرد على استفسار البرامج + نجمع التفاصيل + نوجه للتسجيل",
|
||||
"headline_pain": "موسم تسجيل = استفسارات كثيرة، الرد البطيء = طالب راح لمنافس",
|
||||
"kpi": "inquiries qualified × enrollments started",
|
||||
"best_channel": "phone_task_then_email", "pricing_tier": "Pilot 499",
|
||||
},
|
||||
"dental_clinic": {
|
||||
"primary_offer": "pilot_499_appointment_qualification",
|
||||
"value_prop": "نأخذ تفاصيل المريض + نقيم الحالة قبل الحجز",
|
||||
"headline_pain": "مكالمات استقبال غير مدربة = جدول مزدحم بمواعيد منخفضة الجدية",
|
||||
"kpi": "high-intent appointments × no-show rate reduction",
|
||||
"best_channel": "phone_task", "pricing_tier": "Pilot 499",
|
||||
},
|
||||
"medical_clinic": {
|
||||
"primary_offer": "pilot_499_appointment_qualification",
|
||||
"value_prop": "نأخذ تفاصيل المريض + نقيم الحالة قبل الحجز",
|
||||
"headline_pain": "مكالمات استقبال غير مدربة = جدول مزدحم بمواعيد منخفضة الجدية",
|
||||
"kpi": "high-intent appointments × no-show rate reduction",
|
||||
"best_channel": "phone_task", "pricing_tier": "Pilot 499",
|
||||
},
|
||||
}
|
||||
|
||||
_LEGACY_DEFAULT_OFFER = {
|
||||
"primary_offer": "pilot_499_managed",
|
||||
"value_prop": "Dealix يرد على inbound leads بالعربي الخليجي خلال 45 ثانية",
|
||||
"headline_pain": "سرعة الرد على العميل = ميزة تنافسية مباشرة",
|
||||
"kpi": "qualified leads × demos booked",
|
||||
"best_channel": "phone_or_email", "pricing_tier": "Pilot 499",
|
||||
}
|
||||
|
||||
|
||||
# ── Endpoint: GET signals for an account ──────────────────────────
|
||||
@router.get("/signals/account/{account_id}")
|
||||
async def get_signals_for_account(account_id: str) -> dict[str, Any]:
|
||||
"""Return persisted SignalRecord rows + freshly-detected signals."""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
acc = (await session.execute(
|
||||
select(AccountRecord).where(AccountRecord.id == account_id)
|
||||
)).scalar_one_or_none()
|
||||
if not acc:
|
||||
raise HTTPException(404, "account_not_found")
|
||||
persisted = (await session.execute(
|
||||
select(SignalRecord).where(SignalRecord.account_id == account_id)
|
||||
.order_by(SignalRecord.detected_at.desc())
|
||||
)).scalars().all()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
# Compute fresh signals from current account data (no website crawl here —
|
||||
# that requires the enrichment pipeline)
|
||||
fresh = detect_signals(
|
||||
sector=acc.sector,
|
||||
google_rating=None,
|
||||
google_reviews_count=None,
|
||||
branches_hint=None,
|
||||
)
|
||||
intent_lift = signals_to_intent_lift(fresh)
|
||||
|
||||
return {
|
||||
"account_id": account_id,
|
||||
"company_name": acc.company_name,
|
||||
"persisted_signals": [
|
||||
{
|
||||
"type": s.signal_type, "value": s.signal_value,
|
||||
"confidence": s.confidence, "source_url": s.source_url,
|
||||
"detected_at": s.detected_at.isoformat(),
|
||||
}
|
||||
for s in persisted
|
||||
],
|
||||
"fresh_rule_signals": [s.to_dict() for s in fresh],
|
||||
"computed_intent_lift": intent_lift,
|
||||
"note": "fresh_rule_signals are sector-only; run /leads/enrich/full "
|
||||
"to add website/Maps signals.",
|
||||
}
|
||||
|
||||
|
||||
# ── Endpoint: POST account brief (research + signals + score) ─────
|
||||
@router.post("/accounts/{account_id}/brief")
|
||||
async def account_brief(account_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Full account brief: company_summary + pain_hypothesis + dealix_fit +
|
||||
expected_gain + best_offer + best_channel + objection_risks + risk_note.
|
||||
"""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
acc = (await session.execute(
|
||||
select(AccountRecord).where(AccountRecord.id == account_id)
|
||||
)).scalar_one_or_none()
|
||||
if not acc:
|
||||
raise HTTPException(404, "account_not_found")
|
||||
score = (await session.execute(
|
||||
select(LeadScoreRecord).where(LeadScoreRecord.account_id == account_id)
|
||||
.order_by(LeadScoreRecord.created_at.desc()).limit(1)
|
||||
)).scalar_one_or_none()
|
||||
contacts = (await session.execute(
|
||||
select(ContactRecord).where(ContactRecord.account_id == account_id)
|
||||
)).scalars().all()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
account_dict = {
|
||||
"id": acc.id, "company_name": acc.company_name,
|
||||
"domain": acc.domain, "website": acc.website,
|
||||
"city": acc.city, "country": acc.country, "sector": acc.sector,
|
||||
"google_place_id": acc.google_place_id,
|
||||
"best_source": acc.best_source, "risk_level": acc.risk_level,
|
||||
"allowed_use": (acc.extra or {}).get("allowed_use"),
|
||||
"email": next((c.email for c in contacts if c.email), None),
|
||||
"phone": next((c.phone for c in contacts if c.phone), None),
|
||||
}
|
||||
brief = await research_company_with_llm(account_dict)
|
||||
|
||||
fit = score.fit_score if score else 0.0
|
||||
intent = score.intent_score if score else 0.0
|
||||
urgency = score.urgency_score if score else 0.0
|
||||
revenue = 8.0 # default neutral
|
||||
risk = score.risk_score if score else 0.0
|
||||
|
||||
decision = decide(
|
||||
fit_score=fit, intent_score=intent, urgency_score=urgency,
|
||||
revenue_score=revenue, risk_score=risk,
|
||||
opt_out=any(c.opt_out for c in contacts),
|
||||
has_business_email=bool(account_dict.get("email")),
|
||||
has_phone=bool(account_dict.get("phone")),
|
||||
has_linkedin_handle=False,
|
||||
is_potential_partner=acc.sector in {"marketing_agency", "consulting_firm"},
|
||||
sector=acc.sector, allowed_use=account_dict["allowed_use"],
|
||||
)
|
||||
|
||||
return {
|
||||
"account_id": account_id,
|
||||
"brief": brief.to_dict(),
|
||||
"scores": {
|
||||
"fit": fit, "intent": intent, "urgency": urgency,
|
||||
"risk": risk, "revenue": revenue,
|
||||
},
|
||||
"next_action": decision.to_dict(),
|
||||
"contacts_count": len(contacts),
|
||||
"personalized_by_llm": "llm:groq_polish" in (brief.sources_used or []),
|
||||
}
|
||||
|
||||
|
||||
# ── Endpoint: GET objection bank ──────────────────────────────────
|
||||
@router.get("/objections/bank")
|
||||
async def objections_bank() -> dict[str, Any]:
|
||||
"""Return all 13 objection categories with response drafts."""
|
||||
bank = []
|
||||
for category, tpl in RESPONSE_TEMPLATES.items():
|
||||
bank.append({
|
||||
"category": category,
|
||||
"response_ar": tpl["ar"],
|
||||
"auto_send_allowed": tpl["auto_send_allowed"],
|
||||
"next_action": tpl["next_action"],
|
||||
"deal_stage": tpl["deal_stage"],
|
||||
"followup_days": tpl["followup_days"],
|
||||
})
|
||||
return {"count": len(bank), "objections": bank,
|
||||
"rule_patterns": [p[0] for p in PATTERNS]}
|
||||
|
||||
|
||||
# ── Endpoint: POST offer route by sector ─────────────────────────
|
||||
@router.post("/offers/route")
|
||||
async def offers_route(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Route an offer by sector. Body: sector (str). Returns offer config.
|
||||
"""
|
||||
sector = str(body.get("sector") or "").lower().strip()
|
||||
if not sector:
|
||||
raise HTTPException(400, "sector_required")
|
||||
offer = route_offer(sector)
|
||||
return {"sector": sector, "matched": sector in OFFER_ROUTES, **offer}
|
||||
|
||||
|
||||
# ── Endpoint: POST score-tuner/run (recommend weights) ───────────
|
||||
@router.post("/automation/score-tuner/run")
|
||||
async def score_tuner_run(body: dict[str, Any] = Body(default={})) -> dict[str, Any]:
|
||||
"""
|
||||
Analyze last N days of email sends + replies and recommend scoring weight
|
||||
adjustments. NEVER auto-applies — returns recommendations only.
|
||||
|
||||
Body: days (default 14)
|
||||
"""
|
||||
days = int(body.get("days") or 14)
|
||||
cutoff = _utcnow() - timedelta(days=days)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
sends = (await session.execute(
|
||||
select(EmailSendLog).where(
|
||||
EmailSendLog.sent_at >= cutoff
|
||||
)
|
||||
)).scalars().all()
|
||||
replies = [s for s in sends if s.reply_received_at is not None]
|
||||
account_ids = list({s.account_id for s in sends if s.account_id})
|
||||
scores = (await session.execute(
|
||||
select(LeadScoreRecord).where(
|
||||
LeadScoreRecord.account_id.in_(account_ids)
|
||||
)
|
||||
)).scalars().all() if account_ids else []
|
||||
accounts = (await session.execute(
|
||||
select(AccountRecord).where(
|
||||
AccountRecord.id.in_(account_ids)
|
||||
)
|
||||
)).scalars().all() if account_ids else []
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
sector_by_acc = {a.id: a.sector for a in accounts}
|
||||
|
||||
sector_sent: Counter[str] = Counter()
|
||||
sector_replied: Counter[str] = Counter()
|
||||
sector_positive: Counter[str] = Counter()
|
||||
classification_counts: Counter[str] = Counter()
|
||||
|
||||
for s in sends:
|
||||
sec = sector_by_acc.get(s.account_id, "unknown")
|
||||
sector_sent[sec] += 1
|
||||
for r in replies:
|
||||
sec = sector_by_acc.get(r.account_id, "unknown")
|
||||
sector_replied[sec] += 1
|
||||
if r.reply_classification:
|
||||
classification_counts[r.reply_classification] += 1
|
||||
if r.reply_classification in {"interested", "ask_demo", "ask_price"}:
|
||||
sector_positive[sec] += 1
|
||||
|
||||
by_sector = []
|
||||
for sec, sent in sector_sent.most_common():
|
||||
replied = sector_replied[sec]
|
||||
positive = sector_positive[sec]
|
||||
by_sector.append({
|
||||
"sector": sec, "sent": sent, "replied": replied,
|
||||
"positive": positive,
|
||||
"reply_rate": round(replied / sent, 3) if sent else 0,
|
||||
"positive_rate": round(positive / sent, 3) if sent else 0,
|
||||
})
|
||||
|
||||
# Recommendations (never auto-applied)
|
||||
recommendations: list[dict[str, Any]] = []
|
||||
if by_sector:
|
||||
top = by_sector[0]
|
||||
if top["positive_rate"] > 0.05:
|
||||
recommendations.append({
|
||||
"type": "increase_sector_weight",
|
||||
"sector": top["sector"],
|
||||
"current_implied_priority": "P1-P2",
|
||||
"suggested_action": f"raise fit_weight for {top['sector']} by +5",
|
||||
"rationale": f"positive_rate={top['positive_rate']:.1%} on {top['sent']} sends",
|
||||
"confidence": 0.65,
|
||||
})
|
||||
worst = by_sector[-1]
|
||||
if worst["sent"] >= 10 and worst["reply_rate"] < 0.02:
|
||||
recommendations.append({
|
||||
"type": "decrease_sector_weight",
|
||||
"sector": worst["sector"],
|
||||
"suggested_action": f"reduce fit_weight for {worst['sector']} by -5",
|
||||
"rationale": f"reply_rate={worst['reply_rate']:.1%} on {worst['sent']} sends",
|
||||
"confidence": 0.55,
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"window_days": days,
|
||||
"totals": {
|
||||
"sent": len(sends),
|
||||
"replied": len(replies),
|
||||
"positive": sum(sector_positive.values()),
|
||||
},
|
||||
"by_sector": by_sector,
|
||||
"by_classification": dict(classification_counts),
|
||||
"recommendations": recommendations,
|
||||
"auto_applied": False,
|
||||
"note": "Recommendations are advisory only — review before applying.",
|
||||
}
|
||||
|
||||
|
||||
# ── Endpoint: POST customer proof-pack ────────────────────────────
|
||||
@router.post("/customers/{customer_id}/proof-pack")
|
||||
async def customer_proof_pack(customer_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Generate a case-study + testimonial + referral-ask kit after a pilot.
|
||||
Pulls real metrics from EmailSendLog if account_id is linked, else
|
||||
returns templates for manual fill-in.
|
||||
"""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
cust = (await session.execute(
|
||||
select(CustomerRecord).where(CustomerRecord.id == customer_id)
|
||||
)).scalar_one_or_none()
|
||||
if not cust:
|
||||
raise HTTPException(404, "customer_not_found")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
case_study_template = (
|
||||
f"## Case Study — {cust.company_id or 'العميل'}\n\n"
|
||||
f"**القطاع:** [حدد]\n"
|
||||
f"**المدة:** Pilot 7 أيام ({cust.pilot_start_at or '—'} → {cust.pilot_end_at or '—'})\n\n"
|
||||
f"### قبل Dealix\n"
|
||||
f"- وقت الرد على lead: [X دقائق/ساعات]\n"
|
||||
f"- معدل التحويل من inquiry → demo: [X%]\n"
|
||||
f"- leads مهملة شهرياً: [X]\n\n"
|
||||
f"### بعد Dealix (7 أيام)\n"
|
||||
f"- وقت الرد: 45 ثانية\n"
|
||||
f"- demos محجوزة: [X]\n"
|
||||
f"- leads جادة معالجة: [X]\n\n"
|
||||
f"### اقتباس العميل\n"
|
||||
f"> [Sami: agree on quote with customer post-pilot]\n\n"
|
||||
f"### النتيجة\n"
|
||||
f"العميل أكمل إلى Starter بـ 999 SAR/شهر.\n"
|
||||
)
|
||||
|
||||
testimonial_request = (
|
||||
f"السلام عليكم،\n\n"
|
||||
f"شكراً على إكمال Pilot Dealix معنا. النتائج كانت مفيدة لكم — "
|
||||
f"هل ممكن نسجّل اقتباس قصير (60 ثانية) عن تجربتكم؟\n"
|
||||
f"يمكن نص أو فيديو. نشكركم على الوقت."
|
||||
)
|
||||
|
||||
referral_ask = (
|
||||
f"بناءً على نتيجة Pilot، تعرفون شركة سعودية ثانية تواجه نفس "
|
||||
f"المشكلة (تأخر الرد على leads العربية)؟ نعطي 10% من اشتراكها "
|
||||
f"السنوي لكل إحالة جدية."
|
||||
)
|
||||
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"case_study_md_template": case_study_template,
|
||||
"testimonial_request_ar": testimonial_request,
|
||||
"referral_ask_ar": referral_ask,
|
||||
"next_action": "save case_study to docs/business/case_studies/{customer}.md",
|
||||
}
|
||||
|
||||
|
||||
# ── Endpoint: GET dashboard/dominance ─────────────────────────────
|
||||
@router.post("/partners/revenue-machine/run")
|
||||
async def partners_revenue_machine_run(body: dict[str, Any] = Body(default={})) -> dict[str, Any]:
|
||||
"""
|
||||
Partner-targeted daily run. Pulls top marketing/consulting partners,
|
||||
generates partnership-pitch LinkedIn drafts (manual-send only).
|
||||
|
||||
Body: max_partners (default 10), city (optional)
|
||||
"""
|
||||
max_partners = int(body.get("max_partners") or 10)
|
||||
city = body.get("city")
|
||||
partner_sectors = ["marketing_agency", "consulting_firm"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
q = select(AccountRecord).where(
|
||||
AccountRecord.sector.in_(partner_sectors),
|
||||
AccountRecord.status.in_(["enriched", "new"]),
|
||||
)
|
||||
if city:
|
||||
q = q.where(AccountRecord.city == city)
|
||||
q = q.order_by(AccountRecord.data_quality_score.desc()).limit(max_partners * 2)
|
||||
partner_pool = (await session.execute(q)).scalars().all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
drafts_created: list[dict[str, Any]] = []
|
||||
for acc in partner_pool[:max_partners]:
|
||||
offer = route_offer(acc.sector)
|
||||
search_query = f'"{acc.company_name}" {acc.city or "Saudi"} site:linkedin.com'
|
||||
msg_ar = (
|
||||
f"أهلاً [اسم المسؤول]،\n\n"
|
||||
f"لاحظت أن {acc.company_name} يخدم عملاء في السوق السعودي.\n\n"
|
||||
f"Dealix شريك resell — أنتم تبيعونه لعملائكم، 25% MRR شهرياً.\n"
|
||||
f"3 عملاء وكالة = ~600-1500 ريال شهرياً passive recurring.\n\n"
|
||||
f"رابط شامل: https://dealix.me/partners.html\n\n"
|
||||
f"تناسبكم 20 دقيقة هذا الأسبوع نوضح؟\n\nسامي"
|
||||
)
|
||||
ld = LinkedInDraftRecord(
|
||||
id=_new_id("ld_"), account_id=acc.id,
|
||||
company_name=acc.company_name[:255], contact_name=None,
|
||||
profile_search_query=search_query[:500],
|
||||
company_context=f"Saudi {acc.sector} in {acc.city or '?'}",
|
||||
reason_for_outreach="partnership_resell_pitch",
|
||||
message_ar=msg_ar, message_en=None,
|
||||
followup_day_3="متابعة سريعة — هل عندكم سؤال محدد قبل المكالمة؟",
|
||||
followup_day_7="آخر متابعة. لو لاحقاً يناسب، أنا هنا.",
|
||||
status="draft",
|
||||
)
|
||||
session.add(ld)
|
||||
drafts_created.append({
|
||||
"draft_id": ld.id, "company": acc.company_name,
|
||||
"city": acc.city, "search_query": search_query,
|
||||
"offer_tier": offer.get("pricing_tier"),
|
||||
})
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "commit_failed", "error": str(exc)}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"partners_pool_size": len(partner_pool),
|
||||
"drafts_created": len(drafts_created),
|
||||
"drafts": drafts_created,
|
||||
"approval_required": True,
|
||||
"next_action": "Open /api/v1/linkedin/drafts/today to review + send manually",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dashboard/dominance")
|
||||
async def dashboard_dominance() -> dict[str, Any]:
|
||||
"""
|
||||
Top-tier daily snapshot:
|
||||
- today: drafts/sent/replies
|
||||
- sector leaderboard (last 14d)
|
||||
- channel leaderboard
|
||||
- offer leaderboard (by pricing_tier)
|
||||
- partner pipeline
|
||||
- tomorrow recommendation
|
||||
"""
|
||||
today_start = _utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
cutoff_14d = today_start - timedelta(days=14)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
gmail_today = int((await session.execute(
|
||||
select(func.count()).select_from(GmailDraftRecord).where(
|
||||
GmailDraftRecord.created_at >= today_start
|
||||
)
|
||||
)).scalar() or 0)
|
||||
gmail_sent_today = int((await session.execute(
|
||||
select(func.count()).select_from(GmailDraftRecord).where(
|
||||
GmailDraftRecord.created_at >= today_start,
|
||||
GmailDraftRecord.status == "sent",
|
||||
)
|
||||
)).scalar() or 0)
|
||||
linkedin_today = int((await session.execute(
|
||||
select(func.count()).select_from(LinkedInDraftRecord).where(
|
||||
LinkedInDraftRecord.created_at >= today_start
|
||||
)
|
||||
)).scalar() or 0)
|
||||
replies_14d = int((await session.execute(
|
||||
select(func.count()).select_from(EmailSendLog).where(
|
||||
EmailSendLog.reply_received_at >= cutoff_14d
|
||||
)
|
||||
)).scalar() or 0)
|
||||
partners_active = int((await session.execute(
|
||||
select(func.count()).select_from(PartnerRecord).where(
|
||||
PartnerRecord.status.in_(["active", "prospecting"])
|
||||
)
|
||||
)).scalar() or 0)
|
||||
partners_signed = int((await session.execute(
|
||||
select(func.coalesce(func.sum(PartnerRecord.clients_signed), 0))
|
||||
)).scalar() or 0)
|
||||
customers_total = int((await session.execute(
|
||||
select(func.count()).select_from(CustomerRecord)
|
||||
)).scalar() or 0)
|
||||
customers_paid = int((await session.execute(
|
||||
select(func.count()).select_from(CustomerRecord).where(
|
||||
CustomerRecord.onboarding_status != "kickoff_pending"
|
||||
)
|
||||
)).scalar() or 0)
|
||||
|
||||
# Sector leaderboard from email sends
|
||||
sends_14d = (await session.execute(
|
||||
select(EmailSendLog).where(EmailSendLog.sent_at >= cutoff_14d)
|
||||
)).scalars().all()
|
||||
account_ids = list({s.account_id for s in sends_14d if s.account_id})
|
||||
accounts = (await session.execute(
|
||||
select(AccountRecord).where(AccountRecord.id.in_(account_ids))
|
||||
)).scalars().all() if account_ids else []
|
||||
sector_by_acc = {a.id: a.sector for a in accounts}
|
||||
|
||||
sector_sent: Counter[str] = Counter()
|
||||
sector_replied: Counter[str] = Counter()
|
||||
for s in sends_14d:
|
||||
sec = sector_by_acc.get(s.account_id, "unknown")
|
||||
sector_sent[sec] += 1
|
||||
if s.reply_received_at:
|
||||
sector_replied[sec] += 1
|
||||
sector_leaderboard = sorted(
|
||||
[
|
||||
{
|
||||
"sector": sec, "sent": cnt,
|
||||
"replied": sector_replied[sec],
|
||||
"reply_rate": round(sector_replied[sec] / cnt, 3) if cnt else 0,
|
||||
}
|
||||
for sec, cnt in sector_sent.most_common(10)
|
||||
],
|
||||
key=lambda x: -x["reply_rate"],
|
||||
)
|
||||
|
||||
# Channel leaderboard
|
||||
channel_dist: Counter[str] = Counter()
|
||||
for c in (await session.execute(
|
||||
select(OutreachQueueRecord.channel).where(
|
||||
OutreachQueueRecord.created_at >= cutoff_14d
|
||||
)
|
||||
)).all():
|
||||
channel_dist[c[0] or "unknown"] += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
tomorrow_recommendation = build_tomorrow_recommendation(
|
||||
sector_leaderboard, gmail_today, replies_14d
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"date_utc": today_start.date().isoformat(),
|
||||
"today": {
|
||||
"gmail_drafts": gmail_today,
|
||||
"gmail_sent": gmail_sent_today,
|
||||
"linkedin_drafts": linkedin_today,
|
||||
},
|
||||
"last_14_days": {
|
||||
"email_replies": replies_14d,
|
||||
},
|
||||
"sector_leaderboard_14d": sector_leaderboard,
|
||||
"channel_distribution_14d": dict(channel_dist),
|
||||
"partners": {
|
||||
"active": partners_active,
|
||||
"clients_signed_via_partners": int(partners_signed),
|
||||
},
|
||||
"customers": {
|
||||
"total": customers_total,
|
||||
"in_active_pilot_or_onboarded": customers_paid,
|
||||
},
|
||||
"tomorrow_recommendation": tomorrow_recommendation,
|
||||
}
|
||||
|
||||
|
||||
# helper moved to auto_client_acquisition.intelligence.offers.build_tomorrow_recommendation
|
||||
926
dealix/api/routers/drafts.py
Normal file
926
dealix/api/routers/drafts.py
Normal file
@ -0,0 +1,926 @@
|
||||
"""
|
||||
Draft-First Revenue Machine — Gmail drafts + LinkedIn drafts + revenue-machine/run.
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/automation/revenue-machine/run — daily orchestrator (the brain)
|
||||
POST /api/v1/gmail/drafts/create — single Gmail draft
|
||||
POST /api/v1/gmail/drafts/create-batch — batch Gmail drafts from queue
|
||||
GET /api/v1/gmail/drafts/today — list today's drafts
|
||||
POST /api/v1/linkedin/drafts/create — single LinkedIn draft
|
||||
GET /api/v1/linkedin/drafts/today — list today's LinkedIn queue
|
||||
PATCH /api/v1/linkedin/drafts/{id}/mark-sent — Sami marks "I sent it"
|
||||
POST /api/v1/linkedin/drafts/{id}/manual-capture — paste a reply we got
|
||||
GET /api/v1/dashboard/revenue-machine/today — today's metrics
|
||||
POST /api/v1/automation/daily-report/generate — write docs/ops/daily_reports/YYYY-MM-DD.md
|
||||
|
||||
Rules baked in:
|
||||
- LinkedIn: NEVER auto-send, NEVER scrape (per LinkedIn ToS)
|
||||
- Gmail: drafts.create by default; messages.send only on /email/send-approved
|
||||
- All gated by compliance (suppression / opt-out / risk / allowed_use)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from auto_client_acquisition.email.compliance import (
|
||||
append_opt_out_line,
|
||||
check_outreach,
|
||||
)
|
||||
from auto_client_acquisition.email.daily_targeting import (
|
||||
compute_followup_schedule,
|
||||
llm_personalize,
|
||||
render_email_template,
|
||||
select_top_n_diversified,
|
||||
)
|
||||
from auto_client_acquisition.email.gmail_send import (
|
||||
create_draft as gmail_create_draft,
|
||||
is_configured as gmail_is_configured,
|
||||
)
|
||||
from auto_client_acquisition.email.research_agent import (
|
||||
research_company_with_llm,
|
||||
)
|
||||
from db.models import (
|
||||
AccountRecord,
|
||||
ContactRecord,
|
||||
EmailSendLog,
|
||||
GmailDraftRecord,
|
||||
LeadScoreRecord,
|
||||
LinkedInDraftRecord,
|
||||
OutreachQueueRecord,
|
||||
SuppressionRecord,
|
||||
)
|
||||
from db.session import async_session_factory
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["revenue-machine"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _new_id(prefix: str = "") -> str:
|
||||
return f"{prefix}{uuid.uuid4().hex[:24]}" if prefix else uuid.uuid4().hex[:24]
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
# ── Daily orchestrator ────────────────────────────────────────────
|
||||
@router.post("/automation/revenue-machine/run")
|
||||
async def revenue_machine_run(body: dict[str, Any] = Body(default={})) -> dict[str, Any]:
|
||||
"""
|
||||
Daily Revenue Machine orchestrator. Builds:
|
||||
50 Gmail drafts
|
||||
20 LinkedIn drafts
|
||||
10 call scripts
|
||||
10 partner intros (drafts)
|
||||
|
||||
Body:
|
||||
gmail_drafts: int = 50
|
||||
linkedin_drafts: int = 20
|
||||
call_scripts: int = 10
|
||||
partner_drafts: int = 10
|
||||
candidate_pool_size: int = 200
|
||||
sectors: list[str] | None
|
||||
cities: list[str] | None
|
||||
approval_mode: 'draft_only' (default) | 'auto_send_low_risk'
|
||||
create_gmail_drafts_in_inbox: bool = False (only if Gmail OAuth configured)
|
||||
"""
|
||||
n_gmail = int(body.get("gmail_drafts") or 50)
|
||||
n_linkedin = int(body.get("linkedin_drafts") or 20)
|
||||
n_calls = int(body.get("call_scripts") or 10)
|
||||
n_partners = int(body.get("partner_drafts") or 10)
|
||||
pool_size = int(body.get("candidate_pool_size") or 200)
|
||||
create_in_gmail = bool(body.get("create_gmail_drafts_in_inbox", False))
|
||||
sectors_filter = body.get("sectors")
|
||||
cities_filter = body.get("cities")
|
||||
|
||||
# 1. Pull candidate pool
|
||||
excluded = {"opt_out": 0, "suppressed": 0, "recently_contacted": 0,
|
||||
"high_risk": 0, "no_allowed_use": 0, "no_business_contact": 0}
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
q = select(AccountRecord).where(AccountRecord.status.in_(["enriched", "new"]))
|
||||
if sectors_filter: q = q.where(AccountRecord.sector.in_(sectors_filter))
|
||||
if cities_filter: q = q.where(AccountRecord.city.in_(cities_filter))
|
||||
q = q.order_by(AccountRecord.data_quality_score.desc()).limit(pool_size)
|
||||
accounts = (await session.execute(q)).scalars().all()
|
||||
ids = [a.id for a in accounts]
|
||||
|
||||
scores = (await session.execute(
|
||||
select(LeadScoreRecord).where(LeadScoreRecord.account_id.in_(ids))
|
||||
)).scalars().all() if ids else []
|
||||
score_map: dict[str, LeadScoreRecord] = {}
|
||||
for s in scores:
|
||||
if s.account_id not in score_map or s.created_at > score_map[s.account_id].created_at:
|
||||
score_map[s.account_id] = s
|
||||
|
||||
contacts = (await session.execute(
|
||||
select(ContactRecord).where(ContactRecord.account_id.in_(ids))
|
||||
)).scalars().all() if ids else []
|
||||
contacts_by_acc: dict[str, list[ContactRecord]] = {}
|
||||
for c in contacts:
|
||||
contacts_by_acc.setdefault(c.account_id, []).append(c)
|
||||
|
||||
sup = (await session.execute(select(SuppressionRecord))).scalars().all()
|
||||
sup_emails = {s.email.lower() for s in sup if s.email}
|
||||
sup_domains = {s.domain.lower() for s in sup if s.domain}
|
||||
|
||||
recent_cutoff = _utcnow() - timedelta(days=14)
|
||||
recent_logs = (await session.execute(
|
||||
select(EmailSendLog.account_id).where(
|
||||
EmailSendLog.sent_at >= recent_cutoff
|
||||
).distinct()
|
||||
)).scalars().all() if ids else []
|
||||
recently = set(recent_logs)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
# 2. Filter into eligible candidates
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for a in accounts:
|
||||
if a.id in recently:
|
||||
excluded["recently_contacted"] += 1; continue
|
||||
if (a.risk_level or "").lower() == "high":
|
||||
excluded["high_risk"] += 1; continue
|
||||
allowed = (a.extra or {}).get("allowed_use")
|
||||
if not allowed or allowed in {"unknown", ""}:
|
||||
excluded["no_allowed_use"] += 1; continue
|
||||
if a.domain and a.domain.lower() in sup_domains:
|
||||
excluded["suppressed"] += 1; continue
|
||||
|
||||
ac = contacts_by_acc.get(a.id, [])
|
||||
if any(c.opt_out for c in ac):
|
||||
excluded["opt_out"] += 1; continue
|
||||
biz_email = next(
|
||||
(c.email for c in ac if c.email and c.email.lower() not in sup_emails
|
||||
and not any(p in c.email.lower() for p in
|
||||
["@gmail.com", "@hotmail.com", "@yahoo.com", "@outlook.com", "@icloud.com"])),
|
||||
None,
|
||||
)
|
||||
any_phone = next((c.phone for c in ac if c.phone), None)
|
||||
if not biz_email and not any_phone:
|
||||
excluded["no_business_contact"] += 1; continue
|
||||
|
||||
score = score_map.get(a.id)
|
||||
candidates.append({
|
||||
"id": a.id, "company_name": a.company_name,
|
||||
"domain": a.domain, "website": a.website,
|
||||
"city": a.city, "sector": a.sector, "sector_ar": a.sector,
|
||||
"google_place_id": a.google_place_id,
|
||||
"data_quality_score": a.data_quality_score,
|
||||
"risk_level": a.risk_level,
|
||||
"best_email": biz_email, "best_phone": any_phone,
|
||||
"allowed_use": allowed, "best_source": a.best_source,
|
||||
"total_score": score.total_score if score else 0,
|
||||
"priority": score.priority if score else "P3",
|
||||
"recommended_channel": score.recommended_channel if score else None,
|
||||
})
|
||||
|
||||
# 3. Bucket selection
|
||||
has_email = [c for c in candidates if c["best_email"]]
|
||||
no_email = [c for c in candidates if not c["best_email"] and c["best_phone"]]
|
||||
|
||||
gmail_picks = select_top_n_diversified(has_email, target_count=n_gmail)
|
||||
# LinkedIn lane prefers SaaS / agency / consulting (knowledge-worker contacts)
|
||||
linkedin_pool = [c for c in candidates if c["sector"] in
|
||||
{"saas", "marketing_agency", "consulting_firm", "training_center"}]
|
||||
if len(linkedin_pool) < n_linkedin:
|
||||
linkedin_pool += [c for c in has_email if c not in linkedin_pool]
|
||||
linkedin_picks = select_top_n_diversified(linkedin_pool, target_count=n_linkedin,
|
||||
sector_caps={"saas": n_linkedin})
|
||||
call_picks = select_top_n_diversified(no_email or has_email, target_count=n_calls)
|
||||
partner_pool = [c for c in candidates if c["sector"] in
|
||||
{"marketing_agency", "consulting_firm"}]
|
||||
partner_picks = partner_pool[:n_partners]
|
||||
|
||||
# 4. Generate drafts
|
||||
gmail_drafts_out: list[dict[str, Any]] = []
|
||||
linkedin_drafts_out: list[dict[str, Any]] = []
|
||||
call_scripts_out: list[dict[str, Any]] = []
|
||||
|
||||
async with async_session_factory() as session:
|
||||
# Gmail drafts
|
||||
for cand in gmail_picks:
|
||||
brief = await research_company_with_llm(cand)
|
||||
base = render_email_template(cand, cand.get("priority") or "P2")
|
||||
personalized = await llm_personalize(cand, base)
|
||||
body_with_optout = append_opt_out_line(personalized["body_ar"])
|
||||
subject = base["subject_ar"]
|
||||
|
||||
chk = check_outreach(
|
||||
to_email=cand["best_email"],
|
||||
contact_opt_out=False,
|
||||
risk_score=20.0 if cand["risk_level"] == "medium" else 0.0,
|
||||
allowed_use=cand["allowed_use"],
|
||||
suppression_emails=sup_emails,
|
||||
suppression_domains=sup_domains,
|
||||
bounced_before=False, sent_today_count=0,
|
||||
sent_in_current_batch=0, seconds_since_last_batch=99999,
|
||||
)
|
||||
|
||||
draft_record = GmailDraftRecord(
|
||||
id=_new_id("gd_"),
|
||||
account_id=cand["id"], queue_id=None,
|
||||
to_email=cand["best_email"],
|
||||
subject=subject[:500], body_plain=body_with_optout,
|
||||
sender_email=os.getenv("GMAIL_SENDER_EMAIL", ""),
|
||||
gmail_draft_id=None, gmail_message_id=None,
|
||||
status="created" if chk.allowed else "failed",
|
||||
discarded_reason=None if chk.allowed else "; ".join(chk.blocked_reasons),
|
||||
)
|
||||
|
||||
# Optionally push into Gmail Drafts inbox (real)
|
||||
if create_in_gmail and chk.allowed and gmail_is_configured():
|
||||
gmail_result = await gmail_create_draft(
|
||||
to_email=cand["best_email"],
|
||||
subject=subject,
|
||||
body_plain=body_with_optout,
|
||||
)
|
||||
if gmail_result.status == "ok":
|
||||
draft_record.gmail_draft_id = gmail_result.draft_id
|
||||
draft_record.gmail_message_id = gmail_result.message_id
|
||||
else:
|
||||
draft_record.discarded_reason = (
|
||||
f"gmail_api: {gmail_result.status} {gmail_result.error or ''}"
|
||||
)[:255]
|
||||
|
||||
session.add(draft_record)
|
||||
gmail_drafts_out.append({
|
||||
"draft_id": draft_record.id,
|
||||
"company": cand["company_name"], "to_email": cand["best_email"],
|
||||
"subject": subject, "body_preview": body_with_optout[:300],
|
||||
"status": draft_record.status,
|
||||
"compliance_blocked": chk.blocked_reasons or None,
|
||||
"personalized_by_llm": personalized.get("personalized_by_llm") == "true",
|
||||
"research": brief.to_dict(),
|
||||
"gmail_draft_id_in_inbox": draft_record.gmail_draft_id,
|
||||
})
|
||||
|
||||
# LinkedIn drafts (NEVER auto-send)
|
||||
for cand in linkedin_picks:
|
||||
brief = await research_company_with_llm(cand)
|
||||
search_query = f'"{cand["company_name"]}" {cand.get("city") or "Saudi Arabia"} site:linkedin.com'
|
||||
company_context = brief.company_brief
|
||||
reason = brief.pain_hypothesis
|
||||
msg_ar = (
|
||||
f"{brief.best_first_sentence}\n\n"
|
||||
f"{brief.dealix_fit}\n\n"
|
||||
f"عندنا Pilot 7 أيام بـ 499 ريال. تناسبكم 20 دقيقة هذا الأسبوع؟"
|
||||
)
|
||||
msg_en = (
|
||||
f"Quick reach-out about {cand['company_name']}. "
|
||||
f"{brief.dealix_fit}. We have a 7-day Pilot at 499 SAR — "
|
||||
"open to a 20-min chat this week?"
|
||||
)
|
||||
ld = LinkedInDraftRecord(
|
||||
id=_new_id("ld_"),
|
||||
account_id=cand["id"],
|
||||
company_name=cand["company_name"][:255],
|
||||
contact_name=None,
|
||||
profile_search_query=search_query[:500],
|
||||
company_context=company_context,
|
||||
reason_for_outreach=reason,
|
||||
message_ar=msg_ar, message_en=msg_en,
|
||||
followup_day_3="متابعة سريعة لرسالتي. هل عندكم سؤال محدد؟",
|
||||
followup_day_7="آخر متابعة. لو الوقت غير مناسب الآن، نقدر نتقابل بعد شهر.",
|
||||
status="draft",
|
||||
)
|
||||
session.add(ld)
|
||||
linkedin_drafts_out.append({
|
||||
"draft_id": ld.id,
|
||||
"company": cand["company_name"],
|
||||
"search_query": search_query,
|
||||
"context": company_context,
|
||||
"message_preview": msg_ar[:300],
|
||||
"research": brief.to_dict(),
|
||||
})
|
||||
|
||||
# Call scripts
|
||||
for cand in call_picks:
|
||||
brief = await research_company_with_llm(cand)
|
||||
script = (
|
||||
f"السلام عليكم، معك سامي من Dealix.\n"
|
||||
f"اتصل في وقت مناسب؟\n\n"
|
||||
f"شركتكم في {cand.get('sector_ar') or cand.get('sector') or 'القطاع'} "
|
||||
f"بـ {cand.get('city') or 'السعودية'} — "
|
||||
f"{brief.pain_hypothesis}\n\n"
|
||||
f"نقدم Pilot 7 أيام بـ 499 ريال — نرد على leadsكم نحن، تشوفون النتيجة، ثم تقرّرون.\n\n"
|
||||
f"تناسبكم 20 دقيقة هذا الأسبوع نوضح؟"
|
||||
)
|
||||
call_scripts_out.append({
|
||||
"company": cand["company_name"],
|
||||
"phone": cand["best_phone"],
|
||||
"city": cand.get("city"),
|
||||
"sector": cand.get("sector"),
|
||||
"research": brief.to_dict(),
|
||||
"call_script": script,
|
||||
})
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "commit_failed", "error": str(exc)}
|
||||
|
||||
# Build daily summary
|
||||
return {
|
||||
"status": "ok",
|
||||
"generated_at": _utcnow().isoformat(),
|
||||
"candidates_pool": len(accounts),
|
||||
"candidates_eligible": len(candidates),
|
||||
"excluded": excluded,
|
||||
"produced": {
|
||||
"gmail_drafts": len(gmail_drafts_out),
|
||||
"linkedin_drafts": len(linkedin_drafts_out),
|
||||
"call_scripts": len(call_scripts_out),
|
||||
"partner_drafts_pool": len(partner_picks),
|
||||
},
|
||||
"gmail_drafts_in_inbox": create_in_gmail and gmail_is_configured(),
|
||||
"gmail_drafts": gmail_drafts_out[:n_gmail],
|
||||
"linkedin_drafts": linkedin_drafts_out[:n_linkedin],
|
||||
"call_scripts": call_scripts_out[:n_calls],
|
||||
"approval_required": True,
|
||||
"next_action": (
|
||||
"Open /api/v1/dashboard/revenue-machine/today to review,"
|
||||
" then approve via /api/v1/email/send-approved per row."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ── Gmail draft endpoints ─────────────────────────────────────────
|
||||
@router.post("/gmail/drafts/create")
|
||||
async def gmail_drafts_create(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Create a single Gmail draft. Body: to_email, subject, body_plain, account_id."""
|
||||
to_email = str(body.get("to_email") or "").strip()
|
||||
subject = str(body.get("subject") or "").strip()
|
||||
body_plain = str(body.get("body_plain") or "").strip()
|
||||
if not all([to_email, subject, body_plain]):
|
||||
raise HTTPException(400, "to_email/subject/body_plain required")
|
||||
|
||||
body_with_optout = append_opt_out_line(body_plain)
|
||||
|
||||
record = GmailDraftRecord(
|
||||
id=_new_id("gd_"),
|
||||
account_id=body.get("account_id"),
|
||||
queue_id=body.get("queue_id"),
|
||||
to_email=to_email, subject=subject[:500], body_plain=body_with_optout,
|
||||
sender_email=os.getenv("GMAIL_SENDER_EMAIL", ""),
|
||||
status="created",
|
||||
)
|
||||
|
||||
if gmail_is_configured() and bool(body.get("create_in_inbox", True)):
|
||||
result = await gmail_create_draft(
|
||||
to_email=to_email, subject=subject, body_plain=body_with_optout,
|
||||
)
|
||||
if result.status == "ok":
|
||||
record.gmail_draft_id = result.draft_id
|
||||
record.gmail_message_id = result.message_id
|
||||
else:
|
||||
record.discarded_reason = f"gmail_api: {result.error}"[:255]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
session.add(record)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"draft_id": record.id,
|
||||
"gmail_draft_id_in_inbox": record.gmail_draft_id,
|
||||
"discarded_reason": record.discarded_reason,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/gmail/drafts/today")
|
||||
async def gmail_drafts_today() -> dict[str, Any]:
|
||||
today_start = _utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
rows = (await session.execute(
|
||||
select(GmailDraftRecord).where(
|
||||
GmailDraftRecord.created_at >= today_start
|
||||
).order_by(GmailDraftRecord.created_at.desc())
|
||||
)).scalars().all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc), "items": []}
|
||||
return {
|
||||
"count": len(rows),
|
||||
"items": [
|
||||
{
|
||||
"id": r.id, "account_id": r.account_id,
|
||||
"to_email": r.to_email, "subject": r.subject,
|
||||
"body_preview": r.body_plain[:300],
|
||||
"status": r.status, "gmail_draft_id": r.gmail_draft_id,
|
||||
"created_at": r.created_at.isoformat(),
|
||||
"discarded_reason": r.discarded_reason,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── LinkedIn draft endpoints ──────────────────────────────────────
|
||||
@router.post("/linkedin/drafts/create")
|
||||
async def linkedin_drafts_create(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Create a LinkedIn draft. NEVER auto-sent. Body: company_name, message_ar, optional rest."""
|
||||
company = str(body.get("company_name") or "").strip()
|
||||
msg_ar = str(body.get("message_ar") or "").strip()
|
||||
if not company or not msg_ar:
|
||||
raise HTTPException(400, "company_name and message_ar required")
|
||||
|
||||
rec = LinkedInDraftRecord(
|
||||
id=_new_id("ld_"),
|
||||
account_id=body.get("account_id"),
|
||||
company_name=company[:255],
|
||||
contact_name=body.get("contact_name"),
|
||||
profile_search_query=str(body.get("profile_search_query") or
|
||||
f'"{company}" site:linkedin.com')[:500],
|
||||
company_context=body.get("company_context"),
|
||||
reason_for_outreach=body.get("reason_for_outreach"),
|
||||
message_ar=msg_ar,
|
||||
message_en=body.get("message_en"),
|
||||
followup_day_3=body.get("followup_day_3"),
|
||||
followup_day_7=body.get("followup_day_7"),
|
||||
status="draft",
|
||||
)
|
||||
async with async_session_factory() as session:
|
||||
session.add(rec)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
return {"status": "ok", "draft_id": rec.id}
|
||||
|
||||
|
||||
@router.get("/linkedin/drafts/today")
|
||||
async def linkedin_drafts_today() -> dict[str, Any]:
|
||||
today_start = _utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
rows = (await session.execute(
|
||||
select(LinkedInDraftRecord).where(
|
||||
LinkedInDraftRecord.created_at >= today_start
|
||||
).order_by(LinkedInDraftRecord.created_at.desc())
|
||||
)).scalars().all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc), "items": []}
|
||||
return {
|
||||
"count": len(rows),
|
||||
"items": [
|
||||
{
|
||||
"id": r.id, "company_name": r.company_name,
|
||||
"search_query": r.profile_search_query,
|
||||
"context": r.company_context,
|
||||
"reason": r.reason_for_outreach,
|
||||
"message_ar": r.message_ar, "message_en": r.message_en,
|
||||
"status": r.status,
|
||||
"sent_at": r.sent_at.isoformat() if r.sent_at else None,
|
||||
"reply": r.reply_text,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/linkedin/drafts/{draft_id}/mark-sent")
|
||||
async def linkedin_drafts_mark_sent(draft_id: str) -> dict[str, Any]:
|
||||
"""Sami marks 'I sent this manually'. Updates status + sent_at."""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
rec = (await session.execute(
|
||||
select(LinkedInDraftRecord).where(LinkedInDraftRecord.id == draft_id)
|
||||
)).scalar_one_or_none()
|
||||
if not rec:
|
||||
raise HTTPException(404, "draft_not_found")
|
||||
rec.status = "sent"
|
||||
rec.sent_at = _utcnow()
|
||||
await session.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
return {"status": "ok", "draft_id": draft_id, "marked_sent_at": rec.sent_at.isoformat()}
|
||||
|
||||
|
||||
@router.post("/linkedin/drafts/{draft_id}/manual-capture")
|
||||
async def linkedin_drafts_manual_capture(
|
||||
draft_id: str, body: dict[str, Any] = Body(...)
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Sami pastes a LinkedIn reply they received.
|
||||
Body: reply_text
|
||||
"""
|
||||
reply = str(body.get("reply_text") or "").strip()
|
||||
if not reply:
|
||||
raise HTTPException(400, "reply_text_required")
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
rec = (await session.execute(
|
||||
select(LinkedInDraftRecord).where(LinkedInDraftRecord.id == draft_id)
|
||||
)).scalar_one_or_none()
|
||||
if not rec:
|
||||
raise HTTPException(404, "draft_not_found")
|
||||
rec.reply_text = reply[:2000]
|
||||
rec.reply_received_at = _utcnow()
|
||||
rec.status = "replied"
|
||||
await session.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
# Classify the reply
|
||||
from auto_client_acquisition.email.reply_classifier import classify_reply
|
||||
classification = await classify_reply(reply)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"draft_id": draft_id,
|
||||
"classification": classification.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
# ── Revenue dashboard ─────────────────────────────────────────────
|
||||
@router.get("/dashboard/revenue-machine/today")
|
||||
async def dashboard_revenue_machine_today() -> dict[str, Any]:
|
||||
today_start = _utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
gmail_total = int((await session.execute(
|
||||
select(func.count()).select_from(GmailDraftRecord).where(
|
||||
GmailDraftRecord.created_at >= today_start
|
||||
)
|
||||
)).scalar() or 0)
|
||||
gmail_sent = int((await session.execute(
|
||||
select(func.count()).select_from(GmailDraftRecord).where(
|
||||
GmailDraftRecord.created_at >= today_start,
|
||||
GmailDraftRecord.status == "sent",
|
||||
)
|
||||
)).scalar() or 0)
|
||||
linkedin_total = int((await session.execute(
|
||||
select(func.count()).select_from(LinkedInDraftRecord).where(
|
||||
LinkedInDraftRecord.created_at >= today_start
|
||||
)
|
||||
)).scalar() or 0)
|
||||
linkedin_sent = int((await session.execute(
|
||||
select(func.count()).select_from(LinkedInDraftRecord).where(
|
||||
LinkedInDraftRecord.created_at >= today_start,
|
||||
LinkedInDraftRecord.status == "sent",
|
||||
)
|
||||
)).scalar() or 0)
|
||||
linkedin_replied = int((await session.execute(
|
||||
select(func.count()).select_from(LinkedInDraftRecord).where(
|
||||
LinkedInDraftRecord.reply_received_at >= today_start
|
||||
)
|
||||
)).scalar() or 0)
|
||||
email_replied = int((await session.execute(
|
||||
select(func.count()).select_from(EmailSendLog).where(
|
||||
EmailSendLog.reply_received_at >= today_start
|
||||
)
|
||||
)).scalar() or 0)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"date": today_start.date().isoformat(),
|
||||
"gmail_drafts": {"total": gmail_total, "sent": gmail_sent,
|
||||
"remaining_to_review": max(0, gmail_total - gmail_sent)},
|
||||
"linkedin_drafts": {"total": linkedin_total, "sent": linkedin_sent,
|
||||
"replied": linkedin_replied},
|
||||
"email_replies": email_replied,
|
||||
"approval_queue_open": gmail_total - gmail_sent + (linkedin_total - linkedin_sent),
|
||||
}
|
||||
|
||||
|
||||
# ── Gmail batch draft create (standalone, separate from revenue-machine/run) ─
|
||||
@router.post("/gmail/drafts/create-batch")
|
||||
async def gmail_drafts_create_batch(body: dict[str, Any] = Body(default={})) -> dict[str, Any]:
|
||||
"""
|
||||
Create a batch of Gmail drafts from approved outreach queue rows.
|
||||
Body:
|
||||
max: int (default = EMAIL_BATCH_SIZE)
|
||||
only_status: 'approved' (default) or 'queued'
|
||||
create_in_inbox: bool (default True if Gmail OAuth configured)
|
||||
"""
|
||||
max_n = int(body.get("max") or 10)
|
||||
only_status = str(body.get("only_status") or "approved")
|
||||
create_in_inbox = bool(body.get("create_in_inbox", True)) and gmail_is_configured()
|
||||
if max_n < 1 or max_n > 50:
|
||||
raise HTTPException(400, "max_out_of_range: 1..50")
|
||||
|
||||
created: list[dict[str, Any]] = []
|
||||
failed: list[dict[str, Any]] = []
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
rows = (await session.execute(
|
||||
select(OutreachQueueRecord).where(
|
||||
OutreachQueueRecord.status == only_status,
|
||||
OutreachQueueRecord.channel.in_(["email", "email_warm", "email_followup"]),
|
||||
).limit(max_n)
|
||||
)).scalars().all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
for r in rows:
|
||||
try:
|
||||
contact = (await session.execute(
|
||||
select(ContactRecord).where(
|
||||
ContactRecord.account_id == r.lead_id,
|
||||
ContactRecord.email.is_not(None),
|
||||
ContactRecord.opt_out == False, # noqa: E712
|
||||
).limit(1)
|
||||
)).scalar_one_or_none()
|
||||
acc = (await session.execute(
|
||||
select(AccountRecord).where(AccountRecord.id == r.lead_id)
|
||||
)).scalar_one_or_none()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed.append({"queue_id": r.id, "reason": f"db: {exc}"})
|
||||
continue
|
||||
if not contact or not contact.email:
|
||||
failed.append({"queue_id": r.id, "reason": "no_contact_email"})
|
||||
continue
|
||||
subject = f"Dealix — تجربة تأهيل عملاء لـ {(acc.company_name if acc else 'فريقكم')[:60]}"
|
||||
body_with_optout = append_opt_out_line(r.message)
|
||||
|
||||
draft = GmailDraftRecord(
|
||||
id=_new_id("gd_"),
|
||||
account_id=r.lead_id, queue_id=r.id,
|
||||
to_email=contact.email, subject=subject[:500],
|
||||
body_plain=body_with_optout,
|
||||
sender_email=os.getenv("GMAIL_SENDER_EMAIL", ""),
|
||||
status="created",
|
||||
)
|
||||
if create_in_inbox:
|
||||
gres = await gmail_create_draft(
|
||||
to_email=contact.email, subject=subject, body_plain=body_with_optout,
|
||||
)
|
||||
if gres.status == "ok":
|
||||
draft.gmail_draft_id = gres.draft_id
|
||||
draft.gmail_message_id = gres.message_id
|
||||
else:
|
||||
draft.discarded_reason = f"gmail_api: {gres.error}"[:255]
|
||||
session.add(draft)
|
||||
created.append({
|
||||
"queue_id": r.id, "draft_id": draft.id,
|
||||
"to_email": contact.email, "subject": subject,
|
||||
"gmail_draft_id_in_inbox": draft.gmail_draft_id,
|
||||
})
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "commit_failed", "error": str(exc),
|
||||
"created": created, "failed": failed}
|
||||
|
||||
return {"status": "ok", "created_count": len(created), "failed_count": len(failed),
|
||||
"created": created, "failed": failed}
|
||||
|
||||
|
||||
# ── Replies aliases (respond + route) ────────────────────────────
|
||||
@router.post("/replies/respond")
|
||||
async def replies_respond(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Stateless: generate a response draft for a reply without persisting.
|
||||
Body: text (required), prefer_llm (default True)
|
||||
"""
|
||||
from auto_client_acquisition.email.reply_classifier import classify_reply
|
||||
text = str(body.get("text") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "text_required")
|
||||
classification = await classify_reply(text, prefer_llm=bool(body.get("prefer_llm", True)))
|
||||
return {
|
||||
"category": classification.category,
|
||||
"confidence": classification.confidence,
|
||||
"response_draft_ar": classification.response_draft_ar,
|
||||
"auto_send_allowed": classification.auto_send_allowed,
|
||||
"requires_human_review": classification.requires_human_review,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/replies/route")
|
||||
async def replies_route(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Stateless: route a reply to its deal_stage + next_action without persisting.
|
||||
Body: text (required)
|
||||
"""
|
||||
from auto_client_acquisition.email.reply_classifier import classify_reply
|
||||
text = str(body.get("text") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "text_required")
|
||||
classification = await classify_reply(text, prefer_llm=bool(body.get("prefer_llm", True)))
|
||||
return {
|
||||
"category": classification.category,
|
||||
"next_action": classification.next_action,
|
||||
"deal_stage": classification.deal_stage,
|
||||
"followup_days": classification.followup_days,
|
||||
"requires_human_review": classification.requires_human_review,
|
||||
}
|
||||
|
||||
|
||||
# ── Revenue dashboard history ─────────────────────────────────────
|
||||
@router.get("/dashboard/revenue-machine/history")
|
||||
async def dashboard_revenue_machine_history(days: int = 14) -> dict[str, Any]:
|
||||
"""Last N days of revenue machine output (default 14)."""
|
||||
if days < 1 or days > 90:
|
||||
raise HTTPException(400, "days_out_of_range: 1..90")
|
||||
cutoff = _utcnow() - timedelta(days=days)
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
gmail_rows = (await session.execute(
|
||||
select(GmailDraftRecord).where(GmailDraftRecord.created_at >= cutoff)
|
||||
)).scalars().all()
|
||||
linkedin_rows = (await session.execute(
|
||||
select(LinkedInDraftRecord).where(LinkedInDraftRecord.created_at >= cutoff)
|
||||
)).scalars().all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
# Aggregate by date
|
||||
from collections import defaultdict
|
||||
by_day: dict[str, dict[str, int]] = defaultdict(lambda: {
|
||||
"gmail_drafts": 0, "gmail_sent": 0,
|
||||
"linkedin_drafts": 0, "linkedin_sent": 0, "linkedin_replied": 0,
|
||||
})
|
||||
for r in gmail_rows:
|
||||
d = r.created_at.date().isoformat()
|
||||
by_day[d]["gmail_drafts"] += 1
|
||||
if r.status == "sent": by_day[d]["gmail_sent"] += 1
|
||||
for r in linkedin_rows:
|
||||
d = r.created_at.date().isoformat()
|
||||
by_day[d]["linkedin_drafts"] += 1
|
||||
if r.status == "sent": by_day[d]["linkedin_sent"] += 1
|
||||
if r.reply_received_at: by_day[d]["linkedin_replied"] += 1
|
||||
|
||||
series = sorted(
|
||||
[{"date": d, **stats} for d, stats in by_day.items()],
|
||||
key=lambda x: x["date"],
|
||||
)
|
||||
return {"status": "ok", "days_window": days, "series": series,
|
||||
"totals": {
|
||||
"gmail_drafts": sum(d["gmail_drafts"] for d in series),
|
||||
"gmail_sent": sum(d["gmail_sent"] for d in series),
|
||||
"linkedin_drafts": sum(d["linkedin_drafts"] for d in series),
|
||||
"linkedin_sent": sum(d["linkedin_sent"] for d in series),
|
||||
"linkedin_replied": sum(d["linkedin_replied"] for d in series),
|
||||
}}
|
||||
|
||||
|
||||
# ── Export today's drafts as CSV (for offline review when Gmail OAuth missing) ─
|
||||
@router.get("/automation/revenue-machine/export")
|
||||
async def revenue_machine_export(format: str = "csv") -> dict[str, Any]:
|
||||
"""
|
||||
Export today's drafts as CSV/Markdown so Sami can review them in Excel
|
||||
or paste into Gmail manually when Gmail OAuth isn't yet configured.
|
||||
|
||||
format: csv | markdown
|
||||
Writes to docs/ops/daily_reports/YYYY-MM-DD_drafts.csv (or .md).
|
||||
"""
|
||||
if format not in {"csv", "markdown"}:
|
||||
raise HTTPException(400, "format_must_be_csv_or_markdown")
|
||||
today_start = _utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
gmail_rows = (await session.execute(
|
||||
select(GmailDraftRecord).where(
|
||||
GmailDraftRecord.created_at >= today_start
|
||||
).order_by(GmailDraftRecord.created_at)
|
||||
)).scalars().all()
|
||||
linkedin_rows = (await session.execute(
|
||||
select(LinkedInDraftRecord).where(
|
||||
LinkedInDraftRecord.created_at >= today_start
|
||||
).order_by(LinkedInDraftRecord.created_at)
|
||||
)).scalars().all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
out_dir = Path("docs/ops/daily_reports")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
date_iso = today_start.date().isoformat()
|
||||
|
||||
if format == "csv":
|
||||
import csv as _csv
|
||||
gmail_path = out_dir / f"{date_iso}_gmail_drafts.csv"
|
||||
with open(gmail_path, "w", encoding="utf-8", newline="") as f:
|
||||
w = _csv.DictWriter(f, fieldnames=[
|
||||
"draft_id", "to_email", "subject", "body_plain",
|
||||
"status", "gmail_draft_id", "created_at",
|
||||
])
|
||||
w.writeheader()
|
||||
for r in gmail_rows:
|
||||
w.writerow({
|
||||
"draft_id": r.id, "to_email": r.to_email,
|
||||
"subject": r.subject,
|
||||
"body_plain": (r.body_plain or "").replace("\n", " ⏎ "),
|
||||
"status": r.status, "gmail_draft_id": r.gmail_draft_id or "",
|
||||
"created_at": r.created_at.isoformat(),
|
||||
})
|
||||
linkedin_path = out_dir / f"{date_iso}_linkedin_drafts.csv"
|
||||
with open(linkedin_path, "w", encoding="utf-8", newline="") as f:
|
||||
w = _csv.DictWriter(f, fieldnames=[
|
||||
"draft_id", "company_name", "search_query", "context",
|
||||
"reason", "message_ar", "message_en", "status",
|
||||
])
|
||||
w.writeheader()
|
||||
for r in linkedin_rows:
|
||||
w.writerow({
|
||||
"draft_id": r.id, "company_name": r.company_name,
|
||||
"search_query": r.profile_search_query,
|
||||
"context": r.company_context or "",
|
||||
"reason": r.reason_for_outreach or "",
|
||||
"message_ar": (r.message_ar or "").replace("\n", " ⏎ "),
|
||||
"message_en": (r.message_en or "").replace("\n", " ⏎ "),
|
||||
"status": r.status,
|
||||
})
|
||||
return {"status": "ok", "format": "csv",
|
||||
"gmail_export": str(gmail_path),
|
||||
"linkedin_export": str(linkedin_path),
|
||||
"gmail_count": len(gmail_rows),
|
||||
"linkedin_count": len(linkedin_rows)}
|
||||
|
||||
# markdown
|
||||
md_path = out_dir / f"{date_iso}_drafts.md"
|
||||
lines = [f"# Dealix — Drafts to Send ({date_iso})\n\n"]
|
||||
lines.append(f"## Gmail Drafts ({len(gmail_rows)})\n\n")
|
||||
for i, r in enumerate(gmail_rows, 1):
|
||||
lines.append(f"### {i}. To: `{r.to_email}`\n\n")
|
||||
lines.append(f"**Subject:** {r.subject}\n\n")
|
||||
lines.append("```\n" + (r.body_plain or "") + "\n```\n\n")
|
||||
lines.append("---\n\n")
|
||||
lines.append(f"\n## LinkedIn Drafts ({len(linkedin_rows)}) — manual send only\n\n")
|
||||
for i, r in enumerate(linkedin_rows, 1):
|
||||
lines.append(f"### {i}. {r.company_name}\n\n")
|
||||
lines.append(f"**Search:** `{r.profile_search_query}`\n\n")
|
||||
if r.reason_for_outreach:
|
||||
lines.append(f"**Reason:** {r.reason_for_outreach}\n\n")
|
||||
lines.append(f"**Message (Arabic):**\n\n```\n{r.message_ar}\n```\n\n")
|
||||
if r.message_en:
|
||||
lines.append(f"**Message (English):**\n\n```\n{r.message_en}\n```\n\n")
|
||||
lines.append("---\n\n")
|
||||
md_path.write_text("".join(lines), encoding="utf-8")
|
||||
return {"status": "ok", "format": "markdown",
|
||||
"report_path": str(md_path),
|
||||
"gmail_count": len(gmail_rows),
|
||||
"linkedin_count": len(linkedin_rows)}
|
||||
|
||||
|
||||
# ── Daily report generator ─────────────────────────────────────────
|
||||
@router.post("/automation/daily-report/generate")
|
||||
async def automation_daily_report_generate() -> dict[str, Any]:
|
||||
"""
|
||||
Write a daily markdown report into docs/ops/daily_reports/YYYY-MM-DD.md
|
||||
summarizing today's targeting + sends + replies.
|
||||
"""
|
||||
today_start = _utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
metrics = await dashboard_revenue_machine_today()
|
||||
if metrics.get("status") != "ok":
|
||||
return metrics
|
||||
|
||||
out_dir = Path("docs/ops/daily_reports")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = out_dir / f"{today_start.date().isoformat()}.md"
|
||||
|
||||
content = (
|
||||
f"# Dealix Daily Revenue Report — {today_start.date().isoformat()}\n\n"
|
||||
f"## Drafts produced\n"
|
||||
f"- Gmail drafts: {metrics['gmail_drafts']['total']}\n"
|
||||
f"- Gmail sent: {metrics['gmail_drafts']['sent']}\n"
|
||||
f"- LinkedIn drafts: {metrics['linkedin_drafts']['total']}\n"
|
||||
f"- LinkedIn sent (manual): {metrics['linkedin_drafts']['sent']}\n\n"
|
||||
f"## Replies\n"
|
||||
f"- Email replies received: {metrics['email_replies']}\n"
|
||||
f"- LinkedIn replies received: {metrics['linkedin_drafts']['replied']}\n\n"
|
||||
f"## Approval queue open\n"
|
||||
f"- {metrics['approval_queue_open']} drafts await Sami's review.\n\n"
|
||||
f"## Tomorrow recommendation\n"
|
||||
f"- Re-run /api/v1/automation/revenue-machine/run with same defaults.\n"
|
||||
f"- If reply rate today < 5%, switch top sector for tomorrow.\n"
|
||||
)
|
||||
try:
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "write_failed", "error": str(exc), "metrics": metrics}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"report_path": str(file_path),
|
||||
"metrics": metrics,
|
||||
}
|
||||
564
dealix/api/routers/ecosystem.py
Normal file
564
dealix/api/routers/ecosystem.py
Normal file
@ -0,0 +1,564 @@
|
||||
"""
|
||||
Ecosystem router — outbound webhooks platform.
|
||||
|
||||
Scale tier customers register HTTPS endpoints, Dealix POSTs HMAC-signed
|
||||
events when matching activity occurs. This is the API/webhooks ecosystem
|
||||
play that converts Dealix from a vertical SaaS into a Saudi B2B platform.
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/ecosystem/webhooks — register endpoint
|
||||
GET /api/v1/ecosystem/webhooks — list customer's subs
|
||||
GET /api/v1/ecosystem/webhooks/{sub_id} — get one
|
||||
PATCH /api/v1/ecosystem/webhooks/{sub_id} — toggle/update events
|
||||
DELETE /api/v1/ecosystem/webhooks/{sub_id} — remove
|
||||
POST /api/v1/ecosystem/webhooks/{sub_id}/test — fire test event
|
||||
POST /api/v1/ecosystem/events/emit — internal: emit event
|
||||
GET /api/v1/ecosystem/deliveries — recent delivery log
|
||||
GET /api/v1/ecosystem/event-types — list available events
|
||||
|
||||
Security:
|
||||
- Each subscription gets its own HMAC secret (returned ONCE at creation).
|
||||
- Customers verify signatures using `Dealix-Signature` header (Stripe-format).
|
||||
- Failed deliveries auto-disable subscription after 20 consecutive failures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query
|
||||
from sqlalchemy import desc, select
|
||||
|
||||
from auto_client_acquisition.ecosystem.webhook_dispatcher import (
|
||||
EVENT_TYPES,
|
||||
WebhookSubscription,
|
||||
dispatch,
|
||||
make_event,
|
||||
)
|
||||
from db.models import WebhookDeliveryRecord, WebhookSubscriptionRecord
|
||||
from db.session import async_session_factory
|
||||
|
||||
router = APIRouter(prefix="/api/v1/ecosystem", tags=["ecosystem"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _scrub_secret(record: WebhookSubscriptionRecord) -> dict[str, Any]:
|
||||
"""Return subscription dict without the secret (one-time-shown only)."""
|
||||
return {
|
||||
"id": record.id,
|
||||
"customer_id": record.customer_id,
|
||||
"endpoint_url": record.endpoint_url,
|
||||
"events": list(record.events or []),
|
||||
"description": record.description,
|
||||
"enabled": record.enabled,
|
||||
"last_delivery_at": record.last_delivery_at.isoformat()
|
||||
if record.last_delivery_at
|
||||
else None,
|
||||
"last_status_code": record.last_status_code,
|
||||
"consecutive_failures": record.consecutive_failures,
|
||||
"created_at": record.created_at.isoformat() if record.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def _safe_commit(session) -> bool:
|
||||
try:
|
||||
await session.commit()
|
||||
return True
|
||||
except Exception as exc: # pragma: no cover
|
||||
await session.rollback()
|
||||
log.warning("ecosystem_commit_failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
# ── List allowed event types ───────────────────────────────────────
|
||||
@router.get("/event-types")
|
||||
async def list_event_types() -> dict[str, Any]:
|
||||
"""Discovery endpoint — list every event type Dealix can emit."""
|
||||
descriptions = {
|
||||
"lead.created": "New lead recorded in the system.",
|
||||
"lead.qualified": "Lead passed ICP + qualification gates.",
|
||||
"lead.disqualified": "Lead rejected by qualifier (low fit).",
|
||||
"lead.enriched": "Lead enrichment completed — phone/email/company fields populated.",
|
||||
"draft.created": "Outbound draft (email/LinkedIn/WhatsApp) generated.",
|
||||
"draft.approved": "Human approved the draft for send.",
|
||||
"draft.sent": "Draft was sent through provider chain.",
|
||||
"reply.received": "Inbound reply received and persisted.",
|
||||
"reply.classified": "Reply classified — intent/sentiment/next-action set.",
|
||||
"demo.booked": "Prospect booked a demo via Calendly/portal.",
|
||||
"demo.held": "Demo confirmed completed.",
|
||||
"deal.created": "Deal record created (post-demo).",
|
||||
"deal.won": "Deal closed-won.",
|
||||
"deal.lost": "Deal closed-lost.",
|
||||
"payment.received": "Moyasar/Stripe webhook confirmed payment.",
|
||||
"health.changed": "Customer health bucket changed (healthy/at_risk/critical).",
|
||||
"churn.predicted": "Churn-prediction model flagged customer.",
|
||||
"qbr.generated": "Quarterly Business Review created for customer.",
|
||||
"pulse.published": "Saudi B2B Pulse monthly report published.",
|
||||
}
|
||||
return {
|
||||
"count": len(EVENT_TYPES),
|
||||
"events": [
|
||||
{"type": t, "description": descriptions.get(t, "")} for t in EVENT_TYPES
|
||||
],
|
||||
"signature_format": "t=<unix>,v1=<hmac_hex>",
|
||||
"headers_emitted": [
|
||||
"Dealix-Event-Id",
|
||||
"Dealix-Event-Type",
|
||||
"Dealix-Signature",
|
||||
"Dealix-Delivery-Attempt",
|
||||
],
|
||||
"verification_doc": "/docs#tag/ecosystem",
|
||||
}
|
||||
|
||||
|
||||
# ── Register a webhook subscription ────────────────────────────────
|
||||
@router.post("/webhooks")
|
||||
async def create_subscription(
|
||||
customer_id: str = Body(..., embed=True),
|
||||
endpoint_url: str = Body(..., embed=True),
|
||||
events: list[str] = Body(default_factory=list, embed=True),
|
||||
description: str | None = Body(default=None, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Register a new webhook endpoint.
|
||||
|
||||
Returns the secret ONCE — we never return it again. Customer must store it
|
||||
securely. If lost, they must rotate by deleting + creating a new subscription.
|
||||
"""
|
||||
if not endpoint_url.startswith(("https://", "http://localhost")):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="endpoint_url must be HTTPS (localhost allowed for dev only)",
|
||||
)
|
||||
|
||||
invalid = [e for e in events if e not in EVENT_TYPES]
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"unknown event types: {invalid}. See GET /event-types",
|
||||
)
|
||||
|
||||
sub_id = f"whk_{uuid.uuid4().hex[:24]}"
|
||||
secret = f"whsec_{secrets.token_urlsafe(32)}"
|
||||
|
||||
record = WebhookSubscriptionRecord(
|
||||
id=sub_id,
|
||||
customer_id=customer_id,
|
||||
endpoint_url=endpoint_url,
|
||||
secret=secret,
|
||||
events=list(events),
|
||||
description=description,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
session.add(record)
|
||||
ok = await _safe_commit(session)
|
||||
if not ok:
|
||||
return {
|
||||
"skipped_db_unreachable": True,
|
||||
"would_create": _scrub_secret(record),
|
||||
"secret": secret, # still return for offline-mode demo
|
||||
}
|
||||
except Exception as exc:
|
||||
log.warning("create_subscription_failed: %s", exc)
|
||||
return {"error": str(exc)[:200], "skipped": True}
|
||||
|
||||
return {
|
||||
"id": sub_id,
|
||||
"customer_id": customer_id,
|
||||
"endpoint_url": endpoint_url,
|
||||
"events": list(events) or "all",
|
||||
"description": description,
|
||||
"secret": secret, # ⚠️ ONLY shown once — store securely
|
||||
"secret_warning": "Save this secret now — it will never be shown again.",
|
||||
"verification_example_python": (
|
||||
"import hmac, hashlib\n"
|
||||
"def verify(secret, sig_header, body):\n"
|
||||
" parts = dict(p.split('=',1) for p in sig_header.split(','))\n"
|
||||
" expected = hmac.new(\n"
|
||||
" secret.encode(), f\"{parts['t']}.\".encode()+body, hashlib.sha256\n"
|
||||
" ).hexdigest()\n"
|
||||
" return hmac.compare_digest(expected, parts['v1'])"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ── List subscriptions ─────────────────────────────────────────────
|
||||
@router.get("/webhooks")
|
||||
async def list_subscriptions(
|
||||
customer_id: str = Query(...),
|
||||
enabled_only: bool = Query(default=False),
|
||||
) -> dict[str, Any]:
|
||||
"""List all subscriptions for a customer."""
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
stmt = select(WebhookSubscriptionRecord).where(
|
||||
WebhookSubscriptionRecord.customer_id == customer_id
|
||||
)
|
||||
if enabled_only:
|
||||
stmt = stmt.where(WebhookSubscriptionRecord.enabled.is_(True))
|
||||
stmt = stmt.order_by(desc(WebhookSubscriptionRecord.created_at))
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"count": len(rows),
|
||||
"subscriptions": [_scrub_secret(r) for r in rows],
|
||||
}
|
||||
except Exception as exc:
|
||||
log.warning("list_subscriptions_failed: %s", exc)
|
||||
return {"customer_id": customer_id, "skipped_db_unreachable": True, "error": str(exc)[:200]}
|
||||
|
||||
|
||||
# ── Get one ────────────────────────────────────────────────────────
|
||||
@router.get("/webhooks/{sub_id}")
|
||||
async def get_subscription(sub_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
row = await session.get(WebhookSubscriptionRecord, sub_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="subscription not found")
|
||||
return _scrub_secret(row)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.warning("get_subscription_failed: %s", exc)
|
||||
return {"id": sub_id, "skipped_db_unreachable": True}
|
||||
|
||||
|
||||
# ── Update enabled / events ────────────────────────────────────────
|
||||
@router.patch("/webhooks/{sub_id}")
|
||||
async def update_subscription(
|
||||
sub_id: str,
|
||||
enabled: bool | None = Body(default=None, embed=True),
|
||||
events: list[str] | None = Body(default=None, embed=True),
|
||||
description: str | None = Body(default=None, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
if events is not None:
|
||||
invalid = [e for e in events if e not in EVENT_TYPES]
|
||||
if invalid:
|
||||
raise HTTPException(status_code=400, detail=f"unknown events: {invalid}")
|
||||
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
row = await session.get(WebhookSubscriptionRecord, sub_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="subscription not found")
|
||||
if enabled is not None:
|
||||
row.enabled = enabled
|
||||
if enabled:
|
||||
row.consecutive_failures = 0
|
||||
if events is not None:
|
||||
row.events = list(events)
|
||||
if description is not None:
|
||||
row.description = description
|
||||
row.updated_at = _utcnow()
|
||||
await _safe_commit(session)
|
||||
return _scrub_secret(row)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.warning("update_subscription_failed: %s", exc)
|
||||
return {"id": sub_id, "skipped_db_unreachable": True}
|
||||
|
||||
|
||||
# ── Delete ─────────────────────────────────────────────────────────
|
||||
@router.delete("/webhooks/{sub_id}")
|
||||
async def delete_subscription(sub_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
row = await session.get(WebhookSubscriptionRecord, sub_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="subscription not found")
|
||||
await session.delete(row)
|
||||
await _safe_commit(session)
|
||||
return {"id": sub_id, "deleted": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.warning("delete_subscription_failed: %s", exc)
|
||||
return {"id": sub_id, "skipped_db_unreachable": True}
|
||||
|
||||
|
||||
# ── Test fire — dry-run dispatch with synthetic payload ────────────
|
||||
@router.post("/webhooks/{sub_id}/test")
|
||||
async def test_subscription(
|
||||
sub_id: str,
|
||||
event_type: str = Body(default="lead.created", embed=True),
|
||||
) -> dict[str, Any]:
|
||||
"""Send a synthetic test event to verify the customer's endpoint."""
|
||||
if event_type not in EVENT_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"unknown event type: {event_type}")
|
||||
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
row = await session.get(WebhookSubscriptionRecord, sub_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="subscription not found")
|
||||
sub = WebhookSubscription(
|
||||
customer_id=row.customer_id,
|
||||
endpoint_url=row.endpoint_url,
|
||||
secret=row.secret,
|
||||
events=tuple(row.events or ()),
|
||||
enabled=row.enabled,
|
||||
)
|
||||
evt = make_event(
|
||||
event_type=event_type,
|
||||
customer_id=row.customer_id,
|
||||
payload={
|
||||
"test": True,
|
||||
"note": "Synthetic event from POST /webhooks/{id}/test",
|
||||
"fields": {"company_name": "Test Co.", "fit_score": 0.78},
|
||||
},
|
||||
)
|
||||
summary = dispatch(subscriptions=[sub], event=evt)
|
||||
# Persist the delivery
|
||||
for d in summary.deliveries:
|
||||
session.add(
|
||||
WebhookDeliveryRecord(
|
||||
id=d.delivery_id,
|
||||
subscription_id=sub_id,
|
||||
customer_id=d.customer_id,
|
||||
event_id=d.event_id,
|
||||
event_type=d.event_type,
|
||||
attempt=d.attempt,
|
||||
endpoint_url=d.endpoint_url,
|
||||
status_code=d.status_code,
|
||||
success=d.success,
|
||||
error=d.error,
|
||||
duration_ms=d.duration_ms,
|
||||
request_signature=d.request_signature,
|
||||
payload=evt.envelope(),
|
||||
)
|
||||
)
|
||||
row.last_delivery_at = _utcnow()
|
||||
row.last_status_code = d.status_code
|
||||
if d.success:
|
||||
row.consecutive_failures = 0
|
||||
else:
|
||||
row.consecutive_failures += 1
|
||||
if row.consecutive_failures >= 20:
|
||||
row.enabled = False
|
||||
await _safe_commit(session)
|
||||
return {
|
||||
"test_event": evt.event_id,
|
||||
"matched": summary.matched,
|
||||
"delivered": summary.delivered,
|
||||
"failed": summary.failed,
|
||||
"deliveries": [
|
||||
{
|
||||
"status_code": d.status_code,
|
||||
"success": d.success,
|
||||
"duration_ms": d.duration_ms,
|
||||
"error": d.error,
|
||||
}
|
||||
for d in summary.deliveries
|
||||
],
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.warning("test_subscription_failed: %s", exc)
|
||||
return {"id": sub_id, "skipped_db_unreachable": True, "error": str(exc)[:200]}
|
||||
|
||||
|
||||
# ── Internal: emit event to all matching subs ──────────────────────
|
||||
@router.post("/events/emit")
|
||||
async def emit_event(
|
||||
customer_id: str = Body(..., embed=True),
|
||||
event_type: str = Body(..., embed=True),
|
||||
payload: dict[str, Any] = Body(default_factory=dict, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Internal endpoint — emits an event to all matching subscriptions for the customer.
|
||||
|
||||
In production this is called by deal/lead/payment routers after state changes.
|
||||
Exposed via API so any internal worker (cron, background task) can fire events
|
||||
without having to import the dispatcher.
|
||||
"""
|
||||
if event_type not in EVENT_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"unknown event type: {event_type}")
|
||||
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
stmt = select(WebhookSubscriptionRecord).where(
|
||||
WebhookSubscriptionRecord.customer_id == customer_id,
|
||||
WebhookSubscriptionRecord.enabled.is_(True),
|
||||
)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
subs = [
|
||||
WebhookSubscription(
|
||||
customer_id=r.customer_id,
|
||||
endpoint_url=r.endpoint_url,
|
||||
secret=r.secret,
|
||||
events=tuple(r.events or ()),
|
||||
enabled=r.enabled,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
evt = make_event(
|
||||
event_type=event_type, customer_id=customer_id, payload=payload
|
||||
)
|
||||
summary = dispatch(subscriptions=subs, event=evt)
|
||||
row_by_endpoint = {r.endpoint_url: r for r in rows}
|
||||
for d in summary.deliveries:
|
||||
session.add(
|
||||
WebhookDeliveryRecord(
|
||||
id=d.delivery_id,
|
||||
subscription_id=row_by_endpoint[d.endpoint_url].id,
|
||||
customer_id=d.customer_id,
|
||||
event_id=d.event_id,
|
||||
event_type=d.event_type,
|
||||
attempt=d.attempt,
|
||||
endpoint_url=d.endpoint_url,
|
||||
status_code=d.status_code,
|
||||
success=d.success,
|
||||
error=d.error,
|
||||
duration_ms=d.duration_ms,
|
||||
request_signature=d.request_signature,
|
||||
payload=evt.envelope(),
|
||||
)
|
||||
)
|
||||
src = row_by_endpoint[d.endpoint_url]
|
||||
src.last_delivery_at = _utcnow()
|
||||
src.last_status_code = d.status_code
|
||||
if d.success:
|
||||
src.consecutive_failures = 0
|
||||
else:
|
||||
src.consecutive_failures += 1
|
||||
if src.consecutive_failures >= 20:
|
||||
src.enabled = False
|
||||
await _safe_commit(session)
|
||||
return {
|
||||
"event_id": evt.event_id,
|
||||
"event_type": event_type,
|
||||
"customer_id": customer_id,
|
||||
"matched": summary.matched,
|
||||
"delivered": summary.delivered,
|
||||
"failed": summary.failed,
|
||||
}
|
||||
except Exception as exc:
|
||||
log.warning("emit_event_failed: %s", exc)
|
||||
return {
|
||||
"event_type": event_type,
|
||||
"customer_id": customer_id,
|
||||
"skipped_db_unreachable": True,
|
||||
"error": str(exc)[:200],
|
||||
}
|
||||
|
||||
|
||||
# ── Recent deliveries — debug/replay ───────────────────────────────
|
||||
@router.get("/deliveries")
|
||||
async def list_deliveries(
|
||||
customer_id: str = Query(...),
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
success_only: bool = Query(default=False),
|
||||
failed_only: bool = Query(default=False),
|
||||
) -> dict[str, Any]:
|
||||
if success_only and failed_only:
|
||||
raise HTTPException(status_code=400, detail="cannot filter for both success and failed")
|
||||
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
stmt = (
|
||||
select(WebhookDeliveryRecord)
|
||||
.where(WebhookDeliveryRecord.customer_id == customer_id)
|
||||
.order_by(desc(WebhookDeliveryRecord.created_at))
|
||||
.limit(limit)
|
||||
)
|
||||
if success_only:
|
||||
stmt = stmt.where(WebhookDeliveryRecord.success.is_(True))
|
||||
elif failed_only:
|
||||
stmt = stmt.where(WebhookDeliveryRecord.success.is_(False))
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"count": len(rows),
|
||||
"deliveries": [
|
||||
{
|
||||
"id": r.id,
|
||||
"subscription_id": r.subscription_id,
|
||||
"event_id": r.event_id,
|
||||
"event_type": r.event_type,
|
||||
"attempt": r.attempt,
|
||||
"endpoint_url": r.endpoint_url,
|
||||
"status_code": r.status_code,
|
||||
"success": r.success,
|
||||
"error": r.error,
|
||||
"duration_ms": r.duration_ms,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
except Exception as exc:
|
||||
log.warning("list_deliveries_failed: %s", exc)
|
||||
return {"customer_id": customer_id, "skipped_db_unreachable": True}
|
||||
|
||||
|
||||
# ── Stats — for customer dashboard ─────────────────────────────────
|
||||
@router.get("/stats")
|
||||
async def ecosystem_stats(
|
||||
customer_id: str = Query(...),
|
||||
period_days: int = Query(default=7, ge=1, le=90),
|
||||
) -> dict[str, Any]:
|
||||
"""Per-customer ecosystem stats — useful for portal display."""
|
||||
cutoff = _utcnow() - timedelta(days=period_days)
|
||||
try:
|
||||
async with async_session_factory() as session:
|
||||
stmt = select(WebhookDeliveryRecord).where(
|
||||
WebhookDeliveryRecord.customer_id == customer_id,
|
||||
WebhookDeliveryRecord.created_at >= cutoff,
|
||||
)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
total = len(rows)
|
||||
ok = sum(1 for r in rows if r.success)
|
||||
fail = total - ok
|
||||
by_event: dict[str, dict[str, int]] = {}
|
||||
for r in rows:
|
||||
bucket = by_event.setdefault(
|
||||
r.event_type, {"total": 0, "success": 0, "failed": 0}
|
||||
)
|
||||
bucket["total"] += 1
|
||||
if r.success:
|
||||
bucket["success"] += 1
|
||||
else:
|
||||
bucket["failed"] += 1
|
||||
sub_stmt = select(WebhookSubscriptionRecord).where(
|
||||
WebhookSubscriptionRecord.customer_id == customer_id
|
||||
)
|
||||
subs = (await session.execute(sub_stmt)).scalars().all()
|
||||
avg_latency = (
|
||||
int(sum(r.duration_ms or 0 for r in rows) / total)
|
||||
if total
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"period_days": period_days,
|
||||
"subscriptions": {
|
||||
"total": len(subs),
|
||||
"enabled": sum(1 for s in subs if s.enabled),
|
||||
"disabled": sum(1 for s in subs if not s.enabled),
|
||||
},
|
||||
"deliveries": {
|
||||
"total": total,
|
||||
"success": ok,
|
||||
"failed": fail,
|
||||
"success_rate": round(ok / total, 4) if total else 0.0,
|
||||
"avg_latency_ms": avg_latency,
|
||||
},
|
||||
"by_event_type": by_event,
|
||||
}
|
||||
except Exception as exc:
|
||||
log.warning("ecosystem_stats_failed: %s", exc)
|
||||
return {"customer_id": customer_id, "skipped_db_unreachable": True}
|
||||
440
dealix/api/routers/email_send.py
Normal file
440
dealix/api/routers/email_send.py
Normal file
@ -0,0 +1,440 @@
|
||||
"""
|
||||
Email send router — Gmail OAuth send + status + replies sync.
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/email/connect/gmail — return OAuth setup checklist
|
||||
POST /api/v1/email/send-approved — send a single approved row
|
||||
POST /api/v1/email/send-batch — send a batch of up to BATCH_SIZE rows
|
||||
GET /api/v1/email/status — Gmail config + today counts
|
||||
POST /api/v1/email/replies/sync — manual reply ingestion (until Pub/Sub)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from auto_client_acquisition.email.compliance import (
|
||||
append_opt_out_line,
|
||||
check_outreach,
|
||||
get_batch_interval_seconds,
|
||||
get_batch_size,
|
||||
get_daily_limit,
|
||||
)
|
||||
from auto_client_acquisition.email.gmail_send import (
|
||||
get_oauth_setup_instructions,
|
||||
is_configured as gmail_is_configured,
|
||||
send_email,
|
||||
)
|
||||
from auto_client_acquisition.email.reply_classifier import classify_reply
|
||||
from db.models import (
|
||||
AccountRecord,
|
||||
ContactRecord,
|
||||
EmailSendLog,
|
||||
OutreachQueueRecord,
|
||||
SuppressionRecord,
|
||||
)
|
||||
from db.session import async_session_factory
|
||||
|
||||
router = APIRouter(prefix="/api/v1/email", tags=["email"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _new_id(prefix: str = "") -> str:
|
||||
return f"{prefix}{uuid.uuid4().hex[:24]}" if prefix else uuid.uuid4().hex[:24]
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
@router.post("/connect/gmail")
|
||||
async def connect_gmail() -> dict[str, Any]:
|
||||
"""Returns the exact 8-step OAuth setup Sami runs once locally."""
|
||||
if gmail_is_configured():
|
||||
return {"status": "already_configured", "sender_email": os.getenv("GMAIL_SENDER_EMAIL", "")}
|
||||
return {"status": "needs_setup", **get_oauth_setup_instructions()}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def email_status() -> dict[str, Any]:
|
||||
today_start = _utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
sent_today = int((await session.execute(
|
||||
select(func.count()).select_from(EmailSendLog).where(
|
||||
EmailSendLog.status == "sent",
|
||||
EmailSendLog.sent_at >= today_start,
|
||||
)
|
||||
)).scalar() or 0)
|
||||
queued = int((await session.execute(
|
||||
select(func.count()).select_from(OutreachQueueRecord).where(
|
||||
OutreachQueueRecord.status.in_(["queued", "approved"])
|
||||
)
|
||||
)).scalar() or 0)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
return {
|
||||
"gmail_configured": gmail_is_configured(),
|
||||
"sender_email": os.getenv("GMAIL_SENDER_EMAIL") or "(unset)",
|
||||
"limits": {
|
||||
"daily_email_limit": get_daily_limit(),
|
||||
"batch_size": get_batch_size(),
|
||||
"batch_interval_minutes": get_batch_interval_seconds() // 60,
|
||||
},
|
||||
"sent_today": sent_today,
|
||||
"remaining_today": max(0, get_daily_limit() - sent_today),
|
||||
"approval_queue_size": queued,
|
||||
}
|
||||
|
||||
|
||||
async def _gather_compliance_inputs(
|
||||
*, to_email: str, account_id: str | None
|
||||
) -> dict[str, Any]:
|
||||
"""Pull suppression + contact + recent-send state needed for compliance gate."""
|
||||
async with async_session_factory() as session:
|
||||
sup_emails: set[str] = set()
|
||||
sup_domains: set[str] = set()
|
||||
contact_opt_out = False
|
||||
bounced_before = False
|
||||
risk_score = 0.0
|
||||
allowed_use = "business_contact_research_only"
|
||||
try:
|
||||
sup_rows = (await session.execute(select(SuppressionRecord))).scalars().all()
|
||||
for r in sup_rows:
|
||||
if r.email: sup_emails.add(r.email.lower())
|
||||
if r.domain: sup_domains.add(r.domain.lower())
|
||||
|
||||
if account_id:
|
||||
acc = (await session.execute(
|
||||
select(AccountRecord).where(AccountRecord.id == account_id)
|
||||
)).scalar_one_or_none()
|
||||
if acc:
|
||||
allowed_use = (acc.extra or {}).get("allowed_use") or allowed_use
|
||||
if (acc.risk_level or "").lower() == "high":
|
||||
risk_score = 80.0
|
||||
|
||||
contacts = (await session.execute(
|
||||
select(ContactRecord).where(ContactRecord.account_id == account_id)
|
||||
)).scalars().all()
|
||||
for c in contacts:
|
||||
if c.email and c.email.lower() == to_email.lower() and c.opt_out:
|
||||
contact_opt_out = True
|
||||
|
||||
bounce_log = (await session.execute(
|
||||
select(EmailSendLog).where(
|
||||
EmailSendLog.to_email == to_email,
|
||||
EmailSendLog.status == "bounced",
|
||||
).limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if bounce_log:
|
||||
bounced_before = True
|
||||
|
||||
today_start = _utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
sent_today_count = int((await session.execute(
|
||||
select(func.count()).select_from(EmailSendLog).where(
|
||||
EmailSendLog.status == "sent",
|
||||
EmailSendLog.sent_at >= today_start,
|
||||
)
|
||||
)).scalar() or 0)
|
||||
|
||||
# Last batch timestamp
|
||||
last = (await session.execute(
|
||||
select(EmailSendLog.sent_at).where(
|
||||
EmailSendLog.status == "sent"
|
||||
).order_by(EmailSendLog.sent_at.desc()).limit(1)
|
||||
)).scalar_one_or_none()
|
||||
seconds_since_last = None
|
||||
if last is not None:
|
||||
seconds_since_last = (_utcnow() - last).total_seconds()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("compliance_gather_failed err=%s", exc)
|
||||
return {"db_error": str(exc)}
|
||||
|
||||
return {
|
||||
"sup_emails": sup_emails,
|
||||
"sup_domains": sup_domains,
|
||||
"contact_opt_out": contact_opt_out,
|
||||
"bounced_before": bounced_before,
|
||||
"risk_score": risk_score,
|
||||
"allowed_use": allowed_use,
|
||||
"sent_today_count": sent_today_count,
|
||||
"seconds_since_last_batch": seconds_since_last,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/send-approved")
|
||||
async def send_approved(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Send a single approved row. Body:
|
||||
to_email (required)
|
||||
subject (required)
|
||||
body_plain (required)
|
||||
account_id (optional)
|
||||
queue_id (optional — if from outreach_queue, marks as sent on success)
|
||||
sequence_step (default 0)
|
||||
force (default False — skips DB compliance, NEVER skips Gmail config check)
|
||||
"""
|
||||
to_email = str(body.get("to_email") or "").strip()
|
||||
subject = str(body.get("subject") or "").strip()
|
||||
body_plain = str(body.get("body_plain") or "").strip()
|
||||
if not to_email or not subject or not body_plain:
|
||||
raise HTTPException(400, "to_email/subject/body_plain required")
|
||||
|
||||
account_id = body.get("account_id")
|
||||
queue_id = body.get("queue_id")
|
||||
seq_step = int(body.get("sequence_step") or 0)
|
||||
|
||||
if not gmail_is_configured():
|
||||
return {"status": "blocked_compliance",
|
||||
"reasons": ["gmail_not_configured"],
|
||||
"next_action": "POST /api/v1/email/connect/gmail"}
|
||||
|
||||
# Compliance gate
|
||||
inputs = await _gather_compliance_inputs(to_email=to_email, account_id=account_id)
|
||||
if inputs.get("db_error"):
|
||||
return {"status": "skipped_db_unreachable", "error": inputs["db_error"]}
|
||||
|
||||
chk = check_outreach(
|
||||
to_email=to_email,
|
||||
contact_opt_out=inputs["contact_opt_out"],
|
||||
risk_score=inputs["risk_score"],
|
||||
allowed_use=inputs["allowed_use"],
|
||||
suppression_emails=inputs["sup_emails"],
|
||||
suppression_domains=inputs["sup_domains"],
|
||||
bounced_before=inputs["bounced_before"],
|
||||
sent_today_count=inputs["sent_today_count"],
|
||||
sent_in_current_batch=0,
|
||||
seconds_since_last_batch=inputs.get("seconds_since_last_batch"),
|
||||
)
|
||||
if not chk.allowed:
|
||||
# Persist a compliance-blocked log row
|
||||
async with async_session_factory() as session:
|
||||
session.add(EmailSendLog(
|
||||
id=_new_id("es_"),
|
||||
account_id=account_id, queue_id=queue_id,
|
||||
to_email=to_email, subject=subject[:500],
|
||||
body_preview=body_plain[:500],
|
||||
sender_email=os.getenv("GMAIL_SENDER_EMAIL", ""),
|
||||
status="blocked_compliance",
|
||||
sequence_step=seq_step,
|
||||
compliance_check=chk.to_dict(),
|
||||
))
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
return {"status": "blocked_compliance", "reasons": chk.blocked_reasons}
|
||||
|
||||
# Append opt-out line and send
|
||||
final_body = append_opt_out_line(body_plain)
|
||||
result = await send_email(
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
body_plain=final_body,
|
||||
sender_name=body.get("sender_name") or "Sami | Dealix",
|
||||
)
|
||||
|
||||
# Persist log
|
||||
async with async_session_factory() as session:
|
||||
log_row = EmailSendLog(
|
||||
id=_new_id("es_"),
|
||||
account_id=account_id, queue_id=queue_id,
|
||||
to_email=to_email, subject=subject[:500],
|
||||
body_preview=final_body[:500],
|
||||
sender_email=os.getenv("GMAIL_SENDER_EMAIL", ""),
|
||||
status="sent" if result.status == "ok" else "failed",
|
||||
gmail_message_id=result.gmail_message_id,
|
||||
sent_at=_utcnow() if result.status == "ok" else None,
|
||||
sequence_step=seq_step,
|
||||
compliance_check=chk.to_dict(),
|
||||
bounce_reason=result.error if result.status != "ok" else None,
|
||||
)
|
||||
session.add(log_row)
|
||||
if queue_id and result.status == "ok":
|
||||
qr = (await session.execute(
|
||||
select(OutreachQueueRecord).where(OutreachQueueRecord.id == queue_id)
|
||||
)).scalar_one_or_none()
|
||||
if qr:
|
||||
qr.status = "sent"
|
||||
qr.sent_at = _utcnow()
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "commit_failed", "send_status": result.status, "error": str(exc)}
|
||||
|
||||
return {
|
||||
"status": result.status,
|
||||
"gmail_message_id": result.gmail_message_id,
|
||||
"send_log_id": log_row.id,
|
||||
"error": result.error,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/send-batch")
|
||||
async def send_batch(body: dict[str, Any] = Body(default={})) -> dict[str, Any]:
|
||||
"""
|
||||
Send up to BATCH_SIZE approved rows from the outreach queue.
|
||||
Body:
|
||||
max: int (default = EMAIL_BATCH_SIZE)
|
||||
only_status: 'approved' (default) — skips 'queued' which still need approval
|
||||
"""
|
||||
max_n = int(body.get("max") or get_batch_size())
|
||||
only_status = str(body.get("only_status") or "approved")
|
||||
if max_n < 1 or max_n > 50:
|
||||
raise HTTPException(400, "max_out_of_range: 1..50")
|
||||
|
||||
if not gmail_is_configured():
|
||||
return {"status": "blocked", "reason": "gmail_not_configured",
|
||||
"next_action": "POST /api/v1/email/connect/gmail"}
|
||||
|
||||
sent: list[dict[str, Any]] = []
|
||||
blocked: list[dict[str, Any]] = []
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
rows = (await session.execute(
|
||||
select(OutreachQueueRecord).where(
|
||||
OutreachQueueRecord.status == only_status,
|
||||
OutreachQueueRecord.channel.in_(["email", "email_warm", "email_followup"]),
|
||||
).limit(max_n)
|
||||
)).scalars().all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
for r in rows:
|
||||
# Need to fetch contact email for the account
|
||||
async with async_session_factory() as s2:
|
||||
try:
|
||||
contact = (await s2.execute(
|
||||
select(ContactRecord).where(
|
||||
ContactRecord.account_id == r.lead_id,
|
||||
ContactRecord.email.is_not(None),
|
||||
ContactRecord.opt_out == False, # noqa: E712
|
||||
).limit(1)
|
||||
)).scalar_one_or_none()
|
||||
acc = (await s2.execute(
|
||||
select(AccountRecord).where(AccountRecord.id == r.lead_id)
|
||||
)).scalar_one_or_none()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
blocked.append({"queue_id": r.id, "reason": f"db: {exc}"})
|
||||
continue
|
||||
|
||||
if not contact or not contact.email:
|
||||
blocked.append({"queue_id": r.id, "reason": "no_contact_email"})
|
||||
continue
|
||||
|
||||
subject = f"Dealix — تجربة تأهيل عملاء لـ {(acc.company_name if acc else 'فريقكم')[:60]}"
|
||||
|
||||
send_result = await send_approved.__wrapped__({} if False else {
|
||||
"to_email": contact.email,
|
||||
"subject": subject,
|
||||
"body_plain": r.message,
|
||||
"account_id": r.lead_id,
|
||||
"queue_id": r.id,
|
||||
"sequence_step": 0,
|
||||
}) if False else None # FastAPI doesn't expose __wrapped__; we re-call helper inline
|
||||
|
||||
# Inline send instead of recursive HTTP-style call
|
||||
import asyncio as _asyncio
|
||||
send_payload = {
|
||||
"to_email": contact.email,
|
||||
"subject": subject,
|
||||
"body_plain": r.message,
|
||||
"account_id": r.lead_id,
|
||||
"queue_id": r.id,
|
||||
"sequence_step": 0,
|
||||
}
|
||||
try:
|
||||
send_result = await send_approved(send_payload)
|
||||
except HTTPException as he:
|
||||
blocked.append({"queue_id": r.id, "reason": f"http: {he.detail}"})
|
||||
continue
|
||||
|
||||
if send_result.get("status") == "ok":
|
||||
sent.append({"queue_id": r.id, "to": contact.email,
|
||||
"gmail_message_id": send_result.get("gmail_message_id")})
|
||||
else:
|
||||
blocked.append({
|
||||
"queue_id": r.id, "to": contact.email,
|
||||
"status": send_result.get("status"),
|
||||
"reasons": send_result.get("reasons") or [send_result.get("error")],
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"sent_count": len(sent),
|
||||
"blocked_count": len(blocked),
|
||||
"sent": sent,
|
||||
"blocked": blocked,
|
||||
"limits": {
|
||||
"batch_size": get_batch_size(),
|
||||
"daily_limit": get_daily_limit(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/replies/sync")
|
||||
async def replies_sync(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Manual reply ingestion endpoint (until Gmail Pub/Sub is wired).
|
||||
Body:
|
||||
to_email: original recipient (the prospect)
|
||||
from_email: sender of the reply (their address)
|
||||
subject: reply subject
|
||||
text: reply body
|
||||
original_send_log_id: optional
|
||||
"""
|
||||
text = str(body.get("text") or "").strip()
|
||||
from_email = str(body.get("from_email") or "").strip().lower()
|
||||
if not text or not from_email:
|
||||
raise HTTPException(400, "from_email and text required")
|
||||
|
||||
classification = await classify_reply(text)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
log_row = None
|
||||
if body.get("original_send_log_id"):
|
||||
log_row = (await session.execute(
|
||||
select(EmailSendLog).where(EmailSendLog.id == body["original_send_log_id"])
|
||||
)).scalar_one_or_none()
|
||||
if log_row is None:
|
||||
# Find by recipient = from_email of reply
|
||||
log_row = (await session.execute(
|
||||
select(EmailSendLog).where(
|
||||
EmailSendLog.to_email == from_email,
|
||||
EmailSendLog.status == "sent",
|
||||
).order_by(EmailSendLog.sent_at.desc()).limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if log_row:
|
||||
log_row.status = "replied"
|
||||
log_row.reply_classification = classification.category
|
||||
log_row.reply_received_at = _utcnow()
|
||||
|
||||
# If unsubscribe → add to suppression
|
||||
if classification.category == "unsubscribe":
|
||||
session.add(SuppressionRecord(
|
||||
id=_new_id("sup_"),
|
||||
email=from_email, phone=None, domain=None,
|
||||
reason="opt_out_via_reply",
|
||||
))
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc),
|
||||
"classification": classification.to_dict()}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"classification": classification.to_dict(),
|
||||
"matched_send_log": getattr(log_row, "id", None) if log_row else None,
|
||||
}
|
||||
296
dealix/api/routers/full_os.py
Normal file
296
dealix/api/routers/full_os.py
Normal file
@ -0,0 +1,296 @@
|
||||
"""
|
||||
Full OS Orchestrator — 12-stage deal lifecycle + smart auto-action.
|
||||
|
||||
Connects every Dealix subsystem (reply classifier, draft generator,
|
||||
WhatsApp multi-provider, suppression, scoring, deal stage) into a single
|
||||
state-machine endpoint per inbound event.
|
||||
|
||||
12 stages (deal_stage):
|
||||
new_lead → qualifying → qualified → nurturing → meeting_booked →
|
||||
meeting_done → proposal_sent → negotiating → payment_requested →
|
||||
pilot_active → closed_won / closed_lost / opted_out
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/os/process classify + return next-stage plan
|
||||
POST /api/v1/os/process-and-act same + execute (send WhatsApp, draft email)
|
||||
POST /api/v1/os/bulk-process batch over a list of events
|
||||
GET /api/v1/os/stages list all stages + valid transitions
|
||||
GET /api/v1/os/whatsapp-providers show configured providers + chain status
|
||||
POST /api/v1/os/test-send send a test message (with safety guard)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
|
||||
from auto_client_acquisition.email.reply_classifier import classify_reply
|
||||
from auto_client_acquisition.email.whatsapp_multi_provider import (
|
||||
configured_providers,
|
||||
send_whatsapp_smart,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/os", tags=["full-os"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _new_id(prefix: str = "evt_") -> str:
|
||||
return f"{prefix}{uuid.uuid4().hex[:24]}"
|
||||
|
||||
|
||||
# ── 12-stage transition map ───────────────────────────────────────
|
||||
STAGES: list[str] = [
|
||||
"new_lead", "qualifying", "qualified", "nurturing",
|
||||
"meeting_booked", "meeting_done", "proposal_sent",
|
||||
"negotiating", "payment_requested", "pilot_active",
|
||||
"closed_won", "closed_lost", "opted_out",
|
||||
]
|
||||
|
||||
# Allowed transitions per current stage (forward + key sideways moves)
|
||||
TRANSITIONS: dict[str, list[str]] = {
|
||||
"new_lead": ["qualifying", "nurturing", "opted_out", "closed_lost"],
|
||||
"qualifying": ["qualified", "nurturing", "opted_out", "closed_lost"],
|
||||
"qualified": ["meeting_booked", "proposal_sent", "nurturing", "closed_lost"],
|
||||
"nurturing": ["qualifying", "qualified", "opted_out", "closed_lost"],
|
||||
"meeting_booked": ["meeting_done", "closed_lost", "nurturing"],
|
||||
"meeting_done": ["proposal_sent", "negotiating", "closed_lost"],
|
||||
"proposal_sent": ["negotiating", "payment_requested", "closed_lost", "nurturing"],
|
||||
"negotiating": ["payment_requested", "proposal_sent", "closed_lost", "nurturing"],
|
||||
"payment_requested": ["pilot_active", "negotiating", "closed_lost"],
|
||||
"pilot_active": ["closed_won", "closed_lost"],
|
||||
"closed_won": [], # terminal
|
||||
"closed_lost": ["nurturing"], # can revive after 30 days
|
||||
"opted_out": [], # terminal — suppression
|
||||
}
|
||||
|
||||
# Reply category → next stage suggestion
|
||||
CATEGORY_TO_STAGE: dict[str, str] = {
|
||||
"interested": "qualified",
|
||||
"ask_demo": "meeting_booked",
|
||||
"ask_price": "proposal_sent",
|
||||
"ask_details": "qualifying",
|
||||
"ask_case_study": "nurturing",
|
||||
"objection_budget": "negotiating",
|
||||
"objection_ai": "negotiating",
|
||||
"objection_privacy": "negotiating",
|
||||
"already_has_crm": "qualifying",
|
||||
"partnership": "qualifying", # routed to partner flow
|
||||
"not_now": "nurturing",
|
||||
"no_budget": "closed_lost",
|
||||
"ai_quality_concern": "negotiating",
|
||||
"unsubscribe": "opted_out",
|
||||
"angry": "closed_lost",
|
||||
"unclear": "qualifying",
|
||||
}
|
||||
|
||||
|
||||
def _suggest_next_stage(current: str, category: str) -> tuple[str, bool]:
|
||||
"""
|
||||
Return (suggested_stage, is_valid_transition).
|
||||
If suggested isn't a valid transition from current, default to current.
|
||||
"""
|
||||
target = CATEGORY_TO_STAGE.get(category, current)
|
||||
valid_targets = TRANSITIONS.get(current, [])
|
||||
if target == current:
|
||||
return current, True
|
||||
if target in valid_targets:
|
||||
return target, True
|
||||
# Out-of-order (e.g. unsubscribe from any stage)
|
||||
if target in {"opted_out", "closed_lost"}:
|
||||
return target, True
|
||||
return current, False
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────
|
||||
@router.get("/stages")
|
||||
async def list_stages() -> dict[str, Any]:
|
||||
"""Show all 12 stages + allowed transitions."""
|
||||
return {
|
||||
"stages": STAGES,
|
||||
"transitions": TRANSITIONS,
|
||||
"category_to_stage": CATEGORY_TO_STAGE,
|
||||
"terminal_stages": ["closed_won", "closed_lost", "opted_out"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/whatsapp-providers")
|
||||
async def whatsapp_providers_status() -> dict[str, Any]:
|
||||
"""Which WhatsApp providers are configured + the smart-fallback chain order."""
|
||||
configured = configured_providers()
|
||||
return {
|
||||
"configured_providers": configured,
|
||||
"chain_order": ["green_api", "ultramsg", "fonnte", "meta_cloud"],
|
||||
"active_provider_will_be": configured[0] if configured else None,
|
||||
"mock_mode": os.getenv("WHATSAPP_MOCK_MODE", "").lower() in {"true", "1", "yes"},
|
||||
"recommendation": (
|
||||
"set GREEN_API_INSTANCE_ID + GREEN_API_TOKEN for free-tier primary; "
|
||||
"add ULTRAMSG_* as paid backup; add META_WHATSAPP_* for official fallback"
|
||||
if not configured else "✅ ready — chain will use first listed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/process")
|
||||
async def os_process(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Classify an inbound event and return the next-stage plan WITHOUT executing.
|
||||
Body:
|
||||
phone: str
|
||||
company: str | None
|
||||
message: str (required)
|
||||
current_stage: str (default: "new_lead")
|
||||
prefer_llm: bool (default: True)
|
||||
"""
|
||||
phone = str(body.get("phone") or "").strip()
|
||||
company = str(body.get("company") or "").strip()
|
||||
message = str(body.get("message") or "").strip()
|
||||
current_stage = str(body.get("current_stage") or "new_lead").strip()
|
||||
prefer_llm = bool(body.get("prefer_llm", True))
|
||||
|
||||
if not message:
|
||||
raise HTTPException(400, "message_required")
|
||||
if current_stage not in STAGES:
|
||||
raise HTTPException(400, f"unknown_stage:{current_stage}. Valid: {STAGES}")
|
||||
|
||||
classification = await classify_reply(message, prefer_llm=prefer_llm)
|
||||
new_stage, valid = _suggest_next_stage(current_stage, classification.category)
|
||||
|
||||
response_message_ar = classification.response_draft_ar
|
||||
if company:
|
||||
# Personalize opener if classifier didn't already
|
||||
if not response_message_ar.startswith(("السلام", "أهلاً", "مرحباً")):
|
||||
response_message_ar = f"مرحباً {company}،\n\n{response_message_ar}"
|
||||
|
||||
return {
|
||||
"event_id": _new_id(),
|
||||
"received_at": _utcnow_iso(),
|
||||
"input": {"phone": phone, "company": company, "current_stage": current_stage},
|
||||
"classification": classification.to_dict(),
|
||||
"stage": {
|
||||
"from": current_stage,
|
||||
"to": new_stage,
|
||||
"transition_valid": valid,
|
||||
},
|
||||
"response_message_ar": response_message_ar,
|
||||
"auto_send_allowed": classification.auto_send_allowed,
|
||||
"requires_human_review": classification.requires_human_review,
|
||||
"next_action": classification.next_action,
|
||||
"followup_days": classification.followup_days,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/process-and-act")
|
||||
async def os_process_and_act(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Same as /os/process + execute the action:
|
||||
- if auto_send_allowed=True and not requires_review → send WhatsApp via smart chain
|
||||
- else → return draft for human review (no send)
|
||||
"""
|
||||
plan = await os_process(body)
|
||||
|
||||
execution: dict[str, Any] = {"action_taken": "none", "reason": ""}
|
||||
safe_to_send = (
|
||||
plan["auto_send_allowed"]
|
||||
and not plan["requires_human_review"]
|
||||
and plan["classification"]["category"] not in {"angry", "objection_privacy"}
|
||||
)
|
||||
|
||||
if not safe_to_send:
|
||||
execution["action_taken"] = "draft_for_review"
|
||||
execution["reason"] = "compliance_or_human_review_required"
|
||||
plan["execution"] = execution
|
||||
return plan
|
||||
|
||||
phone = body.get("phone")
|
||||
if not phone:
|
||||
execution["action_taken"] = "no_op"
|
||||
execution["reason"] = "phone_missing"
|
||||
plan["execution"] = execution
|
||||
return plan
|
||||
|
||||
result = await send_whatsapp_smart(str(phone), plan["response_message_ar"])
|
||||
if result.status == "ok":
|
||||
execution["action_taken"] = "whatsapp_sent"
|
||||
execution["provider"] = result.provider
|
||||
execution["message_id"] = result.message_id
|
||||
execution["chain_tried"] = result.fallback_chain_tried
|
||||
elif result.status == "mock":
|
||||
execution["action_taken"] = "whatsapp_mock"
|
||||
execution["provider"] = "mock"
|
||||
elif result.status == "no_keys":
|
||||
execution["action_taken"] = "draft_for_review"
|
||||
execution["reason"] = "no_whatsapp_provider_configured"
|
||||
else:
|
||||
execution["action_taken"] = "send_failed_falling_back_to_draft"
|
||||
execution["reason"] = result.error or result.status
|
||||
execution["chain_tried"] = result.fallback_chain_tried
|
||||
|
||||
plan["execution"] = execution
|
||||
return plan
|
||||
|
||||
|
||||
@router.post("/bulk-process")
|
||||
async def os_bulk_process(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Process a list of events at once. Body: events: list[dict], execute: bool.
|
||||
Each event: {phone, company, message, current_stage}.
|
||||
"""
|
||||
events = body.get("events")
|
||||
execute = bool(body.get("execute", False))
|
||||
if not isinstance(events, list) or not events:
|
||||
raise HTTPException(400, "events_required: list of {phone, message, current_stage}")
|
||||
if len(events) > 50:
|
||||
raise HTTPException(400, "too_many: max 50 per call")
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
fn = os_process_and_act if execute else os_process
|
||||
for ev in events:
|
||||
try:
|
||||
r = await fn(ev)
|
||||
except HTTPException as he:
|
||||
r = {"error": he.detail, "input": ev}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
r = {"error": str(exc), "input": ev}
|
||||
results.append(r)
|
||||
|
||||
sent = sum(1 for r in results
|
||||
if r.get("execution", {}).get("action_taken") == "whatsapp_sent")
|
||||
drafts = sum(1 for r in results
|
||||
if r.get("execution", {}).get("action_taken") == "draft_for_review")
|
||||
return {
|
||||
"count": len(results),
|
||||
"sent": sent,
|
||||
"drafts": drafts,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/test-send")
|
||||
async def os_test_send(phone: str, message: str = "Dealix test ping ✅") -> dict[str, Any]:
|
||||
"""
|
||||
Send a single test WhatsApp via the smart-chain. Use only your own number.
|
||||
Hard guard: refuses to send to a phone that isn't on a small allowlist
|
||||
set via WHATSAPP_TEST_ALLOWLIST (comma-separated digits).
|
||||
"""
|
||||
allowlist = {
|
||||
p.strip() for p in os.getenv("WHATSAPP_TEST_ALLOWLIST", "").split(",")
|
||||
if p.strip()
|
||||
}
|
||||
digits_only = "".join(c for c in phone if c.isdigit())
|
||||
if allowlist and digits_only not in allowlist:
|
||||
raise HTTPException(
|
||||
403,
|
||||
"phone_not_in_test_allowlist: set WHATSAPP_TEST_ALLOWLIST in env "
|
||||
"to your own +966 number(s) before using /os/test-send",
|
||||
)
|
||||
result = await send_whatsapp_smart(phone, message)
|
||||
return result.to_dict()
|
||||
113
dealix/api/routers/health.py
Normal file
113
dealix/api/routers/health.py
Normal file
@ -0,0 +1,113 @@
|
||||
"""Health, liveness, readiness endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.schemas import HealthResponse
|
||||
from core.config.settings import get_settings
|
||||
from core.llm import get_router as get_model_router
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def health() -> HealthResponse:
|
||||
"""Liveness + config summary."""
|
||||
settings = get_settings()
|
||||
providers = [p.value for p in get_model_router().available_providers()]
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
version=settings.app_version,
|
||||
env=settings.app_env,
|
||||
providers=providers,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ready")
|
||||
async def ready() -> dict[str, str]:
|
||||
"""Readiness probe."""
|
||||
return {"status": "ready"}
|
||||
|
||||
|
||||
@router.get("/live")
|
||||
async def live() -> dict[str, str]:
|
||||
"""Liveness probe."""
|
||||
return {"status": "alive"}
|
||||
|
||||
|
||||
@router.get("/health/deep")
|
||||
async def health_deep() -> dict[str, object]:
|
||||
"""Deep health check — verifies DB, Redis, LLM providers."""
|
||||
import os
|
||||
import time
|
||||
|
||||
checks: dict[str, dict[str, object]] = {}
|
||||
overall = "ok"
|
||||
|
||||
# Postgres
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
import psycopg2 # type: ignore
|
||||
|
||||
dsn = os.getenv("DATABASE_URL") or os.getenv("DATABASE_DSN")
|
||||
if dsn:
|
||||
conn = psycopg2.connect(dsn, connect_timeout=3)
|
||||
conn.cursor().execute("SELECT 1")
|
||||
conn.close()
|
||||
checks["postgres"] = {"status": "ok", "ms": round((time.perf_counter() - t0) * 1000, 1)}
|
||||
else:
|
||||
checks["postgres"] = {"status": "skip", "reason": "no DATABASE_URL"}
|
||||
except Exception as e: # pragma: no cover
|
||||
checks["postgres"] = {"status": "fail", "error": str(e)[:200]}
|
||||
overall = "degraded"
|
||||
|
||||
# Redis
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
import redis # type: ignore
|
||||
|
||||
url = os.getenv("REDIS_URL")
|
||||
if url:
|
||||
r = redis.from_url(url, socket_timeout=3)
|
||||
r.ping()
|
||||
checks["redis"] = {"status": "ok", "ms": round((time.perf_counter() - t0) * 1000, 1)}
|
||||
else:
|
||||
checks["redis"] = {"status": "skip", "reason": "no REDIS_URL"}
|
||||
except Exception as e: # pragma: no cover
|
||||
checks["redis"] = {"status": "fail", "error": str(e)[:200]}
|
||||
overall = "degraded"
|
||||
|
||||
# LLM providers
|
||||
providers = [p.value for p in get_model_router().available_providers()]
|
||||
checks["llm_providers"] = {"status": "ok" if providers else "fail", "providers": providers}
|
||||
if not providers:
|
||||
overall = "degraded"
|
||||
|
||||
return {"status": overall, "checks": checks, "version": get_settings().app_version}
|
||||
|
||||
|
||||
@router.get("/healthz", include_in_schema=False)
|
||||
async def healthz() -> dict[str, str]:
|
||||
"""Standard healthz alias for UptimeRobot/K8s probes."""
|
||||
return {"status": "ok", "service": "dealix"}
|
||||
|
||||
|
||||
@router.get("/_test_sentry", include_in_schema=False)
|
||||
async def test_sentry() -> dict[str, str]:
|
||||
"""Deliberate error to verify Sentry integration.
|
||||
|
||||
Protected by ADMIN_TOKEN header in production.
|
||||
"""
|
||||
import os
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
# In dev, allow freely. In prod, require admin token.
|
||||
if os.getenv("APP_ENV", "dev") == "prod":
|
||||
admin_token = os.getenv("ADMIN_TOKEN", "")
|
||||
# Request injection is complex in FastAPI without Depends; keep simple check
|
||||
if not admin_token:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
raise Exception("Test Sentry integration — deliberate error")
|
||||
120
dealix/api/routers/innovation.py
Normal file
120
dealix/api/routers/innovation.py
Normal file
@ -0,0 +1,120 @@
|
||||
"""Innovation / Autonomous Growth Factory — deterministic demo API + DB-backed paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auto_client_acquisition.innovation import (
|
||||
analyze_deal_room,
|
||||
build_demo_command_feed,
|
||||
build_demo_proof_ledger,
|
||||
list_growth_missions,
|
||||
recommend_experiments,
|
||||
)
|
||||
from auto_client_acquisition.innovation.aeo_radar import build_aeo_radar_demo
|
||||
from auto_client_acquisition.innovation.command_feed_live import build_command_feed_from_db
|
||||
from auto_client_acquisition.innovation.proof_ledger_repo import (
|
||||
proof_ledger_append,
|
||||
proof_ledger_list,
|
||||
proof_ledger_weekly_report,
|
||||
)
|
||||
from auto_client_acquisition.innovation.ten_in_ten import build_ten_opportunities
|
||||
from db.session import get_db
|
||||
|
||||
router = APIRouter(prefix="/api/v1/innovation", tags=["innovation"])
|
||||
|
||||
|
||||
@router.get("/command-feed/demo")
|
||||
async def command_feed_demo() -> dict[str, Any]:
|
||||
"""بطاقات Command Feed توضيحية."""
|
||||
return build_demo_command_feed()
|
||||
|
||||
|
||||
@router.get("/command-feed/live")
|
||||
async def command_feed_live(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
tenant_id: str = Query("default"),
|
||||
) -> dict[str, Any]:
|
||||
"""بطاقات من قاعدة البيانات عند توفر أحداث؛ وإلا fallback للعرض التجريبي."""
|
||||
return await build_command_feed_from_db(session, tenant_id=tenant_id)
|
||||
|
||||
|
||||
@router.get("/growth-missions")
|
||||
async def growth_missions() -> dict[str, Any]:
|
||||
"""قائمة مهام النمو بما فيها Kill feature «10 فرص في 10 دقائق»."""
|
||||
return list_growth_missions()
|
||||
|
||||
|
||||
@router.post("/opportunities/ten-in-ten")
|
||||
async def opportunities_ten_in_ten(
|
||||
payload: dict[str, Any] = Body(default_factory=dict),
|
||||
) -> dict[str, Any]:
|
||||
"""10 فرص في 10 دقائق — مسودات بانتظار الموافقة فقط؛ لا إرسال."""
|
||||
return build_ten_opportunities(payload or None)
|
||||
|
||||
|
||||
@router.get("/aeo/radar/demo")
|
||||
async def aeo_radar_demo(sector: str | None = Query(None)) -> dict[str, Any]:
|
||||
"""قائمة تحقق AEO تجريبية حسب القطاع — بدون بحث حي."""
|
||||
return build_aeo_radar_demo(sector)
|
||||
|
||||
|
||||
@router.post("/experiments/recommend")
|
||||
async def experiments_recommend(
|
||||
context: dict[str, Any] = Body(default_factory=dict),
|
||||
) -> dict[str, Any]:
|
||||
"""ثلاث تجارب شهرية مقترحة؛ الجسم اختياري ويدعم past_experiments."""
|
||||
return recommend_experiments(context or None)
|
||||
|
||||
|
||||
@router.get("/proof-ledger/demo")
|
||||
async def proof_ledger_demo() -> dict[str, Any]:
|
||||
"""سجل إثبات تجريبي ثابت."""
|
||||
return build_demo_proof_ledger()
|
||||
|
||||
|
||||
@router.post("/proof-ledger/events")
|
||||
async def proof_ledger_events_create(
|
||||
body: dict[str, Any] = Body(default_factory=dict),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
"""إلحاق حدث في دفتر الإثبات (تقديرات تشغيلية)."""
|
||||
return await proof_ledger_append(
|
||||
session,
|
||||
tenant_id=str(body.get("tenant_id") or "default"),
|
||||
event_type=str(body.get("event_type") or "note"),
|
||||
revenue_influenced_sar_estimate=float(body.get("revenue_influenced_sar_estimate") or 0),
|
||||
notes_ar=str(body.get("notes_ar") or ""),
|
||||
extra=body.get("extra_json") if isinstance(body.get("extra_json"), dict) else {},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/proof-ledger/events")
|
||||
async def proof_ledger_events_list(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
tenant_id: str = Query("default"),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
) -> dict[str, Any]:
|
||||
"""قائمة أحداث دفتر الإثبات."""
|
||||
events = await proof_ledger_list(session, tenant_id=tenant_id, limit=limit)
|
||||
return {"events": events, "tenant_id": tenant_id}
|
||||
|
||||
|
||||
@router.get("/proof-ledger/report/week")
|
||||
async def proof_ledger_report_week(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
tenant_id: str = Query("default"),
|
||||
) -> dict[str, Any]:
|
||||
"""ملخص 7 أيام لتقديرات الإيراد المؤثرة (ليس محاسبة دقيقة)."""
|
||||
return await proof_ledger_weekly_report(session, tenant_id=tenant_id)
|
||||
|
||||
|
||||
@router.post("/deal-room/analyze")
|
||||
async def deal_room_analyze(
|
||||
payload: dict[str, Any] = Body(default_factory=dict),
|
||||
) -> dict[str, Any]:
|
||||
"""تحليل غرفة صفقة تجريبي من جسم الطلب."""
|
||||
return analyze_deal_room(payload or None)
|
||||
269
dealix/api/routers/leads.py
Normal file
269
dealix/api/routers/leads.py
Normal file
@ -0,0 +1,269 @@
|
||||
"""Leads (Phase 8) endpoints + Local/Web Discovery + Enrichment + Outreach Prepare."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
|
||||
from api.dependencies import get_acquisition_pipeline
|
||||
from api.schemas import LeadCreateRequest, LeadResponse, PipelineResponse
|
||||
from auto_client_acquisition.agents.intake import LeadSource
|
||||
from auto_client_acquisition.connectors.google_maps import (
|
||||
INDUSTRY_QUERIES as _LOCAL_INDUSTRY_QUERIES,
|
||||
SAUDI_CITIES as _LOCAL_SAUDI_CITIES,
|
||||
)
|
||||
from auto_client_acquisition.pipeline import AcquisitionPipeline
|
||||
from auto_client_acquisition.pipelines.enrichment import enrich_account
|
||||
from auto_client_acquisition.providers.maps import (
|
||||
discover_with_chain as _discover_with_chain,
|
||||
get_maps_chain as _get_maps_chain,
|
||||
)
|
||||
from auto_client_acquisition.providers.search import (
|
||||
get_search_chain as _get_search_chain,
|
||||
search_with_chain as _search_with_chain,
|
||||
)
|
||||
from db.models import (
|
||||
AccountRecord,
|
||||
ContactRecord,
|
||||
LeadScoreRecord,
|
||||
OutreachQueueRecord,
|
||||
SuppressionRecord,
|
||||
)
|
||||
from db.session import async_session_factory
|
||||
|
||||
router = APIRouter(prefix="/api/v1/leads", tags=["leads"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _new_id(prefix: str = "") -> str:
|
||||
suffix = uuid.uuid4().hex[:24]
|
||||
return f"{prefix}{suffix}" if prefix else suffix
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
# ── Original lead-create endpoint (unchanged) ────────────────────
|
||||
@router.post("", response_model=PipelineResponse)
|
||||
async def create_lead(
|
||||
payload: LeadCreateRequest,
|
||||
pipeline: AcquisitionPipeline = Depends(get_acquisition_pipeline),
|
||||
auto_book: bool = True,
|
||||
auto_proposal: bool = False,
|
||||
) -> PipelineResponse:
|
||||
"""Submit a new lead — runs through the full acquisition pipeline."""
|
||||
try:
|
||||
source = LeadSource(payload.source)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid source: {e}") from e
|
||||
|
||||
result = await pipeline.run(
|
||||
payload=payload.model_dump(exclude_none=True),
|
||||
source=source,
|
||||
auto_book=auto_book,
|
||||
auto_proposal=auto_proposal,
|
||||
)
|
||||
return PipelineResponse(
|
||||
lead=LeadResponse(
|
||||
id=result.lead.id,
|
||||
source=result.lead.source.value,
|
||||
company_name=result.lead.company_name,
|
||||
contact_name=result.lead.contact_name,
|
||||
contact_email=result.lead.contact_email,
|
||||
contact_phone=result.lead.contact_phone,
|
||||
sector=result.lead.sector,
|
||||
region=result.lead.region,
|
||||
status=result.lead.status.value,
|
||||
fit_score=result.lead.fit_score,
|
||||
urgency_score=result.lead.urgency_score,
|
||||
pain_points=result.lead.pain_points,
|
||||
locale=result.lead.locale,
|
||||
created_at=result.lead.created_at,
|
||||
),
|
||||
fit_score=result.fit_score.to_dict() if result.fit_score else None,
|
||||
extraction=result.extraction.to_dict() if result.extraction else None,
|
||||
qualification=result.qualification.to_dict() if result.qualification else None,
|
||||
crm_sync=result.crm_sync.to_dict() if result.crm_sync else None,
|
||||
booking=result.booking.to_dict() if result.booking else None,
|
||||
proposal=result.proposal.to_dict() if result.proposal else None,
|
||||
warnings=result.warnings,
|
||||
)
|
||||
|
||||
|
||||
# ── Local Saudi Lead Engine (Google Places) ────────────────────────
|
||||
@router.get("/discover/local-industries")
|
||||
async def list_local_industries() -> dict[str, Any]:
|
||||
return {
|
||||
"industries": [
|
||||
{"key": k, "queries": v} for k, v in _LOCAL_INDUSTRY_QUERIES.items()
|
||||
],
|
||||
"cities": [
|
||||
{"key": k, "ar": ar, "en": en}
|
||||
for k, (ar, en) in _LOCAL_SAUDI_CITIES.items()
|
||||
],
|
||||
"notes": (
|
||||
"POST /api/v1/leads/discover/local with body "
|
||||
"{industry, city, max_results, hydrate_details, custom_query, page_token}. "
|
||||
"Set GOOGLE_MAPS_API_KEY in Railway env to enable."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/discover/local")
|
||||
async def discover_local_endpoint(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Saudi local lead engine — chains Google Places → SerpApi → Apify → static."""
|
||||
industry = str(body.get("industry") or "").strip()
|
||||
city = str(body.get("city") or "").strip()
|
||||
max_results = int(body.get("max_results") or 20)
|
||||
hydrate_details = bool(body.get("hydrate_details", True))
|
||||
custom_query = body.get("custom_query")
|
||||
page_token = body.get("page_token")
|
||||
|
||||
if not industry and not custom_query:
|
||||
raise HTTPException(400, "industry_required")
|
||||
if not city:
|
||||
raise HTTPException(400, "city_required")
|
||||
if max_results < 1 or max_results > 40:
|
||||
raise HTTPException(400, "max_results_out_of_range: 1..40")
|
||||
|
||||
chain_result = await _discover_with_chain(
|
||||
industry=industry or "custom",
|
||||
city=city,
|
||||
max_results=max_results,
|
||||
page_token=str(page_token) if page_token else None,
|
||||
hydrate_details=hydrate_details,
|
||||
custom_query=str(custom_query) if custom_query else None,
|
||||
)
|
||||
payload = chain_result.to_dict()
|
||||
payload["chain"] = [
|
||||
{"name": p.name, "available": p.is_available()} for p in _get_maps_chain()
|
||||
]
|
||||
return payload
|
||||
|
||||
|
||||
# ── Web Lead Discovery ────────────────────────────────────────────
|
||||
@router.post("/discover/web")
|
||||
async def discover_web_endpoint(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Web lead discovery via SearchProvider chain (Google CSE → Tavily → static)."""
|
||||
query = str(body.get("query") or "").strip()
|
||||
num = int(body.get("num") or 10)
|
||||
site = body.get("site")
|
||||
lang = body.get("lang")
|
||||
|
||||
if len(query) < 5:
|
||||
raise HTTPException(400, "query_too_short: min 5 chars")
|
||||
if num < 1 or num > 10:
|
||||
raise HTTPException(400, "num_out_of_range: 1..10")
|
||||
|
||||
chain_result = await _search_with_chain(
|
||||
query, num=num,
|
||||
site=str(site) if site else None,
|
||||
lang=str(lang) if lang else None,
|
||||
)
|
||||
payload = chain_result.to_dict()
|
||||
payload["chain"] = [
|
||||
{"name": p.name, "available": p.is_available()} for p in _get_search_chain()
|
||||
]
|
||||
return payload
|
||||
|
||||
|
||||
# ── Full enrichment (single account) ──────────────────────────────
|
||||
@router.post("/enrich/full")
|
||||
async def enrich_full_endpoint(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Full enrichment for a single account.
|
||||
Body: {company_name?, domain?, website?, city?, sector?, place_id?, level?}
|
||||
Level: basic | standard (default) | deep.
|
||||
"""
|
||||
if not (body.get("company_name") or body.get("domain") or body.get("website")):
|
||||
raise HTTPException(400, "must_provide_company_name_or_domain_or_website")
|
||||
level = str(body.get("level") or "standard")
|
||||
if level not in {"basic", "standard", "deep"}:
|
||||
raise HTTPException(400, "level_must_be: basic | standard | deep")
|
||||
|
||||
account = {
|
||||
"company_name": body.get("company_name") or "",
|
||||
"domain": body.get("domain"),
|
||||
"website": body.get("website"),
|
||||
"city": body.get("city"),
|
||||
"country": body.get("country") or "SA",
|
||||
"sector": body.get("sector"),
|
||||
"google_place_id": body.get("place_id"),
|
||||
"best_source": body.get("source") or "manual",
|
||||
"allowed_use": body.get("allowed_use") or "business_contact_research_only",
|
||||
"risk_level": body.get("risk_level") or "medium",
|
||||
}
|
||||
return await enrich_account(account, enrichment_level=level)
|
||||
|
||||
|
||||
# ── Batch enrichment over existing accounts ───────────────────────
|
||||
@router.post("/enrich/batch")
|
||||
async def enrich_batch_endpoint(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Enrich a batch of accounts already in the graph.
|
||||
Body: {account_ids: [...], level: basic|standard|deep}
|
||||
"""
|
||||
ids = body.get("account_ids")
|
||||
level = str(body.get("level") or "standard")
|
||||
if not isinstance(ids, list) or not ids:
|
||||
raise HTTPException(400, "account_ids_required")
|
||||
if len(ids) > 100:
|
||||
raise HTTPException(400, "too_many: max 100 per batch")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
accs = (await session.execute(
|
||||
select(AccountRecord).where(AccountRecord.id.in_(ids))
|
||||
)).scalars().all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc), "items": []}
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for acc in accs:
|
||||
account_dict = {
|
||||
"id": acc.id, "company_name": acc.company_name,
|
||||
"domain": acc.domain, "website": acc.website,
|
||||
"city": acc.city, "country": acc.country, "sector": acc.sector,
|
||||
"google_place_id": acc.google_place_id, "best_source": acc.best_source,
|
||||
"risk_level": acc.risk_level,
|
||||
"allowed_use": (acc.extra or {}).get("allowed_use"),
|
||||
}
|
||||
try:
|
||||
result = await enrich_account(account_dict, enrichment_level=level)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
results.append({"id": acc.id, "status": "error", "error": str(exc)})
|
||||
continue
|
||||
score = result.get("score", {})
|
||||
session.add(LeadScoreRecord(
|
||||
id=_new_id("ls_"), account_id=acc.id,
|
||||
fit_score=float(score.get("fit") or 0),
|
||||
intent_score=float(score.get("intent") or 0),
|
||||
urgency_score=float(score.get("urgency") or 0),
|
||||
risk_score=float(score.get("risk") or 0),
|
||||
total_score=float(score.get("total") or 0),
|
||||
priority=str(score.get("priority") or "P3")[:8],
|
||||
recommended_channel=score.get("recommended_channel"),
|
||||
reason=score.get("reason"),
|
||||
))
|
||||
acc.data_quality_score = float(result.get("data_quality", {}).get("score", 0))
|
||||
acc.status = "enriched"
|
||||
acc.updated_at = _utcnow()
|
||||
results.append({
|
||||
"id": acc.id, "status": "ok",
|
||||
"score": score, "dq": result.get("data_quality"),
|
||||
"providers_used": result.get("providers_used"),
|
||||
})
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "commit_failed", "error": str(exc), "items": results}
|
||||
|
||||
return {"count": len(results), "items": results}
|
||||
337
dealix/api/routers/outreach.py
Normal file
337
dealix/api/routers/outreach.py
Normal file
@ -0,0 +1,337 @@
|
||||
"""
|
||||
Outreach preparation router.
|
||||
|
||||
POST /api/v1/outreach/prepare-from-data
|
||||
Take enriched accounts + apply suppression + per-channel policy →
|
||||
produce ready/needs_review/blocked counts. Optionally persist to
|
||||
outreach_queue with approval_required=True.
|
||||
|
||||
GET /api/v1/outreach/queue
|
||||
List queue rows.
|
||||
|
||||
POST /api/v1/outreach/queue/{id}/approve
|
||||
Mark a queued message as approved (does NOT auto-send).
|
||||
|
||||
POST /api/v1/outreach/queue/{id}/skip
|
||||
Mark as skipped with reason.
|
||||
|
||||
PDPL & policy guards:
|
||||
- Suppression hit → blocked
|
||||
- opt_out=true on contact → blocked
|
||||
- high risk → needs_review
|
||||
- missing source → needs_review
|
||||
- approval_required=True for cold outbound regardless
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
from sqlalchemy import select
|
||||
|
||||
from db.models import (
|
||||
AccountRecord,
|
||||
ContactRecord,
|
||||
LeadScoreRecord,
|
||||
OutreachQueueRecord,
|
||||
SuppressionRecord,
|
||||
)
|
||||
from db.session import async_session_factory
|
||||
|
||||
router = APIRouter(prefix="/api/v1/outreach", tags=["outreach"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _new_id(prefix: str = "") -> str:
|
||||
suffix = uuid.uuid4().hex[:24]
|
||||
return f"{prefix}{suffix}" if prefix else suffix
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
# Channel policy
|
||||
CHANNEL_DEFAULT_APPROVAL = {
|
||||
"email_warm": True, # always require approval first 30 days
|
||||
"phone_task": True, # human dials anyway
|
||||
"website_form_or_phone_task": True,
|
||||
"in_person_or_phone": True,
|
||||
"linkedin_manual": True, # never auto, always human
|
||||
"whatsapp_inbound_only": True, # never cold WhatsApp
|
||||
"needs_enrichment": True,
|
||||
}
|
||||
|
||||
|
||||
def _build_message_template(account: dict[str, Any], score: dict[str, Any]) -> str:
|
||||
"""
|
||||
Generate a Khaliji opening message based on account + score.
|
||||
Deterministic — no LLM. Replace later with LLM-generated personalization.
|
||||
"""
|
||||
name = account.get("company_name") or "فريقكم"
|
||||
sector = account.get("sector") or "نشاطكم"
|
||||
city = account.get("city") or "السعودية"
|
||||
priority = score.get("priority") or "P2"
|
||||
channel = score.get("recommended_channel") or "email"
|
||||
|
||||
if priority == "P0":
|
||||
opening = (
|
||||
f"السلام عليكم، نتابع نشاط {name} في {city} ولاحظنا عدة مؤشرات تخص "
|
||||
f"تسريع التعامل مع leads العربية في {sector}. "
|
||||
"Dealix يخدم نفس القطاع ويرد خلال 45 ثانية بالعربي الخليجي مع التزام PDPL. "
|
||||
"تناسبكم 20 دقيقة هذا الأسبوع نوضح كيف يطبق على وضعكم؟"
|
||||
)
|
||||
elif priority == "P1":
|
||||
opening = (
|
||||
f"مرحباً، Dealix منصة AI sales rep بالعربي الخليجي تخدم شركات {sector} في {city}. "
|
||||
"نرد على leads خلال 45 ثانية ونحجز demos تلقائياً. "
|
||||
"هل عندكم تحدي حالي مع وقت الرد على leads؟"
|
||||
)
|
||||
else:
|
||||
opening = (
|
||||
f"السلام عليكم {name}، نقدم AI sales rep بالعربي للسوق السعودي. "
|
||||
"رغبت أعرف هل تواجهون تحدي مع وقت الرد على العملاء الجدد بعد التواصل الأولي؟"
|
||||
)
|
||||
|
||||
opening += f"\n\n— Sami | Dealix\nhttps://dealix.me\nالقناة المقترحة: {channel} | الأولوية: {priority}"
|
||||
return opening
|
||||
|
||||
|
||||
@router.post("/prepare-from-data")
|
||||
async def prepare_from_data(body: dict[str, Any] = Body(default={})) -> dict[str, Any]:
|
||||
"""
|
||||
Walk enriched accounts and produce an outreach plan.
|
||||
|
||||
Body:
|
||||
priority: filter by P0/P1/P2/P3 (default: all P0+P1)
|
||||
max_accounts: int (default 50)
|
||||
persist: bool (default False) — actually create OutreachQueueRecord rows
|
||||
channels: list[str] (default: all)
|
||||
"""
|
||||
priorities = body.get("priority") or ["P0", "P1"]
|
||||
if isinstance(priorities, str):
|
||||
priorities = [priorities]
|
||||
max_accounts = int(body.get("max_accounts") or 50)
|
||||
persist = bool(body.get("persist", False))
|
||||
allowed_channels = body.get("channels")
|
||||
|
||||
if max_accounts < 1 or max_accounts > 500:
|
||||
raise HTTPException(400, "max_accounts_out_of_range")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
# Get enriched accounts with their latest scores
|
||||
accounts = (await session.execute(
|
||||
select(AccountRecord).where(AccountRecord.status == "enriched")
|
||||
.limit(max_accounts * 3) # over-fetch then filter
|
||||
)).scalars().all()
|
||||
|
||||
scores = (await session.execute(
|
||||
select(LeadScoreRecord)
|
||||
.where(LeadScoreRecord.account_id.in_([a.id for a in accounts]))
|
||||
)).scalars().all()
|
||||
score_map: dict[str, LeadScoreRecord] = {}
|
||||
for s in scores:
|
||||
if s.account_id not in score_map or s.created_at > score_map[s.account_id].created_at:
|
||||
score_map[s.account_id] = s
|
||||
|
||||
contacts = (await session.execute(
|
||||
select(ContactRecord).where(
|
||||
ContactRecord.account_id.in_([a.id for a in accounts])
|
||||
)
|
||||
)).scalars().all()
|
||||
contacts_by_acc: dict[str, list[ContactRecord]] = {}
|
||||
for c in contacts:
|
||||
contacts_by_acc.setdefault(c.account_id, []).append(c)
|
||||
|
||||
suppressed = (await session.execute(select(SuppressionRecord))).scalars().all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
sup_emails = {s.email for s in suppressed if s.email}
|
||||
sup_phones = {s.phone for s in suppressed if s.phone}
|
||||
sup_domains = {s.domain for s in suppressed if s.domain}
|
||||
|
||||
ready: list[dict[str, Any]] = []
|
||||
needs_review: list[dict[str, Any]] = []
|
||||
blocked: list[dict[str, Any]] = []
|
||||
queue_rows: list[OutreachQueueRecord] = []
|
||||
|
||||
for acc in accounts:
|
||||
score = score_map.get(acc.id)
|
||||
if not score:
|
||||
continue
|
||||
if score.priority not in priorities:
|
||||
continue
|
||||
|
||||
channel = score.recommended_channel
|
||||
if allowed_channels and channel not in allowed_channels:
|
||||
continue
|
||||
|
||||
account_payload = {
|
||||
"id": acc.id, "company_name": acc.company_name,
|
||||
"domain": acc.domain, "website": acc.website,
|
||||
"city": acc.city, "sector": acc.sector,
|
||||
}
|
||||
score_payload = {
|
||||
"fit": score.fit_score, "intent": score.intent_score,
|
||||
"total": score.total_score, "priority": score.priority,
|
||||
"recommended_channel": channel, "reason": score.reason,
|
||||
}
|
||||
|
||||
ac_contacts = contacts_by_acc.get(acc.id, [])
|
||||
block_reasons: list[str] = []
|
||||
review_reasons: list[str] = []
|
||||
|
||||
# Suppression check
|
||||
if acc.domain and acc.domain in sup_domains:
|
||||
block_reasons.append("domain_suppressed")
|
||||
for c in ac_contacts:
|
||||
if c.opt_out:
|
||||
block_reasons.append("contact_opted_out")
|
||||
if c.email and c.email in sup_emails:
|
||||
block_reasons.append("email_suppressed")
|
||||
if c.phone and c.phone in sup_phones:
|
||||
block_reasons.append("phone_suppressed")
|
||||
|
||||
# Risk gates
|
||||
if (acc.risk_level or "").lower() == "high":
|
||||
review_reasons.append("high_risk_level")
|
||||
if not (acc.extra or {}).get("allowed_use"):
|
||||
review_reasons.append("missing_allowed_use")
|
||||
if not channel or channel == "needs_enrichment":
|
||||
review_reasons.append("needs_enrichment")
|
||||
|
||||
if block_reasons:
|
||||
blocked.append({
|
||||
"account_id": acc.id, "company": acc.company_name,
|
||||
"priority": score.priority, "reasons": block_reasons,
|
||||
})
|
||||
continue
|
||||
|
||||
# Build the message
|
||||
message = _build_message_template(account_payload, score_payload)
|
||||
|
||||
entry = {
|
||||
"account_id": acc.id, "company": acc.company_name,
|
||||
"channel": channel, "priority": score.priority,
|
||||
"score": score.total_score, "message": message,
|
||||
"approval_required": CHANNEL_DEFAULT_APPROVAL.get(channel or "", True),
|
||||
"due_at": (_utcnow() + timedelta(hours=2)).isoformat(),
|
||||
}
|
||||
|
||||
if review_reasons:
|
||||
entry["review_reasons"] = review_reasons
|
||||
needs_review.append(entry)
|
||||
else:
|
||||
ready.append(entry)
|
||||
if persist:
|
||||
queue_rows.append(OutreachQueueRecord(
|
||||
id=_new_id("oq_"),
|
||||
lead_id=acc.id,
|
||||
channel=channel or "manual",
|
||||
message=message,
|
||||
approval_required=True, # always require for first 30 days
|
||||
status="queued",
|
||||
due_at=_utcnow() + timedelta(hours=2),
|
||||
risk_reason=None,
|
||||
))
|
||||
|
||||
if len(ready) + len(needs_review) >= max_accounts:
|
||||
break
|
||||
|
||||
if persist and queue_rows:
|
||||
for q in queue_rows:
|
||||
session.add(q)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "commit_failed", "error": str(exc)}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"filters": {"priorities": priorities, "channels": allowed_channels},
|
||||
"ready_count": len(ready),
|
||||
"needs_review_count": len(needs_review),
|
||||
"blocked_count": len(blocked),
|
||||
"persisted": persist and bool(queue_rows),
|
||||
"ready": ready,
|
||||
"needs_review": needs_review,
|
||||
"blocked": blocked,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/queue")
|
||||
async def list_queue(status: str | None = None, limit: int = 100) -> dict[str, Any]:
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
q = select(OutreachQueueRecord).order_by(OutreachQueueRecord.due_at).limit(min(500, limit))
|
||||
if status:
|
||||
q = q.where(OutreachQueueRecord.status == status)
|
||||
rows = (await session.execute(q)).scalars().all()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc), "items": []}
|
||||
return {
|
||||
"count": len(rows),
|
||||
"items": [
|
||||
{
|
||||
"id": r.id, "lead_id": r.lead_id, "channel": r.channel,
|
||||
"message": r.message, "approval_required": r.approval_required,
|
||||
"status": r.status, "due_at": r.due_at.isoformat(),
|
||||
"sent_at": r.sent_at.isoformat() if r.sent_at else None,
|
||||
"risk_reason": r.risk_reason,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/queue/{queue_id}/approve")
|
||||
async def approve_queue(queue_id: str) -> dict[str, Any]:
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
q = (await session.execute(
|
||||
select(OutreachQueueRecord).where(OutreachQueueRecord.id == queue_id)
|
||||
)).scalar_one_or_none()
|
||||
if not q:
|
||||
raise HTTPException(404, "queue_not_found")
|
||||
q.status = "approved"
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "commit_failed", "error": str(exc)}
|
||||
return {"id": queue_id, "status": "approved"}
|
||||
|
||||
|
||||
@router.post("/queue/{queue_id}/skip")
|
||||
async def skip_queue(queue_id: str, body: dict[str, Any] = Body(default={})) -> dict[str, Any]:
|
||||
reason = str(body.get("reason") or "manual_skip")[:255]
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
q = (await session.execute(
|
||||
select(OutreachQueueRecord).where(OutreachQueueRecord.id == queue_id)
|
||||
)).scalar_one_or_none()
|
||||
if not q:
|
||||
raise HTTPException(404, "queue_not_found")
|
||||
q.status = "skipped"
|
||||
q.risk_reason = reason
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "commit_failed", "error": str(exc)}
|
||||
return {"id": queue_id, "status": "skipped", "reason": reason}
|
||||
146
dealix/api/routers/personal_operator.py
Normal file
146
dealix/api/routers/personal_operator.py
Normal file
@ -0,0 +1,146 @@
|
||||
"""Arabic Personal Strategic Operator endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
|
||||
from auto_client_acquisition.personal_operator import (
|
||||
ApprovalDecision,
|
||||
build_daily_brief,
|
||||
default_sami_profile,
|
||||
draft_follow_up,
|
||||
draft_intro_message,
|
||||
suggest_opportunities,
|
||||
)
|
||||
from auto_client_acquisition.personal_operator.launch_report import build_launch_report
|
||||
from auto_client_acquisition.personal_operator.operator import apply_decision, launch_readiness_score
|
||||
from auto_client_acquisition.v3.project_intelligence import answer_operator_question, explain_project_intelligence_stack
|
||||
|
||||
router = APIRouter(prefix="/api/v1/personal-operator", tags=["personal-operator"])
|
||||
|
||||
|
||||
def _opportunity_by_id(opportunity_id: str):
|
||||
for opportunity in suggest_opportunities(default_sami_profile()):
|
||||
if opportunity.id == opportunity_id:
|
||||
return opportunity
|
||||
opportunities = suggest_opportunities(default_sami_profile())
|
||||
return opportunities[0] if opportunities else None
|
||||
|
||||
|
||||
def _parse_decision(raw: Any) -> ApprovalDecision:
|
||||
try:
|
||||
return ApprovalDecision(str(raw).lower().strip())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="invalid_decision") from None
|
||||
|
||||
|
||||
@router.get("/daily-brief")
|
||||
async def daily_brief() -> dict[str, Any]:
|
||||
"""Arabic executive daily brief for Sami."""
|
||||
return build_daily_brief(default_sami_profile()).to_dict()
|
||||
|
||||
|
||||
@router.get("/opportunities")
|
||||
async def opportunities() -> dict[str, Any]:
|
||||
items = suggest_opportunities(default_sami_profile())
|
||||
return {"count": len(items), "items": [item.to_card() for item in items]}
|
||||
|
||||
|
||||
@router.post("/opportunities")
|
||||
async def create_contextual_opportunities(body: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
"""Return operator opportunities with optional context."""
|
||||
items = suggest_opportunities(default_sami_profile())
|
||||
return {
|
||||
"context_received": body,
|
||||
"count": len(items),
|
||||
"items": [item.to_card() for item in items],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/opportunities/{opportunity_id}/decision")
|
||||
async def decide_opportunity(opportunity_id: str, body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
opportunity = _opportunity_by_id(opportunity_id)
|
||||
if not opportunity:
|
||||
raise HTTPException(status_code=404, detail="opportunity_not_found")
|
||||
decision = _parse_decision(body.get("decision", "draft"))
|
||||
result = apply_decision(opportunity, decision)
|
||||
approval_required = bool(result.get("approval_required", decision != ApprovalDecision.SKIP))
|
||||
next_action = str(result.get("next_action", "none"))
|
||||
return {
|
||||
"opportunity": opportunity.to_card(),
|
||||
"decision": decision.value,
|
||||
"result": result,
|
||||
"approval_required": approval_required,
|
||||
"next_action": next_action,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/messages/draft")
|
||||
async def draft_message(body: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
opportunities = suggest_opportunities(default_sami_profile())
|
||||
selected = opportunities[0]
|
||||
if body.get("opportunity_id"):
|
||||
selected = _opportunity_by_id(str(body["opportunity_id"])) or selected
|
||||
tone = str(body.get("tone", "warm"))
|
||||
return draft_intro_message(selected, tone=tone)
|
||||
|
||||
|
||||
@router.post("/followups/draft")
|
||||
async def followup(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return draft_follow_up(
|
||||
meeting_title=str(body.get("meeting_title", "اجتماع Dealix")),
|
||||
outcome=str(body.get("outcome", "اتفقنا على مراجعة الفكرة وإرسال ملخص")),
|
||||
next_step=str(body.get("next_step", "إرسال ملخص تنفيذي وتجربة قصيرة")),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/project/intelligence")
|
||||
async def project_intelligence() -> dict[str, Any]:
|
||||
return explain_project_intelligence_stack()
|
||||
|
||||
|
||||
@router.post("/project/ask")
|
||||
async def ask_project(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
question = str(body.get("question", "وش ناقص المشروع؟"))
|
||||
deep = bool(body.get("deep_scan", False))
|
||||
root = str(body.get("root", "."))
|
||||
answered = answer_operator_question(question, root=root, deep_scan=deep)
|
||||
readiness = launch_readiness_score()
|
||||
return {
|
||||
"question": question,
|
||||
"answer_ar": answered["answer_ar"],
|
||||
"semantic_status_ar": answered["semantic_status_ar"],
|
||||
"related_files": answered["related_files"],
|
||||
"search_hits": answered.get("search_hits", []),
|
||||
"launch_readiness": readiness,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/launch-readiness")
|
||||
async def launch_readiness() -> dict[str, Any]:
|
||||
return launch_readiness_score()
|
||||
|
||||
|
||||
@router.get("/launch-report")
|
||||
async def launch_report() -> dict[str, Any]:
|
||||
return build_launch_report().to_dict()
|
||||
|
||||
|
||||
@router.post("/meetings/schedule-draft")
|
||||
async def schedule_draft(body: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "calendar_draft_ready",
|
||||
"approval_required": True,
|
||||
"title": body.get("title", "Dealix Strategic Intro"),
|
||||
"duration": int(body.get("duration_minutes", 30)),
|
||||
"duration_minutes": int(body.get("duration_minutes", 30)),
|
||||
"agenda_ar": [
|
||||
"تعريف سريع بـ Dealix",
|
||||
"أخذ رأي الشخص في التموضع والسوق",
|
||||
"تحديد فرصة تعاون أو intro قادمة",
|
||||
],
|
||||
"note_ar": "هذا المسار يجهز payload الاجتماع فقط. إنشاء حدث في Google Calendar يتطلب موافقة صريحة وطبقة تكامل.",
|
||||
"note": "This endpoint prepares the meeting payload. Actual Google Calendar creation should only happen after approval.",
|
||||
}
|
||||
174
dealix/api/routers/pricing.py
Normal file
174
dealix/api/routers/pricing.py
Normal file
@ -0,0 +1,174 @@
|
||||
"""
|
||||
Pricing + Moyasar checkout endpoints.
|
||||
|
||||
Usage:
|
||||
POST /api/v1/checkout body: {"plan":"starter","email":"x@y.com","lead_id":"optional"}
|
||||
→ returns {"invoice_id":"...", "payment_url":"https://..."}
|
||||
POST /api/v1/webhooks/moyasar — Moyasar payment webhook (status updates)
|
||||
|
||||
Plans are intentionally NOT published on the public landing page; the checkout
|
||||
endpoint validates against `ALLOWED_PLANS` to prevent tampering.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from dealix.payments import MoyasarClient, verify_webhook
|
||||
from dealix.reliability.dlq import DLQ, WEBHOOKS_DLQ
|
||||
from dealix.reliability.idempotency import IdempotencyStore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["pricing"])
|
||||
|
||||
|
||||
def _fingerprint(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
# Prices in halalas (SAR x 100). Hidden from landing — only exposed when a lead qualifies.
|
||||
PLANS: dict[str, dict[str, Any]] = {
|
||||
"starter": {
|
||||
"name": "Starter",
|
||||
"amount_halalas": 99900,
|
||||
"monthly": True,
|
||||
}, # 999 SAR/mo
|
||||
"growth": {
|
||||
"name": "Growth",
|
||||
"amount_halalas": 299900,
|
||||
"monthly": True,
|
||||
}, # 2,999 SAR/mo
|
||||
"scale": {
|
||||
"name": "Scale",
|
||||
"amount_halalas": 799900,
|
||||
"monthly": True,
|
||||
}, # 7,999 SAR/mo
|
||||
"pilot_1sar": {
|
||||
"name": "Pilot (1 SAR)",
|
||||
"amount_halalas": 100,
|
||||
"monthly": False,
|
||||
}, # E2E test transaction
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v1/pricing/plans")
|
||||
async def list_plans() -> dict[str, Any]:
|
||||
"""List available plans. Not linked from landing — required for approval-gated quotes."""
|
||||
return {
|
||||
"currency": "SAR",
|
||||
"plans": {
|
||||
k: {
|
||||
"name": v["name"],
|
||||
"amount_sar": v["amount_halalas"] / 100,
|
||||
"monthly": v["monthly"],
|
||||
}
|
||||
for k, v in PLANS.items()
|
||||
if k != "pilot_1sar" # hide pilot from public listing
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/v1/checkout")
|
||||
async def create_checkout(req: Request) -> dict[str, Any]:
|
||||
body = await req.json()
|
||||
plan = str(body.get("plan") or "").lower()
|
||||
email = str(body.get("email") or "").strip()
|
||||
lead_id = str(body.get("lead_id") or "")
|
||||
|
||||
if plan not in PLANS:
|
||||
raise HTTPException(status_code=400, detail=f"unknown_plan: {plan}")
|
||||
if "@" not in email:
|
||||
raise HTTPException(status_code=400, detail="invalid_email")
|
||||
|
||||
plan_info = PLANS[plan]
|
||||
callback_base = os.getenv("APP_URL", "https://dealix.me")
|
||||
callback_url = f"{callback_base}/checkout/return"
|
||||
|
||||
client = MoyasarClient()
|
||||
try:
|
||||
invoice = await client.create_invoice(
|
||||
amount_halalas=int(plan_info["amount_halalas"]),
|
||||
currency="SAR",
|
||||
description=f"Dealix — {plan_info['name']}",
|
||||
callback_url=callback_url,
|
||||
metadata={
|
||||
"plan": plan,
|
||||
"email": email,
|
||||
"lead_id": lead_id,
|
||||
"source": "dealix.checkout",
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
log.exception(
|
||||
"moyasar_invoice_failed plan=%s email_fp=%s",
|
||||
plan,
|
||||
_fingerprint(email),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="payment_provider_error",
|
||||
) from exc
|
||||
|
||||
return {
|
||||
"invoice_id": invoice.get("id"),
|
||||
"status": invoice.get("status"),
|
||||
"amount_sar": plan_info["amount_halalas"] / 100,
|
||||
"payment_url": invoice.get("url"),
|
||||
"plan": plan,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/v1/webhooks/moyasar")
|
||||
async def moyasar_webhook(req: Request) -> dict[str, Any]:
|
||||
"""
|
||||
Moyasar payment webhook. Verifies secret_token in body and dedupes by event id.
|
||||
Failed processing → DLQ(webhooks) for operator replay.
|
||||
"""
|
||||
try:
|
||||
body = await req.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail="invalid_json") from exc
|
||||
|
||||
if not verify_webhook(body):
|
||||
log.warning("moyasar_webhook_bad_signature")
|
||||
raise HTTPException(status_code=401, detail="bad_signature")
|
||||
|
||||
event_id = str(body.get("id") or "")
|
||||
event_type = str(body.get("type") or "")
|
||||
event_fp = _fingerprint(event_id)
|
||||
idem = IdempotencyStore(prefix="idem:moyasar:")
|
||||
if event_id and not idem.claim(event_id, ttl_seconds=7 * 86400):
|
||||
log.info("moyasar_webhook_duplicate event_fp=%s", event_fp)
|
||||
return {"status": "duplicate", "id": event_id}
|
||||
|
||||
try:
|
||||
data = body.get("data") or {}
|
||||
payment = data if data.get("object") in (None, "payment", "invoice") else {}
|
||||
status = payment.get("status") or body.get("type")
|
||||
log.info(
|
||||
"moyasar_webhook_processed event_fp=%s type=%s status=%s amount=%s",
|
||||
event_fp,
|
||||
event_type,
|
||||
status,
|
||||
payment.get("amount"),
|
||||
)
|
||||
# TODO: sync to HubSpot via ConnectorFacade in D+2 E2E test
|
||||
return {"status": "ok", "event_id": event_id, "event_type": event_type}
|
||||
except Exception as exc:
|
||||
log.exception("moyasar_webhook_processing_failed event_fp=%s", event_fp)
|
||||
DLQ(WEBHOOKS_DLQ).push(
|
||||
source="moyasar.webhook",
|
||||
payload=body,
|
||||
error=str(exc)[:500],
|
||||
metadata={"event_id": event_id, "event_type": event_type},
|
||||
)
|
||||
# Still 200 so Moyasar doesn't retry forever; we own replay via DLQ.
|
||||
return {"status": "dlq", "event_id": event_id}
|
||||
792
dealix/api/routers/prospect.py
Normal file
792
dealix/api/routers/prospect.py
Normal file
@ -0,0 +1,792 @@
|
||||
"""
|
||||
Prospect discovery endpoint — public, rate-limited.
|
||||
|
||||
POST /api/v1/prospect/discover
|
||||
body: {"icp": str, "use_case": "sales|partnership|collaboration|investor|b2c_audience", "count": 10}
|
||||
returns: ProspectResult JSON
|
||||
|
||||
POST /api/v1/prospect/demo
|
||||
returns: a canned demo result (no LLM call) for instant landing UI preview
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
|
||||
from auto_client_acquisition.agents.prospector import (
|
||||
MAX_COUNT,
|
||||
USE_CASES,
|
||||
ProspectorAgent,
|
||||
)
|
||||
from auto_client_acquisition.agents.rules_router import (
|
||||
generate_messages as _rules_generate_messages,
|
||||
route_account as _rules_route,
|
||||
)
|
||||
from auto_client_acquisition.connectors.google_search import google_search
|
||||
from auto_client_acquisition.connectors.tech_detect import detect_stack, extract_contact_info
|
||||
|
||||
router = APIRouter(prefix="/api/v1/prospect", tags=["prospect"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_agent = ProspectorAgent()
|
||||
|
||||
|
||||
@router.get("/use-cases")
|
||||
async def list_use_cases() -> dict[str, Any]:
|
||||
return {"use_cases": USE_CASES, "max_count": MAX_COUNT}
|
||||
|
||||
|
||||
@router.post("/discover")
|
||||
async def discover(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
icp = str(body.get("icp") or "").strip()
|
||||
use_case = str(body.get("use_case") or "sales").strip().lower()
|
||||
count = int(body.get("count") or 10)
|
||||
|
||||
if len(icp) < 20:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="icp_too_short: provide at least 20 characters describing your ideal customer",
|
||||
)
|
||||
if len(icp) > 2000:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="icp_too_long: keep ICP under 2000 characters",
|
||||
)
|
||||
if use_case not in USE_CASES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"unknown_use_case: {use_case}. Valid: {list(USE_CASES.keys())}",
|
||||
)
|
||||
if count < 1 or count > MAX_COUNT:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"count_out_of_range: 1..{MAX_COUNT}",
|
||||
)
|
||||
|
||||
try:
|
||||
result = await _agent.run(icp=icp, use_case=use_case, count=count)
|
||||
except Exception as exc:
|
||||
log.warning("prospector_llm_unavailable use_case=%s — serving degraded rules mode", use_case)
|
||||
# Degraded mode: serve the canned demo with a status flag
|
||||
demo_resp = await demo()
|
||||
demo_resp["status"] = "degraded"
|
||||
demo_resp["reason"] = "missing_llm_key"
|
||||
demo_resp["hint"] = "Add GROQ_API_KEY (or ANTHROPIC_API_KEY) in Railway env 'Dealix' service 'web' to enable live discovery."
|
||||
demo_resp["error_type"] = type(exc).__name__
|
||||
return demo_resp
|
||||
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
@router.get("/search-diag")
|
||||
async def search_diag() -> dict[str, Any]:
|
||||
"""Diagnose env var presence without revealing values."""
|
||||
import os
|
||||
def _diag(value: str) -> dict[str, Any]:
|
||||
return {"set": bool(value), "length": len(value)}
|
||||
|
||||
k = os.getenv("GOOGLE_SEARCH_API_KEY", "")
|
||||
c = os.getenv("GOOGLE_SEARCH_CX", "")
|
||||
gm = os.getenv("GOOGLE_MAPS_API_KEY", "")
|
||||
tav = os.getenv("TAVILY_API_KEY", "")
|
||||
fc = os.getenv("FIRECRAWL_API_KEY", "")
|
||||
hu = os.getenv("HUNTER_API_KEY", "")
|
||||
ab = os.getenv("ABSTRACT_API_KEY", "")
|
||||
wp = os.getenv("WAPPALYZER_API_KEY", "")
|
||||
serp = os.getenv("SERPAPI_API_KEY", "")
|
||||
apify = os.getenv("APIFY_TOKEN", "")
|
||||
grq = os.getenv("GROQ_API_KEY", "")
|
||||
ant = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
oai = os.getenv("OPENAI_API_KEY", "")
|
||||
sd = os.getenv("SENTRY_DSN", "")
|
||||
db = os.getenv("DATABASE_URL", "")
|
||||
sg = os.getenv("SENDGRID_API_KEY", "")
|
||||
wa = os.getenv("WHATSAPP_ACCESS_TOKEN", "")
|
||||
m = os.getenv("MOYASAR_SECRET_KEY", "")
|
||||
w = os.getenv("MOYASAR_WEBHOOK_SECRET", "")
|
||||
|
||||
# All env vars whose names start with target prefixes — helps detect typos
|
||||
related = sorted([
|
||||
name for name in os.environ.keys()
|
||||
if name.startswith((
|
||||
"GOOGLE_", "MOYASAR_", "ANTHROPIC_", "OPENAI_", "GROQ_", "POSTHOG_",
|
||||
"SENTRY_", "DATABASE_", "TAVILY_", "FIRECRAWL_", "HUNTER_", "ABSTRACT_",
|
||||
"WAPPALYZER_", "SERPAPI_", "APIFY_", "SENDGRID_", "WHATSAPP_",
|
||||
"APP_URL", "PORT", "RAILWAY_",
|
||||
))
|
||||
])
|
||||
|
||||
# Tier readiness summary
|
||||
tier1_ready = bool(db) and bool(grq or ant or oai) and bool(k and c) and bool(sd)
|
||||
tier2_ready = bool(gm) and (bool(tav) or bool(fc) or bool(hu))
|
||||
|
||||
return {
|
||||
# ── Layer 1 — Required now ──
|
||||
"DATABASE_URL": _diag(db),
|
||||
"GOOGLE_SEARCH_API_KEY": {**_diag(k), "prefix": (k[:6] + "...") if k else ""},
|
||||
"GOOGLE_SEARCH_CX": {**_diag(c), "prefix": (c[:6] + "...") if c else ""},
|
||||
"GROQ_API_KEY": _diag(grq),
|
||||
"ANTHROPIC_API_KEY": _diag(ant),
|
||||
"OPENAI_API_KEY": _diag(oai),
|
||||
"SENTRY_DSN": _diag(sd),
|
||||
# ── Layer 2 — Lead discovery power ──
|
||||
"GOOGLE_MAPS_API_KEY": {**_diag(gm), "prefix": (gm[:6] + "...") if gm else ""},
|
||||
"TAVILY_API_KEY": _diag(tav),
|
||||
"FIRECRAWL_API_KEY": _diag(fc),
|
||||
"HUNTER_API_KEY": _diag(hu),
|
||||
"ABSTRACT_API_KEY": _diag(ab),
|
||||
"WAPPALYZER_API_KEY": _diag(wp),
|
||||
"SERPAPI_API_KEY": _diag(serp),
|
||||
"APIFY_TOKEN": _diag(apify),
|
||||
# ── Layer 3 — Channels ──
|
||||
"SENDGRID_API_KEY": _diag(sg),
|
||||
"WHATSAPP_ACCESS_TOKEN": _diag(wa),
|
||||
# ── Payments ──
|
||||
"MOYASAR_SECRET_KEY": {**_diag(m), "prefix": (m[:6] + "...") if m else ""},
|
||||
"MOYASAR_WEBHOOK_SECRET":_diag(w),
|
||||
# ── Tier readiness summary ──
|
||||
"tier1_ready": tier1_ready,
|
||||
"tier2_ready": tier2_ready,
|
||||
"all_visible_env_var_names_starting_with_known_prefixes": related,
|
||||
"railway_environment_name": os.getenv("RAILWAY_ENVIRONMENT_NAME", "(not set)"),
|
||||
"railway_service_name": os.getenv("RAILWAY_SERVICE_NAME", "(not set)"),
|
||||
"railway_project_name": os.getenv("RAILWAY_PROJECT_NAME", "(not set)"),
|
||||
"hint": (
|
||||
"ready_to_launch" if tier1_ready and tier2_ready else
|
||||
"tier1_only" if tier1_ready else
|
||||
"set_DATABASE_URL_first" if not db else
|
||||
"set_GOOGLE_SEARCH_API_KEY_and_CX" if not (k and c) else
|
||||
"set_GROQ_or_ANTHROPIC_or_OPENAI" if not (grq or ant or oai) else
|
||||
"almost_there"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/search")
|
||||
async def search(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Run a Google Custom Search query using server-side keys.
|
||||
Body: {"query": "...", "num": 10, "site": "linkedin.com" (optional), "lang": "ar"|"en"}
|
||||
Returns: SearchResponse JSON.
|
||||
"""
|
||||
q = str(body.get("query") or "").strip()
|
||||
if len(q) < 3 or len(q) > 500:
|
||||
raise HTTPException(status_code=400, detail="query_length_out_of_range")
|
||||
|
||||
num = int(body.get("num") or 10)
|
||||
if num < 1 or num > 10:
|
||||
raise HTTPException(status_code=400, detail="num_out_of_range: 1..10")
|
||||
|
||||
site = body.get("site")
|
||||
site = str(site).strip() if site else None
|
||||
lang = body.get("lang")
|
||||
lang = str(lang).strip().lower() if lang else None
|
||||
if lang and lang not in {"ar", "en", "fr", "es"}:
|
||||
raise HTTPException(status_code=400, detail="unsupported_lang")
|
||||
|
||||
try:
|
||||
resp = await google_search(q, num=num, site=site, lang=lang, timeout=10.0)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("google_search_call_failed q=%r", q)
|
||||
raise HTTPException(status_code=502, detail="search_error") from exc
|
||||
|
||||
if resp.status == "no_keys":
|
||||
raise HTTPException(status_code=503, detail="search_not_configured")
|
||||
|
||||
return resp.to_dict()
|
||||
|
||||
|
||||
@router.post("/enrich-tech")
|
||||
async def enrich_tech(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Detect tech stack for a domain using Dealix native detector (free, self-hosted).
|
||||
Body: {"domain": "foodics.com", "extra_paths": ["/careers", "/contact"]}
|
||||
"""
|
||||
domain = str(body.get("domain") or "").strip()
|
||||
extra = body.get("extra_paths") or []
|
||||
if not isinstance(extra, list):
|
||||
extra = []
|
||||
extra = [str(p)[:80] for p in extra[:5]]
|
||||
|
||||
if not domain or "." not in domain or len(domain) > 200:
|
||||
raise HTTPException(status_code=400, detail="invalid_domain")
|
||||
|
||||
try:
|
||||
result = await detect_stack(domain, timeout=10.0, extra_paths=extra)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("tech_detect_failed domain=%s", domain)
|
||||
raise HTTPException(status_code=502, detail="tech_detect_error") from exc
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
@router.post("/enrich-domain")
|
||||
async def enrich_domain(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
End-to-end enrichment: given a domain + opportunity hint, combine tech stack
|
||||
detection + LLM analysis to return a full lead record per LEAD_OUTPUT_SCHEMA.
|
||||
|
||||
Body:
|
||||
{
|
||||
"domain": "foodics.com",
|
||||
"opportunity_hint": "DIRECT_CUSTOMER|AGENCY_PARTNER|..." (optional),
|
||||
"context_notes": "optional extra human context"
|
||||
}
|
||||
|
||||
Returns: full lead object (opportunity_type, scores, signals, outreach opening, etc.)
|
||||
"""
|
||||
domain = str(body.get("domain") or "").strip()
|
||||
opportunity_hint = str(body.get("opportunity_hint") or "").strip().upper()
|
||||
context_notes = str(body.get("context_notes") or "").strip()[:1000]
|
||||
|
||||
if not domain or "." not in domain or len(domain) > 200:
|
||||
raise HTTPException(status_code=400, detail="invalid_domain")
|
||||
|
||||
# Step 1 — tech detection (free, always available)
|
||||
try:
|
||||
tech = await detect_stack(domain, timeout=10.0, extra_paths=["/careers", "/about"])
|
||||
except Exception:
|
||||
log.exception("tech_detect_failed domain=%s", domain)
|
||||
tech = None
|
||||
|
||||
tech_dict = tech.to_dict() if tech else {"tools": [], "signals": [], "status": "unavailable"}
|
||||
|
||||
# Step 2 — LLM analysis using ProspectorAgent-style prompt but domain-scoped
|
||||
from auto_client_acquisition.agents.prospector import ProspectorAgent, USE_CASES
|
||||
|
||||
agent = ProspectorAgent()
|
||||
icp_text = (
|
||||
f"الشركة: {domain}\n"
|
||||
f"الأدوات المكتشفة عبر tech detector: "
|
||||
f"{', '.join(t['name'] for t in tech_dict.get('tools', []))}\n"
|
||||
f"الإشارات المستخرجة: "
|
||||
f"{', '.join(s['evidence'] for s in tech_dict.get('signals', []))}\n"
|
||||
+ (f"سياق إضافي: {context_notes}\n" if context_notes else "")
|
||||
+ (f"تلميح لنوع الفرصة: {opportunity_hint}\n" if opportunity_hint else "")
|
||||
+ "\nحلّل هذه الشركة تحديداً: صنّف نوع الفرصة، احسب ال 4 scores، اقترح sequence من الخطوات، وأعد نفس شكل JSON كما هو محدد."
|
||||
)
|
||||
use_case = "sales" # default; the LLM will classify opportunity_type freely
|
||||
|
||||
try:
|
||||
result = await agent.run(icp=icp_text, use_case=use_case, count=1)
|
||||
leads = result.leads
|
||||
lead_dict = leads[0].to_dict() if leads else None
|
||||
search_notes = result.search_notes
|
||||
status = "ok"
|
||||
except Exception:
|
||||
log.warning("enrich_domain_llm_unavailable domain=%s — serving tech-only + rules", domain)
|
||||
# Degraded: run rules router over the tech signals to still produce actionable lead
|
||||
signals_for_router = [
|
||||
{"name": s.get("name", ""), "weight": s.get("weight", 0), "evidence": s.get("evidence", "")}
|
||||
for s in tech_dict.get("signals", [])
|
||||
]
|
||||
res = _rules_route(
|
||||
company=domain.split(".")[0].replace("-", " ").title(),
|
||||
sector="",
|
||||
country="SA",
|
||||
domain=domain,
|
||||
signals=signals_for_router,
|
||||
tags="",
|
||||
decision_maker=None,
|
||||
)
|
||||
# Also produce messages deterministically
|
||||
msgs = _rules_generate_messages(
|
||||
company=domain.split(".")[0].replace("-", " ").title(),
|
||||
decision_maker=None,
|
||||
opportunity_type=res.opportunity_type,
|
||||
signals=signals_for_router,
|
||||
)
|
||||
lead_dict = {
|
||||
**res.to_dict(),
|
||||
"company_en": domain.split(".")[0].replace("-", " ").title(),
|
||||
"company_ar": "",
|
||||
"website": f"https://{domain}",
|
||||
"outreach_opening": msgs["linkedin"][:280],
|
||||
"signals": signals_for_router,
|
||||
"confidence": 60,
|
||||
}
|
||||
search_notes = "degraded mode — rules router + tech detect only (no LLM key)"
|
||||
status = "degraded"
|
||||
|
||||
return {
|
||||
"domain": domain,
|
||||
"tech": tech_dict,
|
||||
"lead": lead_dict,
|
||||
"search_notes": search_notes,
|
||||
"fetched_at": tech_dict.get("fetched_at"),
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/route")
|
||||
async def route_endpoint(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Deterministic rule-based router — classify + score + route an account without LLM.
|
||||
Body: {company, sector?, country?, domain?, signals?, tags?, decision_maker?, size_hint?, is_government?, desired_goal?}
|
||||
"""
|
||||
company = str(body.get("company") or "").strip()
|
||||
if not company:
|
||||
raise HTTPException(status_code=400, detail="company_required")
|
||||
res = _rules_route(
|
||||
company=company,
|
||||
sector=str(body.get("sector") or ""),
|
||||
country=str(body.get("country") or ""),
|
||||
domain=str(body.get("domain") or ""),
|
||||
signals=body.get("signals") or [],
|
||||
tags=str(body.get("tags") or ""),
|
||||
decision_maker=body.get("decision_maker"),
|
||||
size_hint=str(body.get("size_hint") or ""),
|
||||
is_government=bool(body.get("is_government") or False),
|
||||
desired_goal=body.get("desired_goal"),
|
||||
)
|
||||
return {"mode": "rules", "result": res.to_dict()}
|
||||
|
||||
|
||||
@router.post("/score")
|
||||
async def score_endpoint(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Score an account against the 100-pt ICP model. Same inputs as /route.
|
||||
Returns only the score breakdown (no messages).
|
||||
"""
|
||||
company = str(body.get("company") or "").strip()
|
||||
if not company:
|
||||
raise HTTPException(status_code=400, detail="company_required")
|
||||
res = _rules_route(
|
||||
company=company,
|
||||
sector=str(body.get("sector") or ""),
|
||||
country=str(body.get("country") or ""),
|
||||
domain=str(body.get("domain") or ""),
|
||||
signals=body.get("signals") or [],
|
||||
tags=str(body.get("tags") or ""),
|
||||
decision_maker=body.get("decision_maker"),
|
||||
size_hint=str(body.get("size_hint") or ""),
|
||||
is_government=bool(body.get("is_government") or False),
|
||||
)
|
||||
r = res.to_dict()
|
||||
return {
|
||||
"company": company,
|
||||
"fit_score": r["fit_score"],
|
||||
"intent_score": r["intent_score"],
|
||||
"access_score": r["access_score"],
|
||||
"revenue_score": r["revenue_score"],
|
||||
"priority_score": r["priority_score"],
|
||||
"priority_tier": r["priority_tier"],
|
||||
"risk_level": r["risk_level"],
|
||||
"opportunity_type": r["opportunity_type"],
|
||||
"reason": r["reason"],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/message")
|
||||
async def message_endpoint(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Generate templated, signal-aware Arabic outreach for an account.
|
||||
Body: {company, decision_maker?, opportunity_type?, signals?}
|
||||
Returns: {linkedin, email, whatsapp_warm_only, follow_up_plus_2/5/10}
|
||||
"""
|
||||
company = str(body.get("company") or "").strip()
|
||||
if not company:
|
||||
raise HTTPException(status_code=400, detail="company_required")
|
||||
|
||||
opp = str(body.get("opportunity_type") or "").strip().upper()
|
||||
if not opp:
|
||||
# Fall back: classify via rules
|
||||
res = _rules_route(
|
||||
company=company,
|
||||
sector=str(body.get("sector") or ""),
|
||||
tags=str(body.get("tags") or ""),
|
||||
signals=body.get("signals") or [],
|
||||
)
|
||||
opp = res.opportunity_type
|
||||
|
||||
msgs = _rules_generate_messages(
|
||||
company=company,
|
||||
decision_maker=body.get("decision_maker"),
|
||||
opportunity_type=opp,
|
||||
signals=body.get("signals") or [],
|
||||
)
|
||||
return {"mode": "rules", "opportunity_type": opp, "messages": msgs}
|
||||
|
||||
|
||||
@router.post("/bulk-enrich")
|
||||
async def bulk_enrich(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Bulk tech-detect enrichment for a list of domains.
|
||||
Body: {"domains": ["foodics.com", "salla.sa", ...], "concurrency": 5}
|
||||
Returns: {"results": {domain: tech_result, ...}, "summary": {...}}
|
||||
|
||||
Hard limit: 25 domains per request (prevent abuse).
|
||||
"""
|
||||
domains_raw = body.get("domains") or []
|
||||
if not isinstance(domains_raw, list):
|
||||
raise HTTPException(status_code=400, detail="domains_must_be_list")
|
||||
|
||||
domains = [str(d).strip() for d in domains_raw if d and "." in str(d)]
|
||||
domains = list(dict.fromkeys(domains))[:25] # dedupe, cap
|
||||
|
||||
if not domains:
|
||||
raise HTTPException(status_code=400, detail="no_valid_domains")
|
||||
|
||||
concurrency = int(body.get("concurrency") or 5)
|
||||
concurrency = max(1, min(10, concurrency))
|
||||
|
||||
import asyncio as _asyncio
|
||||
sem = _asyncio.Semaphore(concurrency)
|
||||
|
||||
async def _one(d: str) -> tuple[str, dict]:
|
||||
async with sem:
|
||||
try:
|
||||
r = await detect_stack(d, timeout=10.0)
|
||||
return d, r.to_dict()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return d, {"status": "error", "error": str(exc), "domain": d}
|
||||
|
||||
pairs = await _asyncio.gather(*(_one(d) for d in domains))
|
||||
results = dict(pairs)
|
||||
|
||||
total_tools = sum(len(r.get("tools", [])) for r in results.values())
|
||||
total_signals = sum(len(r.get("signals", [])) for r in results.values())
|
||||
ok_count = sum(1 for r in results.values() if r.get("status") == "ok")
|
||||
|
||||
return {
|
||||
"summary": {
|
||||
"domains_requested": len(domains),
|
||||
"ok_count": ok_count,
|
||||
"total_tools_detected": total_tools,
|
||||
"total_signals_detected": total_signals,
|
||||
},
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/contacts")
|
||||
async def contacts(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Extract publicly listed contact info (emails, phones, WhatsApp, social) from a company's public pages.
|
||||
LEGAL: public pages only; business contact only; no PII from private / authenticated sources.
|
||||
Body: {"domain": "foodics.com"}
|
||||
"""
|
||||
domain = str(body.get("domain") or "").strip()
|
||||
if not domain or "." not in domain or len(domain) > 200:
|
||||
raise HTTPException(status_code=400, detail="invalid_domain")
|
||||
try:
|
||||
return await extract_contact_info(domain, timeout=10.0)
|
||||
except Exception as exc:
|
||||
log.exception("contacts_failed domain=%s", domain)
|
||||
raise HTTPException(status_code=502, detail="contact_extraction_error") from exc
|
||||
|
||||
|
||||
@router.post("/inbound/handle")
|
||||
async def inbound_handle(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Autonomous inbound handler — given an incoming lead message, classify, decide,
|
||||
generate Arabic response, pick next_action.
|
||||
Body:
|
||||
{
|
||||
"channel": "whatsapp|email|web_chat|linkedin|sms",
|
||||
"from": "+966501234567" | "ali@example.com",
|
||||
"company": "optional — extracted from domain if known",
|
||||
"message": "the actual customer inquiry"
|
||||
}
|
||||
Returns:
|
||||
{
|
||||
"classification": "interested|price|demo|later|objection|...",
|
||||
"opportunity_type": "DIRECT_CUSTOMER|...",
|
||||
"response_ar": "...",
|
||||
"next_action": "BOOK_DEMO|PREPARE_DM|...",
|
||||
"should_escalate_to_human": bool,
|
||||
"tracker_update": {status, sent_at, next_followup}
|
||||
}
|
||||
"""
|
||||
channel = str(body.get("channel") or "unknown").lower()
|
||||
sender = str(body.get("from") or "").strip()
|
||||
company = str(body.get("company") or "").strip()
|
||||
message = str(body.get("message") or "").strip()
|
||||
|
||||
if len(message) < 2:
|
||||
raise HTTPException(status_code=400, detail="message_required")
|
||||
|
||||
# Very simple offline classifier (same regex rules from scripts/dealix_reply_classifier.py)
|
||||
import re
|
||||
text = message.lower()
|
||||
classification = "interested" # default
|
||||
rules = [
|
||||
("wants_demo", r"demo|ديمو|عرض|تجربة"),
|
||||
("price", r"كم\s*(السعر|يكلف|المبلغ)|السعر|price|pricing|كم\s*ريال"),
|
||||
("send_details", r"ارسل|أرسل|تفاصيل|details|deck|presentation"),
|
||||
("later", r"بعدين|لاحق|later|not\s*now|رمضان"),
|
||||
("opt_out", r"أوقف|إيقاف|stop|unsubscribe|لا\s*شكراً|انهاء"),
|
||||
("arabic_concern", r"العربي|عربي\s*(مضبوط|طبيعي|سيء|سيئ|رديء)|arabic.*quality|خليجي|لهجة"),
|
||||
("not_relevant", r"مو\s*مناسب|not\s*relevant|غير\s*مناسب|لا\s*نحتاج"),
|
||||
("budget_objection", r"ميزانية|budget|غالي|مكلف"),
|
||||
("already_has_crm", r"crm|salesforce|hubspot|zoho"),
|
||||
("arabic_concern", r"لهجة|arabic.*quality|خليجي"),
|
||||
("privacy_concern", r"خصوصية|pdpl|privacy|بيانات"),
|
||||
("partnership_interest",r"شراكة|partner|وكالة|reseller"),
|
||||
("referral_opportunity",r"أعرف|رشح|referral|intro"),
|
||||
]
|
||||
for cat, pat in rules:
|
||||
if re.search(pat, text):
|
||||
classification = cat
|
||||
break
|
||||
# If very short greeting, treat as interested
|
||||
if len(text) < 10 and any(g in text for g in ("مرحب", "سلام", "هلا", "hi", "hello")):
|
||||
classification = "interested"
|
||||
|
||||
# Decide opportunity type from company name keywords
|
||||
opp_type = "DIRECT_CUSTOMER"
|
||||
if any(k in (company or "").lower() for k in ["agency", "وكالة", "marketing"]):
|
||||
opp_type = "AGENCY_PARTNER"
|
||||
elif any(k in (company or "").lower() for k in ["vc", "capital", "ventures", "fund"]):
|
||||
opp_type = "INVESTOR_OR_ADVISOR"
|
||||
|
||||
# Build response
|
||||
CAL = "https://calendly.com/sami-assiri11/dealix-demo"
|
||||
responses = {
|
||||
"opt_out": "تمام، تم إيقاف الرسائل. شكراً لوقتك.",
|
||||
"interested": f"هلا! شكراً على اهتمامك. خلني أحجز معك 20 دقيقة demo بدون أي التزام — تقدر تختار موعدك هنا: {CAL}",
|
||||
"wants_demo": f"ممتاز، نسوي demo. 20 دقيقة، اختار موعد: {CAL}",
|
||||
"price": f"Starter 999/شهر، Growth 2,999، Scale 7,999. في pilot بريال × 7 أيام بدون التزام. 20 دقيقة demo أفصّل الباقة المناسبة: {CAL}",
|
||||
"send_details": f"تفاصيل سريعة: Dealix = AI sales rep بالعربي الخليجي، يرد على leads خلال 45 ثانية، يؤهّل، ويحجز demos. الأفضل نشوفه معاً في 20 دقيقة على سيناريو شركتكم: {CAL}\nأو تصفح: https://dealix.me",
|
||||
"later": "تمام. متى الوقت المناسب يحتمل يكون؟ سأرجع في نفس اليوم بالظبط.",
|
||||
"not_relevant": "أحترم ذلك. سؤال أخير: هل تعرف شخص/شركة سعودية قد تستفيد من AI sales rep بالعربي؟ 10% من MRR لـ 12 شهر لكل referral. شكراً على وقتك.",
|
||||
"budget_objection": "أفهم. عرضنا pilot بريال واحد × 7 أيام — قابل للاسترداد 100% — هدفه يثبت ROI قبل أي التزام. مناسب؟",
|
||||
"already_has_crm": "Dealix ما يستبدل CRM — يشتغل كطبقة أولى فوقه. يرد بالعربي، يؤهّل، ويسلّم الـ CRM قائمة leads جاهزة. تكامل مباشر HubSpot/Salesforce/Zoho/webhook. 20 دقيقة demo: " + CAL,
|
||||
"arabic_concern": f"نقطة مهمة. Dealix خليجي حقيقي، ما يكتب 'حضرتك' و'تعطفكم'. 20 دقيقة demo تختبره بنفسك على سيناريو شركتكم: {CAL}",
|
||||
"privacy_concern": f"مصمم PDPL-compliant: بياناتكم في سيرفرات السعودية، opt-out في كل email، audit log كامل. 20 دقيقة نناقش compliance + demo: {CAL}",
|
||||
"partnership_interest": f"ممتاز. 3 tiers:\n- Referral: 10% MRR × 12 شهر\n- Agency: setup 3-15K + 20-30% MRR\n- White-label (Scale)\n20 دقيقة partner call: https://dealix.me/partners.html",
|
||||
"referral_opportunity": "شكراً! 10% من MRR × 12 شهر لأي عميل يجي عبرك. ممكن تخبرني بمعلومات الشركة والشخص؟",
|
||||
}
|
||||
response_ar = responses.get(classification, responses["interested"])
|
||||
|
||||
# Decide next action
|
||||
action_map = {
|
||||
"opt_out": "STOP_CONTACT",
|
||||
"interested": "BOOK_DEMO",
|
||||
"wants_demo": "BOOK_DEMO",
|
||||
"price": "BOOK_DEMO",
|
||||
"send_details": "PREPARE_DEMO_FLOW",
|
||||
"later": "FOLLOW_UP",
|
||||
"not_relevant": "STOP_CONTACT",
|
||||
"budget_objection": "ROUTE_TO_MANUAL_PAYMENT",
|
||||
"already_has_crm": "BOOK_DEMO",
|
||||
"arabic_concern": "PREPARE_DEMO_FLOW",
|
||||
"privacy_concern": "PREPARE_DEMO_FLOW",
|
||||
"partnership_interest": "PREPARE_PARTNER_PITCH",
|
||||
"referral_opportunity": "FOLLOW_UP",
|
||||
}
|
||||
next_action = action_map.get(classification, "ASK_HUMAN_FINAL_SEND")
|
||||
|
||||
# Escalation rule
|
||||
escalate = classification in ("partnership_interest",) or opp_type == "INVESTOR_OR_ADVISOR"
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
now = datetime.utcnow().isoformat() + "Z"
|
||||
next_followup = (datetime.utcnow() + timedelta(days=2)).date().isoformat()
|
||||
|
||||
return {
|
||||
"classification": classification,
|
||||
"opportunity_type": opp_type,
|
||||
"response_ar": response_ar,
|
||||
"next_action": next_action,
|
||||
"should_escalate_to_human": escalate,
|
||||
"channel_recommended_reply": channel,
|
||||
"tracker_update": {
|
||||
"reply_received_at": now,
|
||||
"classification": classification,
|
||||
"next_followup": next_followup,
|
||||
"status": "engaged",
|
||||
},
|
||||
"compliance_note": (
|
||||
"Response auto-generated using rules-based classifier + templated Khaliji Arabic. "
|
||||
"No LLM used (deterministic). No personal PII stored beyond the inbound message. "
|
||||
"Human review recommended for partnership/investor classifications."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _run_inbound_handler(channel: str, sender: str, company: str, message: str) -> dict[str, Any]:
|
||||
"""Shared internal handler used by all channel webhooks."""
|
||||
return await inbound_handle({
|
||||
"channel": channel,
|
||||
"from": sender,
|
||||
"company": company,
|
||||
"message": message,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/inbound/whatsapp")
|
||||
async def inbound_whatsapp(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
WhatsApp Business API webhook handler.
|
||||
Expected payload format (Meta WhatsApp Cloud API):
|
||||
{"entry":[{"changes":[{"value":{"messages":[{"from":"+966...","text":{"body":"..."}}]}}]}]}
|
||||
Or simplified: {"from":"+966...","message":"..."}
|
||||
"""
|
||||
msg = ""
|
||||
sender = ""
|
||||
# Try both simple and Meta formats
|
||||
if "entry" in body:
|
||||
try:
|
||||
m = body["entry"][0]["changes"][0]["value"]["messages"][0]
|
||||
sender = str(m.get("from") or "")
|
||||
msg = str(m.get("text", {}).get("body") or m.get("body") or "")
|
||||
except (KeyError, IndexError, TypeError):
|
||||
pass
|
||||
msg = msg or str(body.get("message") or "")
|
||||
sender = sender or str(body.get("from") or "")
|
||||
if not msg:
|
||||
raise HTTPException(status_code=400, detail="no_message_body")
|
||||
result = await _run_inbound_handler("whatsapp", sender, str(body.get("company", "")), msg)
|
||||
result["send_reply_instruction"] = (
|
||||
"POST the response_ar via WhatsApp Business Cloud API: "
|
||||
"POST https://graph.facebook.com/v17.0/{PHONE_NUMBER_ID}/messages "
|
||||
"with { messaging_product: 'whatsapp', to: sender, text: { body: response_ar } }"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/inbound/email")
|
||||
async def inbound_email(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Email inbound webhook (SendGrid Inbound Parse / Mailgun Routes format).
|
||||
Expected: {"from":"ali@example.com","subject":"...","text":"..."} or SendGrid inbound format.
|
||||
"""
|
||||
sender = str(body.get("from") or body.get("sender") or "")
|
||||
msg = str(body.get("text") or body.get("body-plain") or body.get("message") or "")
|
||||
subject = str(body.get("subject") or "")
|
||||
if subject and msg:
|
||||
combined = f"[{subject}] {msg}"
|
||||
else:
|
||||
combined = msg or subject
|
||||
if not combined:
|
||||
raise HTTPException(status_code=400, detail="no_message_body")
|
||||
result = await _run_inbound_handler("email", sender, str(body.get("company", "")), combined)
|
||||
result["send_reply_instruction"] = (
|
||||
"Reply via Gmail API / SendGrid / SES — include opt-out footer "
|
||||
"'لإيقاف الرسائل: رد بـ لا شكراً'"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/inbound/form")
|
||||
async def inbound_form(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Generic web-form submission handler. Feeds directly into /inbound/handle.
|
||||
Expected: {"name","email","company","message","source":"web_form"}
|
||||
"""
|
||||
name = str(body.get("name") or "")
|
||||
email = str(body.get("email") or "")
|
||||
company = str(body.get("company") or "")
|
||||
message = str(body.get("message") or "")
|
||||
if not message:
|
||||
raise HTTPException(status_code=400, detail="message_required")
|
||||
sender = email or name
|
||||
result = await _run_inbound_handler("web_form", sender, company, message)
|
||||
result["send_reply_instruction"] = (
|
||||
"Display response_ar inline in form confirmation. Also auto-send email reply "
|
||||
"with response_ar + Calendly link."
|
||||
)
|
||||
# Also create a lead record via pipeline if email + company known
|
||||
if email and company:
|
||||
result["also_created_lead"] = True
|
||||
result["lead_hint"] = "POST /api/v1/leads with this payload to persist"
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/inbound/sms")
|
||||
async def inbound_sms(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
SMS inbound webhook (Twilio format).
|
||||
Expected: {"From":"+966...","Body":"..."} or {"from","message"}
|
||||
"""
|
||||
sender = str(body.get("From") or body.get("from") or "")
|
||||
msg = str(body.get("Body") or body.get("message") or "")
|
||||
if not msg:
|
||||
raise HTTPException(status_code=400, detail="no_message_body")
|
||||
result = await _run_inbound_handler("sms", sender, str(body.get("company", "")), msg)
|
||||
result["send_reply_instruction"] = (
|
||||
"Reply via Twilio / Unifonic / STC. Keep SMS ≤ 160 chars; long messages via WhatsApp link."
|
||||
)
|
||||
# SMS replies should be SHORTER
|
||||
if result.get("response_ar") and len(result["response_ar"]) > 160:
|
||||
result["response_ar_short"] = result["response_ar"][:140] + "... رابط: https://dealix.me"
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/inbound/linkedin")
|
||||
async def inbound_linkedin(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
LinkedIn manual-capture webhook (Sami pastes reply content; Dealix classifies + suggests reply).
|
||||
NOT auto-send (LinkedIn ToS). Human final-send required.
|
||||
Expected: {"from":"...","profile_url":"...","message":"..."}
|
||||
"""
|
||||
sender = str(body.get("from") or body.get("profile_url") or "")
|
||||
msg = str(body.get("message") or "")
|
||||
if not msg:
|
||||
raise HTTPException(status_code=400, detail="no_message_body")
|
||||
result = await _run_inbound_handler("linkedin", sender, str(body.get("company", "")), msg)
|
||||
result["send_reply_instruction"] = (
|
||||
"⚠️ LinkedIn = HUMAN FINAL SEND ONLY (ToS compliance). "
|
||||
"Show response_ar to Sami, Sami pastes manually into LinkedIn DM. NO automation."
|
||||
)
|
||||
result["should_escalate_to_human"] = True # always for LinkedIn
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/demo")
|
||||
async def demo() -> dict[str, Any]:
|
||||
"""Canned demo response for landing UI preview. No LLM call."""
|
||||
return {
|
||||
"use_case": "sales",
|
||||
"icp": "شركات SaaS سعودية B2B بحجم 20-100 موظف تبيع للمطاعم",
|
||||
"count_requested": 3,
|
||||
"count_returned": 3,
|
||||
"search_notes": "نتائج توضيحية — جرب الواجهة الحقيقية للحصول على قائمة مخصصة لمواصفاتك.",
|
||||
"leads": [
|
||||
{
|
||||
"company_ar": "فودكس",
|
||||
"company_en": "Foodics",
|
||||
"industry": "SaaS للمطاعم",
|
||||
"est_size": "200-1000",
|
||||
"website": "https://www.foodics.com",
|
||||
"linkedin": "https://www.linkedin.com/company/foodics",
|
||||
"decision_maker_hints": ["Ahmad Al-Zaini — CEO", "Mosab Alothmani — Co-founder"],
|
||||
"signals": ["جولة Series C بـ $170M 2025", "توسع في الخليج وشمال أفريقيا"],
|
||||
"outreach_opening": "أحمد، مبروك Series C — 170M = فرصة مضاعفة السرعة في onboarding العملاء الجدد.",
|
||||
"fit_score": 92,
|
||||
"confidence": 90,
|
||||
"evidence": "شركة SaaS سعودية واضحة، تستهدف restaurant operators، بحجم يطابق الـ ICP.",
|
||||
},
|
||||
{
|
||||
"company_ar": "رُكاز",
|
||||
"company_en": "Rekaz",
|
||||
"industry": "SaaS للـ SMB",
|
||||
"est_size": "10-50",
|
||||
"website": "https://rekaz.io",
|
||||
"linkedin": None,
|
||||
"decision_maker_hints": ["Abdullah Al-Shalan — Founder"],
|
||||
"signals": ["منصة متخصصة في إدارة المستودعات للتجار"],
|
||||
"outreach_opening": "عبدالله، رُكاز تبني الطبقة التشغيلية للتاجر السعودي — هذا تماماً مكان AI sales rep بالعربي.",
|
||||
"fit_score": 85,
|
||||
"confidence": 75,
|
||||
"evidence": "SMB-focused SaaS سعودي ضمن الحجم المطلوب.",
|
||||
},
|
||||
{
|
||||
"company_ar": "زد",
|
||||
"company_en": "Zid",
|
||||
"industry": "E-commerce Platform",
|
||||
"est_size": "200-1000",
|
||||
"website": "https://zid.sa",
|
||||
"linkedin": "https://www.linkedin.com/company/zidsa",
|
||||
"decision_maker_hints": ["Sultan Mofarreh — Co-founder"],
|
||||
"signals": ["منافس لسلة مع 15K تاجر+", "ركّز على SMB merchants"],
|
||||
"outreach_opening": "سلطان، 15K تاجر = فرصة توزيع هائلة لـ AI sales rep داخل zid marketplace.",
|
||||
"fit_score": 88,
|
||||
"confidence": 85,
|
||||
"evidence": "منصة تجارة إلكترونية سعودية راسخة ضمن الحجم المطلوب.",
|
||||
},
|
||||
],
|
||||
}
|
||||
152
dealix/api/routers/public.py
Normal file
152
dealix/api/routers/public.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""
|
||||
Public endpoints — no auth, CORS-open. Used by the landing page.
|
||||
|
||||
Routes:
|
||||
POST /api/v1/public/demo-request — landing form submission
|
||||
Body: {name, company, email, phone, sector?, size?, message?, consent, website(honeypot)}
|
||||
Returns: {ok: true, calendly_url: "...", lead_id?: "..."}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from dealix.analytics import FUNNEL_EVENTS, capture_event
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1/public", tags=["public"])
|
||||
|
||||
|
||||
CALENDLY_URL = os.getenv(
|
||||
"CALENDLY_URL",
|
||||
"https://calendly.com/sami-assiri11/dealix-demo",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/demo-request")
|
||||
async def demo_request(req: Request) -> dict[str, Any]:
|
||||
"""Public landing form — captures demo request and returns Calendly booking URL."""
|
||||
try:
|
||||
body = await req.json()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail="invalid_json") from e
|
||||
|
||||
# Honeypot: if "website" field is filled, silently drop
|
||||
if body.get("website"):
|
||||
log.info("demo_request_honeypot_triggered")
|
||||
return {"ok": True, "calendly_url": CALENDLY_URL}
|
||||
|
||||
name = str(body.get("name") or "").strip()
|
||||
company = str(body.get("company") or "").strip()
|
||||
email = str(body.get("email") or "").strip()
|
||||
phone = str(body.get("phone") or "").strip()
|
||||
sector = str(body.get("sector") or "").strip()
|
||||
size = str(body.get("size") or "").strip()
|
||||
message = str(body.get("message") or "").strip()
|
||||
consent = bool(body.get("consent"))
|
||||
|
||||
if not name or not company or "@" not in email or not phone:
|
||||
raise HTTPException(status_code=422, detail="missing_required_fields")
|
||||
if not consent:
|
||||
raise HTTPException(status_code=422, detail="consent_required")
|
||||
|
||||
# Fire PostHog event (fire-and-forget — never blocks response)
|
||||
try:
|
||||
await capture_event(
|
||||
(
|
||||
FUNNEL_EVENTS.DEMO_REQUESTED
|
||||
if hasattr(FUNNEL_EVENTS, "DEMO_REQUESTED")
|
||||
else "demo_requested"
|
||||
),
|
||||
distinct_id=email,
|
||||
properties={
|
||||
"name": name,
|
||||
"company": company,
|
||||
"email": email,
|
||||
"phone": phone,
|
||||
"sector": sector,
|
||||
"size": size,
|
||||
"message_len": len(message),
|
||||
"source": "landing.demo_form",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
log.exception("posthog_capture_failed")
|
||||
|
||||
# TODO: once AcquisitionPipeline is DI-wired here, route through pipeline.run()
|
||||
# For now, minimal path: accept + return Calendly URL. Lead is still in PostHog.
|
||||
log.info(
|
||||
"demo_request_accepted email=%s company=%s sector=%s",
|
||||
email,
|
||||
company,
|
||||
sector,
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"calendly_url": CALENDLY_URL,
|
||||
"message": "تم استلام طلبك — سنتواصل خلال 4 ساعات عمل",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def public_health() -> dict[str, Any]:
|
||||
"""Unauthenticated health probe for landing page to show live status."""
|
||||
return {"ok": True, "service": "dealix-api"}
|
||||
|
||||
|
||||
@router.post("/partner-application")
|
||||
async def partner_application(req: Request) -> dict[str, Any]:
|
||||
"""Public partner signup — for agencies/freelancers/consultants."""
|
||||
try:
|
||||
body = await req.json()
|
||||
except Exception:
|
||||
# Also accept form-urlencoded submissions from Formspree-style forms
|
||||
form = await req.form()
|
||||
body = dict(form)
|
||||
|
||||
name = str(body.get("name") or "").strip()
|
||||
company = str(body.get("company") or "").strip()
|
||||
email = str(body.get("email") or "").strip()
|
||||
phone = str(body.get("phone") or "").strip()
|
||||
ptype = str(body.get("partnership_type") or body.get("type") or "referral").strip()
|
||||
services = str(body.get("services") or "").strip()
|
||||
active_clients = str(body.get("active_clients") or body.get("clients") or "0")
|
||||
why = str(body.get("why") or "").strip()
|
||||
|
||||
if not name or not company or "@" not in email:
|
||||
raise HTTPException(status_code=422, detail="missing_required_fields")
|
||||
|
||||
log.info(
|
||||
"partner_application_received company=%s type=%s clients=%s",
|
||||
company,
|
||||
ptype,
|
||||
active_clients,
|
||||
)
|
||||
|
||||
try:
|
||||
await capture_event(
|
||||
"partner_application_submitted",
|
||||
distinct_id=email or company or "anonymous",
|
||||
properties={
|
||||
"company": company,
|
||||
"partnership_type": ptype,
|
||||
"active_clients": active_clients,
|
||||
"has_phone": bool(phone),
|
||||
"has_services": bool(services),
|
||||
"has_why": bool(why),
|
||||
"source": "dealix.partners_page",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
log.warning("posthog_capture_failed", exc_info=True)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "وصلنا طلبك. سنتواصل خلال 48 ساعة.",
|
||||
"next_step": "email_review",
|
||||
}
|
||||
289
dealix/api/routers/revenue.py
Normal file
289
dealix/api/routers/revenue.py
Normal file
@ -0,0 +1,289 @@
|
||||
"""
|
||||
Revenue motion endpoints — fills gaps demanded by the operator playbook.
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/leads/score — alias for prospect score on a lead body
|
||||
POST /api/v1/negotiation/respond — generate negotiation reply (rule-based + LLM)
|
||||
POST /api/v1/customers/daily-report — log a daily delivery report for a customer
|
||||
POST /api/v1/partners/outreach — log partner outreach attempt
|
||||
POST /api/v1/partners/deal — log partner-sourced deal
|
||||
|
||||
All endpoints honor `_safe_commit` for graceful DB-unreachable handling and
|
||||
respect approval_required=True for any outbound message generation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
from sqlalchemy import select
|
||||
|
||||
from auto_client_acquisition.pipelines.scoring import (
|
||||
compute_data_quality,
|
||||
compute_lead_score,
|
||||
)
|
||||
from db.models import (
|
||||
CustomerRecord,
|
||||
PartnerRecord,
|
||||
TaskRecord,
|
||||
)
|
||||
from db.session import async_session_factory
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["revenue"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _new_id(prefix: str = "") -> str:
|
||||
suffix = uuid.uuid4().hex[:24]
|
||||
return f"{prefix}{suffix}" if prefix else suffix
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
# ── Score alias on /leads namespace ───────────────────────────────
|
||||
@router.post("/leads/score")
|
||||
async def score_lead_body(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Score a lead body without persisting. Mirror of prospect/score for /leads namespace.
|
||||
Body: any account-shaped dict — minimum: company_name + (domain | phone | email).
|
||||
"""
|
||||
if not body.get("company_name"):
|
||||
raise HTTPException(400, "company_name_required")
|
||||
score = compute_lead_score(body, signals=body.get("signals") or [], technologies=body.get("technologies") or [])
|
||||
dq, dq_reasons = compute_data_quality(body)
|
||||
return {
|
||||
"company_name": body.get("company_name"),
|
||||
"score": {
|
||||
"fit": score.fit, "intent": score.intent, "urgency": score.urgency,
|
||||
"risk": score.risk, "total": score.total, "priority": score.priority,
|
||||
"recommended_channel": score.recommended_channel, "reason": score.reason,
|
||||
},
|
||||
"data_quality": {"score": dq, "reasons": dq_reasons},
|
||||
}
|
||||
|
||||
|
||||
# ── Negotiation respond ───────────────────────────────────────────
|
||||
NEGOTIATION_TEMPLATES_AR = {
|
||||
"price_objection": (
|
||||
"أفهم القلق على السعر — Pilot 7 أيام بـ 499 ريال هو أرخص طريقة "
|
||||
"تشوف نتيجة قبل أي التزام. لو ما اقتنعتم نرجع المبلغ كامل."
|
||||
),
|
||||
"feature_missing": (
|
||||
"هذي ميزة في طريقها ضمن خطة Q3. الآن نقدر نعمل workaround يدوي خلال "
|
||||
"الـ pilot — تناسبكم نسلمه كذا ونضيف الميزة لاحقاً؟"
|
||||
),
|
||||
"timing_objection": (
|
||||
"متفهم. الـ pilot 7 أيام فقط، نشغله بدون تدخل من فريقكم. "
|
||||
"تبدؤون متى يناسبكم — هذا الأسبوع أو الأسبوع القادم؟"
|
||||
),
|
||||
"trust_objection": (
|
||||
"صحيح، Dealix شركة جديدة. عشان كذا الـ pilot 499 ريال + استرجاع كامل. "
|
||||
"أنتم تجربون قبل أي التزام. تناسبكم نبدأ الاثنين؟"
|
||||
),
|
||||
"competitor_comparison": (
|
||||
"Dealix الوحيد بالعربي الخليجي + متوافق PDPL + Mada. "
|
||||
"البقية إما إنجليزية أو ترجمة آلية. تبون نقارن جانب-جانب على lead حقيقي؟"
|
||||
),
|
||||
"decision_maker_unavailable": (
|
||||
"ممتاز. نرسل لكم one-pager + رابط Calendly تشاركونه مع المسؤول، "
|
||||
"ونرجع نتابع بعد 3 أيام. أرسلها لإيميلكم؟"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/negotiation/respond")
|
||||
async def negotiation_respond(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Generate a negotiation response.
|
||||
Body:
|
||||
objection_type: one of price_objection / feature_missing / timing_objection /
|
||||
trust_objection / competitor_comparison / decision_maker_unavailable
|
||||
company_name: optional, used to personalize
|
||||
custom_context: optional, free-text the rep wants Dealix to address
|
||||
"""
|
||||
obj_type = str(body.get("objection_type") or "").strip()
|
||||
company = str(body.get("company_name") or "العميل").strip()
|
||||
custom = str(body.get("custom_context") or "").strip()
|
||||
|
||||
if obj_type not in NEGOTIATION_TEMPLATES_AR:
|
||||
return {
|
||||
"status": "unknown_objection",
|
||||
"valid_types": list(NEGOTIATION_TEMPLATES_AR.keys()),
|
||||
"hint": "Pick one of the valid objection_type values, OR pass custom_context.",
|
||||
}
|
||||
|
||||
base = NEGOTIATION_TEMPLATES_AR[obj_type]
|
||||
response = f"{company}، {base}"
|
||||
if custom:
|
||||
response += f"\n\nبخصوص ما ذكرتم: {custom[:300]}"
|
||||
return {
|
||||
"objection_type": obj_type,
|
||||
"response_ar": response,
|
||||
"approval_required": True,
|
||||
"send_status": "queued_for_human_approval",
|
||||
"channel_policy": "human_final_send_only_during_first_30_days",
|
||||
}
|
||||
|
||||
|
||||
# ── Customer daily report ─────────────────────────────────────────
|
||||
@router.post("/customers/daily-report")
|
||||
async def customers_daily_report(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Log a daily delivery report for a customer.
|
||||
Body: customer_id, date, leads_handled, demos_booked, response_time_avg_seconds,
|
||||
notes, customer_quote_optional
|
||||
"""
|
||||
customer_id = str(body.get("customer_id") or "").strip()
|
||||
if not customer_id:
|
||||
raise HTTPException(400, "customer_id_required")
|
||||
leads_handled = int(body.get("leads_handled") or 0)
|
||||
demos_booked = int(body.get("demos_booked") or 0)
|
||||
response_avg = float(body.get("response_time_avg_seconds") or 0)
|
||||
notes = str(body.get("notes") or "")[:1000]
|
||||
|
||||
# Persist as a TaskRecord with task_type=daily_report so it's auditable
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
task = TaskRecord(
|
||||
id=_new_id("dr_"),
|
||||
lead_id=None,
|
||||
deal_id=None,
|
||||
task_type="daily_report",
|
||||
status="done",
|
||||
owner="auto",
|
||||
notes=(
|
||||
f"customer_id={customer_id} | "
|
||||
f"date={body.get('date') or _utcnow().date().isoformat()} | "
|
||||
f"leads_handled={leads_handled} | demos_booked={demos_booked} | "
|
||||
f"avg_response={response_avg}s\n{notes}"
|
||||
)[:5000],
|
||||
completed_at=_utcnow(),
|
||||
)
|
||||
session.add(task)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
# Optional: bump customer record metric counters
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
cust = (await session.execute(
|
||||
select(CustomerRecord).where(CustomerRecord.id == customer_id)
|
||||
)).scalar_one_or_none()
|
||||
if cust:
|
||||
cust.daily_report_sent = (cust.daily_report_sent or 0) + 1
|
||||
cust.updated_at = _utcnow()
|
||||
await session.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"status": "logged",
|
||||
"customer_id": customer_id,
|
||||
"metrics": {
|
||||
"leads_handled": leads_handled,
|
||||
"demos_booked": demos_booked,
|
||||
"response_time_avg_seconds": response_avg,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Partner outreach + deal ───────────────────────────────────────
|
||||
@router.post("/partners/outreach")
|
||||
async def partners_outreach(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Log a partner-outreach attempt.
|
||||
Body: partner_id, channel, message_summary, next_action, next_action_at
|
||||
"""
|
||||
partner_id = str(body.get("partner_id") or "").strip()
|
||||
if not partner_id:
|
||||
raise HTTPException(400, "partner_id_required")
|
||||
channel = str(body.get("channel") or "manual")[:32]
|
||||
message = str(body.get("message_summary") or "")[:1000]
|
||||
next_action = str(body.get("next_action") or "follow_up")[:64]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
partner = (await session.execute(
|
||||
select(PartnerRecord).where(PartnerRecord.id == partner_id)
|
||||
)).scalar_one_or_none()
|
||||
if not partner:
|
||||
return {"status": "partner_not_found", "id": partner_id}
|
||||
partner.next_action = next_action
|
||||
partner.notes = ((partner.notes or "") + f"\n[{_utcnow().isoformat()}] outreach via {channel}: {message[:300]}")[:5000]
|
||||
partner.updated_at = _utcnow()
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "commit_failed", "error": str(exc)}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
return {
|
||||
"status": "logged",
|
||||
"partner_id": partner_id,
|
||||
"channel": channel,
|
||||
"next_action": next_action,
|
||||
"approval_required": True,
|
||||
"send_status": "queued_for_human_approval",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/partners/deal")
|
||||
async def partners_deal(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Log a partner-sourced deal.
|
||||
Body: partner_id, customer_company, deal_value_sar, mrr_share_pct (optional)
|
||||
"""
|
||||
partner_id = str(body.get("partner_id") or "").strip()
|
||||
customer = str(body.get("customer_company") or "").strip()
|
||||
deal_value = float(body.get("deal_value_sar") or 0)
|
||||
if not partner_id or not customer:
|
||||
raise HTTPException(400, "partner_id_and_customer_company_required")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
partner = (await session.execute(
|
||||
select(PartnerRecord).where(PartnerRecord.id == partner_id)
|
||||
)).scalar_one_or_none()
|
||||
if not partner:
|
||||
return {"status": "partner_not_found", "id": partner_id}
|
||||
|
||||
# Increment partner's clients_signed counter
|
||||
partner.clients_signed = (partner.clients_signed or 0) + 1
|
||||
partner.updated_at = _utcnow()
|
||||
# Append context to partner notes (DealRecord requires lead FK; skip
|
||||
# creating a real Deal until customer has a corresponding LeadRecord)
|
||||
partner.notes = ((partner.notes or "") + (
|
||||
f"\n[{_utcnow().isoformat()}] partner_deal logged: "
|
||||
f"customer={customer} value_sar={deal_value} "
|
||||
f"mrr_share_pct={body.get('mrr_share_pct')}"
|
||||
))[:5000]
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await session.rollback()
|
||||
return {"status": "commit_failed", "error": str(exc)}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "skipped_db_unreachable", "error": str(exc)}
|
||||
|
||||
return {
|
||||
"status": "deal_logged",
|
||||
"partner_id": partner_id,
|
||||
"customer_company": customer,
|
||||
"deal_value_sar": deal_value,
|
||||
"partner_total_clients": partner.clients_signed,
|
||||
"note": "Deal record created in partner.notes audit trail; "
|
||||
"create a LeadRecord first if you need a full DealRecord row.",
|
||||
}
|
||||
689
dealix/api/routers/revenue_os.py
Normal file
689
dealix/api/routers/revenue_os.py
Normal file
@ -0,0 +1,689 @@
|
||||
"""
|
||||
Revenue OS Router — single integration point for the v3 Autonomous layers.
|
||||
|
||||
Endpoints under /api/v1/revenue-os/:
|
||||
Memory: /events /timeline/{account_id} /replay/{customer_id}
|
||||
Agents: /workflows/run /tasks /tasks/{id}/approve /tasks/{id}/reject
|
||||
Market: /market-radar/signals /market-radar/sectors /market-radar/cities
|
||||
/market-radar/opportunities
|
||||
Copilot: /copilot/ask /copilot/intents /copilot/actions/{id}
|
||||
Forecast: /forecast /attribution /impact /churn /expansion
|
||||
Compliance: /contactability /campaign-risk /ropa /dsr /dsr/{id}/process
|
||||
/vendors
|
||||
Verticals: /verticals /verticals/{id} /verticals/{id}/templates
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query
|
||||
|
||||
# Compliance OS
|
||||
from auto_client_acquisition.compliance_os.consent_ledger import (
|
||||
LawfulBasis,
|
||||
record_consent,
|
||||
record_opt_out,
|
||||
)
|
||||
from auto_client_acquisition.compliance_os.contactability import check_contactability
|
||||
from auto_client_acquisition.compliance_os.data_subject_requests import (
|
||||
DSR_TYPES,
|
||||
DSRStatus,
|
||||
dsr_dashboard,
|
||||
open_dsr,
|
||||
process_dsr,
|
||||
)
|
||||
from auto_client_acquisition.compliance_os.risk_engine import score_campaign_risk
|
||||
from auto_client_acquisition.compliance_os.ropa import build_ropa
|
||||
from auto_client_acquisition.compliance_os.vendor_registry import (
|
||||
DEFAULT_VENDORS,
|
||||
vendors_summary,
|
||||
)
|
||||
|
||||
# Copilot
|
||||
from auto_client_acquisition.copilot import ask
|
||||
from auto_client_acquisition.copilot.intent_router import list_intents
|
||||
from auto_client_acquisition.copilot.safe_actions import SAFE_ACTIONS, get_action
|
||||
|
||||
# Market Intelligence
|
||||
from auto_client_acquisition.market_intelligence.opportunity_feed import (
|
||||
build_opportunity_feed,
|
||||
)
|
||||
from auto_client_acquisition.market_intelligence.sector_pulse import build_sector_pulse
|
||||
from auto_client_acquisition.market_intelligence.signal_detectors import (
|
||||
SIGNAL_TYPES,
|
||||
SignalDetection,
|
||||
detect_ads_signal,
|
||||
detect_funding_signal,
|
||||
detect_hiring_signal,
|
||||
detect_tender_signal,
|
||||
detect_website_change,
|
||||
)
|
||||
|
||||
# Orchestrator
|
||||
from auto_client_acquisition.orchestrator.policies import (
|
||||
AutonomyMode,
|
||||
default_policy,
|
||||
)
|
||||
from auto_client_acquisition.orchestrator.queue import TaskQueue, TaskStatus
|
||||
from auto_client_acquisition.orchestrator.runtime import DAILY_GROWTH_RUN, Orchestrator
|
||||
from auto_client_acquisition.orchestrator.tools import default_executors
|
||||
|
||||
# Revenue Memory
|
||||
from auto_client_acquisition.revenue_memory.event_store import (
|
||||
InMemoryEventStore,
|
||||
get_default_store,
|
||||
)
|
||||
from auto_client_acquisition.revenue_memory.events import (
|
||||
EVENT_TYPES,
|
||||
event_to_dict,
|
||||
make_event,
|
||||
)
|
||||
from auto_client_acquisition.revenue_memory.replay import (
|
||||
replay_for_account,
|
||||
replay_for_customer,
|
||||
)
|
||||
from auto_client_acquisition.revenue_memory.retention import retention_summary
|
||||
|
||||
# Revenue Science
|
||||
from auto_client_acquisition.revenue_science.attribution import (
|
||||
compute_first_touch,
|
||||
compute_last_touch,
|
||||
compute_linear,
|
||||
compute_time_decay,
|
||||
)
|
||||
from auto_client_acquisition.revenue_science.causal_impact import simulate_impact
|
||||
from auto_client_acquisition.revenue_science.churn_model import predict_churn
|
||||
from auto_client_acquisition.revenue_science.expansion_model import predict_expansion
|
||||
from auto_client_acquisition.revenue_science.forecast import compute_forecast
|
||||
|
||||
# Vertical OS
|
||||
from auto_client_acquisition.vertical_os import (
|
||||
ALL_VERTICALS,
|
||||
get_vertical,
|
||||
list_vertical_summaries,
|
||||
)
|
||||
|
||||
# Why-Now (used by opportunity_feed)
|
||||
from auto_client_acquisition.revenue_graph.why_now import (
|
||||
WhyNowSignal,
|
||||
explain_why_now,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/revenue-os", tags=["revenue-os"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
# ── Module-level singletons (in-memory adapters; production replaces) ─
|
||||
_QUEUE = TaskQueue()
|
||||
_ORCHESTRATOR_FACTORY = None
|
||||
|
||||
|
||||
def _get_orchestrator(customer_id: str) -> Orchestrator:
|
||||
"""Build an orchestrator with the default in-memory store + policy."""
|
||||
store = get_default_store()
|
||||
|
||||
def policy_resolver(c):
|
||||
return default_policy(c)
|
||||
|
||||
return Orchestrator(
|
||||
queue=_QUEUE,
|
||||
event_store=store,
|
||||
policy_resolver=policy_resolver,
|
||||
executor_registry=default_executors(),
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# 1. REVENUE MEMORY ENDPOINTS
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
@router.get("/events/types")
|
||||
async def list_event_types() -> dict[str, Any]:
|
||||
"""50 event types Dealix records."""
|
||||
return {"count": len(EVENT_TYPES), "event_types": list(EVENT_TYPES)}
|
||||
|
||||
|
||||
@router.post("/events")
|
||||
async def append_event(
|
||||
event_type: str = Body(..., embed=True),
|
||||
customer_id: str = Body(..., embed=True),
|
||||
subject_type: str = Body(..., embed=True),
|
||||
subject_id: str = Body(..., embed=True),
|
||||
payload: dict[str, Any] = Body(default_factory=dict, embed=True),
|
||||
actor: str = Body(default="system", embed=True),
|
||||
) -> dict[str, Any]:
|
||||
"""Append a new event to the customer's stream."""
|
||||
try:
|
||||
e = make_event(
|
||||
event_type=event_type,
|
||||
customer_id=customer_id,
|
||||
subject_type=subject_type,
|
||||
subject_id=subject_id,
|
||||
payload=payload,
|
||||
actor=actor,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
get_default_store().append(e)
|
||||
return {"event_id": e.event_id, "event_type": e.event_type}
|
||||
|
||||
|
||||
@router.get("/timeline/{account_id}")
|
||||
async def get_timeline(account_id: str, customer_id: str = Query(...)) -> dict[str, Any]:
|
||||
"""Replay account timeline from the event stream."""
|
||||
timeline = replay_for_account(customer_id=customer_id, account_id=account_id)
|
||||
return timeline.to_dict()
|
||||
|
||||
|
||||
@router.get("/replay/{customer_id}")
|
||||
async def replay_customer_roi(
|
||||
customer_id: str,
|
||||
period_days: int = Query(default=30, ge=1, le=365),
|
||||
) -> dict[str, Any]:
|
||||
"""Compute ROI projection for the customer over the period."""
|
||||
period_start = _now() - timedelta(days=period_days)
|
||||
proj = replay_for_customer(customer_id=customer_id, period_start=period_start)
|
||||
return {
|
||||
"customer_id": proj.customer_id,
|
||||
"period_days": period_days,
|
||||
"n_leads": proj.n_leads,
|
||||
"n_meetings": proj.n_meetings,
|
||||
"n_proposals": proj.n_proposals,
|
||||
"n_deals_won": proj.n_deals_won,
|
||||
"revenue_won_sar": proj.revenue_won_sar,
|
||||
"pipeline_added_sar": proj.pipeline_added_sar,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/retention-summary")
|
||||
async def get_retention_summary(customer_id: str = Query(...)) -> dict[str, Any]:
|
||||
"""How many events per retention tier — for Trust Center display."""
|
||||
events = list(get_default_store().read_for_customer(customer_id))
|
||||
return retention_summary(events)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# 2. AGENT ORCHESTRATOR ENDPOINTS
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
@router.post("/workflows/run")
|
||||
async def run_workflow(
|
||||
workflow_id: str = Body(default="daily_growth_run", embed=True),
|
||||
customer_id: str = Body(..., embed=True),
|
||||
autonomy_mode: str = Body(default=AutonomyMode.DRAFT_APPROVE, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
"""Trigger a workflow — Daily Growth Run by default."""
|
||||
if workflow_id != "daily_growth_run":
|
||||
raise HTTPException(status_code=404, detail=f"unknown workflow: {workflow_id}")
|
||||
|
||||
store = get_default_store()
|
||||
|
||||
def resolver(c):
|
||||
p = default_policy(c)
|
||||
p.autonomy_mode = autonomy_mode
|
||||
return p
|
||||
|
||||
orch = Orchestrator(
|
||||
queue=_QUEUE,
|
||||
event_store=store,
|
||||
policy_resolver=resolver,
|
||||
executor_registry=default_executors(),
|
||||
)
|
||||
summary = orch.run_workflow(workflow=DAILY_GROWTH_RUN, customer_id=customer_id)
|
||||
return summary
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
async def list_tasks(
|
||||
customer_id: str = Query(...),
|
||||
status: str | None = Query(default=None),
|
||||
) -> dict[str, Any]:
|
||||
if status:
|
||||
tasks = [t for t in _QUEUE.for_customer(customer_id) if t.status == status]
|
||||
else:
|
||||
tasks = _QUEUE.for_customer(customer_id)
|
||||
return {
|
||||
"summary": _QUEUE.summary(customer_id),
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": t.task_id,
|
||||
"agent_id": t.agent_id,
|
||||
"action_type": t.action_type,
|
||||
"status": t.status,
|
||||
"requires_approval": t.requires_approval,
|
||||
"approval_reason": t.approval_reason,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
}
|
||||
for t in tasks
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/approve")
|
||||
async def approve_task(task_id: str, approved_by: str = Body(..., embed=True)) -> dict[str, Any]:
|
||||
orch = _get_orchestrator("any")
|
||||
try:
|
||||
task = orch.approve_and_execute(task_id=task_id, approved_by=approved_by)
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"task_id": task.task_id, "status": task.status}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/reject")
|
||||
async def reject_task(
|
||||
task_id: str,
|
||||
rejected_by: str = Body(..., embed=True),
|
||||
reason: str = Body(default="", embed=True),
|
||||
) -> dict[str, Any]:
|
||||
orch = _get_orchestrator("any")
|
||||
try:
|
||||
task = orch.reject_task(task_id=task_id, rejected_by=rejected_by, reason=reason)
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return {"task_id": task.task_id, "status": task.status}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# 3. MARKET RADAR ENDPOINTS
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
@router.get("/market-radar/signal-types")
|
||||
async def list_signal_types() -> dict[str, Any]:
|
||||
return {"count": len(SIGNAL_TYPES), "signal_types": list(SIGNAL_TYPES)}
|
||||
|
||||
|
||||
@router.post("/market-radar/detect/hiring")
|
||||
async def detect_hiring(
|
||||
company_id: str = Body(..., embed=True),
|
||||
job_postings: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
# Convert ISO strings to datetimes
|
||||
parsed = []
|
||||
for jp in job_postings:
|
||||
posted = jp.get("posted_at")
|
||||
if isinstance(posted, str):
|
||||
try:
|
||||
jp["posted_at"] = datetime.fromisoformat(posted.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
except Exception:
|
||||
continue
|
||||
parsed.append(jp)
|
||||
sigs = detect_hiring_signal(company_id=company_id, job_postings=parsed)
|
||||
return {"signals": [_signal_to_dict(s) for s in sigs]}
|
||||
|
||||
|
||||
@router.post("/market-radar/sectors/{sector}/pulse")
|
||||
async def sector_pulse(
|
||||
sector: str,
|
||||
signals_this_week: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
signals_prior_week: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
this_w = [_signal_from_dict(s) for s in signals_this_week]
|
||||
prior_w = [_signal_from_dict(s) for s in signals_prior_week]
|
||||
pulse = build_sector_pulse(
|
||||
sector=sector, signals_this_week=this_w, signals_prior_week=prior_w
|
||||
)
|
||||
return pulse.to_dict()
|
||||
|
||||
|
||||
@router.post("/market-radar/opportunities")
|
||||
async def opportunities(
|
||||
signals: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
company_metadata: dict[str, dict[str, Any]] = Body(default_factory=dict, embed=True),
|
||||
sector_trends: dict[str, str] = Body(default_factory=dict, embed=True),
|
||||
top_n: int = Body(default=20, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
parsed_signals = [_signal_from_dict(s) for s in signals]
|
||||
|
||||
def explainer(*, company_id, signals, sector, sector_pulse_trend):
|
||||
wn = [
|
||||
WhyNowSignal(
|
||||
signal_type=s.signal_type,
|
||||
detected_at=s.detected_at,
|
||||
source=s.source,
|
||||
evidence_url=s.evidence_url,
|
||||
payload=s.payload,
|
||||
)
|
||||
for s in signals
|
||||
]
|
||||
return explain_why_now(
|
||||
company_id=company_id,
|
||||
signals=wn,
|
||||
sector=sector,
|
||||
sector_pulse_trend=sector_pulse_trend,
|
||||
)
|
||||
|
||||
feed = build_opportunity_feed(
|
||||
signals=parsed_signals,
|
||||
company_metadata=company_metadata,
|
||||
why_now_explainer=explainer,
|
||||
sector_trends=sector_trends,
|
||||
top_n=top_n,
|
||||
)
|
||||
return {"count": len(feed), "opportunities": [o.to_dict() for o in feed]}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# 4. COPILOT ENDPOINTS
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
@router.post("/copilot/ask")
|
||||
async def copilot_ask(
|
||||
question_ar: str = Body(..., embed=True),
|
||||
customer_id: str = Body(..., embed=True),
|
||||
context: dict[str, Any] = Body(default_factory=dict, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
return ask(question_ar=question_ar, customer_id=customer_id, context=context)
|
||||
|
||||
|
||||
@router.get("/copilot/intents")
|
||||
async def copilot_intents() -> dict[str, Any]:
|
||||
return {"intents": list_intents()}
|
||||
|
||||
|
||||
@router.get("/copilot/actions")
|
||||
async def copilot_actions() -> dict[str, Any]:
|
||||
return {"actions": [a.to_dict() for a in SAFE_ACTIONS]}
|
||||
|
||||
|
||||
@router.get("/copilot/actions/{action_id}")
|
||||
async def copilot_action_detail(action_id: str) -> dict[str, Any]:
|
||||
a = get_action(action_id)
|
||||
if a is None:
|
||||
raise HTTPException(status_code=404, detail=f"unknown action: {action_id}")
|
||||
return a.to_dict()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# 5. REVENUE SCIENCE ENDPOINTS
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
@router.post("/forecast")
|
||||
async def forecast_endpoint(
|
||||
customer_id: str = Body(..., embed=True),
|
||||
open_deals: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
horizon_days: int = Body(default=30, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
f = compute_forecast(customer_id=customer_id, open_deals=open_deals, horizon_days=horizon_days)
|
||||
return {
|
||||
"customer_id": f.customer_id,
|
||||
"horizon_days": f.horizon_days,
|
||||
"period_label": f.period_label,
|
||||
"best": f.best.__dict__,
|
||||
"likely": f.likely.__dict__,
|
||||
"worst": f.worst.__dict__,
|
||||
"deals_breakdown": f.deals_breakdown,
|
||||
"risks_ar": f.risks_ar,
|
||||
"decisions_required_ar": f.decisions_required_ar,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/attribution")
|
||||
async def attribution_endpoint(
|
||||
deals: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
model: str = Body(default="time_decay", embed=True),
|
||||
) -> dict[str, Any]:
|
||||
if model == "first_touch":
|
||||
r = compute_first_touch(deals=deals)
|
||||
elif model == "last_touch":
|
||||
r = compute_last_touch(deals=deals)
|
||||
elif model == "linear":
|
||||
r = compute_linear(deals=deals)
|
||||
else:
|
||||
r = compute_time_decay(deals=deals)
|
||||
return {"model": r.model, "by_channel": r.by_channel, "total_revenue_sar": r.total_revenue_sar}
|
||||
|
||||
|
||||
@router.post("/impact")
|
||||
async def impact_endpoint(
|
||||
current_baseline_revenue_sar: float = Body(..., embed=True),
|
||||
response_time_reduction_hours: float = Body(default=0, embed=True),
|
||||
extra_followup_touches: int = Body(default=0, embed=True),
|
||||
shift_to_whatsapp_pct: float = Body(default=0, embed=True),
|
||||
drop_n_sectors: int = Body(default=0, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
out = simulate_impact(
|
||||
current_baseline_revenue_sar=current_baseline_revenue_sar,
|
||||
response_time_reduction_hours=response_time_reduction_hours,
|
||||
extra_followup_touches=extra_followup_touches,
|
||||
shift_to_whatsapp_pct=shift_to_whatsapp_pct,
|
||||
drop_n_sectors=drop_n_sectors,
|
||||
)
|
||||
return out.__dict__
|
||||
|
||||
|
||||
@router.post("/churn")
|
||||
async def churn_endpoint(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
p = predict_churn(
|
||||
customer_id=payload.get("customer_id", "unknown"),
|
||||
days_since_last_login=int(payload.get("days_since_last_login", 0)),
|
||||
monthly_engagement_drop_pct=float(payload.get("monthly_engagement_drop_pct", 0)),
|
||||
support_tickets_open=int(payload.get("support_tickets_open", 0)),
|
||||
billing_failures_last_90d=int(payload.get("billing_failures_last_90d", 0)),
|
||||
nps=payload.get("nps"),
|
||||
pipeline_added_drop_pct=float(payload.get("pipeline_added_drop_pct", 0)),
|
||||
months_as_customer=int(payload.get("months_as_customer", 6)),
|
||||
)
|
||||
return p.__dict__
|
||||
|
||||
|
||||
@router.post("/expansion")
|
||||
async def expansion_endpoint(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
s = predict_expansion(
|
||||
customer_id=payload.get("customer_id", "unknown"),
|
||||
current_plan=payload.get("current_plan", "Growth"),
|
||||
health_score=float(payload.get("health_score", 0)),
|
||||
monthly_engagement_growth_pct=float(payload.get("monthly_engagement_growth_pct", 0)),
|
||||
sectors_targeted=int(payload.get("sectors_targeted", 1)),
|
||||
pct_of_quota_used=float(payload.get("pct_of_quota_used", 0)),
|
||||
nps=payload.get("nps"),
|
||||
pipeline_added_growth_pct=float(payload.get("pipeline_added_growth_pct", 0)),
|
||||
)
|
||||
return s.__dict__
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# 6. COMPLIANCE OS ENDPOINTS
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
@router.post("/compliance/contactability")
|
||||
async def contactability_endpoint(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Check if a contact can be reached now. Records is a list of consent dicts."""
|
||||
contact_id = payload["contact_id"]
|
||||
records_dicts = payload.get("consent_records", [])
|
||||
# Convert to ConsentRecord (lightweight inline)
|
||||
from auto_client_acquisition.compliance_os.consent_ledger import ConsentRecord
|
||||
|
||||
records = []
|
||||
for r in records_dicts:
|
||||
oa = r.get("occurred_at")
|
||||
if isinstance(oa, str):
|
||||
try:
|
||||
oa = datetime.fromisoformat(oa.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
except Exception:
|
||||
oa = _now()
|
||||
records.append(ConsentRecord(
|
||||
record_id=r.get("record_id", "x"),
|
||||
customer_id=r.get("customer_id", ""),
|
||||
contact_id=contact_id,
|
||||
record_type=r.get("record_type", "consent_granted"),
|
||||
lawful_basis=r.get("lawful_basis"),
|
||||
purpose=r.get("purpose", ""),
|
||||
channel=r.get("channel"),
|
||||
source=r.get("source", "api"),
|
||||
occurred_at=oa,
|
||||
))
|
||||
s = check_contactability(
|
||||
contact_id=contact_id,
|
||||
consent_records=records,
|
||||
messages_sent_this_week=int(payload.get("messages_sent_this_week", 0)),
|
||||
weekly_cap=int(payload.get("weekly_cap", 2)),
|
||||
current_riyadh_hour=int(payload.get("current_riyadh_hour", 12)),
|
||||
)
|
||||
return s.to_dict()
|
||||
|
||||
|
||||
@router.post("/compliance/campaign-risk")
|
||||
async def campaign_risk_endpoint(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
r = score_campaign_risk(
|
||||
target_count=int(payload.get("target_count", 0)),
|
||||
contacts_with_consent=int(payload.get("contacts_with_consent", 0)),
|
||||
contacts_opted_out=int(payload.get("contacts_opted_out", 0)),
|
||||
contacts_no_lawful_basis=int(payload.get("contacts_no_lawful_basis", 0)),
|
||||
template_body=payload.get("template_body", ""),
|
||||
template_subject=payload.get("template_subject", ""),
|
||||
channel=payload.get("channel", "email"),
|
||||
has_unsubscribe_link=bool(payload.get("has_unsubscribe_link", True)),
|
||||
in_quiet_hours=bool(payload.get("in_quiet_hours", False)),
|
||||
)
|
||||
return {
|
||||
"risk_score": r.risk_score,
|
||||
"risk_band": r.risk_band,
|
||||
"issues": r.issues,
|
||||
"blockers": r.blockers,
|
||||
"contacts_safe": r.contacts_safe,
|
||||
"contacts_blocked": r.contacts_blocked,
|
||||
"contacts_needing_review": r.contacts_needing_review,
|
||||
"recommended_fixes_ar": r.recommended_fixes_ar,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/compliance/ropa")
|
||||
async def get_ropa(
|
||||
customer_id: str = Query(...),
|
||||
customer_name: str = Query(default="Customer"),
|
||||
dpo_email: str | None = Query(default=None),
|
||||
) -> dict[str, Any]:
|
||||
r = build_ropa(customer_id=customer_id, customer_name=customer_name, dpo_email=dpo_email)
|
||||
return r.to_json()
|
||||
|
||||
|
||||
@router.post("/compliance/dsr")
|
||||
async def open_dsr_endpoint(
|
||||
customer_id: str = Body(..., embed=True),
|
||||
data_subject_id: str = Body(..., embed=True),
|
||||
request_type: str = Body(..., embed=True),
|
||||
) -> dict[str, Any]:
|
||||
if request_type not in DSR_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"unknown DSR type: {request_type}")
|
||||
r = open_dsr(customer_id=customer_id, data_subject_id=data_subject_id, request_type=request_type)
|
||||
return {
|
||||
"request_id": r.request_id,
|
||||
"request_type": r.request_type,
|
||||
"status": r.status,
|
||||
"received_at": r.received_at.isoformat(),
|
||||
"sla_due_at": r.sla_due_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/compliance/vendors")
|
||||
async def list_vendors() -> dict[str, Any]:
|
||||
return {
|
||||
"summary": vendors_summary(),
|
||||
"vendors": [
|
||||
{
|
||||
"vendor_id": v.vendor_id, "name": v.name, "purpose_ar": v.purpose_ar,
|
||||
"data_accessed": v.data_accessed, "region": v.region,
|
||||
"has_dpa_signed": v.has_dpa_signed, "iso27001": v.iso27001,
|
||||
"soc2": v.soc2, "risk_tier": v.risk_tier, "status": v.status,
|
||||
}
|
||||
for v in DEFAULT_VENDORS
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# 7. VERTICAL OS ENDPOINTS
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
@router.get("/verticals")
|
||||
async def list_verticals() -> dict[str, Any]:
|
||||
return {"summaries": list_vertical_summaries()}
|
||||
|
||||
|
||||
@router.get("/verticals/{vertical_id}")
|
||||
async def get_vertical_detail(vertical_id: str) -> dict[str, Any]:
|
||||
v = get_vertical(vertical_id)
|
||||
if v is None:
|
||||
raise HTTPException(status_code=404, detail=f"unknown vertical: {vertical_id}")
|
||||
return {
|
||||
"vertical_id": v.vertical_id,
|
||||
"sector_ar": v.sector_ar,
|
||||
"sector_en": v.sector_en,
|
||||
"icp_company_size": list(v.icp_company_size),
|
||||
"icp_cities": list(v.icp_cities),
|
||||
"icp_keywords": list(v.icp_keywords),
|
||||
"pain_points_ar": list(v.pain_points_ar),
|
||||
"top_objection_ids": list(v.top_objection_ids),
|
||||
"priority_signals": list(v.priority_signals),
|
||||
"dashboard_kpis": [
|
||||
{"metric_id": k.metric_id, "name_ar": k.name_ar, "description_ar": k.description_ar,
|
||||
"unit": k.unit, "higher_is_better": k.higher_is_better,
|
||||
"target_p50": k.target_p50, "target_p90": k.target_p90}
|
||||
for k in v.dashboard_kpis
|
||||
],
|
||||
"n_message_templates": len(v.message_templates),
|
||||
"avg_deal_value_sar": v.avg_deal_value_sar,
|
||||
"avg_cycle_days": v.avg_cycle_days,
|
||||
"benchmark_reply_rate": v.benchmark_reply_rate,
|
||||
"benchmark_meeting_rate": v.benchmark_meeting_rate,
|
||||
"benchmark_win_rate": v.benchmark_win_rate,
|
||||
"compliance_notes_ar": list(v.compliance_notes_ar),
|
||||
"recommended_channel_mix": v.recommended_channel_mix,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/verticals/{vertical_id}/templates")
|
||||
async def get_vertical_templates(vertical_id: str) -> dict[str, Any]:
|
||||
v = get_vertical(vertical_id)
|
||||
if v is None:
|
||||
raise HTTPException(status_code=404, detail=f"unknown vertical: {vertical_id}")
|
||||
return {
|
||||
"vertical_id": vertical_id,
|
||||
"templates": [
|
||||
{
|
||||
"template_id": t.template_id,
|
||||
"channel": t.channel,
|
||||
"purpose": t.purpose,
|
||||
"subject_ar": t.subject_ar,
|
||||
"body_ar": t.body_ar,
|
||||
"variables": list(t.variables),
|
||||
"expected_reply_rate": t.expected_reply_rate,
|
||||
}
|
||||
for t in v.message_templates
|
||||
],
|
||||
"proposal_template_ar": v.proposal_template_ar,
|
||||
"qbr_section_template_ar": v.qbr_section_template_ar,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
def _signal_to_dict(s: SignalDetection) -> dict[str, Any]:
|
||||
return {
|
||||
"company_id": s.company_id,
|
||||
"signal_type": s.signal_type,
|
||||
"detected_at": s.detected_at.isoformat(),
|
||||
"source": s.source,
|
||||
"confidence": s.confidence,
|
||||
"evidence_url": s.evidence_url,
|
||||
"payload": s.payload,
|
||||
}
|
||||
|
||||
|
||||
def _signal_from_dict(d: dict[str, Any]) -> SignalDetection:
|
||||
detected = d.get("detected_at")
|
||||
if isinstance(detected, str):
|
||||
try:
|
||||
detected = datetime.fromisoformat(detected.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
except Exception:
|
||||
detected = _now()
|
||||
return SignalDetection(
|
||||
company_id=d["company_id"],
|
||||
signal_type=d["signal_type"],
|
||||
detected_at=detected or _now(),
|
||||
source=d.get("source", "api"),
|
||||
confidence=float(d.get("confidence", 0.5)),
|
||||
evidence_url=d.get("evidence_url"),
|
||||
payload=d.get("payload", {}),
|
||||
)
|
||||
73
dealix/api/routers/sales.py
Normal file
73
dealix/api/routers/sales.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""Sales endpoints — scripts, proposals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from api.dependencies import get_proposal_agent
|
||||
from api.schemas import (
|
||||
ProposalRequest,
|
||||
ProposalResponse,
|
||||
SalesScriptRequest,
|
||||
SalesScriptResponse,
|
||||
)
|
||||
from auto_client_acquisition.agents.intake import Lead, LeadSource
|
||||
from auto_client_acquisition.agents.proposal import ProposalAgent
|
||||
from core.prompts.sales_scripts import get_sales_script
|
||||
from core.utils import generate_id
|
||||
|
||||
router = APIRouter(prefix="/api/v1/sales", tags=["sales"])
|
||||
|
||||
|
||||
@router.post("/script", response_model=SalesScriptResponse)
|
||||
async def build_script(request: SalesScriptRequest) -> SalesScriptResponse:
|
||||
"""Return a bilingual sales script for a given sector + type."""
|
||||
try:
|
||||
script = get_sales_script(
|
||||
request.script_type,
|
||||
locale=request.locale,
|
||||
name=request.name or "",
|
||||
sector=request.sector,
|
||||
company=request.company or "",
|
||||
date="",
|
||||
time="",
|
||||
link="",
|
||||
)
|
||||
except KeyError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
return SalesScriptResponse(
|
||||
script=script,
|
||||
locale=request.locale,
|
||||
script_type=request.script_type,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/proposal", response_model=ProposalResponse)
|
||||
async def generate_proposal(
|
||||
request: ProposalRequest,
|
||||
agent: ProposalAgent = Depends(get_proposal_agent),
|
||||
) -> ProposalResponse:
|
||||
"""Generate a proposal on demand (outside the pipeline)."""
|
||||
lead = Lead(
|
||||
id=request.lead_id or generate_id("lead"),
|
||||
source=LeadSource.MANUAL,
|
||||
company_name=request.company_name,
|
||||
contact_name="",
|
||||
sector=request.sector,
|
||||
region=request.region,
|
||||
budget=request.budget_hint,
|
||||
pain_points=request.pain_points,
|
||||
locale=request.locale,
|
||||
)
|
||||
proposal = await agent.run(lead=lead, outcomes=request.outcomes or None)
|
||||
return ProposalResponse(
|
||||
id=proposal.id,
|
||||
lead_id=proposal.lead_id,
|
||||
company_name=proposal.company_name,
|
||||
body_markdown=proposal.body_markdown,
|
||||
budget_min=proposal.budget_min,
|
||||
budget_max=proposal.budget_max,
|
||||
currency=proposal.currency,
|
||||
valid_until=proposal.valid_until,
|
||||
created_at=proposal.created_at,
|
||||
)
|
||||
61
dealix/api/routers/sectors.py
Normal file
61
dealix/api/routers/sectors.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""Sectors (Phase 9) endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from api.dependencies import get_content_agent, get_sector_intel_agent
|
||||
from api.schemas import ContentRequest, ContentResponse, SectorIntelResponse
|
||||
from autonomous_growth.agents.content import ContentCreatorAgent
|
||||
from autonomous_growth.agents.sector_intel import SaudiSector, SectorIntelAgent
|
||||
|
||||
router = APIRouter(prefix="/api/v1/sectors", tags=["sectors"])
|
||||
|
||||
|
||||
@router.get("/{sector}", response_model=SectorIntelResponse)
|
||||
async def sector_intel(
|
||||
sector: str,
|
||||
enrich_with_llm: bool = Query(False, description="Enrich baseline with LLM research"),
|
||||
agent: SectorIntelAgent = Depends(get_sector_intel_agent),
|
||||
) -> SectorIntelResponse:
|
||||
"""Deep intel for one Saudi sector."""
|
||||
try:
|
||||
sector_enum = SaudiSector(sector)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown sector: {sector}") from e
|
||||
intel = await agent.run(sector=sector_enum, enrich_with_llm=enrich_with_llm)
|
||||
return SectorIntelResponse(**intel.to_dict())
|
||||
|
||||
|
||||
@router.get("/best/opportunity", response_model=SectorIntelResponse)
|
||||
async def best_opportunity(
|
||||
agent: SectorIntelAgent = Depends(get_sector_intel_agent),
|
||||
) -> SectorIntelResponse:
|
||||
"""Return the highest-leverage sector."""
|
||||
intel = await agent.best_opportunity()
|
||||
return SectorIntelResponse(**intel.to_dict())
|
||||
|
||||
|
||||
@router.get("/target/list", response_model=list[SectorIntelResponse])
|
||||
async def target_sectors(
|
||||
agent: SectorIntelAgent = Depends(get_sector_intel_agent),
|
||||
) -> list[SectorIntelResponse]:
|
||||
"""Our top-5 target sectors."""
|
||||
intels = agent.target_sectors()
|
||||
return [SectorIntelResponse(**i.to_dict()) for i in intels]
|
||||
|
||||
|
||||
@router.post("/content", response_model=ContentResponse)
|
||||
async def generate_content(
|
||||
request: ContentRequest,
|
||||
agent: ContentCreatorAgent = Depends(get_content_agent),
|
||||
) -> ContentResponse:
|
||||
"""Generate a content piece for a sector topic."""
|
||||
piece = await agent.run(
|
||||
topic=request.topic,
|
||||
content_type=request.content_type, # type: ignore[arg-type]
|
||||
channel=request.channel, # type: ignore[arg-type]
|
||||
locale=request.locale,
|
||||
length=request.length,
|
||||
)
|
||||
return ContentResponse(**piece.to_dict())
|
||||
159
dealix/api/routers/v3.py
Normal file
159
dealix/api/routers/v3.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""Dealix v3 Autonomous Revenue OS endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from auto_client_acquisition.v3.agents import AgentName, AgentTask, SafeAgentRuntime, agent_catalog
|
||||
from auto_client_acquisition.v3.compliance_os import ContactPolicyInput, assess_contactability, campaign_risk_report, ropa_stub
|
||||
from auto_client_acquisition.v3.market_radar import demo_signals, rank_opportunities, sector_heatmap, signal_catalog
|
||||
from auto_client_acquisition.v3.memory import EventType, RevenueEvent, demo_memory
|
||||
from auto_client_acquisition.v3.revenue_science import FunnelInputs, churn_risk_score, demo_forecast, forecast_revenue, impact_simulation
|
||||
|
||||
router = APIRouter(prefix="/api/v1/v3", tags=["v3-autonomous-revenue-os"])
|
||||
_runtime = SafeAgentRuntime()
|
||||
_memory = demo_memory()
|
||||
|
||||
|
||||
@router.get("/stack")
|
||||
async def stack() -> dict[str, Any]:
|
||||
return {
|
||||
"name": "Dealix v3 Autonomous Saudi Revenue OS",
|
||||
"layers": [
|
||||
"Revenue Memory",
|
||||
"Safe Agent Runtime",
|
||||
"Saudi Market Radar",
|
||||
"PDPL Compliance OS",
|
||||
"Revenue Science",
|
||||
"Command Center Copilot",
|
||||
"Vertical OS",
|
||||
"Ecosystem Integrations",
|
||||
],
|
||||
"recommended_tools": {
|
||||
"agent_workflows": ["LangGraph", "OpenAI Agents SDK", "Pydantic AI"],
|
||||
"rag": ["LlamaIndex", "Qdrant or pgvector"],
|
||||
"observability": ["Langfuse", "OpenTelemetry", "Sentry"],
|
||||
"automation": ["n8n", "MCP connectors"],
|
||||
"frontend": ["Next.js", "Tailwind", "shadcn/ui", "Recharts", "TanStack Query"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/agents")
|
||||
async def agents() -> dict[str, Any]:
|
||||
return {"count": len(agent_catalog()), "items": agent_catalog()}
|
||||
|
||||
|
||||
@router.post("/agents/tasks")
|
||||
async def create_agent_task(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
task = AgentTask(
|
||||
agent=AgentName(body.get("agent", "prospecting")),
|
||||
objective=str(body.get("objective", "")) or "Find next best revenue action",
|
||||
customer_id=str(body.get("customer_id", "demo")),
|
||||
context=dict(body.get("context") or {}),
|
||||
requires_approval=bool(body.get("requires_approval", True)),
|
||||
risk_level=str(body.get("risk_level", "medium")),
|
||||
)
|
||||
return _runtime.create_task(task).to_dict()
|
||||
|
||||
|
||||
@router.post("/agents/tasks/{task_id}/approve")
|
||||
async def approve_task(task_id: str) -> dict[str, Any]:
|
||||
return _runtime.approve(task_id).to_dict()
|
||||
|
||||
|
||||
@router.post("/agents/tasks/{task_id}/execute")
|
||||
async def execute_task(task_id: str) -> dict[str, Any]:
|
||||
return _runtime.execute(task_id)
|
||||
|
||||
|
||||
@router.get("/market-radar")
|
||||
async def market_radar() -> dict[str, Any]:
|
||||
signals = demo_signals()
|
||||
return {
|
||||
"opportunities": rank_opportunities(signals),
|
||||
"sector_heatmap": sector_heatmap(signals),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/market-radar/signal-catalog")
|
||||
async def market_radar_signal_catalog() -> dict[str, Any]:
|
||||
return {"count": len(signal_catalog()), "items": signal_catalog()}
|
||||
|
||||
|
||||
@router.post("/compliance/contactability")
|
||||
async def contactability(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
item = ContactPolicyInput(**body)
|
||||
return assess_contactability(item)
|
||||
|
||||
|
||||
@router.post("/compliance/campaign-risk")
|
||||
async def campaign_risk(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
contacts = [ContactPolicyInput(**item) for item in body.get("contacts", [])]
|
||||
return campaign_risk_report(contacts)
|
||||
|
||||
|
||||
@router.get("/compliance/ropa")
|
||||
async def ropa(process_name: str = "Outbound Revenue Operations", purpose: str = "B2B sales follow-up") -> dict[str, Any]:
|
||||
return ropa_stub(process_name, purpose)
|
||||
|
||||
|
||||
@router.get("/memory/{aggregate_id}")
|
||||
async def memory_projection(aggregate_id: str) -> dict[str, Any]:
|
||||
return {"projection": _memory.projection(aggregate_id), "timeline": _memory.timeline(aggregate_id)}
|
||||
|
||||
|
||||
@router.post("/memory/events")
|
||||
async def append_event(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
event = RevenueEvent(
|
||||
event_type=EventType(body.get("event_type", "signal.detected")),
|
||||
customer_id=str(body.get("customer_id", "demo")),
|
||||
aggregate_id=str(body.get("aggregate_id", "demo_account")),
|
||||
payload=dict(body.get("payload") or {}),
|
||||
actor=str(body.get("actor", "api")),
|
||||
)
|
||||
_memory.append(event)
|
||||
return event.to_dict()
|
||||
|
||||
|
||||
@router.post("/revenue-science/forecast")
|
||||
async def forecast(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return forecast_revenue(FunnelInputs(**body))
|
||||
|
||||
|
||||
@router.post("/revenue-science/impact")
|
||||
async def impact(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return impact_simulation(FunnelInputs(**body["base"]), FunnelInputs(**body["improved"]))
|
||||
|
||||
|
||||
@router.get("/revenue-science/demo")
|
||||
async def revenue_demo() -> dict[str, Any]:
|
||||
return demo_forecast()
|
||||
|
||||
|
||||
@router.post("/revenue-science/churn-risk")
|
||||
async def churn(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return churn_risk_score(
|
||||
usage_days_30=int(body.get("usage_days_30", 0)),
|
||||
outcomes_seen=int(body.get("outcomes_seen", 0)),
|
||||
support_sentiment=float(body.get("support_sentiment", 0.5)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/command-center/snapshot")
|
||||
async def command_center_snapshot() -> dict[str, Any]:
|
||||
signals = demo_signals()
|
||||
return {
|
||||
"today_decisions": [
|
||||
"Approve 12 safe WhatsApp follow-ups from warm inbound replies.",
|
||||
"Pause cold WhatsApp campaign: compliance risk blocked.",
|
||||
"Focus this week on clinics in Riyadh and real estate in Jeddah.",
|
||||
],
|
||||
"agents": agent_catalog(),
|
||||
"market_radar": rank_opportunities(signals, limit=3),
|
||||
"forecast": demo_forecast(),
|
||||
"compliance": assess_contactability(ContactPolicyInput(channel="email", has_prior_relationship=True)),
|
||||
"memory": _memory.projection("clinic_riyadh_01"),
|
||||
}
|
||||
94
dealix/api/routers/webhooks.py
Normal file
94
dealix/api/routers/webhooks.py
Normal file
@ -0,0 +1,94 @@
|
||||
"""Incoming webhooks — WhatsApp, HubSpot, Calendly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
||||
|
||||
from api.dependencies import get_acquisition_pipeline
|
||||
from auto_client_acquisition.agents.intake import LeadSource
|
||||
from core.config.settings import get_settings
|
||||
from core.logging import get_logger
|
||||
from integrations.whatsapp import WhatsAppClient
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix="/api/v1/webhooks", tags=["webhooks"])
|
||||
|
||||
|
||||
# ── WhatsApp ───────────────────────────────────────────────────
|
||||
@router.get("/whatsapp")
|
||||
async def whatsapp_verify(
|
||||
hub_mode: str = Query(..., alias="hub.mode"),
|
||||
hub_verify_token: str = Query(..., alias="hub.verify_token"),
|
||||
hub_challenge: str = Query(..., alias="hub.challenge"),
|
||||
) -> Any:
|
||||
"""Meta WhatsApp webhook verification."""
|
||||
client = WhatsAppClient()
|
||||
challenge = client.verify_webhook(hub_mode, hub_verify_token, hub_challenge)
|
||||
if challenge is None:
|
||||
raise HTTPException(status_code=403, detail="Invalid verification token")
|
||||
return int(challenge)
|
||||
|
||||
|
||||
@router.post("/whatsapp")
|
||||
async def whatsapp_incoming(
|
||||
request: Request,
|
||||
x_hub_signature_256: str = Header(default=""),
|
||||
) -> dict[str, Any]:
|
||||
"""Handle incoming WhatsApp messages — route them as leads."""
|
||||
body = await request.body()
|
||||
client = WhatsAppClient()
|
||||
settings = get_settings()
|
||||
has_secret = bool(client.settings.whatsapp_app_secret)
|
||||
|
||||
# Staging/production with app secret: require valid Meta signature always.
|
||||
if has_secret and settings.app_env in ("staging", "production"):
|
||||
if not x_hub_signature_256 or not client.verify_signature(body, x_hub_signature_256):
|
||||
logger.warning("whatsapp_missing_or_invalid_signature_strict_env")
|
||||
raise HTTPException(status_code=403, detail="missing_or_invalid_signature")
|
||||
elif x_hub_signature_256 and has_secret and not client.verify_signature(body, x_hub_signature_256):
|
||||
logger.warning("whatsapp_invalid_signature")
|
||||
raise HTTPException(status_code=403, detail="Invalid signature")
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}") from e
|
||||
|
||||
messages = client.parse_incoming(payload)
|
||||
pipeline = get_acquisition_pipeline()
|
||||
processed = []
|
||||
|
||||
for msg in messages:
|
||||
if msg["type"] != "text" or not msg.get("text"):
|
||||
continue
|
||||
lead_payload = {
|
||||
"name": msg.get("contact_name") or "",
|
||||
"phone": f"+{msg['from']}",
|
||||
"message": msg["text"],
|
||||
"company": "",
|
||||
}
|
||||
result = await pipeline.run(payload=lead_payload, source=LeadSource.WHATSAPP)
|
||||
processed.append(result.lead.id)
|
||||
logger.info("whatsapp_webhook_processed", count=len(processed))
|
||||
return {"processed": processed, "count": len(processed)}
|
||||
|
||||
|
||||
# ── Calendly ───────────────────────────────────────────────────
|
||||
@router.post("/calendly")
|
||||
async def calendly_webhook(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Receive Calendly event lifecycle notifications."""
|
||||
event = payload.get("event") or payload.get("type") or "unknown"
|
||||
logger.info("calendly_webhook_received", event=event)
|
||||
return {"ok": True, "event": event}
|
||||
|
||||
|
||||
# ── HubSpot ────────────────────────────────────────────────────
|
||||
@router.post("/hubspot")
|
||||
async def hubspot_webhook(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Receive HubSpot subscription events."""
|
||||
logger.info(
|
||||
"hubspot_webhook_received", n_events=len(payload) if isinstance(payload, list) else 1
|
||||
)
|
||||
return {"ok": True}
|
||||
156
dealix/api/schemas/__init__.py
Normal file
156
dealix/api/schemas/__init__.py
Normal file
@ -0,0 +1,156 @@
|
||||
"""Pydantic schemas for API requests/responses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Common
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = "ok"
|
||||
version: str
|
||||
env: str
|
||||
providers: list[str]
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error: str
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Leads (Phase 8)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
class LeadCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
company: str = Field(..., min_length=1, max_length=200)
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = None
|
||||
sector: str | None = None
|
||||
company_size: str | None = None
|
||||
region: str | None = "Saudi Arabia"
|
||||
budget: float | None = None
|
||||
message: str | None = None
|
||||
locale: str | None = None
|
||||
source: str = "website"
|
||||
|
||||
|
||||
class LeadResponse(BaseModel):
|
||||
id: str
|
||||
source: str
|
||||
company_name: str
|
||||
contact_name: str
|
||||
contact_email: str | None
|
||||
contact_phone: str | None
|
||||
sector: str | None
|
||||
region: str | None
|
||||
status: str
|
||||
fit_score: float
|
||||
urgency_score: float
|
||||
pain_points: list[str]
|
||||
locale: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PipelineResponse(BaseModel):
|
||||
lead: LeadResponse
|
||||
fit_score: dict[str, Any] | None
|
||||
extraction: dict[str, Any] | None
|
||||
qualification: dict[str, Any] | None
|
||||
crm_sync: dict[str, Any] | None
|
||||
booking: dict[str, Any] | None
|
||||
proposal: dict[str, Any] | None
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Sales
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
class SalesScriptRequest(BaseModel):
|
||||
sector: str
|
||||
locale: str = Field(default="ar", pattern="^(ar|en)$")
|
||||
script_type: str = Field(
|
||||
default="opener",
|
||||
description="opener | follow_up_1 | follow_up_2 | demo_confirm | proposal_cover",
|
||||
)
|
||||
name: str = ""
|
||||
company: str = ""
|
||||
|
||||
|
||||
class SalesScriptResponse(BaseModel):
|
||||
script: str
|
||||
locale: str
|
||||
script_type: str
|
||||
|
||||
|
||||
class ProposalRequest(BaseModel):
|
||||
lead_id: str | None = None
|
||||
company_name: str
|
||||
sector: str
|
||||
pain_points: list[str] = []
|
||||
outcomes: list[str] = []
|
||||
budget_hint: float | None = None
|
||||
locale: str = "ar"
|
||||
region: str = "Saudi Arabia"
|
||||
|
||||
|
||||
class ProposalResponse(BaseModel):
|
||||
id: str
|
||||
lead_id: str
|
||||
company_name: str
|
||||
body_markdown: str
|
||||
budget_min: float
|
||||
budget_max: float
|
||||
currency: str
|
||||
valid_until: datetime
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Sectors (Phase 9)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
class SectorIntelResponse(BaseModel):
|
||||
sector: str
|
||||
market_size_sar: float
|
||||
market_size_sar_formatted: str
|
||||
growth_rate: float
|
||||
key_players: list[str]
|
||||
pain_points: list[str]
|
||||
opportunities: list[str]
|
||||
ai_readiness: float
|
||||
regulations: list[str]
|
||||
trends: list[str]
|
||||
vision_2030_alignment: str
|
||||
|
||||
|
||||
class ContentRequest(BaseModel):
|
||||
topic: str = Field(..., min_length=3)
|
||||
content_type: str = "article"
|
||||
channel: str = "blog"
|
||||
locale: str = "ar"
|
||||
length: int | None = None
|
||||
|
||||
|
||||
class ContentResponse(BaseModel):
|
||||
id: str
|
||||
content_type: str
|
||||
channel: str
|
||||
locale: str
|
||||
topic: str
|
||||
title: str
|
||||
body_markdown: str
|
||||
word_count: int
|
||||
tags: list[str]
|
||||
cta: str
|
||||
created_at: datetime
|
||||
19
dealix/api/security/__init__.py
Normal file
19
dealix/api/security/__init__.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""Security module — rate limiting, API keys, webhook verification."""
|
||||
|
||||
from api.security.api_key import APIKeyMiddleware, verify_api_key
|
||||
from api.security.rate_limit import limiter, setup_rate_limit
|
||||
from api.security.webhook_signatures import (
|
||||
verify_calendly_signature,
|
||||
verify_hubspot_signature,
|
||||
verify_n8n_signature,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"APIKeyMiddleware",
|
||||
"limiter",
|
||||
"setup_rate_limit",
|
||||
"verify_api_key",
|
||||
"verify_calendly_signature",
|
||||
"verify_hubspot_signature",
|
||||
"verify_n8n_signature",
|
||||
]
|
||||
91
dealix/api/security/api_key.py
Normal file
91
dealix/api/security/api_key.py
Normal file
@ -0,0 +1,91 @@
|
||||
"""
|
||||
API key authentication middleware.
|
||||
وسيط مصادقة مفتاح API.
|
||||
|
||||
Policy:
|
||||
* Requests to /health* and /docs*, /openapi.json, / are public.
|
||||
* Webhook endpoints use webhook signatures (see webhook_signatures.py).
|
||||
* All other /api/* endpoints require a valid X-API-Key header
|
||||
that matches one of the secrets in settings.api_keys (comma separated).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import os
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
|
||||
from fastapi import Request, status
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
from core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Paths that are always public — no API key required
|
||||
PUBLIC_PATHS: set[str] = {
|
||||
"/",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/health",
|
||||
"/health/live",
|
||||
"/health/ready",
|
||||
"/health/deep",
|
||||
# Public pricing list — prospects need to see plans without an API key.
|
||||
# Checkout + plan-specific tampering protection stays on /api/v1/checkout.
|
||||
"/api/v1/pricing/plans",
|
||||
}
|
||||
PUBLIC_PREFIXES: tuple[str, ...] = (
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/static",
|
||||
"/api/v1/webhooks/", # webhooks use signatures instead
|
||||
"/api/v1/public/", # public landing endpoints (demo-request, health)
|
||||
)
|
||||
|
||||
|
||||
def _configured_keys() -> list[str]:
|
||||
raw = os.getenv("API_KEYS", "")
|
||||
return [k.strip() for k in raw.split(",") if k.strip()]
|
||||
|
||||
|
||||
def verify_api_key(key: str | None, allowed: Iterable[str] | None = None) -> bool:
|
||||
if not key:
|
||||
return False
|
||||
allowed_keys = list(allowed) if allowed is not None else _configured_keys()
|
||||
if not allowed_keys:
|
||||
# No keys configured → allow (dev mode). Production MUST set API_KEYS.
|
||||
return True
|
||||
return any(hmac.compare_digest(k, key) for k in allowed_keys)
|
||||
|
||||
|
||||
class APIKeyMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(
|
||||
self,
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
path = request.url.path
|
||||
if path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES):
|
||||
return await call_next(request)
|
||||
|
||||
# Enforce key only when API_KEYS is configured
|
||||
allowed = _configured_keys()
|
||||
if not allowed:
|
||||
return await call_next(request)
|
||||
|
||||
provided = request.headers.get("X-API-Key")
|
||||
if not verify_api_key(provided, allowed):
|
||||
logger.warning("api_key_invalid", path=path, has_key=bool(provided))
|
||||
# Return a proper JSONResponse instead of raising HTTPException —
|
||||
# BaseHTTPMiddleware does not route exceptions through FastAPI's
|
||||
# exception handlers, so raising here produces a bare 500 at the
|
||||
# edge. Returning a Response gives clients a clean 401.
|
||||
return JSONResponse(
|
||||
{"detail": "Invalid or missing X-API-Key"},
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
83
dealix/api/security/rate_limit.py
Normal file
83
dealix/api/security/rate_limit.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""
|
||||
Rate limiting via slowapi.
|
||||
تحديد المعدل عبر slowapi.
|
||||
|
||||
Default policy (per route):
|
||||
POST /api/v1/leads → 10/min
|
||||
POST /api/v1/sales/* → 30/min
|
||||
POST /api/v1/webhooks/wa → 100/min
|
||||
Other API routes → 60/min
|
||||
Global (per IP, all paths) → 1000/min
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
try:
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
_HAS_SLOWAPI = True
|
||||
except ImportError: # pragma: no cover
|
||||
_HAS_SLOWAPI = False
|
||||
Limiter = None # type: ignore
|
||||
RateLimitExceeded = Exception # type: ignore
|
||||
|
||||
|
||||
def _key_func(request: Request) -> str:
|
||||
"""Prefer API key (authenticated callers) over IP."""
|
||||
key = request.headers.get("X-API-Key")
|
||||
if key:
|
||||
return f"api:{key[:16]}"
|
||||
if _HAS_SLOWAPI:
|
||||
return get_remote_address(request)
|
||||
return request.client.host if request.client else "anon"
|
||||
|
||||
|
||||
DEFAULT_GLOBAL_LIMIT = os.getenv("RL_GLOBAL", "1000/minute")
|
||||
|
||||
limiter: Any = None
|
||||
if _HAS_SLOWAPI:
|
||||
limiter = Limiter(
|
||||
key_func=_key_func,
|
||||
default_limits=[DEFAULT_GLOBAL_LIMIT],
|
||||
storage_uri=os.getenv("RL_STORAGE_URI", "memory://"),
|
||||
strategy="fixed-window",
|
||||
)
|
||||
|
||||
|
||||
# Per-route limits (applied via decorators in routers)
|
||||
LIMITS = {
|
||||
"leads_create": os.getenv("RL_LEADS", "10/minute"),
|
||||
"sales_any": os.getenv("RL_SALES", "30/minute"),
|
||||
"whatsapp_webhook": os.getenv("RL_WA_WEBHOOK", "100/minute"),
|
||||
"generic_api": os.getenv("RL_GENERIC", "60/minute"),
|
||||
}
|
||||
|
||||
|
||||
def setup_rate_limit(app: FastAPI) -> None:
|
||||
"""Wire slowapi into the FastAPI app. No-op if slowapi is missing."""
|
||||
if not _HAS_SLOWAPI or limiter is None:
|
||||
return
|
||||
|
||||
app.state.limiter = limiter
|
||||
|
||||
async def _rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
"error": "RateLimitExceeded",
|
||||
"detail": f"Too many requests: {exc.detail}",
|
||||
"ar": "تجاوزت الحد المسموح، يرجى المحاولة لاحقاً.",
|
||||
},
|
||||
)
|
||||
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_handler)
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
105
dealix/api/security/webhook_signatures.py
Normal file
105
dealix/api/security/webhook_signatures.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""
|
||||
Webhook signature verification for HubSpot, Calendly, and n8n.
|
||||
التحقق من توقيع webhook.
|
||||
|
||||
Each function returns True/False; the caller should 401 on False.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
def _get_secret(env_var: str, override: str | None = None) -> str | None:
|
||||
return override or os.getenv(env_var)
|
||||
|
||||
|
||||
# ── HubSpot v3 signatures ──────────────────────────────────────────
|
||||
# https://developers.hubspot.com/docs/api/webhooks/validating-requests
|
||||
def verify_hubspot_signature(
|
||||
*,
|
||||
method: str,
|
||||
url: str,
|
||||
body: bytes,
|
||||
timestamp: str | None,
|
||||
signature: str | None,
|
||||
secret: str | None = None,
|
||||
) -> bool:
|
||||
s = _get_secret("HUBSPOT_APP_SECRET", secret)
|
||||
if not s or not signature or not timestamp:
|
||||
return False
|
||||
source = f"{method.upper()}{url}{body.decode('utf-8', 'replace')}{timestamp}"
|
||||
digest = hmac.new(s.encode(), source.encode(), hashlib.sha256).digest()
|
||||
expected = base64.b64encode(digest).decode()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
# ── Calendly signatures ────────────────────────────────────────────
|
||||
# https://developer.calendly.com/api-docs/ZG9jOjE2OTM0NjE4-webhook-signatures
|
||||
def verify_calendly_signature(
|
||||
*,
|
||||
body: bytes,
|
||||
header: str | None,
|
||||
secret: str | None = None,
|
||||
) -> bool:
|
||||
s = _get_secret("CALENDLY_WEBHOOK_SECRET", secret)
|
||||
if not s or not header:
|
||||
return False
|
||||
# header format: "t=<timestamp>,v1=<signature>"
|
||||
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
|
||||
ts = parts.get("t")
|
||||
sig = parts.get("v1")
|
||||
if not ts or not sig:
|
||||
return False
|
||||
signed = f"{ts}.{body.decode('utf-8', 'replace')}"
|
||||
expected = hmac.new(s.encode(), signed.encode(), hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, sig)
|
||||
|
||||
|
||||
# ── n8n (generic HMAC-SHA256 hex) ──────────────────────────────────
|
||||
def verify_n8n_signature(
|
||||
*,
|
||||
body: bytes,
|
||||
signature: str | None,
|
||||
secret: str | None = None,
|
||||
) -> bool:
|
||||
s = _get_secret("N8N_WEBHOOK_SECRET", secret)
|
||||
if not s or not signature:
|
||||
return False
|
||||
expected = hmac.new(s.encode(), body, hashlib.sha256).hexdigest()
|
||||
provided = signature.removeprefix("sha256=")
|
||||
return hmac.compare_digest(expected, provided)
|
||||
|
||||
|
||||
# ── Generic helper for FastAPI request objects ────────────────────
|
||||
async def require_signed(
|
||||
request_body: bytes,
|
||||
headers: Mapping[str, str],
|
||||
*,
|
||||
provider: str,
|
||||
method: str = "POST",
|
||||
url: str = "",
|
||||
) -> bool:
|
||||
if provider == "hubspot":
|
||||
return verify_hubspot_signature(
|
||||
method=method,
|
||||
url=url,
|
||||
body=request_body,
|
||||
timestamp=headers.get("X-HubSpot-Request-Timestamp"),
|
||||
signature=headers.get("X-HubSpot-Signature-v3"),
|
||||
)
|
||||
if provider == "calendly":
|
||||
return verify_calendly_signature(
|
||||
body=request_body,
|
||||
header=headers.get("Calendly-Webhook-Signature"),
|
||||
)
|
||||
if provider == "n8n":
|
||||
return verify_n8n_signature(
|
||||
body=request_body,
|
||||
signature=headers.get("X-N8N-Signature"),
|
||||
)
|
||||
return False
|
||||
4
dealix/auto_client_acquisition/__init__.py
Normal file
4
dealix/auto_client_acquisition/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""
|
||||
Phase 8 — Auto Client Acquisition.
|
||||
المرحلة 8 — اكتساب العملاء تلقائياً.
|
||||
"""
|
||||
34
dealix/auto_client_acquisition/agents/__init__.py
Normal file
34
dealix/auto_client_acquisition/agents/__init__.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""Phase 8 agents package."""
|
||||
|
||||
from auto_client_acquisition.agents.booking import BookingAgent
|
||||
from auto_client_acquisition.agents.crm import CRMAgent
|
||||
from auto_client_acquisition.agents.followup import FollowUpAgent
|
||||
from auto_client_acquisition.agents.icp_matcher import ICP, FitScore, ICPMatcherAgent
|
||||
from auto_client_acquisition.agents.intake import IntakeAgent, Lead, LeadSource, LeadStatus
|
||||
from auto_client_acquisition.agents.outreach import OutreachAgent
|
||||
from auto_client_acquisition.agents.pain_extractor import (
|
||||
ExtractionResult,
|
||||
PainExtractorAgent,
|
||||
PainPoint,
|
||||
)
|
||||
from auto_client_acquisition.agents.proposal import ProposalAgent
|
||||
from auto_client_acquisition.agents.qualification import QualificationAgent
|
||||
|
||||
__all__ = [
|
||||
"ICP",
|
||||
"BookingAgent",
|
||||
"CRMAgent",
|
||||
"ExtractionResult",
|
||||
"FitScore",
|
||||
"FollowUpAgent",
|
||||
"ICPMatcherAgent",
|
||||
"IntakeAgent",
|
||||
"Lead",
|
||||
"LeadSource",
|
||||
"LeadStatus",
|
||||
"OutreachAgent",
|
||||
"PainExtractorAgent",
|
||||
"PainPoint",
|
||||
"ProposalAgent",
|
||||
"QualificationAgent",
|
||||
]
|
||||
178
dealix/auto_client_acquisition/agents/booking.py
Normal file
178
dealix/auto_client_acquisition/agents/booking.py
Normal file
@ -0,0 +1,178 @@
|
||||
"""
|
||||
Booking Agent — books discovery calls via Calendly (preferred) or Google Calendar.
|
||||
وكيل الحجز — يحجز مكالمات الاستكشاف عبر Calendly أو Google Calendar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from auto_client_acquisition.agents.intake import Lead
|
||||
from core.agents.base import BaseAgent
|
||||
from core.config.settings import get_settings
|
||||
from core.prompts.sales_scripts import get_sales_script
|
||||
from core.utils import generate_id
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookingResult:
|
||||
booking_id: str
|
||||
provider: str # calendly | google | manual
|
||||
link: str | None
|
||||
scheduled_at: datetime | None
|
||||
meeting_minutes: int
|
||||
invitee_email: str | None
|
||||
invitee_phone: str | None
|
||||
confirmation_message: str
|
||||
success: bool
|
||||
reason: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"booking_id": self.booking_id,
|
||||
"provider": self.provider,
|
||||
"link": self.link,
|
||||
"scheduled_at": self.scheduled_at.isoformat() if self.scheduled_at else None,
|
||||
"meeting_minutes": self.meeting_minutes,
|
||||
"invitee_email": self.invitee_email,
|
||||
"invitee_phone": self.invitee_phone,
|
||||
"confirmation_message": self.confirmation_message,
|
||||
"success": self.success,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
class BookingAgent(BaseAgent):
|
||||
"""
|
||||
Attempts to book a meeting using the best available provider.
|
||||
Priority: Calendly (scheduling link) → Google Calendar → manual fallback.
|
||||
"""
|
||||
|
||||
name = "booking"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.settings = get_settings()
|
||||
self.tz = ZoneInfo(self.settings.app_timezone)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*,
|
||||
lead: Lead,
|
||||
preferred_time: datetime | None = None,
|
||||
meeting_minutes: int = 30,
|
||||
**_: Any,
|
||||
) -> BookingResult:
|
||||
"""Return booking details or manual fallback."""
|
||||
booking_id = generate_id("bkg")
|
||||
|
||||
# 1. Calendly (preferred for self-service scheduling)
|
||||
if self.settings.calendly_api_token and self.settings.calendly_user_uri:
|
||||
link = self._calendly_scheduling_link()
|
||||
confirm = self._confirm_message(lead, "calendly", None, link)
|
||||
self.log.info("booking_calendly_link_sent", lead_id=lead.id, link=link)
|
||||
return BookingResult(
|
||||
booking_id=booking_id,
|
||||
provider="calendly",
|
||||
link=link,
|
||||
scheduled_at=None,
|
||||
meeting_minutes=meeting_minutes,
|
||||
invitee_email=lead.contact_email,
|
||||
invitee_phone=lead.contact_phone,
|
||||
confirmation_message=confirm,
|
||||
success=True,
|
||||
reason="Sent Calendly scheduling link",
|
||||
)
|
||||
|
||||
# 2. Google Calendar direct-create (if credentials present)
|
||||
if self.settings.google_calendar_credentials_file:
|
||||
scheduled = preferred_time or self._default_slot()
|
||||
# NOTE: actual Google API call happens in integrations/calendar.py
|
||||
# The integration layer will be invoked via a callable if present.
|
||||
confirm = self._confirm_message(
|
||||
lead, "google", scheduled, link=None, meeting_minutes=meeting_minutes
|
||||
)
|
||||
self.log.info("booking_google_scheduled", lead_id=lead.id, when=scheduled.isoformat())
|
||||
return BookingResult(
|
||||
booking_id=booking_id,
|
||||
provider="google",
|
||||
link=None,
|
||||
scheduled_at=scheduled,
|
||||
meeting_minutes=meeting_minutes,
|
||||
invitee_email=lead.contact_email,
|
||||
invitee_phone=lead.contact_phone,
|
||||
confirmation_message=confirm,
|
||||
success=True,
|
||||
reason="Scheduled via Google Calendar",
|
||||
)
|
||||
|
||||
# 3. Manual fallback — return instructions
|
||||
confirm = self._confirm_message(lead, "manual", None, None)
|
||||
self.log.warning("booking_manual_fallback", lead_id=lead.id)
|
||||
return BookingResult(
|
||||
booking_id=booking_id,
|
||||
provider="manual",
|
||||
link=None,
|
||||
scheduled_at=None,
|
||||
meeting_minutes=meeting_minutes,
|
||||
invitee_email=lead.contact_email,
|
||||
invitee_phone=lead.contact_phone,
|
||||
confirmation_message=confirm,
|
||||
success=False,
|
||||
reason="No booking provider configured",
|
||||
)
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────
|
||||
def _calendly_scheduling_link(self) -> str:
|
||||
"""Return the public Calendly link derived from the user URI."""
|
||||
user_uri = self.settings.calendly_user_uri or ""
|
||||
if user_uri.startswith("http"):
|
||||
return user_uri
|
||||
return f"https://calendly.com/{user_uri}"
|
||||
|
||||
def _default_slot(self) -> datetime:
|
||||
"""Next business-day 10:00 Riyadh."""
|
||||
now = datetime.now(self.tz)
|
||||
# Skip Fri/Sat (weekend in Saudi)
|
||||
target = now + timedelta(days=1)
|
||||
while target.weekday() in (4, 5):
|
||||
target += timedelta(days=1)
|
||||
return target.replace(hour=10, minute=0, second=0, microsecond=0)
|
||||
|
||||
def _confirm_message(
|
||||
self,
|
||||
lead: Lead,
|
||||
provider: str,
|
||||
scheduled: datetime | None,
|
||||
link: str | None,
|
||||
meeting_minutes: int = 30,
|
||||
) -> str:
|
||||
if provider == "manual":
|
||||
if lead.locale == "ar":
|
||||
return (
|
||||
f"شكراً {lead.contact_name or ''}. "
|
||||
f"فريقنا سيتواصل معك خلال 24 ساعة لتحديد موعد مناسب."
|
||||
)
|
||||
return (
|
||||
f"Thanks {lead.contact_name or ''}. "
|
||||
f"Our team will reach out within 24 hours to schedule."
|
||||
)
|
||||
if provider == "calendly" and link:
|
||||
if lead.locale == "ar":
|
||||
return (
|
||||
f"مرحباً {lead.contact_name or ''}،\n" f"اختر الموعد المناسب لك من هنا: {link}"
|
||||
)
|
||||
return f"Hi {lead.contact_name or ''},\n" f"Pick a slot that works for you: {link}"
|
||||
if provider == "google" and scheduled:
|
||||
return get_sales_script(
|
||||
"demo_confirm",
|
||||
locale=lead.locale,
|
||||
name=lead.contact_name or "",
|
||||
date=scheduled.strftime("%Y-%m-%d"),
|
||||
time=scheduled.strftime("%H:%M"),
|
||||
link="(meeting link will be sent separately)",
|
||||
)
|
||||
return "Booking pending."
|
||||
212
dealix/auto_client_acquisition/agents/crm.py
Normal file
212
dealix/auto_client_acquisition/agents/crm.py
Normal file
@ -0,0 +1,212 @@
|
||||
"""
|
||||
CRM Agent — syncs leads to HubSpot (contact + deal creation).
|
||||
وكيل CRM — يُزامن العملاء مع HubSpot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
||||
|
||||
from auto_client_acquisition.agents.icp_matcher import FitScore
|
||||
from auto_client_acquisition.agents.intake import Lead, LeadStatus
|
||||
from core.agents.base import BaseAgent
|
||||
from core.config.settings import get_settings
|
||||
from core.errors import IntegrationError
|
||||
|
||||
|
||||
@dataclass
|
||||
class CRMSyncResult:
|
||||
synced: bool
|
||||
contact_id: str | None = None
|
||||
deal_id: str | None = None
|
||||
provider: str = "hubspot"
|
||||
reason: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"synced": self.synced,
|
||||
"contact_id": self.contact_id,
|
||||
"deal_id": self.deal_id,
|
||||
"provider": self.provider,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
# Map internal status → HubSpot deal stage (customize per portal)
|
||||
STATUS_TO_STAGE: dict[LeadStatus, str] = {
|
||||
LeadStatus.NEW: "appointmentscheduled",
|
||||
LeadStatus.QUALIFIED: "qualifiedtobuy",
|
||||
LeadStatus.DISCOVERY: "presentationscheduled",
|
||||
LeadStatus.PROPOSAL: "decisionmakerboughtin",
|
||||
LeadStatus.NEGOTIATION: "contractsent",
|
||||
LeadStatus.WON: "closedwon",
|
||||
LeadStatus.LOST: "closedlost",
|
||||
LeadStatus.DISQUALIFIED: "closedlost",
|
||||
}
|
||||
|
||||
|
||||
class CRMAgent(BaseAgent):
|
||||
"""Creates/updates contacts and deals in HubSpot."""
|
||||
|
||||
name = "crm"
|
||||
HUBSPOT_BASE_URL = "https://api.hubapi.com"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.settings = get_settings()
|
||||
|
||||
@property
|
||||
def _configured(self) -> bool:
|
||||
return self.settings.hubspot_access_token is not None
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
if not self.settings.hubspot_access_token:
|
||||
raise IntegrationError("HUBSPOT_ACCESS_TOKEN not configured")
|
||||
token = self.settings.hubspot_access_token.get_secret_value()
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*,
|
||||
lead: Lead,
|
||||
fit_score: FitScore | None = None,
|
||||
create_deal: bool = True,
|
||||
**_: Any,
|
||||
) -> CRMSyncResult:
|
||||
"""Sync a lead to HubSpot: upsert contact, optionally create deal."""
|
||||
if not self._configured:
|
||||
self.log.warning("crm_not_configured")
|
||||
return CRMSyncResult(synced=False, reason="HubSpot not configured — skipped")
|
||||
|
||||
try:
|
||||
contact_id = await self._upsert_contact(lead, fit_score)
|
||||
deal_id: str | None = None
|
||||
if create_deal and lead.company_name:
|
||||
deal_id = await self._create_deal(lead, contact_id, fit_score)
|
||||
|
||||
self.log.info("crm_sync_ok", lead_id=lead.id, contact_id=contact_id, deal_id=deal_id)
|
||||
return CRMSyncResult(synced=True, contact_id=contact_id, deal_id=deal_id)
|
||||
except Exception as e:
|
||||
self.log.exception("crm_sync_failed", error=str(e))
|
||||
return CRMSyncResult(synced=False, reason=str(e))
|
||||
|
||||
# ── Contact upsert ──────────────────────────────────────────
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=2, max=10),
|
||||
retry=retry_if_exception_type((httpx.TimeoutException, httpx.HTTPStatusError)),
|
||||
reraise=True,
|
||||
)
|
||||
async def _upsert_contact(self, lead: Lead, fit: FitScore | None) -> str:
|
||||
"""Create or update contact by email, return contact_id."""
|
||||
properties: dict[str, Any] = {
|
||||
"email": lead.contact_email or f"noemail+{lead.id}@ai-company.sa",
|
||||
"firstname": (lead.contact_name or "").split(" ")[0] if lead.contact_name else "",
|
||||
"lastname": " ".join((lead.contact_name or "").split(" ")[1:]),
|
||||
"phone": lead.contact_phone or "",
|
||||
"company": lead.company_name,
|
||||
"lifecyclestage": "lead",
|
||||
"hs_lead_status": "NEW" if lead.status == LeadStatus.NEW else "OPEN",
|
||||
}
|
||||
if lead.sector:
|
||||
properties["industry"] = lead.sector
|
||||
if fit:
|
||||
properties["hs_analytics_source_data_1"] = f"fit_tier_{fit.tier}"
|
||||
|
||||
payload = {"properties": properties}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
# Try create first
|
||||
response = await client.post(
|
||||
f"{self.HUBSPOT_BASE_URL}/crm/v3/objects/contacts",
|
||||
json=payload,
|
||||
headers=self._headers(),
|
||||
)
|
||||
if response.status_code == 409:
|
||||
# Already exists — search by email
|
||||
search_url = f"{self.HUBSPOT_BASE_URL}/crm/v3/objects/contacts/search"
|
||||
search_resp = await client.post(
|
||||
search_url,
|
||||
json={
|
||||
"filterGroups": [
|
||||
{
|
||||
"filters": [
|
||||
{
|
||||
"propertyName": "email",
|
||||
"operator": "EQ",
|
||||
"value": properties["email"],
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"limit": 1,
|
||||
},
|
||||
headers=self._headers(),
|
||||
)
|
||||
search_resp.raise_for_status()
|
||||
results = search_resp.json().get("results", [])
|
||||
if not results:
|
||||
raise IntegrationError("Contact exists but cannot be found")
|
||||
contact_id = str(results[0]["id"])
|
||||
# Update
|
||||
update_resp = await client.patch(
|
||||
f"{self.HUBSPOT_BASE_URL}/crm/v3/objects/contacts/{contact_id}",
|
||||
json=payload,
|
||||
headers=self._headers(),
|
||||
)
|
||||
update_resp.raise_for_status()
|
||||
return contact_id
|
||||
|
||||
response.raise_for_status()
|
||||
return str(response.json()["id"])
|
||||
|
||||
# ── Deal creation ───────────────────────────────────────────
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=2, max=10),
|
||||
retry=retry_if_exception_type((httpx.TimeoutException, httpx.HTTPStatusError)),
|
||||
reraise=True,
|
||||
)
|
||||
async def _create_deal(self, lead: Lead, contact_id: str, fit: FitScore | None) -> str:
|
||||
"""Create a deal and associate with contact."""
|
||||
stage = STATUS_TO_STAGE.get(lead.status, "appointmentscheduled")
|
||||
deal_name = f"{lead.company_name} — {lead.sector or 'Discovery'}"
|
||||
amount = lead.budget or 0.0
|
||||
|
||||
payload = {
|
||||
"properties": {
|
||||
"dealname": deal_name,
|
||||
"dealstage": stage,
|
||||
"amount": str(amount),
|
||||
"pipeline": "default",
|
||||
}
|
||||
}
|
||||
if fit:
|
||||
payload["properties"]["description"] = (
|
||||
f"Fit tier {fit.tier} (score {fit.overall_score:.2f}). "
|
||||
+ "; ".join(fit.reasons[:3])
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
f"{self.HUBSPOT_BASE_URL}/crm/v3/objects/deals",
|
||||
json=payload,
|
||||
headers=self._headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
deal_id = str(response.json()["id"])
|
||||
|
||||
# Associate deal with contact
|
||||
await client.put(
|
||||
f"{self.HUBSPOT_BASE_URL}/crm/v4/objects/deals/{deal_id}/associations/"
|
||||
f"default/contacts/{contact_id}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
return deal_id
|
||||
118
dealix/auto_client_acquisition/agents/followup.py
Normal file
118
dealix/auto_client_acquisition/agents/followup.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""
|
||||
Follow-up Agent — generates time-appropriate follow-up messages.
|
||||
وكيل المتابعة — يُنشئ رسائل متابعة مناسبة لكل مرحلة.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from auto_client_acquisition.agents.intake import Lead, LeadStatus
|
||||
from core.agents.base import BaseAgent
|
||||
from core.config.models import Task
|
||||
from core.llm.base import Message
|
||||
from core.prompts import get_prompt
|
||||
from core.prompts.sales_scripts import get_sales_script
|
||||
from core.utils import utcnow
|
||||
|
||||
|
||||
@dataclass
|
||||
class FollowUpPlan:
|
||||
attempt: int
|
||||
scheduled_for: datetime
|
||||
channel: str
|
||||
body: str
|
||||
should_pause: bool = False
|
||||
reason: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"attempt": self.attempt,
|
||||
"scheduled_for": self.scheduled_for.isoformat(),
|
||||
"channel": self.channel,
|
||||
"body": self.body,
|
||||
"should_pause": self.should_pause,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
# Cadence: [days_after_previous_touch] per attempt
|
||||
DEFAULT_CADENCE_DAYS = [0, 3, 7, 14] # immediate, 3d, 7d, 14d
|
||||
|
||||
|
||||
class FollowUpAgent(BaseAgent):
|
||||
"""Plans and generates follow-up messages."""
|
||||
|
||||
name = "followup"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*,
|
||||
lead: Lead,
|
||||
attempt: int = 1,
|
||||
last_touch: datetime | None = None,
|
||||
history_summary: str = "",
|
||||
channel: str = "email",
|
||||
**_: Any,
|
||||
) -> FollowUpPlan:
|
||||
"""Generate a follow-up message for the given attempt."""
|
||||
# Short-circuit if lead is closed/won/lost
|
||||
if lead.status in (LeadStatus.WON, LeadStatus.LOST, LeadStatus.DISQUALIFIED):
|
||||
return FollowUpPlan(
|
||||
attempt=attempt,
|
||||
scheduled_for=utcnow(),
|
||||
channel=channel,
|
||||
body="",
|
||||
should_pause=True,
|
||||
reason=f"Lead already in terminal status: {lead.status.value}",
|
||||
)
|
||||
|
||||
# Determine schedule
|
||||
last_touch = last_touch or utcnow()
|
||||
days = DEFAULT_CADENCE_DAYS[min(attempt, len(DEFAULT_CADENCE_DAYS) - 1)]
|
||||
scheduled = last_touch + timedelta(days=days)
|
||||
|
||||
# Use canned scripts for attempts 1-2, LLM for bespoke attempt 3+
|
||||
if attempt == 1:
|
||||
body = get_sales_script(
|
||||
"follow_up_1",
|
||||
locale=lead.locale,
|
||||
name=lead.contact_name or "",
|
||||
sector=lead.sector or ("قطاعكم" if lead.locale == "ar" else "your sector"),
|
||||
)
|
||||
elif attempt == 2:
|
||||
body = get_sales_script(
|
||||
"follow_up_2",
|
||||
locale=lead.locale,
|
||||
name=lead.contact_name or "",
|
||||
)
|
||||
else:
|
||||
prompt = get_prompt(
|
||||
"followup",
|
||||
attempt=attempt,
|
||||
history=history_summary or "No prior context",
|
||||
status=lead.status.value,
|
||||
locale=lead.locale,
|
||||
)
|
||||
response = await self.router.run(
|
||||
task=Task.PAGE_COPY,
|
||||
messages=[Message(role="user", content=prompt)],
|
||||
max_tokens=300,
|
||||
temperature=0.6,
|
||||
)
|
||||
body = response.content.strip()
|
||||
|
||||
self.log.info(
|
||||
"followup_planned",
|
||||
lead_id=lead.id,
|
||||
attempt=attempt,
|
||||
scheduled=scheduled.isoformat(),
|
||||
)
|
||||
return FollowUpPlan(
|
||||
attempt=attempt,
|
||||
scheduled_for=scheduled,
|
||||
channel=channel,
|
||||
body=body,
|
||||
)
|
||||
278
dealix/auto_client_acquisition/agents/icp_matcher.py
Normal file
278
dealix/auto_client_acquisition/agents/icp_matcher.py
Normal file
@ -0,0 +1,278 @@
|
||||
"""
|
||||
ICP Matcher Agent — scores how well a lead fits our Ideal Customer Profile.
|
||||
وكيل مطابقة العميل المثالي — يُقيّم مدى ملاءمة العميل.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from auto_client_acquisition.agents.intake import Lead
|
||||
from core.agents.base import BaseAgent
|
||||
|
||||
|
||||
class Industry(StrEnum):
|
||||
TECHNOLOGY = "technology"
|
||||
REAL_ESTATE = "real_estate"
|
||||
HEALTHCARE = "healthcare"
|
||||
EDUCATION = "education"
|
||||
LOGISTICS = "logistics"
|
||||
RETAIL = "retail"
|
||||
FINANCE = "finance"
|
||||
MANUFACTURING = "manufacturing"
|
||||
CONSULTING = "consulting"
|
||||
CONSTRUCTION = "construction"
|
||||
OIL_GAS = "oil_gas"
|
||||
TOURISM = "tourism"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class CompanySize(StrEnum):
|
||||
MICRO = "micro" # 1-9
|
||||
SMALL = "small" # 10-49
|
||||
MEDIUM = "medium" # 50-199
|
||||
LARGE = "large" # 200-999
|
||||
ENTERPRISE = "enterprise" # 1000+
|
||||
|
||||
|
||||
@dataclass
|
||||
class ICP:
|
||||
"""Ideal Customer Profile definition | تعريف العميل المثالي."""
|
||||
|
||||
industries: list[Industry] = field(default_factory=list)
|
||||
company_sizes: list[CompanySize] = field(default_factory=list)
|
||||
regions: list[str] = field(default_factory=list)
|
||||
budget_range: tuple[float, float] = (10_000, 200_000) # SAR
|
||||
pain_points: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FitScore:
|
||||
"""Result of ICP matching | نتيجة المطابقة."""
|
||||
|
||||
overall_score: float
|
||||
industry_match: float
|
||||
size_match: float
|
||||
region_match: float
|
||||
budget_match: float
|
||||
pain_match: float
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
recommendations: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def tier(self) -> str:
|
||||
"""Tier label | تصنيف."""
|
||||
if self.overall_score >= 0.8:
|
||||
return "A" # hot
|
||||
if self.overall_score >= 0.6:
|
||||
return "B" # warm
|
||||
if self.overall_score >= 0.4:
|
||||
return "C" # cold
|
||||
return "D" # disqualified
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"overall_score": round(self.overall_score, 3),
|
||||
"industry_match": round(self.industry_match, 3),
|
||||
"size_match": round(self.size_match, 3),
|
||||
"region_match": round(self.region_match, 3),
|
||||
"budget_match": round(self.budget_match, 3),
|
||||
"pain_match": round(self.pain_match, 3),
|
||||
"tier": self.tier,
|
||||
"reasons": self.reasons,
|
||||
"recommendations": self.recommendations,
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_ICP = ICP(
|
||||
industries=[
|
||||
Industry.TECHNOLOGY,
|
||||
Industry.REAL_ESTATE,
|
||||
Industry.HEALTHCARE,
|
||||
Industry.EDUCATION,
|
||||
Industry.LOGISTICS,
|
||||
],
|
||||
company_sizes=[CompanySize.SMALL, CompanySize.MEDIUM, CompanySize.LARGE],
|
||||
regions=[
|
||||
"saudi arabia",
|
||||
"sa",
|
||||
"ksa",
|
||||
"uae",
|
||||
"ae",
|
||||
"kuwait",
|
||||
"kw",
|
||||
"bahrain",
|
||||
"bh",
|
||||
"qatar",
|
||||
"qa",
|
||||
"oman",
|
||||
"om",
|
||||
"السعودية",
|
||||
"الإمارات",
|
||||
"الكويت",
|
||||
"البحرين",
|
||||
"قطر",
|
||||
"عمان",
|
||||
],
|
||||
budget_range=(10_000, 200_000),
|
||||
pain_points=[
|
||||
"lead management",
|
||||
"sales automation",
|
||||
"customer service",
|
||||
"data analysis",
|
||||
"digital marketing",
|
||||
"crm",
|
||||
"إدارة العملاء",
|
||||
"أتمتة المبيعات",
|
||||
"خدمة العملاء",
|
||||
"تحليل البيانات",
|
||||
"التسويق الرقمي",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class ICPMatcherAgent(BaseAgent):
|
||||
"""Scores leads against an ICP across 5 dimensions with weights."""
|
||||
|
||||
name = "icp_matcher"
|
||||
|
||||
# Dimension weights (must sum to 1.0)
|
||||
WEIGHTS = {
|
||||
"industry": 0.25,
|
||||
"size": 0.15,
|
||||
"region": 0.20,
|
||||
"budget": 0.20,
|
||||
"pain": 0.20,
|
||||
}
|
||||
|
||||
def __init__(self, icp: ICP | None = None) -> None:
|
||||
super().__init__()
|
||||
self.icp = icp or DEFAULT_ICP
|
||||
|
||||
async def run(self, *, lead: Lead, **_: Any) -> FitScore:
|
||||
"""Score a lead against the ICP."""
|
||||
industry_match, industry_reason = self._match_industry(lead.sector)
|
||||
size_match, size_reason = self._match_size(lead.company_size)
|
||||
region_match, region_reason = self._match_region(lead.region)
|
||||
budget_match, budget_reason = self._match_budget(lead.budget)
|
||||
pain_match, pain_reason = self._match_pains(lead.pain_points, lead.message)
|
||||
|
||||
overall = (
|
||||
self.WEIGHTS["industry"] * industry_match
|
||||
+ self.WEIGHTS["size"] * size_match
|
||||
+ self.WEIGHTS["region"] * region_match
|
||||
+ self.WEIGHTS["budget"] * budget_match
|
||||
+ self.WEIGHTS["pain"] * pain_match
|
||||
)
|
||||
|
||||
reasons = [industry_reason, size_reason, region_reason, budget_reason, pain_reason]
|
||||
reasons = [r for r in reasons if r]
|
||||
|
||||
recommendations = self._build_recommendations(
|
||||
overall, industry_match, size_match, region_match, budget_match, pain_match
|
||||
)
|
||||
|
||||
score = FitScore(
|
||||
overall_score=overall,
|
||||
industry_match=industry_match,
|
||||
size_match=size_match,
|
||||
region_match=region_match,
|
||||
budget_match=budget_match,
|
||||
pain_match=pain_match,
|
||||
reasons=reasons,
|
||||
recommendations=recommendations,
|
||||
)
|
||||
|
||||
self.log.info(
|
||||
"icp_scored",
|
||||
lead_id=lead.id,
|
||||
overall_score=round(overall, 3),
|
||||
tier=score.tier,
|
||||
)
|
||||
return score
|
||||
|
||||
# ── Dimension matchers ──────────────────────────────────────
|
||||
def _match_industry(self, sector: str | None) -> tuple[float, str]:
|
||||
if not sector:
|
||||
return 0.3, "Unknown industry — neutral default"
|
||||
sector_lower = sector.lower().strip()
|
||||
target_values = {i.value for i in self.icp.industries}
|
||||
if sector_lower in target_values:
|
||||
return 1.0, f"Industry '{sector}' is in target ICP"
|
||||
for target in target_values:
|
||||
if target in sector_lower or sector_lower in target:
|
||||
return 0.8, f"Industry '{sector}' partially matches '{target}'"
|
||||
return 0.2, f"Industry '{sector}' not in target ICP"
|
||||
|
||||
def _match_size(self, size: str | None) -> tuple[float, str]:
|
||||
if not size:
|
||||
return 0.4, "Company size unknown"
|
||||
size_lower = size.lower().strip()
|
||||
target_values = {s.value for s in self.icp.company_sizes}
|
||||
if size_lower in target_values:
|
||||
return 1.0, f"Size '{size}' matches ICP"
|
||||
if size_lower in {"enterprise", "micro"}:
|
||||
return 0.4, f"Size '{size}' outside sweet spot"
|
||||
return 0.5, f"Size '{size}' unrecognized — neutral"
|
||||
|
||||
def _match_region(self, region: str | None) -> tuple[float, str]:
|
||||
if not region:
|
||||
return 0.4, "Region unknown"
|
||||
region_lower = region.lower().strip()
|
||||
for target in self.icp.regions:
|
||||
if target in region_lower or region_lower in target:
|
||||
return 1.0, f"Region '{region}' is in target GCC"
|
||||
return 0.2, f"Region '{region}' outside GCC"
|
||||
|
||||
def _match_budget(self, budget: float | None) -> tuple[float, str]:
|
||||
if budget is None:
|
||||
return 0.5, "Budget unknown"
|
||||
min_b, max_b = self.icp.budget_range
|
||||
if min_b <= budget <= max_b:
|
||||
return 1.0, f"Budget {budget:,.0f} SAR in target range"
|
||||
if budget < min_b:
|
||||
ratio = budget / min_b if min_b else 0
|
||||
return max(0.2, ratio), f"Budget {budget:,.0f} SAR below minimum"
|
||||
# above max
|
||||
return 0.9, f"Budget {budget:,.0f} SAR above target (still good)"
|
||||
|
||||
def _match_pains(self, lead_pains: list[str], message: str | None) -> tuple[float, str]:
|
||||
haystack = " ".join([*lead_pains, message or ""]).lower()
|
||||
if not haystack.strip():
|
||||
return 0.3, "No pain points provided"
|
||||
matches = [p for p in self.icp.pain_points if p.lower() in haystack]
|
||||
if matches:
|
||||
score = min(1.0, 0.3 + 0.2 * len(matches))
|
||||
return score, f"Pain matches: {', '.join(matches[:3])}"
|
||||
return 0.3, "No explicit pain matches — will probe in qualification"
|
||||
|
||||
def _build_recommendations(
|
||||
self,
|
||||
overall: float,
|
||||
industry: float,
|
||||
size: float,
|
||||
region: float,
|
||||
budget: float,
|
||||
pain: float,
|
||||
) -> list[str]:
|
||||
recs: list[str] = []
|
||||
if overall >= 0.8:
|
||||
recs.append("Tier A — prioritize; book discovery call within 24h")
|
||||
elif overall >= 0.6:
|
||||
recs.append("Tier B — qualify via short email/WhatsApp exchange")
|
||||
elif overall >= 0.4:
|
||||
recs.append("Tier C — nurture sequence; revisit in 30 days")
|
||||
else:
|
||||
recs.append("Tier D — politely decline or route to partner")
|
||||
|
||||
if industry < 0.5:
|
||||
recs.append("Confirm industry/use case before committing")
|
||||
if budget < 0.5:
|
||||
recs.append("Clarify budget expectations early")
|
||||
if region < 0.5:
|
||||
recs.append("Check if we serve this region / need local partner")
|
||||
if pain < 0.5:
|
||||
recs.append("Run discovery to surface concrete pain points")
|
||||
return recs
|
||||
182
dealix/auto_client_acquisition/agents/intake.py
Normal file
182
dealix/auto_client_acquisition/agents/intake.py
Normal file
@ -0,0 +1,182 @@
|
||||
"""
|
||||
Intake Agent — captures leads from multiple sources and normalizes them.
|
||||
وكيل الاستقبال — يلتقط العملاء من مصادر متعددة ويوحّد صيغتهم.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from core.agents.base import BaseAgent
|
||||
from core.utils import (
|
||||
detect_locale,
|
||||
generate_id,
|
||||
hash_text,
|
||||
normalize_email,
|
||||
normalize_phone,
|
||||
utcnow,
|
||||
)
|
||||
|
||||
|
||||
class LeadSource(StrEnum):
|
||||
"""Lead source channels | مصادر العملاء."""
|
||||
|
||||
WEBSITE = "website"
|
||||
WHATSAPP = "whatsapp"
|
||||
EMAIL = "email"
|
||||
REFERRAL = "referral"
|
||||
LINKEDIN = "linkedin"
|
||||
COLD_OUTREACH = "cold_outreach"
|
||||
MANUAL = "manual"
|
||||
API = "api"
|
||||
|
||||
|
||||
class LeadStatus(StrEnum):
|
||||
"""Lead stages through the funnel | مراحل العميل في القمع."""
|
||||
|
||||
NEW = "new"
|
||||
QUALIFIED = "qualified"
|
||||
DISCOVERY = "discovery"
|
||||
PROPOSAL = "proposal"
|
||||
NEGOTIATION = "negotiation"
|
||||
WON = "won"
|
||||
LOST = "lost"
|
||||
DISQUALIFIED = "disqualified"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Lead:
|
||||
"""A captured lead | عميل محتمل ملتقط."""
|
||||
|
||||
id: str
|
||||
source: LeadSource
|
||||
company_name: str = ""
|
||||
contact_name: str = ""
|
||||
contact_email: str | None = None
|
||||
contact_phone: str | None = None
|
||||
contact_channel: str = ""
|
||||
sector: str | None = None
|
||||
company_size: str | None = None
|
||||
region: str | None = None
|
||||
budget: float | None = None
|
||||
message: str | None = None
|
||||
urgency_score: float = 0.0
|
||||
fit_score: float = 0.0
|
||||
status: LeadStatus = LeadStatus.NEW
|
||||
pain_points: list[str] = field(default_factory=list)
|
||||
locale: str = "ar"
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
updated_at: datetime = field(default_factory=utcnow)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
dedup_hash: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize for storage / API response."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"source": self.source.value,
|
||||
"company_name": self.company_name,
|
||||
"contact_name": self.contact_name,
|
||||
"contact_email": self.contact_email,
|
||||
"contact_phone": self.contact_phone,
|
||||
"contact_channel": self.contact_channel,
|
||||
"sector": self.sector,
|
||||
"company_size": self.company_size,
|
||||
"region": self.region,
|
||||
"budget": self.budget,
|
||||
"message": self.message,
|
||||
"urgency_score": self.urgency_score,
|
||||
"fit_score": self.fit_score,
|
||||
"status": self.status.value,
|
||||
"pain_points": self.pain_points,
|
||||
"locale": self.locale,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"metadata": self.metadata,
|
||||
"dedup_hash": self.dedup_hash,
|
||||
}
|
||||
|
||||
|
||||
class IntakeAgent(BaseAgent):
|
||||
"""
|
||||
Receives raw lead payloads and produces normalized Lead objects.
|
||||
Does: validation, phone/email normalization, locale detection, dedup hashing.
|
||||
"""
|
||||
|
||||
name = "intake"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._seen_hashes: set[str] = set()
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
source: LeadSource | str = LeadSource.WEBSITE,
|
||||
**_: Any,
|
||||
) -> Lead:
|
||||
"""Normalize a raw payload into a Lead."""
|
||||
if isinstance(source, str):
|
||||
source = LeadSource(source)
|
||||
|
||||
company = str(payload.get("company") or payload.get("company_name") or "").strip()
|
||||
name = str(payload.get("name") or payload.get("contact_name") or "").strip()
|
||||
email = normalize_email(str(payload.get("email") or ""))
|
||||
phone = normalize_phone(str(payload.get("phone") or ""))
|
||||
message = str(payload.get("message") or "").strip() or None
|
||||
locale = str(payload.get("locale") or "").strip()
|
||||
if not locale:
|
||||
locale = detect_locale(message or company or name)
|
||||
|
||||
contact_channel = email or phone or str(payload.get("channel") or source.value)
|
||||
|
||||
# Dedup based on (email or phone) + company
|
||||
dedup_source = f"{email or phone or ''}|{company.lower()}"
|
||||
dedup_hash = hash_text(dedup_source) if dedup_source.strip("|") else ""
|
||||
is_duplicate = dedup_hash and dedup_hash in self._seen_hashes
|
||||
if dedup_hash:
|
||||
self._seen_hashes.add(dedup_hash)
|
||||
|
||||
lead = Lead(
|
||||
id=generate_id("lead"),
|
||||
source=source,
|
||||
company_name=company,
|
||||
contact_name=name,
|
||||
contact_email=email,
|
||||
contact_phone=phone,
|
||||
contact_channel=contact_channel,
|
||||
sector=payload.get("sector"),
|
||||
company_size=payload.get("company_size"),
|
||||
region=payload.get("region"),
|
||||
budget=self._parse_float(payload.get("budget")),
|
||||
message=message,
|
||||
status=LeadStatus.NEW,
|
||||
locale=locale,
|
||||
dedup_hash=dedup_hash,
|
||||
metadata={
|
||||
"is_duplicate": is_duplicate,
|
||||
"raw_payload": payload,
|
||||
},
|
||||
)
|
||||
|
||||
self.log.info(
|
||||
"lead_intake",
|
||||
lead_id=lead.id,
|
||||
source=source.value,
|
||||
company=company,
|
||||
duplicate=is_duplicate,
|
||||
)
|
||||
return lead
|
||||
|
||||
@staticmethod
|
||||
def _parse_float(value: Any) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
99
dealix/auto_client_acquisition/agents/outreach.py
Normal file
99
dealix/auto_client_acquisition/agents/outreach.py
Normal file
@ -0,0 +1,99 @@
|
||||
"""
|
||||
Outreach Agent — generates personalized cold outreach messages.
|
||||
وكيل الوصول — يُنشئ رسائل وصول باردة مخصصة.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from auto_client_acquisition.agents.intake import Lead
|
||||
from core.agents.base import BaseAgent
|
||||
from core.config.models import Task
|
||||
from core.llm.base import Message
|
||||
from core.prompts import get_prompt
|
||||
|
||||
Channel = Literal["email", "whatsapp", "linkedin", "sms"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutreachMessage:
|
||||
channel: Channel
|
||||
subject: str | None
|
||||
body: str
|
||||
locale: str
|
||||
recipient_channel_value: str | None # email or phone
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"channel": self.channel,
|
||||
"subject": self.subject,
|
||||
"body": self.body,
|
||||
"locale": self.locale,
|
||||
"recipient_channel_value": self.recipient_channel_value,
|
||||
}
|
||||
|
||||
|
||||
class OutreachAgent(BaseAgent):
|
||||
"""Generates opener messages for cold outreach."""
|
||||
|
||||
name = "outreach"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*,
|
||||
lead: Lead,
|
||||
channel: Channel = "email",
|
||||
trigger: str = "We saw your profile",
|
||||
**_: Any,
|
||||
) -> OutreachMessage:
|
||||
"""Generate a personalized cold opener."""
|
||||
prompt = get_prompt(
|
||||
"outreach_opener",
|
||||
channel=channel,
|
||||
locale=lead.locale,
|
||||
name=lead.contact_name or "there",
|
||||
company=lead.company_name or "your company",
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
response = await self.router.run(
|
||||
task=Task.PAGE_COPY,
|
||||
messages=[Message(role="user", content=prompt)],
|
||||
max_tokens=400,
|
||||
temperature=0.6,
|
||||
)
|
||||
|
||||
subject: str | None = None
|
||||
body = response.content.strip()
|
||||
if channel == "email":
|
||||
subject = self._build_subject(lead)
|
||||
|
||||
recipient = lead.contact_email if channel == "email" else lead.contact_phone
|
||||
|
||||
message = OutreachMessage(
|
||||
channel=channel,
|
||||
subject=subject,
|
||||
body=body,
|
||||
locale=lead.locale,
|
||||
recipient_channel_value=recipient,
|
||||
)
|
||||
|
||||
self.log.info(
|
||||
"outreach_generated",
|
||||
lead_id=lead.id,
|
||||
channel=channel,
|
||||
locale=lead.locale,
|
||||
)
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def _build_subject(lead: Lead) -> str:
|
||||
if lead.locale == "ar":
|
||||
if lead.sector:
|
||||
return f"فرصة سريعة لـ {lead.company_name or 'شركتكم'} في {lead.sector}"
|
||||
return f"سؤال قصير لـ {lead.company_name or 'شركتكم'}"
|
||||
if lead.sector:
|
||||
return f"Quick idea for {lead.company_name or 'your team'} in {lead.sector}"
|
||||
return f"Quick question for {lead.company_name or 'your team'}"
|
||||
239
dealix/auto_client_acquisition/agents/pain_extractor.py
Normal file
239
dealix/auto_client_acquisition/agents/pain_extractor.py
Normal file
@ -0,0 +1,239 @@
|
||||
"""
|
||||
Pain Extractor — extracts pain points, urgency, and next-step hints.
|
||||
وكيل استخلاص المشاكل — يستخرج المشاكل ودرجة الاستعجال والخطوة التالية.
|
||||
|
||||
Hybrid approach:
|
||||
1. Fast keyword pass (local, zero-cost)
|
||||
2. Optional LLM pass for richer extraction (routed to GLM for Arabic)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from core.agents.base import BaseAgent
|
||||
from core.config.models import Task
|
||||
from core.llm.base import Message
|
||||
from core.prompts import get_prompt
|
||||
from core.utils import detect_locale
|
||||
|
||||
PAIN_KEYWORDS: dict[str, str] = {
|
||||
# Arabic
|
||||
"مشكلة": "general",
|
||||
"معقد": "complexity",
|
||||
"بطيء": "performance",
|
||||
"نحتاج": "need",
|
||||
"نفتقر": "missing",
|
||||
"صعوبة": "difficulty",
|
||||
"نعاني": "struggle",
|
||||
"يدوي": "manual",
|
||||
"فوضى": "chaos",
|
||||
"مكلف": "cost",
|
||||
"تأخير": "delay",
|
||||
# English
|
||||
"problem": "general",
|
||||
"issue": "general",
|
||||
"need": "need",
|
||||
"struggling": "struggle",
|
||||
"challenge": "challenge",
|
||||
"manual": "manual",
|
||||
"slow": "performance",
|
||||
"expensive": "cost",
|
||||
"inefficient": "efficiency",
|
||||
"missing": "missing",
|
||||
"broken": "broken",
|
||||
}
|
||||
|
||||
URGENCY_KEYWORDS: dict[str, float] = {
|
||||
# Arabic
|
||||
"عاجل": 1.0,
|
||||
"فوراً": 1.0,
|
||||
"الآن": 0.8,
|
||||
"بسرعة": 0.7,
|
||||
"هذا الأسبوع": 0.6,
|
||||
"هذا الشهر": 0.5,
|
||||
"قريباً": 0.4,
|
||||
# English
|
||||
"urgent": 1.0,
|
||||
"asap": 1.0,
|
||||
"now": 0.8,
|
||||
"quickly": 0.7,
|
||||
"this week": 0.6,
|
||||
"this month": 0.5,
|
||||
"soon": 0.4,
|
||||
"immediately": 1.0,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PainPoint:
|
||||
text: str
|
||||
category: str
|
||||
severity: float = 0.5
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"text": self.text,
|
||||
"category": self.category,
|
||||
"severity": round(self.severity, 2),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtractionResult:
|
||||
pain_points: list[PainPoint] = field(default_factory=list)
|
||||
urgency_score: float = 0.0
|
||||
likely_offer: str = ""
|
||||
recommended_next_step: str = ""
|
||||
key_phrases: list[str] = field(default_factory=list)
|
||||
method: str = "keyword" # keyword | llm | hybrid
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"pain_points": [p.to_dict() for p in self.pain_points],
|
||||
"urgency_score": round(self.urgency_score, 2),
|
||||
"likely_offer": self.likely_offer,
|
||||
"recommended_next_step": self.recommended_next_step,
|
||||
"key_phrases": self.key_phrases,
|
||||
"method": self.method,
|
||||
}
|
||||
|
||||
|
||||
class PainExtractorAgent(BaseAgent):
|
||||
"""Extracts pain signals from lead messages."""
|
||||
|
||||
name = "pain_extractor"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*,
|
||||
message: str,
|
||||
locale: str | None = None,
|
||||
use_llm: bool = True,
|
||||
**_: Any,
|
||||
) -> ExtractionResult:
|
||||
"""Run keyword pass, optionally enrich with LLM."""
|
||||
if not message or not message.strip():
|
||||
return ExtractionResult(method="empty")
|
||||
|
||||
locale = locale or detect_locale(message)
|
||||
kw_result = self._keyword_pass(message)
|
||||
|
||||
if not use_llm:
|
||||
kw_result.method = "keyword"
|
||||
return kw_result
|
||||
|
||||
# LLM enrichment — route to GLM for Arabic, Claude otherwise
|
||||
try:
|
||||
task = Task.ARABIC_TASKS if locale == "ar" else Task.REASONING
|
||||
prompt = get_prompt("pain_extraction", locale=locale, message=message)
|
||||
response = await self.router.run(
|
||||
task=task,
|
||||
messages=[Message(role="user", content=prompt)],
|
||||
max_tokens=1024,
|
||||
temperature=0.2,
|
||||
)
|
||||
parsed = self.parse_json_response(response.content)
|
||||
llm_result = self._from_llm_json(parsed)
|
||||
merged = self._merge(kw_result, llm_result)
|
||||
merged.method = "hybrid"
|
||||
self.log.info(
|
||||
"pain_extracted",
|
||||
n_pains=len(merged.pain_points),
|
||||
urgency=merged.urgency_score,
|
||||
locale=locale,
|
||||
)
|
||||
return merged
|
||||
except Exception as e:
|
||||
self.log.warning("llm_extract_failed_falling_back", error=str(e))
|
||||
kw_result.method = "keyword"
|
||||
return kw_result
|
||||
|
||||
# ── Keyword pass ────────────────────────────────────────────
|
||||
def _keyword_pass(self, text: str) -> ExtractionResult:
|
||||
lower = text.lower()
|
||||
pains: list[PainPoint] = []
|
||||
key_phrases: list[str] = []
|
||||
|
||||
for keyword, category in PAIN_KEYWORDS.items():
|
||||
if keyword in lower:
|
||||
pains.append(PainPoint(text=keyword, category=category, severity=0.5))
|
||||
key_phrases.append(keyword)
|
||||
|
||||
urgency = 0.0
|
||||
for keyword, score in URGENCY_KEYWORDS.items():
|
||||
if keyword in lower:
|
||||
urgency = max(urgency, score)
|
||||
key_phrases.append(keyword)
|
||||
|
||||
return ExtractionResult(
|
||||
pain_points=pains,
|
||||
urgency_score=urgency,
|
||||
likely_offer=self._suggest_offer(pains),
|
||||
recommended_next_step=self._suggest_step(urgency, len(pains)),
|
||||
key_phrases=list(set(key_phrases)),
|
||||
method="keyword",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _suggest_offer(pains: list[PainPoint]) -> str:
|
||||
categories = {p.category for p in pains}
|
||||
if "manual" in categories or "efficiency" in categories:
|
||||
return "Process Automation Retainer"
|
||||
if "performance" in categories:
|
||||
return "AI Performance Optimization Setup"
|
||||
if "cost" in categories:
|
||||
return "Cost Reduction AI Assessment"
|
||||
if categories:
|
||||
return "Discovery Workshop + Proposal"
|
||||
return "Discovery Call"
|
||||
|
||||
@staticmethod
|
||||
def _suggest_step(urgency: float, n_pains: int) -> str:
|
||||
if urgency >= 0.8:
|
||||
return "Call within 24 hours — high urgency"
|
||||
if urgency >= 0.5 or n_pains >= 2:
|
||||
return "Book discovery call this week"
|
||||
return "Send value-add nurture sequence"
|
||||
|
||||
# ── LLM JSON parsing ────────────────────────────────────────
|
||||
def _from_llm_json(self, data: dict[str, Any]) -> ExtractionResult:
|
||||
raw_pains = data.get("pain_points") or []
|
||||
pains: list[PainPoint] = []
|
||||
for p in raw_pains:
|
||||
if isinstance(p, dict):
|
||||
pains.append(
|
||||
PainPoint(
|
||||
text=str(p.get("text", "")),
|
||||
category=str(p.get("category", "general")),
|
||||
severity=float(p.get("severity", 0.5)),
|
||||
)
|
||||
)
|
||||
elif isinstance(p, str):
|
||||
pains.append(PainPoint(text=p, category="general", severity=0.5))
|
||||
|
||||
return ExtractionResult(
|
||||
pain_points=pains,
|
||||
urgency_score=float(data.get("urgency_score", 0.0)),
|
||||
likely_offer=str(data.get("likely_offer", "")),
|
||||
recommended_next_step=str(data.get("recommended_next_step", "")),
|
||||
key_phrases=list(data.get("key_phrases") or []),
|
||||
method="llm",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _merge(kw: ExtractionResult, llm: ExtractionResult) -> ExtractionResult:
|
||||
"""LLM takes priority for rich fields; keyword augments key_phrases."""
|
||||
combined_pains = {p.text.lower(): p for p in kw.pain_points}
|
||||
for p in llm.pain_points:
|
||||
combined_pains[p.text.lower()] = p # overwrite with LLM version
|
||||
|
||||
return ExtractionResult(
|
||||
pain_points=list(combined_pains.values()),
|
||||
urgency_score=max(kw.urgency_score, llm.urgency_score),
|
||||
likely_offer=llm.likely_offer or kw.likely_offer,
|
||||
recommended_next_step=llm.recommended_next_step or kw.recommended_next_step,
|
||||
key_phrases=list(set(kw.key_phrases + llm.key_phrases)),
|
||||
method="hybrid",
|
||||
)
|
||||
145
dealix/auto_client_acquisition/agents/proposal.py
Normal file
145
dealix/auto_client_acquisition/agents/proposal.py
Normal file
@ -0,0 +1,145 @@
|
||||
"""
|
||||
Proposal Agent — generates tailored proposals using Claude.
|
||||
وكيل العروض — يُعدّ عروضاً مخصصة باستخدام Claude.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from auto_client_acquisition.agents.icp_matcher import FitScore
|
||||
from auto_client_acquisition.agents.intake import Lead
|
||||
from core.agents.base import BaseAgent
|
||||
from core.config.models import Task
|
||||
from core.config.settings import get_settings
|
||||
from core.llm.base import Message
|
||||
from core.prompts import get_prompt
|
||||
from core.utils import generate_id, utcnow
|
||||
|
||||
|
||||
@dataclass
|
||||
class Proposal:
|
||||
id: str
|
||||
lead_id: str
|
||||
company_name: str
|
||||
sector: str | None
|
||||
locale: str
|
||||
body_markdown: str
|
||||
budget_min: float
|
||||
budget_max: float
|
||||
currency: str
|
||||
valid_until: datetime
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"lead_id": self.lead_id,
|
||||
"company_name": self.company_name,
|
||||
"sector": self.sector,
|
||||
"locale": self.locale,
|
||||
"body_markdown": self.body_markdown,
|
||||
"budget_min": self.budget_min,
|
||||
"budget_max": self.budget_max,
|
||||
"currency": self.currency,
|
||||
"valid_until": self.valid_until.isoformat(),
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class ProposalAgent(BaseAgent):
|
||||
"""Generates an LLM-authored proposal tailored to the lead."""
|
||||
|
||||
name = "proposal"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.settings = get_settings()
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*,
|
||||
lead: Lead,
|
||||
fit_score: FitScore | None = None,
|
||||
outcomes: list[str] | None = None,
|
||||
start_date: datetime | None = None,
|
||||
**_: Any,
|
||||
) -> Proposal:
|
||||
"""Generate a proposal tailored to the lead context."""
|
||||
outcomes = outcomes or [
|
||||
"Reduce manual work by 50%+",
|
||||
"Increase qualified pipeline by 2–3x",
|
||||
"Cut response time from hours to minutes",
|
||||
]
|
||||
start_date = start_date or (utcnow() + timedelta(days=14))
|
||||
|
||||
# Determine pricing tier based on region
|
||||
budget_min, budget_max, currency = self._pricing_for_region(lead.region)
|
||||
|
||||
prompt = get_prompt(
|
||||
"proposal_generation",
|
||||
locale=lead.locale,
|
||||
company_name=lead.company_name or "Your Company",
|
||||
sector=lead.sector or "General",
|
||||
pain_points="; ".join(lead.pain_points) or lead.message or "To be confirmed",
|
||||
outcomes="; ".join(outcomes),
|
||||
budget_min=f"{budget_min:,.0f}",
|
||||
budget_max=f"{budget_max:,.0f}",
|
||||
start_date=start_date.strftime("%Y-%m-%d"),
|
||||
)
|
||||
|
||||
response = await self.router.run(
|
||||
task=Task.PROPOSAL,
|
||||
messages=[Message(role="user", content=prompt)],
|
||||
max_tokens=3000,
|
||||
temperature=0.5,
|
||||
)
|
||||
|
||||
proposal = Proposal(
|
||||
id=generate_id("prop"),
|
||||
lead_id=lead.id,
|
||||
company_name=lead.company_name,
|
||||
sector=lead.sector,
|
||||
locale=lead.locale,
|
||||
body_markdown=response.content,
|
||||
budget_min=budget_min,
|
||||
budget_max=budget_max,
|
||||
currency=currency,
|
||||
valid_until=utcnow() + timedelta(days=30),
|
||||
)
|
||||
|
||||
self.log.info(
|
||||
"proposal_generated",
|
||||
lead_id=lead.id,
|
||||
proposal_id=proposal.id,
|
||||
locale=lead.locale,
|
||||
budget_range=f"{budget_min:,.0f}-{budget_max:,.0f} {currency}",
|
||||
)
|
||||
return proposal
|
||||
|
||||
# ── Pricing logic ───────────────────────────────────────────
|
||||
def _pricing_for_region(self, region: str | None) -> tuple[float, float, str]:
|
||||
"""Return (setup_min, setup_max, currency) for the lead's region."""
|
||||
s = self.settings
|
||||
if not region:
|
||||
return float(s.pricing_sa_setup_min), float(s.pricing_sa_setup_max), "SAR"
|
||||
|
||||
region_lower = region.lower()
|
||||
gcc_tokens = {"uae", "kuwait", "bahrain", "qatar", "oman", "الإمارات", "الكويت"}
|
||||
if any(t in region_lower for t in gcc_tokens):
|
||||
return (
|
||||
float(s.pricing_gcc_setup_min),
|
||||
float(s.pricing_gcc_setup_max),
|
||||
"SAR-equivalent",
|
||||
)
|
||||
sa_tokens = {"saudi", "ksa", "sa", "riyadh", "jeddah", "السعودية"}
|
||||
if any(t in region_lower for t in sa_tokens):
|
||||
return (float(s.pricing_sa_setup_min), float(s.pricing_sa_setup_max), "SAR")
|
||||
# Global
|
||||
return (
|
||||
float(s.pricing_global_setup_min_usd),
|
||||
float(s.pricing_global_setup_max_usd),
|
||||
"USD",
|
||||
)
|
||||
356
dealix/auto_client_acquisition/agents/prospector.py
Normal file
356
dealix/auto_client_acquisition/agents/prospector.py
Normal file
@ -0,0 +1,356 @@
|
||||
"""
|
||||
Prospector Agent — discovers real leads matching a natural-language ICP.
|
||||
|
||||
Inputs:
|
||||
icp: str — Arabic or English description of the ideal target
|
||||
use_case: str — sales | partnership | collaboration | investor | b2c_audience
|
||||
count: int — how many leads to return (max 20)
|
||||
|
||||
Output: list[LeadCandidate] with:
|
||||
company_ar, company_en, industry, est_size, website, linkedin, decision_maker_hints,
|
||||
signals, outreach_opening (Saudi Khaliji Arabic), fit_score (0-100), evidence
|
||||
|
||||
Design principles:
|
||||
- Public-data only; no scraping behind auth walls
|
||||
- LLM is grounded with strict "only real entities you're confident exist" prompt
|
||||
- Output is normalized JSON; invalid entries are dropped
|
||||
- Use case steers both the query and the scoring
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Any
|
||||
|
||||
from core.agents.base import BaseAgent
|
||||
from core.config.models import Task
|
||||
from core.llm import Message
|
||||
|
||||
MAX_COUNT = 20
|
||||
USE_CASES = {
|
||||
"sales": "استهداف مبيعات B2B — بحث عن شركات عندها الألم ومتخذي قرار واضحين.",
|
||||
"partnership": "شراكات استراتيجية — شركات عندها قنوات توزيع أو منتجات مكمّلة.",
|
||||
"collaboration": "تعاون محتوى/تقني — صانعي محتوى، thought leaders، منتجات متكاملة.",
|
||||
"investor": "مستثمرون/VC — صناديق ومستثمرين نشطين في السوق السعودي.",
|
||||
"b2c_audience": "جمهور B2C — شرائح ديموغرافية محددة بسلوك شرائي واضح.",
|
||||
}
|
||||
|
||||
SYSTEM_PROMPT = """أنت Dealix Lead Intelligence Router — محلل GTM سعودي/خليجي سيادي.
|
||||
مهمتك: تحويل وصف العميل المثالي (ICP) إلى قائمة leads حقيقية قابلة للتنفيذ، مع تصنيف الفرصة، درجة تأهيل 100-نقطة، تقييم مخاطر، وقناة تواصل قانونية.
|
||||
|
||||
منظومتك مبنية على مرجعين:
|
||||
- SIGNAL_TAXONOMY: 9 أنواع فرص (DIRECT_CUSTOMER, AGENCY_PARTNER, IMPLEMENTATION_PARTNER, REFERRAL_PARTNER, STRATEGIC_PARTNER, CONTENT_COLLABORATION, INVESTOR_OR_ADVISOR, SUPPLIER_OR_INTEGRATION, B2C_AUDIENCE)
|
||||
- ICP_SCORING_MODEL (100 نقطة): Fit 40 + Intent 30 + Accessibility 15 + Revenue Potential 15 → P0 (80+) | P1 (65-79) | P2 (45-64) | BACKLOG (<45)
|
||||
|
||||
قواعد صارمة:
|
||||
1. **لا تختلق شركات**. اقترح فقط كيانات أنت متأكد منها من معرفتك الموسوعية للسوق السعودي/الخليجي.
|
||||
2. إذا الطلب يصعب تلبيته بدقة، أرجع قائمة أقصر بدل اختراع أسماء.
|
||||
3. **URLs (website/linkedin):** فقط لو متأكد من صحتها — وإلا اترك null.
|
||||
4. **إشارات (signals):** فقط معلومات منشورة علناً (جولات تمويل، إعلانات توظيف، إطلاقات، تصريحات).
|
||||
5. **اللغة:** استخدم الاسم العربي الرسمي + الاسم الإنجليزي. سطر الافتتاح باللهجة الخليجية (ليس MSA).
|
||||
6. **الامتثال (compliance_note):** اذكر الأساس القانوني لكل lead — مصدر عام، لا scraping، لا bots، human-final-send على LinkedIn.
|
||||
7. **خطاب الافتتاح (outreach_opening):** ≤280 حرف، يذكر إشارة محددة واحدة من evidence.
|
||||
8. **JSON only** — بدون markdown code fences.
|
||||
|
||||
تنسيق JSON المطلوب (v2 schema):
|
||||
{
|
||||
"leads": [
|
||||
{
|
||||
"company_ar": "الاسم العربي",
|
||||
"company_en": "English Name",
|
||||
"industry": "SaaS / E-commerce / Fintech / Agency / ...",
|
||||
"est_size": "1-10 | 10-50 | 50-200 | 200-1000 | 1000+",
|
||||
"website": "https://example.com or null",
|
||||
"linkedin": "https://linkedin.com/company/X or null",
|
||||
"opportunity_type": "DIRECT_CUSTOMER|AGENCY_PARTNER|IMPLEMENTATION_PARTNER|REFERRAL_PARTNER|STRATEGIC_PARTNER|CONTENT_COLLABORATION|INVESTOR_OR_ADVISOR|SUPPLIER_OR_INTEGRATION|B2C_AUDIENCE",
|
||||
"decision_maker_hints": ["CEO الاسم", "CTO الاسم"],
|
||||
"signals": ["جولة Series A 2025", "توسع في الرياض"],
|
||||
"fit_score": 35,
|
||||
"intent_score": 22,
|
||||
"access_score": 13,
|
||||
"revenue_score": 12,
|
||||
"priority_score": 82,
|
||||
"priority_tier": "P0|P1|P2|BACKLOG",
|
||||
"risk_level": "LOW|MEDIUM|HIGH|BLOCKED",
|
||||
"recommended_channel": "LINKEDIN_MANUAL|EMAIL|WHATSAPP_WARM_ONLY|PARTNER_INTRO|PHONE|CONTENT_MENTION|IN_PERSON_EVENT|HOLD_FOR_APPROVAL",
|
||||
"next_action": "PREPARE_DM|PREPARE_EMAIL|PREPARE_PARTNER_PITCH|BOOK_DEMO|RESEARCH_MORE|...",
|
||||
"outreach_opening": "سطر افتتاحي قصير باللهجة الخليجية يذكر إشارة واحدة محددة",
|
||||
"message_angle": "الزاوية الأساسية للرسالة",
|
||||
"reason": "سطر واحد — لماذا هذا lead مطابق ل ICP",
|
||||
"evidence": "معلومة محددة تبرّر الترشيح",
|
||||
"compliance_note": "e.g. Public business contact via LinkedIn; no bots; single personalized DM",
|
||||
"confidence": 85
|
||||
}
|
||||
],
|
||||
"search_notes": "مصادر المعلومات، حدود الدقة، أي lead مشكوك فيه حُذف."
|
||||
}
|
||||
|
||||
تفوّق على Apollo/ZoomInfo/Clay في:
|
||||
- الدقة السعودية (أسماء خليجية، لهجة، إشارات محلية من Wamda/MAGNiTT/MISA)
|
||||
- الشفافية (evidence لكل claim، لا بيانات مخترعة)
|
||||
- السلامة القانونية (PDPL-aware، لا scraping، لا LinkedIn bots)
|
||||
- الـ routing (كل lead معه next_action واضح، ليس مجرد اسم)
|
||||
"""
|
||||
|
||||
|
||||
OPPORTUNITY_TYPES = {
|
||||
"DIRECT_CUSTOMER",
|
||||
"AGENCY_PARTNER",
|
||||
"IMPLEMENTATION_PARTNER",
|
||||
"REFERRAL_PARTNER",
|
||||
"STRATEGIC_PARTNER",
|
||||
"CONTENT_COLLABORATION",
|
||||
"INVESTOR_OR_ADVISOR",
|
||||
"SUPPLIER_OR_INTEGRATION",
|
||||
"B2C_AUDIENCE",
|
||||
}
|
||||
PRIORITY_TIERS = {"P0", "P1", "P2", "BACKLOG"}
|
||||
RISK_LEVELS = {"LOW", "MEDIUM", "HIGH", "BLOCKED"}
|
||||
CHANNELS = {
|
||||
"LINKEDIN_MANUAL",
|
||||
"EMAIL",
|
||||
"WHATSAPP_WARM_ONLY",
|
||||
"PARTNER_INTRO",
|
||||
"PHONE",
|
||||
"CONTENT_MENTION",
|
||||
"IN_PERSON_EVENT",
|
||||
"HOLD_FOR_APPROVAL",
|
||||
}
|
||||
NEXT_ACTIONS = {
|
||||
"RESEARCH_MORE", "ENRICH_ACCOUNT", "SCORE_LEAD",
|
||||
"PREPARE_DM", "PREPARE_EMAIL", "PREPARE_WHATSAPP",
|
||||
"PREPARE_PARTNER_PITCH", "PREPARE_INVESTOR_NOTE",
|
||||
"PREPARE_DEMO_FLOW", "PREPARE_NEGOTIATION_RESPONSE",
|
||||
"SEND_IF_AUTHORIZED", "ASK_HUMAN_FINAL_SEND",
|
||||
"BOOK_DEMO", "REQUEST_PAYMENT", "ROUTE_TO_MANUAL_PAYMENT",
|
||||
"ONBOARD_CUSTOMER", "FOLLOW_UP", "STOP_CONTACT", "DISQUALIFY",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LeadCandidate:
|
||||
company_ar: str
|
||||
company_en: str
|
||||
industry: str
|
||||
est_size: str
|
||||
website: str | None
|
||||
linkedin: str | None
|
||||
opportunity_type: str
|
||||
decision_maker_hints: list[str]
|
||||
signals: list[str]
|
||||
fit_score: int
|
||||
intent_score: int
|
||||
access_score: int
|
||||
revenue_score: int
|
||||
priority_score: int
|
||||
priority_tier: str
|
||||
risk_level: str
|
||||
recommended_channel: str
|
||||
next_action: str
|
||||
outreach_opening: str
|
||||
message_angle: str
|
||||
reason: str
|
||||
evidence: str
|
||||
compliance_note: str
|
||||
confidence: int
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProspectResult:
|
||||
use_case: str
|
||||
icp: str
|
||||
count_requested: int
|
||||
count_returned: int
|
||||
leads: list[LeadCandidate]
|
||||
search_notes: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"use_case": self.use_case,
|
||||
"icp": self.icp,
|
||||
"count_requested": self.count_requested,
|
||||
"count_returned": self.count_returned,
|
||||
"leads": [l.to_dict() for l in self.leads],
|
||||
"search_notes": self.search_notes,
|
||||
}
|
||||
|
||||
|
||||
class ProspectorAgent(BaseAgent):
|
||||
"""
|
||||
Natural-language ICP → ranked list of real leads.
|
||||
Uses the LLM router's RESEARCH task (Gemini primary, with fallback chain).
|
||||
"""
|
||||
|
||||
name = "prospector"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
icp: str,
|
||||
use_case: str = "sales",
|
||||
count: int = 10,
|
||||
) -> ProspectResult:
|
||||
count = max(1, min(MAX_COUNT, int(count)))
|
||||
use_case = (use_case or "sales").strip().lower()
|
||||
if use_case not in USE_CASES:
|
||||
use_case = "sales"
|
||||
|
||||
user_prompt = self._build_user_prompt(icp=icp, use_case=use_case, count=count)
|
||||
|
||||
self.log.info(
|
||||
"prospector_run use_case=%s count=%d icp_len=%d",
|
||||
use_case,
|
||||
count,
|
||||
len(icp or ""),
|
||||
)
|
||||
|
||||
response = await self.router.run(
|
||||
task=Task.RESEARCH,
|
||||
messages=[Message(role="user", content=user_prompt)],
|
||||
system=SYSTEM_PROMPT,
|
||||
max_tokens=4096,
|
||||
temperature=0.3,
|
||||
)
|
||||
|
||||
parsed = self._parse_json(response.text)
|
||||
raw_leads = parsed.get("leads") or []
|
||||
search_notes = str(parsed.get("search_notes") or "")
|
||||
|
||||
leads: list[LeadCandidate] = []
|
||||
for item in raw_leads[:count]:
|
||||
lead = self._safe_lead(item)
|
||||
if lead is not None:
|
||||
leads.append(lead)
|
||||
|
||||
# Sort by priority_score (already weighted), then confidence
|
||||
leads.sort(key=lambda l: (l.priority_score, l.confidence), reverse=True)
|
||||
|
||||
return ProspectResult(
|
||||
use_case=use_case,
|
||||
icp=icp,
|
||||
count_requested=count,
|
||||
count_returned=len(leads),
|
||||
leads=leads,
|
||||
search_notes=search_notes,
|
||||
)
|
||||
|
||||
# ── internals ──────────────────────────────────────────────
|
||||
def _build_user_prompt(self, *, icp: str, use_case: str, count: int) -> str:
|
||||
return (
|
||||
f"حالة الاستخدام: {use_case} — {USE_CASES[use_case]}\n\n"
|
||||
f"وصف العميل المثالي (ICP):\n{icp.strip()}\n\n"
|
||||
f"أعد {count} leads حقيقية مطابقة للـ ICP، مرتّبة من الأعلى fit_score.\n"
|
||||
f"إذا الطلب متعلق بالسعودية أو الخليج، ركّز على الشركات المحلية أولاً.\n"
|
||||
f"تذكير: لا تختلق شركات. أعد JSON فقط — بدون markdown code fences."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_json(text: str) -> dict[str, Any]:
|
||||
if not text:
|
||||
return {}
|
||||
# Strip optional code fences
|
||||
t = text.strip()
|
||||
t = re.sub(r"^```(?:json)?\s*", "", t)
|
||||
t = re.sub(r"\s*```$", "", t)
|
||||
try:
|
||||
return json.loads(t)
|
||||
except Exception:
|
||||
# Try to extract the first {...} block
|
||||
m = re.search(r"\{.*\}", t, re.DOTALL)
|
||||
if not m:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(m.group(0))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _coerce_enum(value: Any, allowed: set[str], default: str) -> str:
|
||||
v = str(value or "").strip().upper().replace("-", "_").replace(" ", "_")
|
||||
return v if v in allowed else default
|
||||
|
||||
@staticmethod
|
||||
def _derive_tier(score: int) -> str:
|
||||
if score >= 80:
|
||||
return "P0"
|
||||
if score >= 65:
|
||||
return "P1"
|
||||
if score >= 45:
|
||||
return "P2"
|
||||
return "BACKLOG"
|
||||
|
||||
@classmethod
|
||||
def _safe_lead(cls, item: Any) -> LeadCandidate | None:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
try:
|
||||
company_ar = str(item.get("company_ar") or "").strip()
|
||||
company_en = str(item.get("company_en") or "").strip()
|
||||
if not (company_ar or company_en):
|
||||
return None
|
||||
|
||||
fit = int(max(0, min(40, item.get("fit_score") or 0)))
|
||||
intent = int(max(0, min(30, item.get("intent_score") or 0)))
|
||||
access = int(max(0, min(15, item.get("access_score") or 0)))
|
||||
revenue = int(max(0, min(15, item.get("revenue_score") or 0)))
|
||||
priority_raw = item.get("priority_score")
|
||||
priority = (
|
||||
int(max(0, min(100, priority_raw)))
|
||||
if isinstance(priority_raw, (int, float))
|
||||
else (fit + intent + access + revenue)
|
||||
)
|
||||
tier_raw = item.get("priority_tier")
|
||||
tier = (
|
||||
str(tier_raw).upper()
|
||||
if str(tier_raw).upper() in PRIORITY_TIERS
|
||||
else cls._derive_tier(priority)
|
||||
)
|
||||
|
||||
opportunity_type = cls._coerce_enum(
|
||||
item.get("opportunity_type"), OPPORTUNITY_TYPES, "DIRECT_CUSTOMER"
|
||||
)
|
||||
risk = cls._coerce_enum(item.get("risk_level"), RISK_LEVELS, "MEDIUM")
|
||||
channel = cls._coerce_enum(
|
||||
item.get("recommended_channel"), CHANNELS, "LINKEDIN_MANUAL"
|
||||
)
|
||||
next_action = cls._coerce_enum(
|
||||
item.get("next_action"), NEXT_ACTIONS, "PREPARE_DM"
|
||||
)
|
||||
|
||||
return LeadCandidate(
|
||||
company_ar=company_ar or company_en,
|
||||
company_en=company_en or company_ar,
|
||||
industry=str(item.get("industry") or "").strip(),
|
||||
est_size=str(item.get("est_size") or "").strip(),
|
||||
website=(str(item.get("website")).strip() if item.get("website") else None),
|
||||
linkedin=(str(item.get("linkedin")).strip() if item.get("linkedin") else None),
|
||||
opportunity_type=opportunity_type,
|
||||
decision_maker_hints=[
|
||||
str(x) for x in (item.get("decision_maker_hints") or []) if x
|
||||
][:5],
|
||||
signals=[str(x) for x in (item.get("signals") or []) if x][:8],
|
||||
fit_score=fit,
|
||||
intent_score=intent,
|
||||
access_score=access,
|
||||
revenue_score=revenue,
|
||||
priority_score=priority,
|
||||
priority_tier=tier,
|
||||
risk_level=risk,
|
||||
recommended_channel=channel,
|
||||
next_action=next_action,
|
||||
outreach_opening=str(item.get("outreach_opening") or "").strip()[:280],
|
||||
message_angle=str(item.get("message_angle") or "").strip()[:280],
|
||||
reason=str(item.get("reason") or "").strip()[:280],
|
||||
evidence=str(item.get("evidence") or "").strip()[:280],
|
||||
compliance_note=str(
|
||||
item.get("compliance_note")
|
||||
or "Public business contact; single personalized manual DM; no bots."
|
||||
).strip()[:280],
|
||||
confidence=int(max(0, min(100, item.get("confidence") or 0))),
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
225
dealix/auto_client_acquisition/agents/qualification.py
Normal file
225
dealix/auto_client_acquisition/agents/qualification.py
Normal file
@ -0,0 +1,225 @@
|
||||
"""
|
||||
Qualification Agent — generates BANT questions and updates Fit Score.
|
||||
وكيل التأهيل — يُولّد أسئلة BANT ويُحدّث درجة الملاءمة.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from auto_client_acquisition.agents.icp_matcher import FitScore
|
||||
from auto_client_acquisition.agents.intake import Lead, LeadStatus
|
||||
from core.agents.base import BaseAgent
|
||||
from core.config.models import Task
|
||||
from core.llm.base import Message
|
||||
from core.prompts import get_prompt
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualificationQuestion:
|
||||
q: str
|
||||
bant: str # budget | authority | need | timeline
|
||||
why: str
|
||||
answered: bool = False
|
||||
answer: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"q": self.q,
|
||||
"bant": self.bant,
|
||||
"why": self.why,
|
||||
"answered": self.answered,
|
||||
"answer": self.answer,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualificationResult:
|
||||
questions: list[QualificationQuestion] = field(default_factory=list)
|
||||
budget_clarified: bool = False
|
||||
authority_confirmed: bool = False
|
||||
need_explicit: bool = False
|
||||
timeline_known: bool = False
|
||||
new_status: LeadStatus = LeadStatus.NEW
|
||||
updated_fit: FitScore | None = None
|
||||
|
||||
@property
|
||||
def bant_score(self) -> float:
|
||||
return (
|
||||
int(self.budget_clarified)
|
||||
+ int(self.authority_confirmed)
|
||||
+ int(self.need_explicit)
|
||||
+ int(self.timeline_known)
|
||||
) / 4.0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"questions": [q.to_dict() for q in self.questions],
|
||||
"budget_clarified": self.budget_clarified,
|
||||
"authority_confirmed": self.authority_confirmed,
|
||||
"need_explicit": self.need_explicit,
|
||||
"timeline_known": self.timeline_known,
|
||||
"bant_score": round(self.bant_score, 2),
|
||||
"new_status": self.new_status.value,
|
||||
"updated_fit": self.updated_fit.to_dict() if self.updated_fit else None,
|
||||
}
|
||||
|
||||
|
||||
class QualificationAgent(BaseAgent):
|
||||
"""Generates discovery questions and advances lead status."""
|
||||
|
||||
name = "qualification"
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*,
|
||||
lead: Lead,
|
||||
fit_score: FitScore | None = None,
|
||||
answers: dict[str, str] | None = None,
|
||||
**_: Any,
|
||||
) -> QualificationResult:
|
||||
"""Produce 5 BANT-style qualification questions (and ingest answers if provided)."""
|
||||
context = self._build_context(lead, fit_score)
|
||||
prompt = get_prompt("qualification_questions", locale=lead.locale, context=context)
|
||||
|
||||
try:
|
||||
response = await self.router.run(
|
||||
task=Task.REASONING,
|
||||
messages=[Message(role="user", content=prompt)],
|
||||
max_tokens=800,
|
||||
temperature=0.3,
|
||||
)
|
||||
parsed = self.parse_json_response(response.content)
|
||||
raw_questions = parsed.get("questions", [])
|
||||
questions = [
|
||||
QualificationQuestion(
|
||||
q=str(q.get("q", "")),
|
||||
bant=str(q.get("bant", "need")).lower(),
|
||||
why=str(q.get("why", "")),
|
||||
)
|
||||
for q in raw_questions
|
||||
if isinstance(q, dict)
|
||||
]
|
||||
except Exception as e:
|
||||
self.log.warning("llm_qual_failed_using_fallback", error=str(e))
|
||||
questions = self._fallback_questions(lead.locale)
|
||||
|
||||
# Ingest answers if user provided them
|
||||
budget_clarified = lead.budget is not None
|
||||
need_explicit = bool(lead.pain_points) or bool(lead.message)
|
||||
authority_confirmed = False
|
||||
timeline_known = False
|
||||
|
||||
if answers:
|
||||
for q in questions:
|
||||
key = q.bant
|
||||
if answers.get(key):
|
||||
q.answered = True
|
||||
q.answer = answers[key]
|
||||
authority_confirmed = bool(answers.get("authority"))
|
||||
timeline_known = bool(answers.get("timeline"))
|
||||
if answers.get("budget"):
|
||||
budget_clarified = True
|
||||
if answers.get("need"):
|
||||
need_explicit = True
|
||||
|
||||
# Determine new status
|
||||
bant_total = sum([budget_clarified, authority_confirmed, need_explicit, timeline_known])
|
||||
if bant_total >= 3:
|
||||
new_status = LeadStatus.QUALIFIED
|
||||
elif bant_total >= 2:
|
||||
new_status = LeadStatus.DISCOVERY
|
||||
else:
|
||||
new_status = lead.status
|
||||
|
||||
result = QualificationResult(
|
||||
questions=questions,
|
||||
budget_clarified=budget_clarified,
|
||||
authority_confirmed=authority_confirmed,
|
||||
need_explicit=need_explicit,
|
||||
timeline_known=timeline_known,
|
||||
new_status=new_status,
|
||||
)
|
||||
self.log.info(
|
||||
"qualification_done",
|
||||
lead_id=lead.id,
|
||||
bant_score=result.bant_score,
|
||||
new_status=new_status.value,
|
||||
)
|
||||
return result
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _build_context(lead: Lead, fit: FitScore | None) -> str:
|
||||
parts = [
|
||||
f"Company: {lead.company_name}",
|
||||
f"Sector: {lead.sector or 'unknown'}",
|
||||
f"Size: {lead.company_size or 'unknown'}",
|
||||
f"Region: {lead.region or 'unknown'}",
|
||||
f"Budget: {lead.budget or 'unknown'}",
|
||||
f"Message: {lead.message or '(none)'}",
|
||||
f"Locale: {lead.locale}",
|
||||
]
|
||||
if fit:
|
||||
parts.append(f"Fit tier: {fit.tier} (score {fit.overall_score:.2f})")
|
||||
parts.append(f"Recommendations: {'; '.join(fit.recommendations)}")
|
||||
return "\n".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _fallback_questions(locale: str) -> list[QualificationQuestion]:
|
||||
if locale == "ar":
|
||||
return [
|
||||
QualificationQuestion(
|
||||
q="ما الميزانية التقريبية المخصصة لهذا المشروع هذا الربع؟",
|
||||
bant="budget",
|
||||
why="تحديد النطاق المناسب من الحل",
|
||||
),
|
||||
QualificationQuestion(
|
||||
q="من سيشارك في اتخاذ قرار الاعتماد؟",
|
||||
bant="authority",
|
||||
why="التأكد من وجود صانع القرار",
|
||||
),
|
||||
QualificationQuestion(
|
||||
q="ما أكبر تحدٍ محدد تحاولون حله الآن؟",
|
||||
bant="need",
|
||||
why="ربط الحل بمشكلة حقيقية",
|
||||
),
|
||||
QualificationQuestion(
|
||||
q="ما الإطار الزمني المثالي لبدء العمل؟",
|
||||
bant="timeline",
|
||||
why="قياس مدى الاستعجال",
|
||||
),
|
||||
QualificationQuestion(
|
||||
q="هل جربتم حلولاً سابقة لهذه المشكلة؟ وماذا حدث؟",
|
||||
bant="need",
|
||||
why="فهم السياق وتجنب تكرار الأخطاء",
|
||||
),
|
||||
]
|
||||
return [
|
||||
QualificationQuestion(
|
||||
q="What budget is earmarked for this initiative this quarter?",
|
||||
bant="budget",
|
||||
why="To size the solution appropriately",
|
||||
),
|
||||
QualificationQuestion(
|
||||
q="Who else is involved in the decision?",
|
||||
bant="authority",
|
||||
why="Confirm decision-maker is in the loop",
|
||||
),
|
||||
QualificationQuestion(
|
||||
q="What's the single biggest problem you're trying to solve?",
|
||||
bant="need",
|
||||
why="Anchor the solution to real pain",
|
||||
),
|
||||
QualificationQuestion(
|
||||
q="What timeline would you ideally want to start?",
|
||||
bant="timeline",
|
||||
why="Gauge urgency",
|
||||
),
|
||||
QualificationQuestion(
|
||||
q="Have you tried anything for this before? What happened?",
|
||||
bant="need",
|
||||
why="Avoid re-running failed approaches",
|
||||
),
|
||||
]
|
||||
509
dealix/auto_client_acquisition/agents/rules_router.py
Normal file
509
dealix/auto_client_acquisition/agents/rules_router.py
Normal file
@ -0,0 +1,509 @@
|
||||
"""
|
||||
Rules Router — deterministic, zero-LLM lead classification + scoring + messaging.
|
||||
|
||||
Works fully without any LLM API key. Uses explicit rules over:
|
||||
- sector keywords
|
||||
- signals (from tech_detect output OR manual)
|
||||
- company hints (name/URL/country)
|
||||
|
||||
Produces:
|
||||
- opportunity_type (9 types)
|
||||
- fit_score / intent_score / access_score / revenue_score (100-pt model)
|
||||
- priority_tier (P0/P1/P2/BACKLOG)
|
||||
- risk_level (LOW/MEDIUM/HIGH/BLOCKED)
|
||||
- recommended_channel
|
||||
- next_action
|
||||
- first_message_angle
|
||||
- compliance_note
|
||||
|
||||
This is the "graceful degraded mode" backbone. When LLM becomes available, it
|
||||
can layer on top of this — but the rules alone are production-usable today.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Any
|
||||
|
||||
# ── Keyword taxonomies (lowercased substring match) ───────────
|
||||
|
||||
AGENCY_KEYWORDS = {
|
||||
"agency", "digital marketing", "performance marketing", "media agency",
|
||||
"creative", "branding", "pr agency", "paid ads", "content agency",
|
||||
"وكالة", "تسويق", "إعلام", "إبداع",
|
||||
}
|
||||
IMPL_PARTNER_KEYWORDS = {
|
||||
"crm consultant", "hubspot partner", "salesforce partner", "revops",
|
||||
"implementation partner", "automation consultant", "zapier expert",
|
||||
"make expert", "integration services",
|
||||
}
|
||||
STRATEGIC_KEYWORDS = {
|
||||
"platform", "marketplace", "ecosystem", "payment gateway", "crm vendor",
|
||||
"accelerator", "incubator", "manso'ah", "misk", "kaust",
|
||||
"accounting saas", "wafeq", "qoyod", "dafater",
|
||||
"tap payments", "moyasar", "paytabs", "hyperpay", "stc pay",
|
||||
"salla", "zid", "shopify", "foodics",
|
||||
}
|
||||
INVESTOR_KEYWORDS = {
|
||||
"investor", "venture", "capital", "vc", "angel", "fund",
|
||||
"sanabil", "stv", "wamda", "raed", "500 startups", "gate ventures",
|
||||
"arzan", "vision ventures", "investment",
|
||||
}
|
||||
CONTENT_KEYWORDS = {
|
||||
"podcast", "newsletter", "community", "creator", "writer", "influencer",
|
||||
"content platform", "thought leader", "founder community",
|
||||
"مجتمع", "بودكاست", "نشرة",
|
||||
}
|
||||
SUPPLIER_KEYWORDS = {
|
||||
"supplier", "vendor", "tool", "integration partner",
|
||||
}
|
||||
B2C_KEYWORDS = {
|
||||
"delivery", "retail", "consumer", "b2c", "ecommerce",
|
||||
"food delivery", "grocery", "fashion",
|
||||
}
|
||||
DIRECT_CUSTOMER_SECTORS = {
|
||||
"saas", "fintech", "proptech", "contech", "edtech", "healthtech",
|
||||
"logistics", "marketplace", "b2b marketplace",
|
||||
"hr tech", "hr saas", "cxm", "restaurant", "ecom platform",
|
||||
}
|
||||
|
||||
# ── Intent signal names (match against detected signals) ──────
|
||||
INTENT_SIGNALS = {
|
||||
"uses booking tool": 5,
|
||||
"CRM in use": 6,
|
||||
"marketing automation": 4,
|
||||
"payment gateway": 3,
|
||||
"MENA payment gateway": 5,
|
||||
"e-commerce platform": 4,
|
||||
"Salla/Zid merchant": 8,
|
||||
"live chat": 3,
|
||||
"WhatsApp widget": 8,
|
||||
"analytics active": 3,
|
||||
"running paid ads": 6,
|
||||
"inbound form": 5,
|
||||
"CMS": 1,
|
||||
"framework": 1,
|
||||
}
|
||||
|
||||
FIT_ANCHORS = {
|
||||
"saas": 10, "fintech": 9, "proptech": 8, "contech": 8,
|
||||
"restaurant": 8, "ecom": 8, "marketplace": 9,
|
||||
"cxm": 9, "hr tech": 7, "hr saas": 8, "logistics": 7,
|
||||
"edtech": 6, "healthtech": 5, "agency": 8,
|
||||
"retail": 5, "telecom": 4,
|
||||
}
|
||||
|
||||
CHANNELS_BY_TYPE = {
|
||||
"DIRECT_CUSTOMER": "LINKEDIN_MANUAL",
|
||||
"AGENCY_PARTNER": "LINKEDIN_MANUAL",
|
||||
"IMPLEMENTATION_PARTNER": "LINKEDIN_MANUAL",
|
||||
"REFERRAL_PARTNER": "LINKEDIN_MANUAL",
|
||||
"STRATEGIC_PARTNER": "PARTNER_INTRO",
|
||||
"CONTENT_COLLABORATION": "LINKEDIN_MANUAL",
|
||||
"INVESTOR_OR_ADVISOR": "EMAIL",
|
||||
"SUPPLIER_OR_INTEGRATION":"EMAIL",
|
||||
"B2C_AUDIENCE": "CONTENT_MENTION",
|
||||
}
|
||||
|
||||
NEXT_ACTION_BY_TYPE = {
|
||||
"DIRECT_CUSTOMER": "PREPARE_DM",
|
||||
"AGENCY_PARTNER": "PREPARE_PARTNER_PITCH",
|
||||
"IMPLEMENTATION_PARTNER": "PREPARE_PARTNER_PITCH",
|
||||
"REFERRAL_PARTNER": "PREPARE_PARTNER_PITCH",
|
||||
"STRATEGIC_PARTNER": "PREPARE_PARTNER_PITCH",
|
||||
"CONTENT_COLLABORATION": "PREPARE_DM",
|
||||
"INVESTOR_OR_ADVISOR": "PREPARE_INVESTOR_NOTE",
|
||||
"SUPPLIER_OR_INTEGRATION":"RESEARCH_MORE",
|
||||
"B2C_AUDIENCE": "RESEARCH_MORE",
|
||||
}
|
||||
|
||||
MESSAGE_ANGLES = {
|
||||
"DIRECT_CUSTOMER": (
|
||||
"AI sales rep بالعربي يرد على leads خلال 45 ثانية، يؤهّل، ويحجز demo — "
|
||||
"يركب فوق CRM الحالي ويسلّم sequence جاهزة للـ SDR."
|
||||
),
|
||||
"AGENCY_PARTNER": (
|
||||
"للوكالات: setup 3-15K + 20-30% من MRR كل عميل دائم. "
|
||||
"3-5 عملاء = revenue stream جديد بدون tech build."
|
||||
),
|
||||
"IMPLEMENTATION_PARTNER": (
|
||||
"شريك تنفيذ: Dealix يوفر الطبقة، أنت تقدم setup + retainer لعملائك."
|
||||
),
|
||||
"REFERRAL_PARTNER": (
|
||||
"referral 10% من MRR لـ 12 شهر على كل عميل يجي عبرك — صفر setup."
|
||||
),
|
||||
"STRATEGIC_PARTNER": (
|
||||
"Dealix + منتجك = offering مكمّل لعملائكم. add-on أو bundle — نناقش النسب."
|
||||
),
|
||||
"CONTENT_COLLABORATION": (
|
||||
"محتوى سعودي حول AI sales + GTM — تعاون podcast/newsletter/سلسلة."
|
||||
),
|
||||
"INVESTOR_OR_ADVISOR": (
|
||||
"Dealix = Arabic-first AI sales operator. نبحث عن portfolio introductions + advisory."
|
||||
),
|
||||
"SUPPLIER_OR_INTEGRATION": (
|
||||
"integration أو supply مقترح — نناقش التفاصيل."
|
||||
),
|
||||
"B2C_AUDIENCE": (
|
||||
"جمهور B2C — تفعيل عبر content + paid عبر الوكالات/الشركاء."
|
||||
),
|
||||
}
|
||||
|
||||
COMPLIANCE_NOTES = {
|
||||
"LOW": "Public business contact only; no personal PII used.",
|
||||
"MEDIUM": "Public business contact from public source; single personalized manual DM/email; no bots.",
|
||||
"HIGH": "Personal PII path — requires explicit human approval before outreach.",
|
||||
"BLOCKED":"Source or channel disallowed; do not contact without legal review.",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteResult:
|
||||
opportunity_type: str
|
||||
fit_score: int
|
||||
intent_score: int
|
||||
access_score: int
|
||||
revenue_score: int
|
||||
priority_score: int
|
||||
priority_tier: str
|
||||
risk_level: str
|
||||
recommended_channel: str
|
||||
next_action: str
|
||||
first_message_angle: str
|
||||
human_approval_required: bool
|
||||
compliance_note: str
|
||||
reason: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _contains_any(text: str, bag: set[str]) -> bool:
|
||||
t = (text or "").lower()
|
||||
return any(k in t for k in bag)
|
||||
|
||||
|
||||
def _classify_opportunity(*, sector: str, company: str, tags: str) -> str:
|
||||
combined = " ".join([sector or "", company or "", tags or ""]).lower()
|
||||
|
||||
if _contains_any(combined, AGENCY_KEYWORDS):
|
||||
return "AGENCY_PARTNER"
|
||||
if _contains_any(combined, IMPL_PARTNER_KEYWORDS):
|
||||
return "IMPLEMENTATION_PARTNER"
|
||||
if _contains_any(combined, INVESTOR_KEYWORDS):
|
||||
return "INVESTOR_OR_ADVISOR"
|
||||
if _contains_any(combined, CONTENT_KEYWORDS):
|
||||
return "CONTENT_COLLABORATION"
|
||||
if _contains_any(combined, STRATEGIC_KEYWORDS):
|
||||
# Only flag strategic if it's a platform/ecosystem — not e.g. a customer using salla
|
||||
if any(k in combined for k in ("platform", "ecosystem", "marketplace", "vendor", "accelerator", "partner", "accounting saas")):
|
||||
return "STRATEGIC_PARTNER"
|
||||
if _contains_any(combined, SUPPLIER_KEYWORDS):
|
||||
return "SUPPLIER_OR_INTEGRATION"
|
||||
if _contains_any(combined, B2C_KEYWORDS):
|
||||
# Only B2C_AUDIENCE if consumer-final, not b2b-ecom platform
|
||||
if "b2c" in combined or "consumer" in combined:
|
||||
return "B2C_AUDIENCE"
|
||||
|
||||
# Default — direct customer
|
||||
return "DIRECT_CUSTOMER"
|
||||
|
||||
|
||||
def _score(
|
||||
*,
|
||||
opportunity_type: str,
|
||||
sector: str,
|
||||
signals: list[dict],
|
||||
country: str,
|
||||
has_decision_maker: bool,
|
||||
size_hint: str,
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""Returns (fit, intent, access, revenue) — each capped at max."""
|
||||
s_text = (sector or "").lower()
|
||||
fit = 0
|
||||
for anchor, pts in FIT_ANCHORS.items():
|
||||
if anchor in s_text:
|
||||
fit = max(fit, pts)
|
||||
|
||||
# Saudi / GCC market bump
|
||||
if country and country.upper() in {"SA", "KSA", "AE-SA", "SA-GCC"}:
|
||||
fit += 5
|
||||
elif country and country.upper() in {"AE", "KW", "QA", "BH", "OM"}:
|
||||
fit += 2
|
||||
|
||||
# Size fit (sweet spot 20-500)
|
||||
size_bump = 5 if size_hint in {"10-50", "50-200", "200-1000"} else 2
|
||||
fit += size_bump
|
||||
|
||||
# Lead flow + sales workflow — credit if signals include forms/CRM/booking
|
||||
sig_names = [s.get("name", "").lower() for s in (signals or [])]
|
||||
sig_evid = [s.get("evidence", "").lower() for s in (signals or [])]
|
||||
sig_all = " ".join(sig_names + sig_evid)
|
||||
if "form" in sig_all or "inbound form" in sig_all:
|
||||
fit += 5
|
||||
if "crm" in sig_all:
|
||||
fit += 5
|
||||
if "booking" in sig_all:
|
||||
fit += 5
|
||||
fit = min(fit, 40)
|
||||
|
||||
# Intent
|
||||
intent = 0
|
||||
for sig in (signals or []):
|
||||
name = sig.get("name", "")
|
||||
w = sig.get("weight", 0)
|
||||
# Look up known intent signal weights
|
||||
for k, default in INTENT_SIGNALS.items():
|
||||
if k.lower() in name.lower():
|
||||
intent += min(w or default, default)
|
||||
break
|
||||
intent = min(intent, 30)
|
||||
|
||||
# Access
|
||||
access = 5 if has_decision_maker else 3
|
||||
if opportunity_type in {"DIRECT_CUSTOMER", "AGENCY_PARTNER"}:
|
||||
access += 5 # public LinkedIn path
|
||||
else:
|
||||
access += 3
|
||||
# personalization angle — any non-trivial signal counts
|
||||
access += 5 if len(signals or []) >= 2 else (3 if signals else 2)
|
||||
access = min(access, 15)
|
||||
|
||||
# Revenue
|
||||
revenue = 5 # pilot affordable for anyone w/ real business
|
||||
if opportunity_type in {"AGENCY_PARTNER", "STRATEGIC_PARTNER", "REFERRAL_PARTNER"}:
|
||||
revenue += 7 # partner distribution multiplies
|
||||
elif opportunity_type == "DIRECT_CUSTOMER":
|
||||
revenue += 7 # retainer potential
|
||||
else:
|
||||
revenue += 3
|
||||
revenue += 3 # default partner expansion credit
|
||||
revenue = min(revenue, 15)
|
||||
|
||||
return fit, intent, access, revenue
|
||||
|
||||
|
||||
def _tier(priority_score: int) -> str:
|
||||
if priority_score >= 80:
|
||||
return "P0"
|
||||
if priority_score >= 65:
|
||||
return "P1"
|
||||
if priority_score >= 45:
|
||||
return "P2"
|
||||
return "BACKLOG"
|
||||
|
||||
|
||||
def _risk(
|
||||
*,
|
||||
opportunity_type: str,
|
||||
has_decision_maker: bool,
|
||||
contact_channel: str,
|
||||
is_government: bool,
|
||||
) -> str:
|
||||
if is_government:
|
||||
return "HIGH"
|
||||
if contact_channel.upper() in {"PHONE", "WHATSAPP_UNKNOWN"}:
|
||||
return "HIGH"
|
||||
if opportunity_type in {"DIRECT_CUSTOMER", "AGENCY_PARTNER", "REFERRAL_PARTNER"}:
|
||||
return "LOW" if has_decision_maker else "MEDIUM"
|
||||
if opportunity_type == "INVESTOR_OR_ADVISOR":
|
||||
return "MEDIUM"
|
||||
return "LOW"
|
||||
|
||||
|
||||
def route_account(
|
||||
*,
|
||||
company: str,
|
||||
sector: str = "",
|
||||
country: str = "",
|
||||
domain: str = "",
|
||||
signals: list[dict] | None = None,
|
||||
tags: str = "",
|
||||
decision_maker: str | None = None,
|
||||
size_hint: str = "",
|
||||
is_government: bool = False,
|
||||
desired_goal: str | None = None,
|
||||
) -> RouteResult:
|
||||
"""
|
||||
Classify + score + route — deterministic, no LLM required.
|
||||
Returns a RouteResult ready for the Lead Output Schema.
|
||||
"""
|
||||
signals = signals or []
|
||||
|
||||
opportunity_type = _classify_opportunity(
|
||||
sector=sector, company=company, tags=tags
|
||||
)
|
||||
# If explicit hint provided by caller, prefer it
|
||||
if desired_goal:
|
||||
hint = desired_goal.upper().replace("-", "_").replace(" ", "_")
|
||||
if hint in set(CHANNELS_BY_TYPE.keys()):
|
||||
opportunity_type = hint
|
||||
|
||||
has_dm = bool(decision_maker and decision_maker.strip())
|
||||
fit, intent, access, revenue = _score(
|
||||
opportunity_type=opportunity_type,
|
||||
sector=sector,
|
||||
signals=signals,
|
||||
country=country,
|
||||
has_decision_maker=has_dm,
|
||||
size_hint=size_hint,
|
||||
)
|
||||
priority_score = fit + intent + access + revenue
|
||||
tier = _tier(priority_score)
|
||||
channel = CHANNELS_BY_TYPE.get(opportunity_type, "LINKEDIN_MANUAL")
|
||||
risk = _risk(
|
||||
opportunity_type=opportunity_type,
|
||||
has_decision_maker=has_dm,
|
||||
contact_channel=channel,
|
||||
is_government=is_government,
|
||||
)
|
||||
human_approval = risk in {"HIGH", "BLOCKED"}
|
||||
if human_approval:
|
||||
channel = "HOLD_FOR_APPROVAL"
|
||||
|
||||
reason_parts = []
|
||||
if country:
|
||||
reason_parts.append(f"market={country}")
|
||||
if sector:
|
||||
reason_parts.append(f"sector={sector}")
|
||||
if signals:
|
||||
reason_parts.append(f"signals={len(signals)}")
|
||||
if has_dm:
|
||||
reason_parts.append("DM known")
|
||||
reason = " · ".join(reason_parts) or "baseline classification"
|
||||
|
||||
return RouteResult(
|
||||
opportunity_type=opportunity_type,
|
||||
fit_score=fit,
|
||||
intent_score=intent,
|
||||
access_score=access,
|
||||
revenue_score=revenue,
|
||||
priority_score=priority_score,
|
||||
priority_tier=tier,
|
||||
risk_level=risk,
|
||||
recommended_channel=channel,
|
||||
next_action=NEXT_ACTION_BY_TYPE.get(opportunity_type, "RESEARCH_MORE"),
|
||||
first_message_angle=MESSAGE_ANGLES.get(opportunity_type, ""),
|
||||
human_approval_required=human_approval,
|
||||
compliance_note=COMPLIANCE_NOTES[risk],
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
# ── Message generator ──────────────────────────────────────────
|
||||
|
||||
def _primary_signal(signals: list[dict]) -> dict | None:
|
||||
if not signals:
|
||||
return None
|
||||
return max(signals, key=lambda s: s.get("weight", 0))
|
||||
|
||||
|
||||
def generate_messages(
|
||||
*,
|
||||
company: str,
|
||||
decision_maker: str | None,
|
||||
opportunity_type: str,
|
||||
signals: list[dict] | None = None,
|
||||
calendly_url: str = "https://calendly.com/sami-assiri11/dealix-demo",
|
||||
partners_url: str = "https://dealix.me/partners.html",
|
||||
) -> dict[str, str]:
|
||||
"""Return LinkedIn DM + email + WhatsApp (warm only) + 3 follow-ups."""
|
||||
name = decision_maker or f"فريق {company}"
|
||||
sig = _primary_signal(signals or [])
|
||||
sig_evid = sig.get("evidence", "") if sig else ""
|
||||
sig_name = sig.get("name", "") if sig else ""
|
||||
|
||||
def linkedin_direct() -> str:
|
||||
hook = ""
|
||||
if "WhatsApp" in sig_evid:
|
||||
hook = f"لاحظت إن {company} تستخدم WhatsApp كقناة مبيعات رئيسية — Dealix يضاعف الاستجابة بردود عربية خلال 45 ثانية، يؤهّل، ويحجز demo قبل ما يبرد."
|
||||
elif "CRM" in sig_name:
|
||||
hook = f"لاحظت إن {company} تستخدم {sig_evid} — Dealix يركب فوقه: يرد بالعربي، يؤهّل BANT، ويسلّم سجل جاهز داخل نفس الـ CRM."
|
||||
elif "booking" in sig_name:
|
||||
hook = f"لاحظت {sig_evid} عند {company} — Dealix يسبقه: يرد، يؤهّل، ويحجز slot في نفس الأداة."
|
||||
elif "paid ads" in sig_name or "ads" in sig_name:
|
||||
hook = f"{company} تدير حملات مدفوعة. المشكلة الشائعة بعد click: lead يدخل funnel، الرد بطيء، CPA يرتفع. Dealix يرد بالعربي خلال 45 ثانية."
|
||||
elif "Salla" in sig_evid or "Zid" in sig_evid or "ecom_mena" in sig_name:
|
||||
hook = f"{company} على منصة {sig_evid} — Dealix يرد على استفسارات المتجر بالعربي، يؤكد الطلب، ويسلّم للـ agent فقط عند negotiation."
|
||||
else:
|
||||
hook = f"Dealix = AI sales rep بالعربي يرد على leads خلال 45 ثانية، يؤهّل، ويحجز demo — فوق CRM الحالي."
|
||||
return (
|
||||
f"{name} مرحباً،\n\n{hook}\n\n"
|
||||
f"20 دقيقة demo نشوف مناسبته لـ {company}؟\n"
|
||||
f"📅 {calendly_url}\n\n"
|
||||
f"سامي — Dealix"
|
||||
)
|
||||
|
||||
def linkedin_partner() -> str:
|
||||
return (
|
||||
f"{name} السلام عليكم،\n\n"
|
||||
f"{company} تقدّم خدمات تسويق/CRM/automation لعملاء B2B. Dealix يضاعف قيمة خدمتك:\n"
|
||||
f"- AI sales rep بالعربي فوق عملاء {company}\n"
|
||||
f"- setup 3-15K ريال + 20-30% من MRR كل عميل دائم\n"
|
||||
f"- 3-5 عملاء = 1,500-3,750 ريال شهري إضافي بدون tech build\n\n"
|
||||
f"20 دقيقة partner meeting هذا الأسبوع؟\n"
|
||||
f"🤝 {partners_url}\n📅 {calendly_url}\n\nسامي"
|
||||
)
|
||||
|
||||
def linkedin_strategic() -> str:
|
||||
return (
|
||||
f"{name} مرحباً،\n\n"
|
||||
f"Dealix = Arabic-first AI sales ops layer. {company} منصة / ecosystem مكمّل لذلك.\n"
|
||||
f"اقتراح شراكة استراتيجية: بحث add-on داخل منصتكم أو bundle مشترك.\n"
|
||||
f"20 دقيقة نستكشف الفكرة؟\n📅 {calendly_url}\n\nسامي"
|
||||
)
|
||||
|
||||
def linkedin_investor() -> str:
|
||||
return (
|
||||
f"{name} السلام عليكم،\n\n"
|
||||
f"Dealix = Arabic-first AI sales operator للسوق السعودي. "
|
||||
f"نبحث عن advisor/investor familiar with B2B SaaS + MENA GTM. "
|
||||
f"15 دقيقة مكالمة نستكشف fit + possible portfolio introductions؟\n"
|
||||
f"📅 {calendly_url}\n\nسامي"
|
||||
)
|
||||
|
||||
def email_variant(base_linkedin: str) -> str:
|
||||
return (
|
||||
base_linkedin
|
||||
+ "\n\n---\n"
|
||||
"لإيقاف هذه الرسائل، رد بكلمة: لا شكراً. نحترم رغبتك فوراً."
|
||||
)
|
||||
|
||||
def whatsapp_warm() -> str:
|
||||
return (
|
||||
f"السلام عليكم،\n"
|
||||
f"سامي من Dealix. AI sales rep بالعربي — يرد، يؤهّل، يحجز demo.\n"
|
||||
f"مناسب نتكلم 10 دقائق؟"
|
||||
)
|
||||
|
||||
picker = {
|
||||
"DIRECT_CUSTOMER": linkedin_direct,
|
||||
"AGENCY_PARTNER": linkedin_partner,
|
||||
"IMPLEMENTATION_PARTNER": linkedin_partner,
|
||||
"REFERRAL_PARTNER": linkedin_partner,
|
||||
"STRATEGIC_PARTNER": linkedin_strategic,
|
||||
"CONTENT_COLLABORATION": linkedin_direct,
|
||||
"INVESTOR_OR_ADVISOR": linkedin_investor,
|
||||
"SUPPLIER_OR_INTEGRATION":linkedin_direct,
|
||||
"B2C_AUDIENCE": linkedin_direct,
|
||||
}
|
||||
base = picker.get(opportunity_type, linkedin_direct)()
|
||||
|
||||
return {
|
||||
"linkedin": base,
|
||||
"email": email_variant(base),
|
||||
"whatsapp_warm_only": whatsapp_warm(),
|
||||
"follow_up_plus_2": (
|
||||
f"{name} تذكير سريع للرسالة السابقة — هل فرصة لـ 15 دقيقة demo هذا الأسبوع؟\n📅 {calendly_url}"
|
||||
),
|
||||
"follow_up_plus_5": (
|
||||
f"{name} مرحباً — شاركت لك case study قصير عن شركة سعودية حصلت نتائج في 7 أيام. "
|
||||
f"أرسله؟ [أو فقط قل: اهتمام/لاحقاً/لا]"
|
||||
),
|
||||
"follow_up_plus_10": (
|
||||
f"{name} آخر متابعة — لو ما هو الوقت المناسب حالياً، تمام.\n"
|
||||
f"سؤال أخير: هل تعرف شركة ثانية في السعودية قد تستفيد؟ "
|
||||
f"referral 10% من MRR لـ 12 شهر."
|
||||
),
|
||||
}
|
||||
17
dealix/auto_client_acquisition/ai/__init__.py
Normal file
17
dealix/auto_client_acquisition/ai/__init__.py
Normal file
@ -0,0 +1,17 @@
|
||||
"""AI routing and task helpers (no external API calls in core helpers)."""
|
||||
|
||||
from auto_client_acquisition.ai.model_router import (
|
||||
ModelRoute,
|
||||
ModelTask,
|
||||
estimate_model_cost_class,
|
||||
get_model_route,
|
||||
requires_guardrail,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ModelRoute",
|
||||
"ModelTask",
|
||||
"estimate_model_cost_class",
|
||||
"get_model_route",
|
||||
"requires_guardrail",
|
||||
]
|
||||
78
dealix/auto_client_acquisition/ai/model_router.py
Normal file
78
dealix/auto_client_acquisition/ai/model_router.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""Task-based model routing — provider-agnostic, deterministic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
CostClass = Literal["low", "medium", "high"]
|
||||
|
||||
|
||||
class ModelTask(StrEnum):
|
||||
STRATEGIC_REASONING = "strategic_reasoning"
|
||||
ARABIC_WRITING = "arabic_writing"
|
||||
CLASSIFICATION = "classification"
|
||||
COMPLIANCE_GUARDRAIL = "compliance_guardrail"
|
||||
PROJECT_CODE_UNDERSTANDING = "project_code_understanding"
|
||||
SUMMARIZATION = "summarization"
|
||||
EXTRACTION = "extraction"
|
||||
FORECASTING = "forecasting"
|
||||
CUSTOMER_SUPPORT = "customer_support"
|
||||
BULK_ENRICHMENT = "bulk_enrichment"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelRoute:
|
||||
task: ModelTask
|
||||
quality_tier: Literal["standard", "high"]
|
||||
latency: Literal["low", "medium", "high"]
|
||||
cost_class: CostClass
|
||||
fallback_task: ModelTask | None
|
||||
guardrail_required: bool
|
||||
eval_metric: str
|
||||
|
||||
|
||||
def get_model_route(task: ModelTask) -> ModelRoute:
|
||||
"""Return routing guidance without binding to a vendor model name."""
|
||||
table: dict[ModelTask, ModelRoute] = {
|
||||
ModelTask.STRATEGIC_REASONING: ModelRoute(
|
||||
task, "high", "medium", "high", ModelTask.SUMMARIZATION, True, "decision_accuracy",
|
||||
),
|
||||
ModelTask.ARABIC_WRITING: ModelRoute(
|
||||
task, "high", "medium", "medium", ModelTask.SUMMARIZATION, True, "arabic_tone_and_grounding",
|
||||
),
|
||||
ModelTask.CLASSIFICATION: ModelRoute(
|
||||
task, "standard", "low", "low", None, True, "precision_recall",
|
||||
),
|
||||
ModelTask.COMPLIANCE_GUARDRAIL: ModelRoute(
|
||||
task, "high", "low", "medium", ModelTask.CLASSIFICATION, True, "block_rate_vs_false_positives",
|
||||
),
|
||||
ModelTask.PROJECT_CODE_UNDERSTANDING: ModelRoute(
|
||||
task, "high", "medium", "high", ModelTask.SUMMARIZATION, True, "grounded_citations",
|
||||
),
|
||||
ModelTask.SUMMARIZATION: ModelRoute(
|
||||
task, "standard", "low", "low", None, False, "faithfulness",
|
||||
),
|
||||
ModelTask.EXTRACTION: ModelRoute(
|
||||
task, "standard", "medium", "medium", ModelTask.CLASSIFICATION, True, "field_f1",
|
||||
),
|
||||
ModelTask.FORECASTING: ModelRoute(
|
||||
task, "high", "high", "high", ModelTask.SUMMARIZATION, True, "forecast_error",
|
||||
),
|
||||
ModelTask.CUSTOMER_SUPPORT: ModelRoute(
|
||||
task, "standard", "low", "medium", ModelTask.SUMMARIZATION, True, "resolution_rate",
|
||||
),
|
||||
ModelTask.BULK_ENRICHMENT: ModelRoute(
|
||||
task, "standard", "high", "low", None, False, "cost_per_row",
|
||||
),
|
||||
}
|
||||
return table.get(task, table[ModelTask.SUMMARIZATION])
|
||||
|
||||
|
||||
def estimate_model_cost_class(task: ModelTask) -> CostClass:
|
||||
return get_model_route(task).cost_class
|
||||
|
||||
|
||||
def requires_guardrail(task: ModelTask) -> bool:
|
||||
return get_model_route(task).guardrail_required
|
||||
57
dealix/auto_client_acquisition/business/__init__.py
Normal file
57
dealix/auto_client_acquisition/business/__init__.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""Dealix business strategy, pricing, GTM, and unit economics (deterministic, import-safe)."""
|
||||
|
||||
from auto_client_acquisition.business.gtm_plan import (
|
||||
channel_strategy,
|
||||
first_100_customers_plan,
|
||||
first_10_customers_plan,
|
||||
founder_led_sales_script,
|
||||
partner_strategy,
|
||||
)
|
||||
from auto_client_acquisition.business.launch_metrics import (
|
||||
activation_metrics,
|
||||
ai_quality_metrics,
|
||||
north_star_metrics,
|
||||
retention_metrics,
|
||||
revenue_metrics,
|
||||
)
|
||||
from auto_client_acquisition.business.market_positioning import (
|
||||
compare_competitors,
|
||||
dealix_differentiators,
|
||||
positioning_statement,
|
||||
)
|
||||
from auto_client_acquisition.business.pricing_strategy import (
|
||||
calculate_performance_fee,
|
||||
estimate_roi,
|
||||
get_pricing_tiers,
|
||||
recommend_plan,
|
||||
)
|
||||
from auto_client_acquisition.business.unit_economics import (
|
||||
estimate_cac_payback,
|
||||
estimate_gross_margin,
|
||||
estimate_ltv,
|
||||
estimate_mrr_path,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"activation_metrics",
|
||||
"ai_quality_metrics",
|
||||
"calculate_performance_fee",
|
||||
"channel_strategy",
|
||||
"compare_competitors",
|
||||
"dealix_differentiators",
|
||||
"estimate_cac_payback",
|
||||
"estimate_gross_margin",
|
||||
"estimate_ltv",
|
||||
"estimate_mrr_path",
|
||||
"estimate_roi",
|
||||
"first_100_customers_plan",
|
||||
"first_10_customers_plan",
|
||||
"founder_led_sales_script",
|
||||
"get_pricing_tiers",
|
||||
"north_star_metrics",
|
||||
"partner_strategy",
|
||||
"positioning_statement",
|
||||
"recommend_plan",
|
||||
"retention_metrics",
|
||||
"revenue_metrics",
|
||||
]
|
||||
83
dealix/auto_client_acquisition/business/gtm_plan.py
Normal file
83
dealix/auto_client_acquisition/business/gtm_plan.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""GTM plans and scripts — deterministic artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def first_10_customers_plan() -> dict[str, Any]:
|
||||
return {
|
||||
"who": [
|
||||
"B2B founders in Riyadh/Jeddah with outbound pain",
|
||||
"SMB revenue leaders in clinics, logistics, training",
|
||||
"Agencies wanting a differentiated Saudi stack",
|
||||
],
|
||||
"how_to_find": [
|
||||
"Warm intros from Sami network",
|
||||
"LinkedIn lists + manual verify (no cold WhatsApp)",
|
||||
"Sector events + follow-up drafts",
|
||||
],
|
||||
"qualification": [
|
||||
"Has ICP clarity or willing to define in onboarding",
|
||||
"Uses WhatsApp for business conversations",
|
||||
"Willing to pilot with weekly proof pack",
|
||||
],
|
||||
"pilot_offer_ar": "تجربة 7 أيام لمشغّل المؤسس + تقرير جاهزية + 10 فرص مؤهلة تجريبياً.",
|
||||
"success_criteria": [
|
||||
"Weekly active review of daily brief",
|
||||
">=3 approved drafts / week OR 1 booked meeting / month",
|
||||
"Documented ROI story",
|
||||
],
|
||||
"actions": [
|
||||
"Build list of 30 targets, close 10 pilots",
|
||||
"Run demo using command center snapshot + market radar",
|
||||
"Send WhatsApp-style approval cards in demo only",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def first_100_customers_plan() -> dict[str, Any]:
|
||||
return {
|
||||
"channel_mix": [
|
||||
"Founder content (Arabic case studies)",
|
||||
"Partner agencies (15–30% rev share band)",
|
||||
"Referrals from pilots",
|
||||
"Select webinars (PDPL-safe outreach)",
|
||||
],
|
||||
"partnerships": ["Regional CRM implementers", "Supabase consultants", "GTM freelancers"],
|
||||
"referral_loop": "Give pilots a structured referral incentive after proof pack month 2.",
|
||||
"notes": ["Cold email only with suppression lists + compliance review."],
|
||||
}
|
||||
|
||||
|
||||
def channel_strategy() -> dict[str, Any]:
|
||||
return {
|
||||
"primary": "founder_led_outbound_plus_partners",
|
||||
"secondary": "community_whatsapp_opt_in",
|
||||
"avoid": ["cold_whatsapp_broadcasts", "unchecked_scraped_lists"],
|
||||
}
|
||||
|
||||
|
||||
def partner_strategy() -> dict[str, Any]:
|
||||
return {
|
||||
"agency": {"rev_share_pct_range": [15, 30], "setup_fee_sar_range": [3000, 25000]},
|
||||
"technology": ["Supabase partners for memory hardening"],
|
||||
"positioning_ar": "الشريك يبيع التنفيذ؛ Dealix يبيع المنصة والاشتراك.",
|
||||
}
|
||||
|
||||
|
||||
def founder_led_sales_script() -> dict[str, Any]:
|
||||
return {
|
||||
"discovery_questions": [
|
||||
"من أهم 3 قرارات إيرادات هذا الأسبوع؟",
|
||||
"كيف تتابع واتساب اليوم بدون فوضى؟",
|
||||
"وش يثبت للإدارة أن التسويق نجح؟",
|
||||
],
|
||||
"demo_story_ar": "أعرض: رادار السوق → فرصة → مسودة عربية → زر موافقة → تقرير جاهزية.",
|
||||
"objections": {
|
||||
"crm": "Dealix ليس بديل CRM بالكامل؛ هو طبقة إيرادات وفهم سياق فوق أدواتكم.",
|
||||
"price": "نبدأ بمشغّل المؤسس أو pilot بسيط ثم نربط الأداء بالنتائج.",
|
||||
"ai_failed_before": "هنا التنفيذ مسودة + موافقة + تتبع؛ لا إرسال تلقائي خارجي.",
|
||||
},
|
||||
"pilot_framing_ar": "أسبوعان: موجز يومي + 10 فرص + تقرير جاهزية + مسودات بموافقة.",
|
||||
}
|
||||
46
dealix/auto_client_acquisition/business/launch_metrics.py
Normal file
46
dealix/auto_client_acquisition/business/launch_metrics.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""North-star and supporting metrics definitions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def north_star_metrics() -> dict[str, Any]:
|
||||
return {
|
||||
"primary": "weekly_qualified_opportunities_accepted_or_drafted",
|
||||
"secondary": "meetings_booked_post_approval",
|
||||
"guardrail": "blocked_high_risk_outreach_count",
|
||||
}
|
||||
|
||||
|
||||
def activation_metrics() -> dict[str, Any]:
|
||||
return {
|
||||
"time_to_first_brief_view_minutes": "target < 15",
|
||||
"time_to_first_opportunity_review": "target < 1 day",
|
||||
"first_approved_draft_hours": "target < 72h from signup",
|
||||
}
|
||||
|
||||
|
||||
def retention_metrics() -> dict[str, Any]:
|
||||
return {
|
||||
"weekly_active_brief": "WAU brief opens",
|
||||
"proof_pack_open_rate": "target > 60%",
|
||||
"expansion_trigger": "multi-seat or performance addon attach",
|
||||
}
|
||||
|
||||
|
||||
def revenue_metrics() -> dict[str, Any]:
|
||||
return {
|
||||
"mrr": "subscriptions + recurring performance (contracted)",
|
||||
"nrr": "expansion minus churn",
|
||||
"pipeline_influenced_sar": "attributed opportunities tracked in revenue memory",
|
||||
}
|
||||
|
||||
|
||||
def ai_quality_metrics() -> dict[str, Any]:
|
||||
return {
|
||||
"approval_rate": "drafts approved / drafts proposed",
|
||||
"blocked_action_rate": "guardrail stops / risky attempts",
|
||||
"arabic_tone_checks": "sampled human review weekly",
|
||||
"hallucination_checks": "grounding to project chunks + radar evidence",
|
||||
}
|
||||
114
dealix/auto_client_acquisition/business/market_positioning.py
Normal file
114
dealix/auto_client_acquisition/business/market_positioning.py
Normal file
@ -0,0 +1,114 @@
|
||||
"""Competitive positioning — deterministic reference data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
Segment = Literal["founder", "sme", "enterprise", "agency"]
|
||||
|
||||
|
||||
def compare_competitors() -> list[dict[str, Any]]:
|
||||
"""High-level comparison; not exhaustive feature matrices."""
|
||||
return [
|
||||
{
|
||||
"name": "HubSpot",
|
||||
"strengths": ["Wide CRM/marketing suite", "AI + context narrative (2026)"],
|
||||
"weaknesses_sa_gcc": ["Not Arabic-native operator", "Generic B2B, not Saudi revenue graph"],
|
||||
"dealix_wins": ["Arabic Chief of Staff", "PDPL-first posture", "WhatsApp approval-native flows"],
|
||||
"do_not_copy": ["Boil-the-ocean suite creep"],
|
||||
"borrow": ["Context-rich AI positioning", "agent + deal progression story"],
|
||||
},
|
||||
{
|
||||
"name": "Salesforce",
|
||||
"strengths": ["Enterprise platform depth"],
|
||||
"weaknesses_sa_gcc": ["Heavy ops", "Slow founder-led adoption", "Arabic UX gap"],
|
||||
"dealix_wins": ["Founder speed", "Saudi signal packs", "Outcome pricing option"],
|
||||
"do_not_copy": ["Customization trap without outcomes"],
|
||||
"borrow": ["Account-centric revenue thinking"],
|
||||
},
|
||||
{
|
||||
"name": "Gong",
|
||||
"strengths": ["Revenue intelligence", "expanding to enablement + AM (Mission Andromeda narrative)"],
|
||||
"weaknesses_sa_gcc": ["Call-centric origins", "Arabic market nuance"],
|
||||
"dealix_wins": ["WhatsApp-first reality", "why-now radar + Arabic drafts"],
|
||||
"do_not_copy": ["Recording-heavy compliance risk without clear PDPL story"],
|
||||
"borrow": ["Revenue OS narrative breadth beyond raw calls"],
|
||||
},
|
||||
{
|
||||
"name": "Apollo / ZoomInfo",
|
||||
"strengths": ["Prospecting data scale"],
|
||||
"weaknesses_sa_gcc": ["Cold outreach culture", "Compliance friction in GCC"],
|
||||
"dealix_wins": ["Approval gates", "contactability OS", "Saudi context"],
|
||||
"do_not_copy": ["Spray-and-pray automation"],
|
||||
"borrow": ["Structured prospect lists as input, not autopilot"],
|
||||
},
|
||||
{
|
||||
"name": "Zoho / Odoo",
|
||||
"strengths": ["Price + ERP breadth"],
|
||||
"weaknesses_sa_gcc": ["Not a revenue memory + operator system"],
|
||||
"dealix_wins": ["Strategic operator + proof pack + market radar"],
|
||||
"do_not_copy": ["ERP generalism as core story"],
|
||||
"borrow": ["SMB packaging discipline"],
|
||||
},
|
||||
{
|
||||
"name": "WhatsApp automation tools",
|
||||
"strengths": ["Channel reach"],
|
||||
"weaknesses_sa_gcc": ["Cold spam risk", "weak PDPL story"],
|
||||
"dealix_wins": ["Opt-in + approval + audit", "Arabic relationship operator"],
|
||||
"do_not_copy": ["Auto-send cold campaigns"],
|
||||
"borrow": ["Interactive buttons — max 3 per message; two-step flows"],
|
||||
},
|
||||
{
|
||||
"name": "Boardy-style intro tools",
|
||||
"strengths": ["Accept/skip UX for intros"],
|
||||
"weaknesses_sa_gcc": ["Limited Saudi B2B + revenue proof loop"],
|
||||
"dealix_wins": ["Revenue memory + command center + compliance + Arabic"],
|
||||
"do_not_copy": ["Shallow CRM replacement claims"],
|
||||
"borrow": ["Relationship card UX patterns"],
|
||||
},
|
||||
{
|
||||
"name": "SocraticCode-style indexing",
|
||||
"strengths": ["Repo understanding"],
|
||||
"weaknesses_sa_gcc": ["Not revenue + market + Arabic operator"],
|
||||
"dealix_wins": ["Project intelligence + strategic memory + GTM"],
|
||||
"do_not_copy": ["Dev-only scope"],
|
||||
"borrow": ["Chunking + local index before vectors"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def dealix_differentiators() -> list[str]:
|
||||
return [
|
||||
"Saudi-first GTM context",
|
||||
"Arabic-first personal operator",
|
||||
"WhatsApp-first but compliance-safe",
|
||||
"Why-now market signals",
|
||||
"Project intelligence + strategic memory",
|
||||
"Revenue memory",
|
||||
"Agent approval flows",
|
||||
"PDPL-aware contactability",
|
||||
"Outcome / performance packaging option",
|
||||
"Founder daily brief",
|
||||
"Vertical playbooks",
|
||||
"Relationship-to-revenue workflow",
|
||||
]
|
||||
|
||||
|
||||
def positioning_statement(segment: Segment) -> str:
|
||||
statements: dict[Segment, str] = {
|
||||
"founder": (
|
||||
"Dealix هو نظام إيرادات B2B سعودي مع مشغّل استراتيجي عربي: يومياً يقول لك ماذا يهم، "
|
||||
"من تكلّم، ماذا تقول، وما يحتاج موافقة قبل أي إرسال خارجي."
|
||||
),
|
||||
"sme": (
|
||||
"Dealix يربط إشارات السوق السعودية بقرارات المبيعات والمتابعة عبر واتساب وبريد "
|
||||
"بمسارات موافقة وتتبع عائد."
|
||||
),
|
||||
"enterprise": (
|
||||
"Dealix للمؤسسات: حوكمة، تكاملات، ذاكرة مشروع/إيرادات، ووكلاء آمنون مع سياسات واضحة وSSO عند النشر الخاص."
|
||||
),
|
||||
"agency": (
|
||||
"لوكالات النمو: تنفذون التطبيق والتدريب، وDealix يبقى منصة الاشتراك مع حزمة أداء اختيارية مُعرّفة تعاقدياً."
|
||||
),
|
||||
}
|
||||
return statements.get(segment, statements["founder"])
|
||||
173
dealix/auto_client_acquisition/business/pricing_strategy.py
Normal file
173
dealix/auto_client_acquisition/business/pricing_strategy.py
Normal file
@ -0,0 +1,173 @@
|
||||
"""Pricing tiers, plan recommendation, performance fees, ROI estimates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
PlanKey = Literal[
|
||||
"founder_operator",
|
||||
"growth_os",
|
||||
"scale_os",
|
||||
"performance_addon",
|
||||
"enterprise",
|
||||
"agency_partner",
|
||||
]
|
||||
|
||||
|
||||
def get_pricing_tiers() -> dict[str, Any]:
|
||||
"""Product packaging aligned with docs/PRICING_STRATEGY.md (SAR/month unless noted)."""
|
||||
return {
|
||||
"currency": "SAR",
|
||||
"tiers": [
|
||||
{
|
||||
"key": "founder_operator",
|
||||
"name_ar": "مشغّل المؤسس",
|
||||
"target": "solo founders / early B2B startups",
|
||||
"price_monthly_sar_range": [299, 499],
|
||||
"price_future_sar": 999,
|
||||
"includes": [
|
||||
"Arabic daily brief",
|
||||
"20 strategic opportunities / month",
|
||||
"project memory (local + Supabase path)",
|
||||
"draft messages (approval-first)",
|
||||
"launch readiness",
|
||||
"limited market radar",
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "growth_os",
|
||||
"name_ar": "نظام النمو",
|
||||
"target": "B2B SMEs",
|
||||
"price_monthly_sar": 2999,
|
||||
"includes": [
|
||||
"Revenue Command Center",
|
||||
"Market Radar",
|
||||
"500 prospects / month (enrichment cap — policy)",
|
||||
"AI message drafts",
|
||||
"WhatsApp approval flow",
|
||||
"Gmail draft",
|
||||
"meeting schedule drafts",
|
||||
"weekly proof pack",
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "scale_os",
|
||||
"name_ar": "نظام التوسّع",
|
||||
"target": "mature B2B teams",
|
||||
"price_monthly_sar": 7999,
|
||||
"includes": [
|
||||
"multi-seat",
|
||||
"team performance",
|
||||
"customer success signals",
|
||||
"churn / expansion scoring",
|
||||
"integrations",
|
||||
"advanced analytics",
|
||||
"API / webhooks",
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "performance_addon",
|
||||
"name_ar": "طبقة الأداء",
|
||||
"target": "optional add-on",
|
||||
"fee_qualified_lead_sar_range": [25, 75],
|
||||
"fee_booked_meeting_sar_range": [150, 500],
|
||||
"success_fee_pct_range": [3, 10],
|
||||
"notes": ["Strict qualification + dispute logic required before billing."],
|
||||
},
|
||||
{
|
||||
"key": "enterprise",
|
||||
"name_ar": "المؤسسات / نشر خاص",
|
||||
"target": "enterprise",
|
||||
"pricing": "custom",
|
||||
"includes": ["SSO", "advanced PDPL", "custom integrations", "dedicated success", "private data", "SLA"],
|
||||
},
|
||||
{
|
||||
"key": "agency_partner",
|
||||
"name_ar": "شراكة وكالات",
|
||||
"setup_fee_sar_range": [3000, 25000],
|
||||
"revenue_share_pct_range": [15, 30],
|
||||
"notes": ["Dealix keeps platform subscription; agency sells implementation."],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def recommend_plan(
|
||||
*,
|
||||
company_size: str,
|
||||
monthly_budget_sar: float,
|
||||
goal: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Heuristic plan recommendation — deterministic rules."""
|
||||
size = company_size.lower().strip()
|
||||
goal_l = goal.lower()
|
||||
recommended: PlanKey = "founder_operator"
|
||||
rationale_ar = "ميزانية محدودة أو مرحلة مبكرة — ابدأ بمشغّل المؤسس للتحقق السريع."
|
||||
|
||||
if monthly_budget_sar >= 6500 or size in ("large", "enterprise", "scale"):
|
||||
recommended = "scale_os"
|
||||
rationale_ar = "فريق ناضج أو ميزانية عالية — Scale OS يلائم التنسيق متعدد المقاعد والتحليلات."
|
||||
elif monthly_budget_sar >= 2000 or size in ("sme", "medium", "growth"):
|
||||
recommended = "growth_os"
|
||||
rationale_ar = "شركة B2B نامية — Growth OS يوازن بين الرادار والتنفيذ الآمن ودليل العائد."
|
||||
|
||||
if "performance" in goal_l or "pay per" in goal_l:
|
||||
rationale_ar += " أضف طبقة الأداء لاحقاً بعد تعريف التأهيل والنزاعات."
|
||||
|
||||
tiers = get_pricing_tiers()
|
||||
tier = next((t for t in tiers["tiers"] if t["key"] == recommended), tiers["tiers"][0])
|
||||
return {
|
||||
"recommended_plan": recommended,
|
||||
"rationale_ar": rationale_ar,
|
||||
"tier_summary": tier,
|
||||
"inputs": {"company_size": company_size, "monthly_budget_sar": monthly_budget_sar, "goal": goal},
|
||||
}
|
||||
|
||||
|
||||
def calculate_performance_fee(
|
||||
*,
|
||||
qualified_leads: int,
|
||||
booked_meetings: int,
|
||||
won_revenue_sar: float,
|
||||
lead_fee_sar: float = 40.0,
|
||||
meeting_fee_sar: float = 250.0,
|
||||
success_fee_pct: float = 5.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Demo calculation — real contracts need legal + qualification definitions."""
|
||||
lead_component = max(0, qualified_leads) * lead_fee_sar
|
||||
meeting_component = max(0, booked_meetings) * meeting_fee_sar
|
||||
success_component = max(0.0, won_revenue_sar) * (success_fee_pct / 100.0)
|
||||
total = round(lead_component + meeting_component + success_component, 2)
|
||||
return {
|
||||
"qualified_leads": qualified_leads,
|
||||
"booked_meetings": booked_meetings,
|
||||
"won_revenue_sar": won_revenue_sar,
|
||||
"components_sar": {
|
||||
"leads": round(lead_component, 2),
|
||||
"meetings": round(meeting_component, 2),
|
||||
"success": round(success_component, 2),
|
||||
},
|
||||
"total_performance_fees_sar": total,
|
||||
"disclaimer_ar": "يجب ربط أي رسوم أداء بعقود وتأهيل واضح وتتبع نزاعات قبل الفوترة.",
|
||||
}
|
||||
|
||||
|
||||
def estimate_roi(
|
||||
*,
|
||||
plan_price_sar: float,
|
||||
expected_pipeline_sar: float,
|
||||
expected_revenue_sar: float,
|
||||
) -> dict[str, Any]:
|
||||
"""Simple ROI framing — not financial advice."""
|
||||
if plan_price_sar <= 0:
|
||||
return {"error": "plan_price_must_be_positive"}
|
||||
pipeline_multiple = round(expected_pipeline_sar / plan_price_sar, 2) if plan_price_sar else 0.0
|
||||
revenue_multiple = round(expected_revenue_sar / plan_price_sar, 2) if plan_price_sar else 0.0
|
||||
return {
|
||||
"plan_price_sar": plan_price_sar,
|
||||
"expected_pipeline_sar": expected_pipeline_sar,
|
||||
"expected_revenue_sar": expected_revenue_sar,
|
||||
"pipeline_to_subscription_multiple": pipeline_multiple,
|
||||
"revenue_to_subscription_multiple": revenue_multiple,
|
||||
"verdict_ar": "إذا تعدت المضاعفات 3–5x على الأنابيب المتوقع، يصير الاشتراك منطقياً مع تتبع أسبوعي.",
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user