Spaces:
Sleeping
Sleeping
Z User commited on
Commit ·
fb0ec21
1
Parent(s): 8617c6c
feat: ABM Engine v1 — OpenSearch store + Apify enrichment + ICP scoring + k-NN memory + optional Neo4j mirror
Browse files- Aiven OpenSearch 3.6 (k-NN plugin): accounts/contacts/events/memory indices
- Apify: owner's own actors (google maps master scraper / linkedin company emails)
- ICP scoring: deterministic, explainable, 0-100 with breakdown (A/B/C/D labels)
- Vector memory: fastembed MiniLM-384 with hash-embedding fallback
- Graph mirror: BetterDB (managed Neo4j) or direct Aura, auto-detect, optional
- Cache: Upstash REST optional, in-process TTL fallback
- Auth: Supabase JWKS (same session JWT as validator) + optional API key
- Tests: 13 unit tests (normalizers, scoring, embeddings, auth, API contract)
- Dockerfile +18 -0
- README.md +31 -4
- app/__init__.py +0 -0
- app/auth.py +119 -0
- app/config.py +82 -0
- app/main.py +285 -0
- app/services/__init__.py +0 -0
- app/services/accounts.py +140 -0
- app/services/enrich.py +159 -0
- app/services/memory.py +73 -0
- app/services/signals.py +68 -0
- app/stores/__init__.py +0 -0
- app/stores/apify.py +155 -0
- app/stores/cache.py +79 -0
- app/stores/embeddings.py +114 -0
- app/stores/graph.py +182 -0
- app/stores/opensearch.py +183 -0
- pytest.ini +3 -0
- requirements.txt +19 -0
- tests/__init__.py +0 -0
- tests/test_abm.py +128 -0
Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
| 5 |
+
|
| 6 |
+
COPY requirements.txt .
|
| 7 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 8 |
+
|
| 9 |
+
# Bake the embedding model at build time so a cold Space makes zero
|
| 10 |
+
# network calls for inference (fastembed caches under HF_HOME).
|
| 11 |
+
ENV HF_HOME=/app/models
|
| 12 |
+
RUN python3 -c "from fastembed import TextEmbedding; list(TextEmbedding('sentence-transformers/all-MiniLM-L6-v2').embed(['warm']))" \
|
| 13 |
+
&& echo "[BUILD] embedding model baked"
|
| 14 |
+
|
| 15 |
+
COPY app/ ./app/
|
| 16 |
+
|
| 17 |
+
EXPOSE 7860
|
| 18 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,37 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
|
|
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: ABM Engine
|
| 3 |
+
emoji: 🎯
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
+
# FortyDN ABM Engine
|
| 11 |
|
| 12 |
+
Outbound-intelligence service: account store + Apify enrichment + ICP scoring
|
| 13 |
+
+ vector memory + optional Neo4j graph mirror.
|
| 14 |
+
|
| 15 |
+
**Stack**: FastAPI on Hugging Face Space · Aiven OpenSearch 3.6 (store, search,
|
| 16 |
+
k-NN vectors) · Apify actors (owner's own scrapers) · BetterDB Neo4j (optional
|
| 17 |
+
mirror) · Upstash Redis (optional cache) · Supabase JWKS identity.
|
| 18 |
+
|
| 19 |
+
## Endpoints (under /api/v1)
|
| 20 |
+
| Route | Purpose |
|
| 21 |
+
|---|---|
|
| 22 |
+
| GET /health | component report (open) |
|
| 23 |
+
| POST /accounts | upsert account (domain-keyed) |
|
| 24 |
+
| GET /accounts?q= | search with filters |
|
| 25 |
+
| GET /accounts/{id} | account + contacts + events |
|
| 26 |
+
| POST /enrich | run Apify actor → normalize → score → embed → mirror |
|
| 27 |
+
| POST /signals/score | rescore an account |
|
| 28 |
+
| POST /memory, /memory/batch | remember texts (384-dim embeddings) |
|
| 29 |
+
| POST /memory/search | k-NN recall |
|
| 30 |
+
| GET /graph/overview | Neo4j mirror status |
|
| 31 |
+
|
| 32 |
+
## Secrets (Space settings)
|
| 33 |
+
`OS_URL`, `OS_USER`, `OS_PASSWORD` (Aiven OpenSearch) · `APIFY_TOKEN` ·
|
| 34 |
+
`SUPABASE_URL` · optional `BETTERDB_URL`+`BETTERDB_TOKEN` or
|
| 35 |
+
`NEO4J_URI`+`NEO4J_USER`+`NEO4J_PASSWORD` · optional
|
| 36 |
+
`UPSTASH_REDIS_REST_URL`+`UPSTASH_REDIS_REST_TOKEN` · optional `API_KEY`
|
| 37 |
+
(service calls) · `LICENSE_ENFORCEMENT=true` at go-live.
|
app/__init__.py
ADDED
|
File without changes
|
app/auth.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Supabase JWT verification — same identity plane as the validator engine.
|
| 2 |
+
|
| 3 |
+
The ABM engine is a product backend: the site attaches the user's Supabase
|
| 4 |
+
session JWT (Authorization: Bearer) and this module verifies it against the
|
| 5 |
+
project's public JWKS endpoint. Mirrors app/auth.py of modul-x (ES256/RS256,
|
| 6 |
+
audience "authenticated"). A static X-API-Key (settings.api_key) is accepted
|
| 7 |
+
for service-to-service calls; both are optional while enforcement is off.
|
| 8 |
+
"""
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
from fastapi import Request
|
| 14 |
+
from fastapi.responses import JSONResponse
|
| 15 |
+
from jose import jwt as jose_jwt
|
| 16 |
+
from jose import jwk as jose_jwk
|
| 17 |
+
from jose.exceptions import JWTError
|
| 18 |
+
|
| 19 |
+
from .config import settings
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger("abm.auth")
|
| 22 |
+
|
| 23 |
+
_jwks_cache: Optional[dict] = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def enforcement_on() -> bool:
|
| 27 |
+
return (settings.license_enforcement or "").strip().lower() == "true"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
async def _get_jwks() -> dict:
|
| 31 |
+
global _jwks_cache
|
| 32 |
+
if _jwks_cache is None:
|
| 33 |
+
url = f"{settings.supabase_url}/auth/v1/.well-known/jwks.json"
|
| 34 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 35 |
+
res = await client.get(url)
|
| 36 |
+
res.raise_for_status()
|
| 37 |
+
_jwks_cache = res.json()
|
| 38 |
+
return _jwks_cache # type: ignore[return-value]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def verify_supabase_jwt(token: str) -> dict:
|
| 42 |
+
jwks = await _get_jwks()
|
| 43 |
+
last_error: Optional[Exception] = None
|
| 44 |
+
for key_data in jwks.get("keys", []):
|
| 45 |
+
try:
|
| 46 |
+
key = jose_jwk.construct(key_data)
|
| 47 |
+
claims = jose_jwt.decode(
|
| 48 |
+
token,
|
| 49 |
+
key,
|
| 50 |
+
algorithms=["ES256", "RS256"],
|
| 51 |
+
audience="authenticated",
|
| 52 |
+
)
|
| 53 |
+
if not claims.get("sub"):
|
| 54 |
+
raise JWTError("JWT has no sub claim")
|
| 55 |
+
return claims
|
| 56 |
+
except JWTError as exc:
|
| 57 |
+
last_error = exc
|
| 58 |
+
continue
|
| 59 |
+
raise JWTError(f"JWT verification failed: {last_error}")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
async def has_abm_license(user_id: str) -> bool:
|
| 63 |
+
"""Active 'abm-engine' license in the central project (service-role read).
|
| 64 |
+
Falls back to modul-x license so existing customers are not locked out
|
| 65 |
+
during rollout."""
|
| 66 |
+
service_key = settings.supabase_service_role_key
|
| 67 |
+
if not service_key:
|
| 68 |
+
return False
|
| 69 |
+
headers = {"apikey": service_key, "Authorization": f"Bearer {service_key}"}
|
| 70 |
+
try:
|
| 71 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 72 |
+
res = await client.get(
|
| 73 |
+
f"{settings.supabase_url}/rest/v1/licenses",
|
| 74 |
+
params={"user_id": f"eq.{user_id}", "status": "eq.active", "select": "product"},
|
| 75 |
+
headers=headers,
|
| 76 |
+
)
|
| 77 |
+
if res.status_code != 200:
|
| 78 |
+
return False
|
| 79 |
+
products = {row.get("product") for row in res.json()}
|
| 80 |
+
return bool(products & {"abm-engine", "modul-x", "combo-full-bridge"})
|
| 81 |
+
except Exception:
|
| 82 |
+
logger.warning("license lookup failed", exc_info=True)
|
| 83 |
+
return False
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _api_key_ok(request: Request) -> bool:
|
| 87 |
+
supplied = request.headers.get("x-api-key", "")
|
| 88 |
+
return bool(settings.api_key) and supplied == settings.api_key
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
async def authenticate(request: Request) -> dict:
|
| 92 |
+
"""Authenticate a paid-route request.
|
| 93 |
+
|
| 94 |
+
Accepts, in order: X-API-Key (service calls), Supabase session JWT.
|
| 95 |
+
Returns {"user_id", "email", "via"}. Raises PermissionError on failure —
|
| 96 |
+
the route layer maps it to 401/402. When enforcement is off, an invalid
|
| 97 |
+
JWT still authenticates anonymously so demo deployments keep working.
|
| 98 |
+
"""
|
| 99 |
+
if _api_key_ok(request):
|
| 100 |
+
return {"user_id": "service", "email": None, "via": "api-key"}
|
| 101 |
+
|
| 102 |
+
header = request.headers.get("authorization", "")
|
| 103 |
+
token = header[7:].strip() if header.lower().startswith("bearer ") else ""
|
| 104 |
+
if token and settings.supabase_url:
|
| 105 |
+
try:
|
| 106 |
+
claims = await verify_supabase_jwt(token)
|
| 107 |
+
user_id = claims.get("sub", "")
|
| 108 |
+
if enforcement_on() and not await has_abm_license(user_id):
|
| 109 |
+
raise PermissionError("402:No active ABM Engine license — see /pricing.")
|
| 110 |
+
return {"user_id": user_id, "email": claims.get("email"), "via": "jwt"}
|
| 111 |
+
except PermissionError:
|
| 112 |
+
raise
|
| 113 |
+
except JWTError:
|
| 114 |
+
if enforcement_on():
|
| 115 |
+
raise PermissionError("401:Invalid or expired token — sign in again.")
|
| 116 |
+
return {"user_id": "anonymous", "email": None, "via": "anonymous"}
|
| 117 |
+
if enforcement_on():
|
| 118 |
+
raise PermissionError("401:Missing bearer token — sign in first.")
|
| 119 |
+
return {"user_id": "anonymous", "email": None, "via": "anonymous"}
|
app/config.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ABM Engine configuration — all optional at boot, degrade gracefully.
|
| 2 |
+
import os
|
| 3 |
+
from typing import List
|
| 4 |
+
|
| 5 |
+
from pydantic import ConfigDict
|
| 6 |
+
from pydantic_settings import BaseSettings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Settings(BaseSettings):
|
| 10 |
+
app_name: str = "FortyDN ABM Engine"
|
| 11 |
+
app_version: str = "1.0.0"
|
| 12 |
+
debug: bool = False
|
| 13 |
+
|
| 14 |
+
# ── Supabase identity (same project as the validator) ────────────────
|
| 15 |
+
supabase_url: str = ""
|
| 16 |
+
supabase_service_role_key: str = ""
|
| 17 |
+
license_enforcement: str = "" # "true" gates paid routes on licenses
|
| 18 |
+
|
| 19 |
+
# ── Aiven OpenSearch (primary store + search + k-NN vectors) ─────────
|
| 20 |
+
os_url: str = ""
|
| 21 |
+
os_user: str = ""
|
| 22 |
+
os_password: str = ""
|
| 23 |
+
os_index_prefix: str = "abm"
|
| 24 |
+
|
| 25 |
+
# ── Apify enrichment ─────────────────────────────────────────────────
|
| 26 |
+
apify_token: str = ""
|
| 27 |
+
apify_actor_google_maps: str = "sitcod3.lab/fdn-google-map-master-scraper"
|
| 28 |
+
apify_actor_linkedin: str = "sitcod3.lab/linkedin-company-email-scraper"
|
| 29 |
+
apify_run_timeout_s: int = 900
|
| 30 |
+
|
| 31 |
+
# ── Optional graph mirror: BetterDB (managed Neo4j) or direct Aura ───
|
| 32 |
+
betterdb_url: str = ""
|
| 33 |
+
betterdb_token: str = ""
|
| 34 |
+
neo4j_uri: str = ""
|
| 35 |
+
neo4j_user: str = ""
|
| 36 |
+
neo4j_password: str = ""
|
| 37 |
+
|
| 38 |
+
# ── Optional Upstash Redis cache (REST) ─────────────────────────────
|
| 39 |
+
upstash_rest_url: str = ""
|
| 40 |
+
upstash_rest_token: str = ""
|
| 41 |
+
|
| 42 |
+
# ── Embeddings ───────────────────────────────────────────────────────
|
| 43 |
+
embedding_provider: str = "auto" # auto | fastembed | hash
|
| 44 |
+
embedding_dim: int = 384
|
| 45 |
+
|
| 46 |
+
# ── ICP scoring defaults (override via env) ─────────────────────────
|
| 47 |
+
icp_industries: str = "software,saas,technology,manufacturing,logistics,healthcare,finance,professional services"
|
| 48 |
+
icp_countries: str = "united states,usa,canada,united kingdom,germany,netherlands,australia,denmark,sweden"
|
| 49 |
+
icp_min_headcount: int = 10
|
| 50 |
+
icp_max_headcount: int = 2000
|
| 51 |
+
|
| 52 |
+
# ── Access ───────────────────────────────────────────────────────────
|
| 53 |
+
api_key: str = "" # optional X-API-Key for service-to-service calls
|
| 54 |
+
|
| 55 |
+
@property
|
| 56 |
+
def icp_industries_list(self) -> List[str]:
|
| 57 |
+
return [x.strip().lower() for x in self.icp_industries.split(",") if x.strip()]
|
| 58 |
+
|
| 59 |
+
@property
|
| 60 |
+
def icp_countries_list(self) -> List[str]:
|
| 61 |
+
return [x.strip().lower() for x in self.icp_countries.split(",") if x.strip()]
|
| 62 |
+
|
| 63 |
+
@property
|
| 64 |
+
def os_configured(self) -> bool:
|
| 65 |
+
return bool(self.os_url)
|
| 66 |
+
|
| 67 |
+
@property
|
| 68 |
+
def apify_configured(self) -> bool:
|
| 69 |
+
return bool(self.apify_token)
|
| 70 |
+
|
| 71 |
+
@property
|
| 72 |
+
def graph_configured(self) -> bool:
|
| 73 |
+
return bool((self.betterdb_url and self.betterdb_token) or self.neo4j_uri)
|
| 74 |
+
|
| 75 |
+
@property
|
| 76 |
+
def upstash_configured(self) -> bool:
|
| 77 |
+
return bool(self.upstash_rest_url and self.upstash_rest_token)
|
| 78 |
+
|
| 79 |
+
model_config = ConfigDict(env_file=".env", extra="ignore")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
settings = Settings()
|
app/main.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FortyDN ABM Engine — outbound intelligence service.
|
| 2 |
+
|
| 3 |
+
Account graph + enrichment + ICP signals + vector memory, served as the
|
| 4 |
+
product backend for the ABM module. Components (all optional at boot):
|
| 5 |
+
|
| 6 |
+
- Aiven OpenSearch : primary store, search, k-NN vector memory (required)
|
| 7 |
+
- Apify : enrichment actors (Google Maps / LinkedIn)
|
| 8 |
+
- BetterDB / Aura : optional Neo4j graph mirror
|
| 9 |
+
- Upstash Redis : optional distributed cache
|
| 10 |
+
- Supabase JWKS : user identity (same session JWT as the validator)
|
| 11 |
+
|
| 12 |
+
Endpoints
|
| 13 |
+
GET /health component report
|
| 14 |
+
POST /accounts upsert account
|
| 15 |
+
GET /accounts search (?q=&industry=&country=&min_score=&limit=)
|
| 16 |
+
GET /accounts/{id} account + contacts + events
|
| 17 |
+
POST /enrich Apify enrichment run (google_maps | linkedin)
|
| 18 |
+
POST /signals/score rescore an account
|
| 19 |
+
POST /memory remember a text document (embedded)
|
| 20 |
+
POST /memory/batch remember many
|
| 21 |
+
POST /memory/search k-NN recall
|
| 22 |
+
GET /graph/overview Neo4j mirror status/counts
|
| 23 |
+
"""
|
| 24 |
+
import logging
|
| 25 |
+
from contextlib import asynccontextmanager
|
| 26 |
+
from typing import Any, Dict, List, Optional
|
| 27 |
+
|
| 28 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 29 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 30 |
+
from fastapi.responses import JSONResponse
|
| 31 |
+
from pydantic import BaseModel, Field
|
| 32 |
+
|
| 33 |
+
from .auth import authenticate
|
| 34 |
+
from .config import settings
|
| 35 |
+
from .services import accounts as accounts_svc
|
| 36 |
+
from .services import enrich as enrich_svc
|
| 37 |
+
from .services import memory as memory_svc
|
| 38 |
+
from .services import signals as signals_svc
|
| 39 |
+
from .stores import opensearch
|
| 40 |
+
from .stores.apify import apify_client as apify
|
| 41 |
+
from .stores.cache import cache
|
| 42 |
+
from .stores.embeddings import provider_name
|
| 43 |
+
from .stores.graph import graph
|
| 44 |
+
|
| 45 |
+
logging.basicConfig(level=logging.INFO)
|
| 46 |
+
logger = logging.getLogger("abm.main")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@asynccontextmanager
|
| 50 |
+
async def lifespan(app: FastAPI):
|
| 51 |
+
logger.info("%s v%s starting", settings.app_name, settings.app_version)
|
| 52 |
+
if settings.os_configured:
|
| 53 |
+
try:
|
| 54 |
+
results = await opensearch.store.bootstrap()
|
| 55 |
+
logger.info("OpenSearch bootstrap: %s", results)
|
| 56 |
+
graph_ok = await graph.connect()
|
| 57 |
+
logger.info("graph mirror: %s", graph_ok)
|
| 58 |
+
except Exception as exc:
|
| 59 |
+
logger.warning("OpenSearch bootstrap failed (best-effort): %s", exc)
|
| 60 |
+
else:
|
| 61 |
+
logger.warning("OpenSearch not configured — set OS_URL/OS_USER/OS_PASSWORD")
|
| 62 |
+
yield
|
| 63 |
+
await opensearch.store.close()
|
| 64 |
+
await graph.close()
|
| 65 |
+
await apify.close()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
app = FastAPI(title=settings.app_name, version=settings.app_version, lifespan=lifespan)
|
| 69 |
+
|
| 70 |
+
app.add_middleware(
|
| 71 |
+
CORSMiddleware,
|
| 72 |
+
allow_origins=["*"],
|
| 73 |
+
allow_credentials=False,
|
| 74 |
+
allow_methods=["*"],
|
| 75 |
+
allow_headers=["*"],
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@app.middleware("http")
|
| 80 |
+
async def auth_gate(request: Request, call_next):
|
| 81 |
+
"""Paid-route gate: /health is open; everything under /api requires
|
| 82 |
+
authentication. Enforcement off → anonymous demo access still works."""
|
| 83 |
+
path = request.url.path
|
| 84 |
+
if path == "/health" or path == "/" or not path.startswith("/api"):
|
| 85 |
+
return await call_next(request)
|
| 86 |
+
try:
|
| 87 |
+
request.state.auth = await authenticate(request)
|
| 88 |
+
except PermissionError as exc:
|
| 89 |
+
message = str(exc)
|
| 90 |
+
if message.startswith("402"):
|
| 91 |
+
return JSONResponse(status_code=402, content={"detail": message.split(":", 1)[1]})
|
| 92 |
+
return JSONResponse(status_code=401, content={"detail": message.split(":", 1)[-1]})
|
| 93 |
+
return await call_next(request)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# All product routes live under /api/v1 (productApi.enginePath() convention).
|
| 97 |
+
api_prefix = "/api/v1"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class AccountIn(BaseModel):
|
| 101 |
+
domain: Optional[str] = None
|
| 102 |
+
name: Optional[str] = None
|
| 103 |
+
industry: Optional[str] = None
|
| 104 |
+
employee_count: Optional[int] = None
|
| 105 |
+
hq_country: Optional[str] = None
|
| 106 |
+
city: Optional[str] = None
|
| 107 |
+
website: Optional[str] = None
|
| 108 |
+
phone: Optional[str] = None
|
| 109 |
+
address: Optional[str] = None
|
| 110 |
+
description: Optional[str] = None
|
| 111 |
+
source: Optional[str] = None
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class EnrichIn(BaseModel):
|
| 115 |
+
source: str = Field(default="google_maps", description="google_maps | linkedin")
|
| 116 |
+
queries: List[str] = Field(..., min_length=1, max_length=20)
|
| 117 |
+
limit: int = Field(default=20, ge=1, le=500)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class ScoreIn(BaseModel):
|
| 121 |
+
account_id: str
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class MemoryIn(BaseModel):
|
| 125 |
+
text: str = Field(..., min_length=1, max_length=8000)
|
| 126 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class MemoryBatchIn(BaseModel):
|
| 130 |
+
texts: List[str] = Field(..., min_length=1, max_length=200)
|
| 131 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class MemorySearchIn(BaseModel):
|
| 135 |
+
query: str = Field(..., min_length=1)
|
| 136 |
+
k: int = Field(default=8, ge=1, le=50)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ── health ───────────────────────────────────────────────────────────────────
|
| 140 |
+
|
| 141 |
+
@app.get("/")
|
| 142 |
+
async def root():
|
| 143 |
+
return {"app": settings.app_name, "version": settings.app_version, "docs": "/docs"}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@app.get("/api/health")
|
| 147 |
+
@app.get(f"{api_prefix}/health")
|
| 148 |
+
@app.get("/health")
|
| 149 |
+
async def health():
|
| 150 |
+
os_ok = await opensearch.store.ping()
|
| 151 |
+
apify_ok = await apify.ping()
|
| 152 |
+
cache_ok = await cache.ping()
|
| 153 |
+
components = {
|
| 154 |
+
"opensearch": {"ok": os_ok, "configured": opensearch.store.configured},
|
| 155 |
+
"apify": {"ok": apify_ok, "configured": apify.configured},
|
| 156 |
+
"graph": {"mode": graph.mode},
|
| 157 |
+
"cache": {"mode": cache.mode, "ok": cache_ok},
|
| 158 |
+
"embeddings": {"provider": provider_name()},
|
| 159 |
+
"enforcement": bool(settings.license_enforcement),
|
| 160 |
+
}
|
| 161 |
+
counts = {}
|
| 162 |
+
if os_ok:
|
| 163 |
+
try:
|
| 164 |
+
counts = {
|
| 165 |
+
"accounts": await opensearch.store.count("accounts"),
|
| 166 |
+
"contacts": await opensearch.store.count("contacts"),
|
| 167 |
+
"memory": await opensearch.store.count("memory"),
|
| 168 |
+
}
|
| 169 |
+
except Exception:
|
| 170 |
+
pass
|
| 171 |
+
core_ok = os_ok # the store is the only hard dependency
|
| 172 |
+
return {
|
| 173 |
+
"status": "healthy" if core_ok else "degraded",
|
| 174 |
+
"app": settings.app_name,
|
| 175 |
+
"version": settings.app_version,
|
| 176 |
+
"components": components,
|
| 177 |
+
"counts": counts,
|
| 178 |
+
"timestamp": __import__("time").time(),
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ── accounts ────────────────────────────────────────────────────────────────
|
| 183 |
+
|
| 184 |
+
@app.post(f"{api_prefix}/accounts")
|
| 185 |
+
async def create_account(payload: AccountIn):
|
| 186 |
+
try:
|
| 187 |
+
doc = await accounts_svc.upsert_account(payload.model_dump(exclude_none=True))
|
| 188 |
+
except ValueError as exc:
|
| 189 |
+
raise HTTPException(400, str(exc))
|
| 190 |
+
except Exception as exc:
|
| 191 |
+
raise HTTPException(503, f"Datastore unavailable: {exc}")
|
| 192 |
+
await graph.upsert_account(doc)
|
| 193 |
+
return {"account": doc}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
@app.get(f"{api_prefix}/accounts")
|
| 197 |
+
async def list_accounts(q: str = "", industry: str = "", country: str = "",
|
| 198 |
+
min_score: float = 0.0, limit: int = 25):
|
| 199 |
+
try:
|
| 200 |
+
hits = await accounts_svc.search_accounts(q, industry, country, min_score, limit)
|
| 201 |
+
except Exception as exc:
|
| 202 |
+
raise HTTPException(503, f"Datastore unavailable: {exc}")
|
| 203 |
+
return {"total": len(hits), "accounts": hits}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
@app.get(f"{api_prefix}/accounts/{{account_id}}")
|
| 207 |
+
async def read_account(account_id: str):
|
| 208 |
+
result = await accounts_svc.get_account(account_id)
|
| 209 |
+
if result is None:
|
| 210 |
+
raise HTTPException(404, f"Account {account_id} not found")
|
| 211 |
+
return result
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# ── enrichment ──────────────────────────────────────────────────────────────
|
| 215 |
+
|
| 216 |
+
@app.post(f"{api_prefix}/enrich")
|
| 217 |
+
async def run_enrich(payload: EnrichIn):
|
| 218 |
+
try:
|
| 219 |
+
summary = await enrich_svc.enrich(payload.source, payload.queries, payload.limit)
|
| 220 |
+
except ValueError as exc:
|
| 221 |
+
raise HTTPException(400, str(exc))
|
| 222 |
+
except RuntimeError as exc:
|
| 223 |
+
raise HTTPException(502, str(exc))
|
| 224 |
+
except Exception as exc:
|
| 225 |
+
logger.exception("enrichment failed")
|
| 226 |
+
raise HTTPException(500, f"Enrichment failed: {exc}")
|
| 227 |
+
return summary
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
# ── signals ─────────────────────────────────────────────────────────────────
|
| 231 |
+
|
| 232 |
+
@app.post(f"{api_prefix}/signals/score")
|
| 233 |
+
async def score(payload: ScoreIn):
|
| 234 |
+
account = await opensearch.store.get_doc("accounts", payload.account_id)
|
| 235 |
+
if account is None:
|
| 236 |
+
raise HTTPException(404, f"Account {payload.account_id} not found")
|
| 237 |
+
try:
|
| 238 |
+
res = await opensearch.store.client().post(
|
| 239 |
+
f"/{settings.os_index_prefix}-contacts/_count",
|
| 240 |
+
json={"query": {"term": {"account_id": payload.account_id}}},
|
| 241 |
+
)
|
| 242 |
+
contact_count = int(res.json().get("count", 0)) if res.status_code == 200 else 0
|
| 243 |
+
except Exception:
|
| 244 |
+
contact_count = 0
|
| 245 |
+
scored = signals_svc.score_account(account, contact_count)
|
| 246 |
+
doc = {**account, **scored}
|
| 247 |
+
await opensearch.store.upsert_doc("accounts", doc, payload.account_id)
|
| 248 |
+
await accounts_svc.record_event(payload.account_id, "rescored", scored)
|
| 249 |
+
return scored
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
# ── vector memory ───────────────────────────────────────────────────────────
|
| 253 |
+
|
| 254 |
+
@app.post(f"{api_prefix}/memory")
|
| 255 |
+
async def memory_add(payload: MemoryIn):
|
| 256 |
+
try:
|
| 257 |
+
return await memory_svc.remember(payload.text, payload.metadata)
|
| 258 |
+
except ValueError as exc:
|
| 259 |
+
raise HTTPException(400, str(exc))
|
| 260 |
+
except Exception as exc:
|
| 261 |
+
raise HTTPException(503, f"Datastore unavailable: {exc}")
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
@app.post(f"{api_prefix}/memory/batch")
|
| 265 |
+
async def memory_add_batch(payload: MemoryBatchIn):
|
| 266 |
+
try:
|
| 267 |
+
return {"docs": await memory_svc.remember_batch(payload.texts, payload.metadata)}
|
| 268 |
+
except Exception as exc:
|
| 269 |
+
raise HTTPException(503, f"Datastore unavailable: {exc}")
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
@app.post(f"{api_prefix}/memory/search")
|
| 273 |
+
async def memory_search(payload: MemorySearchIn):
|
| 274 |
+
try:
|
| 275 |
+
hits = await memory_svc.recall(payload.query, payload.k)
|
| 276 |
+
except Exception as exc:
|
| 277 |
+
raise HTTPException(503, f"Datastore unavailable: {exc}")
|
| 278 |
+
return {"total": len(hits), "results": hits}
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# ── graph ───────────────────────────────────────────────────────────────────
|
| 282 |
+
|
| 283 |
+
@app.get(f"{api_prefix}/graph/overview")
|
| 284 |
+
async def graph_overview():
|
| 285 |
+
return await graph.overview()
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/accounts.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Account + contact services on top of OpenSearch."""
|
| 2 |
+
import uuid
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
from typing import Any, Dict, List, Optional
|
| 5 |
+
|
| 6 |
+
from ..stores import opensearch
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _now() -> str:
|
| 10 |
+
return datetime.now(timezone.utc).isoformat()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def upsert_account(payload: Dict[str, Any]) -> Dict[str, Any]:
|
| 14 |
+
"""Create or update an account keyed by domain (fallback: name hash)."""
|
| 15 |
+
domain = (payload.get("domain") or "").lower().strip() or None
|
| 16 |
+
name = (payload.get("name") or "").strip() or None
|
| 17 |
+
if not domain and not name:
|
| 18 |
+
raise ValueError("domain or name is required")
|
| 19 |
+
|
| 20 |
+
existing = None
|
| 21 |
+
if domain:
|
| 22 |
+
hits = await opensearch.store.search("accounts", {
|
| 23 |
+
"query": {"term": {"domain": domain}},
|
| 24 |
+
"size": 1,
|
| 25 |
+
})
|
| 26 |
+
existing = hits[0] if hits else None
|
| 27 |
+
|
| 28 |
+
account_id = (existing or {}).get("account_id") or str(uuid.uuid4())
|
| 29 |
+
doc = {
|
| 30 |
+
"account_id": account_id,
|
| 31 |
+
"domain": domain,
|
| 32 |
+
"name": name or (existing or {}).get("name"),
|
| 33 |
+
"industry": payload.get("industry") or (existing or {}).get("industry"),
|
| 34 |
+
"employee_count": payload.get("employee_count") or (existing or {}).get("employee_count"),
|
| 35 |
+
"hq_country": payload.get("hq_country") or (existing or {}).get("hq_country"),
|
| 36 |
+
"city": payload.get("city") or (existing or {}).get("city"),
|
| 37 |
+
"website": payload.get("website") or (existing or {}).get("website"),
|
| 38 |
+
"phone": payload.get("phone") or (existing or {}).get("phone"),
|
| 39 |
+
"address": payload.get("address") or (existing or {}).get("address"),
|
| 40 |
+
"description": payload.get("description") or (existing or {}).get("description"),
|
| 41 |
+
"rating": payload.get("rating") or (existing or {}).get("rating"),
|
| 42 |
+
"source": payload.get("source") or (existing or {}).get("source") or "manual",
|
| 43 |
+
"icp_score": (existing or {}).get("icp_score"),
|
| 44 |
+
"icp_label": (existing or {}).get("icp_label"),
|
| 45 |
+
"updated_at": _now(),
|
| 46 |
+
}
|
| 47 |
+
doc = {k: v for k, v in doc.items() if v is not None}
|
| 48 |
+
await opensearch.store.upsert_doc("accounts", doc, account_id)
|
| 49 |
+
return doc
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
async def get_account(account_id: str) -> Optional[dict]:
|
| 53 |
+
account = await opensearch.store.get_doc("accounts", account_id)
|
| 54 |
+
if account is None:
|
| 55 |
+
return None
|
| 56 |
+
contacts = await opensearch.store.search("contacts", {
|
| 57 |
+
"query": {"term": {"account_id": account_id}},
|
| 58 |
+
"size": 100,
|
| 59 |
+
"sort": [{"_id": "asc"}],
|
| 60 |
+
})
|
| 61 |
+
events = await opensearch.store.search("events", {
|
| 62 |
+
"query": {"term": {"account_id": account_id}},
|
| 63 |
+
"size": 50,
|
| 64 |
+
"sort": [{"created_at": {"order": "desc"}}],
|
| 65 |
+
})
|
| 66 |
+
return {"account": account, "contacts": contacts, "events": events}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def search_accounts(q: str = "", industry: str = "", country: str = "",
|
| 70 |
+
min_score: float = 0.0, limit: int = 25) -> List[dict]:
|
| 71 |
+
filters: List[dict] = []
|
| 72 |
+
if industry:
|
| 73 |
+
filters.append({"term": {"industry": industry.lower()}})
|
| 74 |
+
if country:
|
| 75 |
+
filters.append({"term": {"hq_country": country.lower()}})
|
| 76 |
+
if min_score > 0:
|
| 77 |
+
filters.append({"range": {"icp_score": {"gte": min_score}}})
|
| 78 |
+
must: List[dict] = []
|
| 79 |
+
if q:
|
| 80 |
+
must.append({"multi_match": {
|
| 81 |
+
"query": q,
|
| 82 |
+
"fields": ["name^3", "name.kw^2", "domain^3", "description", "industry"],
|
| 83 |
+
"fuzziness": "AUTO",
|
| 84 |
+
}})
|
| 85 |
+
body = {
|
| 86 |
+
"query": {"bool": {"must": must or [{"match_all": {}}], "filter": filters}},
|
| 87 |
+
"size": min(max(limit, 1), 100),
|
| 88 |
+
}
|
| 89 |
+
return await opensearch.store.search("accounts", body)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
async def upsert_contact(account_id: str, contact: Dict[str, Any]) -> Optional[dict]:
|
| 93 |
+
"""Dedupe contacts per account on email (fallback: linkedin_url)."""
|
| 94 |
+
email = (contact.get("email") or "").lower().strip() or None
|
| 95 |
+
li = contact.get("linkedin_url")
|
| 96 |
+
existing = None
|
| 97 |
+
if email:
|
| 98 |
+
hits = await opensearch.store.search("contacts", {
|
| 99 |
+
"query": {"bool": {"filter": [
|
| 100 |
+
{"term": {"account_id": account_id}}, {"term": {"email": email}}
|
| 101 |
+
]}},
|
| 102 |
+
"size": 1,
|
| 103 |
+
})
|
| 104 |
+
existing = hits[0] if hits else None
|
| 105 |
+
if existing is None and li:
|
| 106 |
+
hits = await opensearch.store.search("contacts", {
|
| 107 |
+
"query": {"bool": {"filter": [
|
| 108 |
+
{"term": {"account_id": account_id}}, {"term": {"linkedin_url": li}}
|
| 109 |
+
]}},
|
| 110 |
+
"size": 1,
|
| 111 |
+
})
|
| 112 |
+
existing = hits[0] if hits else None
|
| 113 |
+
|
| 114 |
+
contact_id = (existing or {}).get("contact_id") or str(uuid.uuid4())
|
| 115 |
+
doc = {
|
| 116 |
+
"contact_id": contact_id,
|
| 117 |
+
"account_id": account_id,
|
| 118 |
+
"email": email,
|
| 119 |
+
"full_name": contact.get("full_name"),
|
| 120 |
+
"title": contact.get("title"),
|
| 121 |
+
"linkedin_url": li,
|
| 122 |
+
"phone": contact.get("phone"),
|
| 123 |
+
"source": contact.get("source") or "manual",
|
| 124 |
+
"updated_at": _now(),
|
| 125 |
+
}
|
| 126 |
+
doc = {k: v for k, v in doc.items() if v is not None}
|
| 127 |
+
await opensearch.store.upsert_doc("contacts", doc, contact_id)
|
| 128 |
+
return doc
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
async def record_event(account_id: str, event_type: str, payload: dict) -> dict:
|
| 132 |
+
doc = {
|
| 133 |
+
"event_id": str(uuid.uuid4()),
|
| 134 |
+
"account_id": account_id,
|
| 135 |
+
"type": event_type,
|
| 136 |
+
"payload": payload,
|
| 137 |
+
"created_at": _now(),
|
| 138 |
+
}
|
| 139 |
+
await opensearch.store.index_doc("events", doc)
|
| 140 |
+
return doc
|
app/services/enrich.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Enrichment pipeline: Apify actors → normalized accounts/contacts →
|
| 2 |
+
OpenSearch + ICP score + vector memory + optional Neo4j mirror."""
|
| 3 |
+
import logging
|
| 4 |
+
import uuid
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
from typing import Any, Dict, List, Optional
|
| 7 |
+
|
| 8 |
+
from ..config import settings
|
| 9 |
+
from ..stores import opensearch
|
| 10 |
+
from ..stores.apify import apify_client as apify, normalize_account, normalize_contact
|
| 11 |
+
from ..stores.cache import cache
|
| 12 |
+
from ..stores.graph import graph
|
| 13 |
+
from . import accounts as accounts_svc
|
| 14 |
+
from . import signals as signals_svc
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("abm.enrich")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _now() -> str:
|
| 20 |
+
return datetime.now(timezone.utc).isoformat()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
SOURCE_ACTORS = {
|
| 24 |
+
"google_maps": lambda: settings.apify_actor_google_maps,
|
| 25 |
+
"linkedin": lambda: settings.apify_actor_linkedin,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def build_actor_input(source: str, queries: List[str], limit: int) -> dict:
|
| 30 |
+
"""Actor input is schema-flexible on purpose: both of the owner's actors
|
| 31 |
+
accept a search-terms list plus result caps under slightly different keys,
|
| 32 |
+
so the pipeline sets the common superset and lets the actor ignore the
|
| 33 |
+
rest."""
|
| 34 |
+
if source == "linkedin":
|
| 35 |
+
return {
|
| 36 |
+
"searchUrl": queries[0] if queries and queries[0].startswith("http") else None,
|
| 37 |
+
"companies": [q for q in queries if not q.startswith("http")],
|
| 38 |
+
"maxItems": limit,
|
| 39 |
+
"maxResults": limit,
|
| 40 |
+
"scrapeEmails": True,
|
| 41 |
+
}
|
| 42 |
+
return {
|
| 43 |
+
"searchStringsArray": queries,
|
| 44 |
+
"maxCrawledPlaces": limit,
|
| 45 |
+
"maxCrawledPlacesPerSearch": max(limit // max(len(queries), 1), 1),
|
| 46 |
+
"language": "en",
|
| 47 |
+
"scrapeContacts": True,
|
| 48 |
+
"scrapeWebsiteEmails": True,
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
async def enrich(
|
| 53 |
+
source: str,
|
| 54 |
+
queries: List[str],
|
| 55 |
+
limit: int = 20,
|
| 56 |
+
account_limit: int = 100,
|
| 57 |
+
) -> Dict[str, Any]:
|
| 58 |
+
"""Run one enrichment batch end-to-end. Returns a summary with counts."""
|
| 59 |
+
if source not in SOURCE_ACTORS:
|
| 60 |
+
raise ValueError(f"unknown source '{source}' (use google_maps or linkedin)")
|
| 61 |
+
if not apify.configured:
|
| 62 |
+
raise RuntimeError("Apify is not configured (APIFY_TOKEN missing)")
|
| 63 |
+
actor = SOURCE_ACTORS[source]()
|
| 64 |
+
run_input = build_actor_input(source, queries, limit)
|
| 65 |
+
|
| 66 |
+
cache_key = f"enrich:{source}:{hash(tuple(sorted(queries)))}:{limit}"
|
| 67 |
+
cached = await cache.get(cache_key)
|
| 68 |
+
if cached:
|
| 69 |
+
return {**cached, "cached": True}
|
| 70 |
+
|
| 71 |
+
started = _now()
|
| 72 |
+
items = await apify.run_sync(actor, run_input)
|
| 73 |
+
summary: Dict[str, Any] = {
|
| 74 |
+
"source": source,
|
| 75 |
+
"actor": actor,
|
| 76 |
+
"queries": queries,
|
| 77 |
+
"items_raw": len(items),
|
| 78 |
+
"accounts": 0,
|
| 79 |
+
"contacts": 0,
|
| 80 |
+
"skipped": 0,
|
| 81 |
+
"account_ids": [],
|
| 82 |
+
"started_at": started,
|
| 83 |
+
"finished_at": None,
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
if source == "linkedin":
|
| 87 |
+
# LinkedIn items are people; group them under a company account when
|
| 88 |
+
# the item names one, otherwise under the query itself.
|
| 89 |
+
for raw in items[:account_limit * 5]:
|
| 90 |
+
contact = normalize_contact(raw)
|
| 91 |
+
if not contact:
|
| 92 |
+
summary["skipped"] += 1
|
| 93 |
+
continue
|
| 94 |
+
company_name = contact.pop("company", None) or (queries[0] if queries else "Unknown")
|
| 95 |
+
domain = contact.get("email", "").split("@")[-1] if contact.get("email") else None
|
| 96 |
+
account = await accounts_svc.upsert_account({
|
| 97 |
+
"name": company_name, "domain": domain, "source": "linkedin",
|
| 98 |
+
})
|
| 99 |
+
saved = await accounts_svc.upsert_contact(account["account_id"], contact)
|
| 100 |
+
if saved:
|
| 101 |
+
summary["contacts"] += 1
|
| 102 |
+
await graph.upsert_contact(account["account_id"], saved or {})
|
| 103 |
+
if account["account_id"] not in summary["account_ids"]:
|
| 104 |
+
summary["account_ids"].append(account["account_id"])
|
| 105 |
+
summary["accounts"] = len(summary["account_ids"])
|
| 106 |
+
else:
|
| 107 |
+
for raw in items[:account_limit]:
|
| 108 |
+
account_fields = normalize_account(raw)
|
| 109 |
+
if not account_fields:
|
| 110 |
+
summary["skipped"] += 1
|
| 111 |
+
continue
|
| 112 |
+
account = await accounts_svc.upsert_account(account_fields)
|
| 113 |
+
summary["account_ids"].append(account["account_id"])
|
| 114 |
+
summary["accounts"] += 1
|
| 115 |
+
# Map-scraper items sometimes carry contact rows alongside.
|
| 116 |
+
raw_contacts = raw.get("emails") or raw.get("contacts") or []
|
| 117 |
+
if isinstance(raw_contacts, list):
|
| 118 |
+
for c in raw_contacts:
|
| 119 |
+
if isinstance(c, str):
|
| 120 |
+
c = {"email": c}
|
| 121 |
+
contact = normalize_contact(c)
|
| 122 |
+
if contact:
|
| 123 |
+
saved = await accounts_svc.upsert_contact(account["account_id"], contact)
|
| 124 |
+
if saved:
|
| 125 |
+
summary["contacts"] += 1
|
| 126 |
+
await _finalize_account(account)
|
| 127 |
+
await graph.upsert_account(account)
|
| 128 |
+
|
| 129 |
+
summary["finished_at"] = _now()
|
| 130 |
+
await cache.set(cache_key, summary, ttl_s=900)
|
| 131 |
+
await opensearch.store.refresh("accounts")
|
| 132 |
+
await opensearch.store.refresh("contacts")
|
| 133 |
+
return summary
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
async def _finalize_account(account: dict) -> None:
|
| 137 |
+
"""Score + embed + persist enrichment events for one account."""
|
| 138 |
+
contacts = await opensearch.store.search("contacts", {
|
| 139 |
+
"query": {"term": {"account_id": account["account_id"]}},
|
| 140 |
+
"size": 1,
|
| 141 |
+
})
|
| 142 |
+
# count without pulling rows
|
| 143 |
+
from ..stores.opensearch import store
|
| 144 |
+
try:
|
| 145 |
+
res = await store.client().post(
|
| 146 |
+
f"/{store._index('contacts')}/_count",
|
| 147 |
+
json={"query": {"term": {"account_id": account["account_id"]}}},
|
| 148 |
+
)
|
| 149 |
+
contact_count = int(res.json().get("count", 0)) if res.status_code == 200 else 0
|
| 150 |
+
except Exception:
|
| 151 |
+
contact_count = len(contacts)
|
| 152 |
+
|
| 153 |
+
scored = signals_svc.score_account(account, contact_count)
|
| 154 |
+
doc = {**account, **scored, "embedding": signals_svc.embed_account(account)}
|
| 155 |
+
await store.upsert_doc("accounts", doc, account["account_id"])
|
| 156 |
+
await accounts_svc.record_event(account["account_id"], "enrichment_completed", {
|
| 157 |
+
"icp_score": scored["icp_score"], "icp_label": scored["icp_label"],
|
| 158 |
+
"contacts": contact_count, "at": _now(),
|
| 159 |
+
})
|
app/services/memory.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vector memory over the OpenSearch k-NN index."""
|
| 2 |
+
import json
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from typing import Any, Dict, List, Optional
|
| 6 |
+
|
| 7 |
+
from ..stores import opensearch
|
| 8 |
+
from ..stores.embeddings import embed, embed_batch, provider_name
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _now() -> str:
|
| 12 |
+
return datetime.now(timezone.utc).isoformat()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _memory_index() -> str:
|
| 16 |
+
return f"{opensearch.settings.os_index_prefix}-memory"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
async def remember(text: str, metadata: Optional[dict] = None) -> dict:
|
| 20 |
+
text = (text or "").strip()
|
| 21 |
+
if not text:
|
| 22 |
+
raise ValueError("text must not be empty")
|
| 23 |
+
doc_id = str(uuid.uuid4())
|
| 24 |
+
doc = {
|
| 25 |
+
"doc_id": doc_id,
|
| 26 |
+
"text": text[:8000],
|
| 27 |
+
"metadata": metadata or {},
|
| 28 |
+
"embedding": embed(text),
|
| 29 |
+
"created_at": _now(),
|
| 30 |
+
}
|
| 31 |
+
await opensearch.store.index_doc("memory", doc)
|
| 32 |
+
return {"doc_id": doc_id, "provider": provider_name()}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def remember_batch(texts: List[str], metadata: Optional[dict] = None) -> List[dict]:
|
| 36 |
+
texts = [t.strip()[:8000] for t in texts if t and t.strip()]
|
| 37 |
+
if not texts:
|
| 38 |
+
return []
|
| 39 |
+
vectors = embed_batch(texts)
|
| 40 |
+
docs = []
|
| 41 |
+
for text, vector in zip(texts, vectors):
|
| 42 |
+
docs.append({
|
| 43 |
+
"doc_id": str(uuid.uuid4()),
|
| 44 |
+
"text": text,
|
| 45 |
+
"metadata": metadata or {},
|
| 46 |
+
"embedding": vector,
|
| 47 |
+
"created_at": _now(),
|
| 48 |
+
})
|
| 49 |
+
lines: List[str] = []
|
| 50 |
+
for d in docs:
|
| 51 |
+
lines.append(json.dumps({"index": {"_index": _memory_index(), "_id": d["doc_id"]}}))
|
| 52 |
+
lines.append(json.dumps(d))
|
| 53 |
+
res = await opensearch.store.client().post(
|
| 54 |
+
"/_bulk",
|
| 55 |
+
content=("\n".join(lines) + "\n").encode(),
|
| 56 |
+
headers={"Content-Type": "application/x-ndjson"},
|
| 57 |
+
)
|
| 58 |
+
res.raise_for_status()
|
| 59 |
+
await opensearch.store.refresh("memory")
|
| 60 |
+
return [{"doc_id": d["doc_id"]} for d in docs]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
async def recall(query: str, k: int = 8) -> List[dict]:
|
| 64 |
+
query = (query or "").strip()
|
| 65 |
+
if not query:
|
| 66 |
+
return []
|
| 67 |
+
k = max(1, min(k, 50))
|
| 68 |
+
body = {
|
| 69 |
+
"size": k,
|
| 70 |
+
"query": {"knn": {"embedding": {"vector": embed(query), "k": k}}},
|
| 71 |
+
"_source": ["text", "metadata", "created_at"],
|
| 72 |
+
}
|
| 73 |
+
return await opensearch.store.search("memory", body)
|
app/services/signals.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ICP scoring — deterministic, explainable account prioritization.
|
| 2 |
+
|
| 3 |
+
Score 0-100 from additive signals with the breakdown preserved, so sales can
|
| 4 |
+
see WHY an account scored what it scored. Labels: A ≥ 75, B ≥ 50, C ≥ 30.
|
| 5 |
+
"""
|
| 6 |
+
from typing import Any, Dict
|
| 7 |
+
|
| 8 |
+
from ..config import settings
|
| 9 |
+
from ..stores import embeddings
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def score_account(account: Dict[str, Any], contact_count: int = 0) -> Dict[str, Any]:
|
| 13 |
+
breakdown: Dict[str, float] = {}
|
| 14 |
+
|
| 15 |
+
industry = str(account.get("industry") or "").lower()
|
| 16 |
+
hit = next((x for x in settings.icp_industries_list if x and x in industry), None)
|
| 17 |
+
breakdown["industry_fit"] = 20.0 if hit else 0.0
|
| 18 |
+
|
| 19 |
+
country = str(account.get("hq_country") or "").lower()
|
| 20 |
+
breakdown["geo_fit"] = 15.0 if any(x in country for x in settings.icp_countries_list if x) else 0.0
|
| 21 |
+
|
| 22 |
+
headcount = account.get("employee_count")
|
| 23 |
+
if isinstance(headcount, str) and headcount.isdigit():
|
| 24 |
+
headcount = int(headcount)
|
| 25 |
+
if isinstance(headcount, (int, float)) and headcount:
|
| 26 |
+
lo, hi = settings.icp_min_headcount, settings.icp_max_headcount
|
| 27 |
+
breakdown["size_band"] = 15.0 if lo <= headcount <= hi else 5.0
|
| 28 |
+
else:
|
| 29 |
+
breakdown["size_band"] = 0.0
|
| 30 |
+
|
| 31 |
+
breakdown["digital_presence"] = 15.0 if (account.get("domain") or account.get("website")) else 0.0
|
| 32 |
+
|
| 33 |
+
rating = account.get("rating")
|
| 34 |
+
if isinstance(rating, (int, float)) and rating:
|
| 35 |
+
# Google rating (1-5) → up to 10 points; presence alone is a weak signal
|
| 36 |
+
breakdown["market_signal"] = min(max((rating - 3.0) / 2.0, 0.0), 1.0) * 10.0
|
| 37 |
+
elif isinstance(rating, str) and rating.replace(".", "").isdigit():
|
| 38 |
+
breakdown["market_signal"] = min(max((float(rating) - 3.0) / 2.0, 0.0), 1.0) * 10.0
|
| 39 |
+
else:
|
| 40 |
+
breakdown["market_signal"] = 0.0
|
| 41 |
+
|
| 42 |
+
breakdown["contact_coverage"] = min(contact_count, 5) * 2.0 # up to 10
|
| 43 |
+
|
| 44 |
+
score = round(min(sum(breakdown.values()), 100.0), 1)
|
| 45 |
+
label = "A" if score >= 75 else "B" if score >= 50 else "C" if score >= 30 else "D"
|
| 46 |
+
return {
|
| 47 |
+
"icp_score": score,
|
| 48 |
+
"icp_label": label,
|
| 49 |
+
"breakdown": {k: round(v, 1) for k, v in breakdown.items()},
|
| 50 |
+
"scoring_version": "icp-v1",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def account_text(account: Dict[str, Any]) -> str:
|
| 55 |
+
"""The text that represents an account in the vector memory."""
|
| 56 |
+
parts = [
|
| 57 |
+
account.get("name") or "",
|
| 58 |
+
account.get("industry") or "",
|
| 59 |
+
account.get("description") or "",
|
| 60 |
+
f"domain {account.get('domain')}" if account.get("domain") else "",
|
| 61 |
+
f"city {account.get('city')}" if account.get("city") else "",
|
| 62 |
+
f"country {account.get('hq_country')}" if account.get("hq_country") else "",
|
| 63 |
+
]
|
| 64 |
+
return ". ".join(p for p in parts if p)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def embed_account(account: Dict[str, Any]):
|
| 68 |
+
return embeddings.embed(account_text(account))
|
app/stores/__init__.py
ADDED
|
File without changes
|
app/stores/apify.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Apify enrichment client — runs the owner's own actors and fetches results.
|
| 2 |
+
|
| 3 |
+
Actors (defaults are the account's own published actors, configurable via env):
|
| 4 |
+
- sitcod3.lab/fdn-google-map-master-scraper (Google Maps → companies)
|
| 5 |
+
- sitcod3.lab/linkedin-company-email-scraper (LinkedIn → employees + emails)
|
| 6 |
+
|
| 7 |
+
Both sync (run-sync-get-dataset-items, capped) and async (run + poll) modes
|
| 8 |
+
are supported. Output items are schema-flexible: the normalizer probes common
|
| 9 |
+
Google Maps / LinkedIn export field names case-insensitively.
|
| 10 |
+
"""
|
| 11 |
+
import asyncio
|
| 12 |
+
import logging
|
| 13 |
+
import time
|
| 14 |
+
from typing import Any, Dict, List, Optional
|
| 15 |
+
|
| 16 |
+
import httpx
|
| 17 |
+
|
| 18 |
+
from ..config import settings
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger("abm.apify")
|
| 21 |
+
|
| 22 |
+
API = "https://api.apify.com/v2"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ApifyClient:
|
| 26 |
+
def __init__(self) -> None:
|
| 27 |
+
self._http: Optional[httpx.AsyncClient] = None
|
| 28 |
+
|
| 29 |
+
@property
|
| 30 |
+
def configured(self) -> bool:
|
| 31 |
+
return settings.apify_configured
|
| 32 |
+
|
| 33 |
+
def client(self) -> httpx.AsyncClient:
|
| 34 |
+
if self._http is None:
|
| 35 |
+
self._http = httpx.AsyncClient(
|
| 36 |
+
base_url=API, params={"token": settings.apify_token},
|
| 37 |
+
timeout=httpx.Timeout(60.0),
|
| 38 |
+
)
|
| 39 |
+
return self._http
|
| 40 |
+
|
| 41 |
+
async def close(self) -> None:
|
| 42 |
+
if self._http is not None:
|
| 43 |
+
await self._http.aclose()
|
| 44 |
+
self._http = None
|
| 45 |
+
|
| 46 |
+
async def ping(self) -> bool:
|
| 47 |
+
if not self.configured:
|
| 48 |
+
return False
|
| 49 |
+
try:
|
| 50 |
+
res = await self.client().get("/users/me")
|
| 51 |
+
return res.status_code == 200
|
| 52 |
+
except Exception:
|
| 53 |
+
return False
|
| 54 |
+
|
| 55 |
+
async def run_sync(self, actor: str, run_input: dict, timeout_s: Optional[int] = None) -> List[dict]:
|
| 56 |
+
"""Run an actor and collect its default dataset items (capped wait)."""
|
| 57 |
+
deadline = timeout_s or settings.apify_run_timeout_s
|
| 58 |
+
url = f"/acts/{actor}/run-sync-get-dataset-items"
|
| 59 |
+
params = {"timeout": int(deadline * 1000)}
|
| 60 |
+
res = await self.client().post(url, params=params, json=run_input)
|
| 61 |
+
if res.status_code in (402, 403):
|
| 62 |
+
raise RuntimeError(f"Apify rejected the run (HTTP {res.status_code}): {res.text[:200]}")
|
| 63 |
+
res.raise_for_status()
|
| 64 |
+
try:
|
| 65 |
+
items = res.json()
|
| 66 |
+
return items if isinstance(items, list) else []
|
| 67 |
+
except Exception:
|
| 68 |
+
return []
|
| 69 |
+
|
| 70 |
+
async def run_async(self, actor: str, run_input: dict) -> Dict[str, str]:
|
| 71 |
+
"""Start an actor run; returns {run_id, dataset_id} for polling."""
|
| 72 |
+
res = await self.client().post(f"/acts/{actor}/runs", json=run_input)
|
| 73 |
+
res.raise_for_status()
|
| 74 |
+
data = res.json().get("data", {})
|
| 75 |
+
return {"run_id": data.get("id"), "dataset_id": data.get("defaultDatasetId")}
|
| 76 |
+
|
| 77 |
+
async def wait_run(self, run_id: str, poll_s: int = 10, timeout_s: Optional[int] = None) -> str:
|
| 78 |
+
deadline = time.monotonic() + (timeout_s or settings.apify_run_timeout_s)
|
| 79 |
+
while time.monotonic() < deadline:
|
| 80 |
+
res = await self.client().get(f"/actor-runs/{run_id}")
|
| 81 |
+
res.raise_for_status()
|
| 82 |
+
data = res.json().get("data", {})
|
| 83 |
+
status = data.get("status")
|
| 84 |
+
if status in ("SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"):
|
| 85 |
+
return status
|
| 86 |
+
await asyncio.sleep(poll_s)
|
| 87 |
+
return "TIMED-OUT"
|
| 88 |
+
|
| 89 |
+
async def dataset_items(self, dataset_id: str, limit: int = 5000) -> List[dict]:
|
| 90 |
+
res = await self.client().get(f"/datasets/{dataset_id}/items", params={"limit": limit})
|
| 91 |
+
res.raise_for_status()
|
| 92 |
+
items = res.json()
|
| 93 |
+
return items if isinstance(items, list) else []
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
apify_client = ApifyClient()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ── normalization: raw actor items → ABM records ─────────────────────────────
|
| 100 |
+
|
| 101 |
+
def _pick(item: dict, *keys: str) -> Any:
|
| 102 |
+
low = {k.lower(): v for k, v in item.items() if isinstance(k, str)}
|
| 103 |
+
for k in keys:
|
| 104 |
+
if k in low and low[k]:
|
| 105 |
+
return low[k]
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def normalize_account(raw: dict) -> Optional[dict]:
|
| 110 |
+
"""Map a Google Maps-ish item to an ABM account (schema-flexible)."""
|
| 111 |
+
if not isinstance(raw, dict):
|
| 112 |
+
return None
|
| 113 |
+
name = _pick(raw, "name", "title", "companyname", "company", "searchstring")
|
| 114 |
+
if not name:
|
| 115 |
+
return None
|
| 116 |
+
website = _pick(raw, "website", "websiteurl", "url", "site", "domain")
|
| 117 |
+
domain = _pick(raw, "domain", "website_domain")
|
| 118 |
+
if not domain and website:
|
| 119 |
+
domain = str(website).replace("https://", "").replace("http://", "").split("/")[0]
|
| 120 |
+
if isinstance(domain, str):
|
| 121 |
+
domain = domain.removeprefix("www.").lower() or None
|
| 122 |
+
return {
|
| 123 |
+
"name": str(name)[:200],
|
| 124 |
+
"domain": domain,
|
| 125 |
+
"website": website,
|
| 126 |
+
"phone": _pick(raw, "phone", "phonenumber", "telephone"),
|
| 127 |
+
"industry": _pick(raw, "category", "maincategory", "categories", "industry", "subcategories"),
|
| 128 |
+
"city": _pick(raw, "city", "locality"),
|
| 129 |
+
"hq_country": _pick(raw, "country", "countrycode"),
|
| 130 |
+
"address": _pick(raw, "address", "fulladdress", "location", "street", "addressline"),
|
| 131 |
+
"employee_count": _pick(raw, "employeecount", "total_employees", "employees"),
|
| 132 |
+
"rating": _pick(raw, "rating", "totalScore", "reviews", "reviewCount"),
|
| 133 |
+
"description": _pick(raw, "description", "about", "snippet"),
|
| 134 |
+
"source": "google_maps",
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def normalize_contact(raw: dict, account_id_hint: Optional[str] = None) -> Optional[dict]:
|
| 139 |
+
"""Map a LinkedIn-ish item to an ABM contact."""
|
| 140 |
+
if not isinstance(raw, dict):
|
| 141 |
+
return None
|
| 142 |
+
email = _pick(raw, "email", "emailaddress", "mail", "business_email")
|
| 143 |
+
full_name = _pick(raw, "fullname", "name", "full_name", " firstname") or \
|
| 144 |
+
" ".join(x for x in [_pick(raw, "firstname", "first_name"), _pick(raw, "lastname", "last_name")] if x)
|
| 145 |
+
if not email and not full_name:
|
| 146 |
+
return None
|
| 147 |
+
return {
|
| 148 |
+
"email": str(email).strip().lower() if email else None,
|
| 149 |
+
"full_name": str(full_name).strip()[:160] if full_name else None,
|
| 150 |
+
"title": _pick(raw, "title", "position", "jobtitle", "role", "headline"),
|
| 151 |
+
"linkedin_url": _pick(raw, "linkedinurl", "linkedin_url", "profile", "url"),
|
| 152 |
+
"phone": _pick(raw, "phone", "phonenumber"),
|
| 153 |
+
"company": _pick(raw, "company", "companyname", "organization", "current_company"),
|
| 154 |
+
"source": "linkedin",
|
| 155 |
+
}
|
app/stores/cache.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Optional Upstash Redis cache (REST) with an in-process TTL fallback.
|
| 2 |
+
|
| 3 |
+
Upstash credentials arrive as REST URL + token (the console shows both). When
|
| 4 |
+
they are absent the engine uses a process-local TTL dict — correct per
|
| 5 |
+
container, lost on restart, which is acceptable for enrichment caching.
|
| 6 |
+
"""
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import time
|
| 10 |
+
from typing import Any, Optional
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
|
| 14 |
+
from ..config import settings
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("abm.cache")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Cache:
|
| 20 |
+
def __init__(self) -> None:
|
| 21 |
+
self._http: Optional[httpx.AsyncClient] = None
|
| 22 |
+
self._mem: dict[str, tuple[float, str]] = {}
|
| 23 |
+
|
| 24 |
+
@property
|
| 25 |
+
def mode(self) -> str:
|
| 26 |
+
return "upstash" if settings.upstash_configured else "memory"
|
| 27 |
+
|
| 28 |
+
def _client(self) -> httpx.AsyncClient:
|
| 29 |
+
if self._http is None:
|
| 30 |
+
self._http = httpx.AsyncClient(
|
| 31 |
+
base_url=settings.upstash_rest_url.rstrip("/"),
|
| 32 |
+
headers={"Authorization": f"Bearer {settings.upstash_rest_token}"},
|
| 33 |
+
timeout=httpx.Timeout(10.0),
|
| 34 |
+
)
|
| 35 |
+
return self._http
|
| 36 |
+
|
| 37 |
+
async def get(self, key: str) -> Optional[Any]:
|
| 38 |
+
if settings.upstash_configured:
|
| 39 |
+
try:
|
| 40 |
+
res = await self._client().get(f"/get/{key}")
|
| 41 |
+
if res.status_code == 200:
|
| 42 |
+
raw = res.json().get("result")
|
| 43 |
+
return json.loads(raw) if raw else None
|
| 44 |
+
except Exception as exc:
|
| 45 |
+
logger.warning("cache get failed: %s", exc)
|
| 46 |
+
return None
|
| 47 |
+
item = self._mem.get(key)
|
| 48 |
+
if not item:
|
| 49 |
+
return None
|
| 50 |
+
expires, raw = item
|
| 51 |
+
if expires < time.time():
|
| 52 |
+
self._mem.pop(key, None)
|
| 53 |
+
return None
|
| 54 |
+
return json.loads(raw)
|
| 55 |
+
|
| 56 |
+
async def set(self, key: str, value: Any, ttl_s: int = 3600) -> None:
|
| 57 |
+
raw = json.dumps(value, default=str)
|
| 58 |
+
if settings.upstash_configured:
|
| 59 |
+
try:
|
| 60 |
+
await self._client().get(f"/set/{key}?EX={int(ttl_s)}",
|
| 61 |
+
headers={"Content-Type": "text/plain"},
|
| 62 |
+
content=raw.encode())
|
| 63 |
+
return
|
| 64 |
+
except Exception as exc:
|
| 65 |
+
logger.warning("cache set failed: %s", exc)
|
| 66 |
+
return
|
| 67 |
+
self._mem[key] = (time.time() + ttl_s, raw)
|
| 68 |
+
|
| 69 |
+
async def ping(self) -> bool:
|
| 70 |
+
if not settings.upstash_configured:
|
| 71 |
+
return True # memory mode always "works"
|
| 72 |
+
try:
|
| 73 |
+
res = await self._client().get("/ping")
|
| 74 |
+
return res.status_code == 200
|
| 75 |
+
except Exception:
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
cache = Cache()
|
app/stores/embeddings.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Embedding providers for the ABM vector memory.
|
| 2 |
+
|
| 3 |
+
`auto` prefers fastembed (ONNX MiniLM, 384-dim, real semantic similarity) and
|
| 4 |
+
falls back to a deterministic hashing embedding when the model is unavailable
|
| 5 |
+
(cold boot, build without the model cache). Both produce L2-normalized vectors
|
| 6 |
+
of settings.embedding_dim so the k-NN index stays compatible either way.
|
| 7 |
+
"""
|
| 8 |
+
import hashlib
|
| 9 |
+
import logging
|
| 10 |
+
import math
|
| 11 |
+
import re
|
| 12 |
+
from typing import List
|
| 13 |
+
|
| 14 |
+
from ..config import settings
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("abm.embeddings")
|
| 17 |
+
|
| 18 |
+
_TOKEN_RE = re.compile(r"[a-z0-9]{2,}")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class HashEmbedding:
|
| 22 |
+
"""Deterministic feature-hashing embedding (bag of tokens + bigrams).
|
| 23 |
+
|
| 24 |
+
Honest about what it is: lexical-ish similarity, not deep semantics.
|
| 25 |
+
Zero dependencies, instant, stable across restarts — the safe default.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
name = "hash-v1"
|
| 29 |
+
|
| 30 |
+
def _dims(self) -> int:
|
| 31 |
+
return settings.embedding_dim
|
| 32 |
+
|
| 33 |
+
def _index(self, token: str) -> tuple[int, float]:
|
| 34 |
+
digest = hashlib.md5(token.encode("utf-8")).digest()
|
| 35 |
+
idx = int.from_bytes(digest[:4], "little") % self._dims()
|
| 36 |
+
sign = 1.0 if digest[4] % 2 == 0 else -1.0
|
| 37 |
+
return idx, sign
|
| 38 |
+
|
| 39 |
+
def encode(self, text: str) -> List[float]:
|
| 40 |
+
vec = [0.0] * self._dims()
|
| 41 |
+
tokens = _TOKEN_RE.findall((text or "").lower())
|
| 42 |
+
grams: List[str] = list(tokens)
|
| 43 |
+
grams += [f"{a}_{b}" for a, b in zip(tokens, tokens[1:])]
|
| 44 |
+
for g in grams:
|
| 45 |
+
idx, sign = self._index(g)
|
| 46 |
+
vec[idx] += sign
|
| 47 |
+
norm = math.sqrt(sum(v * v for v in vec)) or 1.0
|
| 48 |
+
return [round(v / norm, 6) for v in vec]
|
| 49 |
+
|
| 50 |
+
def encode_batch(self, texts: List[str]) -> List[List[float]]:
|
| 51 |
+
return [self.encode(t) for t in texts]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class FastEmbedEmbedding:
|
| 55 |
+
"""ONNX MiniLM via fastembed — real semantic similarity, no torch."""
|
| 56 |
+
|
| 57 |
+
def __init__(self) -> None:
|
| 58 |
+
from fastembed import TextEmbedding # imported lazily
|
| 59 |
+
|
| 60 |
+
self._model = TextEmbedding("sentence-transformers/all-MiniLM-L6-v2")
|
| 61 |
+
self.name = "fastembed-minilm-384"
|
| 62 |
+
|
| 63 |
+
def _normalize(self, vec) -> List[float]:
|
| 64 |
+
norm = math.sqrt(sum(v * v for v in vec)) or 1.0
|
| 65 |
+
return [round(float(v) / norm, 6) for v in vec]
|
| 66 |
+
|
| 67 |
+
def encode(self, text: str) -> List[float]:
|
| 68 |
+
out = list(self._model.embed([text]))[0]
|
| 69 |
+
return self._normalize(out)
|
| 70 |
+
|
| 71 |
+
def encode_batch(self, texts: List[str]) -> List[List[float]]:
|
| 72 |
+
return [self._normalize(v) for v in self._model.embed(texts)]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
_provider = None
|
| 76 |
+
_tried = False
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def get_provider():
|
| 80 |
+
global _provider, _tried
|
| 81 |
+
if _provider is not None:
|
| 82 |
+
return _provider
|
| 83 |
+
if _tried:
|
| 84 |
+
return None
|
| 85 |
+
_tried = True
|
| 86 |
+
choice = (settings.embedding_provider or "auto").lower()
|
| 87 |
+
if choice in ("auto", "fastembed"):
|
| 88 |
+
try:
|
| 89 |
+
_provider = FastEmbedEmbedding()
|
| 90 |
+
logger.info("embeddings: fastembed active (%s)", _provider.name)
|
| 91 |
+
return _provider
|
| 92 |
+
except Exception as exc:
|
| 93 |
+
logger.warning("fastembed unavailable (%s) — using hash embeddings", exc)
|
| 94 |
+
_provider = HashEmbedding()
|
| 95 |
+
return _provider
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def embed(text: str) -> List[float]:
|
| 99 |
+
p = get_provider()
|
| 100 |
+
if p is None:
|
| 101 |
+
return HashEmbedding().encode(text)
|
| 102 |
+
return p.encode(text)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def embed_batch(texts: List[str]) -> List[List[float]]:
|
| 106 |
+
p = get_provider()
|
| 107 |
+
if p is None:
|
| 108 |
+
return HashEmbedding().encode_batch(texts)
|
| 109 |
+
return p.encode_batch(texts)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def provider_name() -> str:
|
| 113 |
+
p = get_provider()
|
| 114 |
+
return p.name if p else "hash-v1"
|
app/stores/graph.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Optional graph mirror — BetterDB (managed Neo4j) or direct Aura.
|
| 2 |
+
|
| 3 |
+
The ABM graph (accounts, contacts, enrichment events) mirrors into Neo4j when
|
| 4 |
+
a connection is available; the engine runs fully without it (OpenSearch is the
|
| 5 |
+
source of truth). Two transports, auto-detected at boot:
|
| 6 |
+
|
| 7 |
+
1. BETTERDB_URL + BETTERDB_TOKEN — managed Neo4j behind betterdb.com. The
|
| 8 |
+
HTTP query API is probed at a few known paths; BetterDB answers "waiting"
|
| 9 |
+
until the owner connects their instance in the console, in which case the
|
| 10 |
+
mirror stays disabled and /health reports it honestly.
|
| 11 |
+
2. NEO4J_URI (+user/password) — a direct Neo4j/Aura endpoint via the official
|
| 12 |
+
driver (bolt+ssc for Aura).
|
| 13 |
+
"""
|
| 14 |
+
import logging
|
| 15 |
+
from datetime import datetime, timezone
|
| 16 |
+
from typing import Any, Dict, List, Optional
|
| 17 |
+
|
| 18 |
+
import httpx
|
| 19 |
+
|
| 20 |
+
from ..config import settings
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger("abm.graph")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class GraphStore:
|
| 26 |
+
def __init__(self) -> None:
|
| 27 |
+
self._mode: Optional[str] = None # "betterdb" | "neo4j" | None
|
| 28 |
+
self._driver = None
|
| 29 |
+
self._http: Optional[httpx.AsyncClient] = None
|
| 30 |
+
self._query_path: Optional[str] = None
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def mode(self) -> Optional[str]:
|
| 34 |
+
return self._mode
|
| 35 |
+
|
| 36 |
+
async def connect(self) -> bool:
|
| 37 |
+
if settings.neo4j_uri:
|
| 38 |
+
try:
|
| 39 |
+
from neo4j import GraphDatabase # optional dependency
|
| 40 |
+
|
| 41 |
+
scheme = "neo4j+ssc" if "+ssc" not in settings.neo4j_uri else None
|
| 42 |
+
uri = settings.neo4j_uri
|
| 43 |
+
if uri.startswith("neo4j://"):
|
| 44 |
+
uri = uri.replace("neo4j://", "neo4j+ssc://", 1)
|
| 45 |
+
elif scheme and not uri.startswith("neo4j+"):
|
| 46 |
+
uri = f"{scheme}://{uri}"
|
| 47 |
+
self._driver = GraphDatabase.driver(
|
| 48 |
+
uri, auth=(settings.neo4j_user or "neo4j", settings.neo4j_password or "")
|
| 49 |
+
)
|
| 50 |
+
await asyncio_wrap(lambda: self._driver.verify_connectivity())
|
| 51 |
+
self._mode = "neo4j"
|
| 52 |
+
logger.info("graph: direct Neo4j connected (%s)", uri)
|
| 53 |
+
return True
|
| 54 |
+
except Exception as exc:
|
| 55 |
+
logger.warning("graph: direct Neo4j connect failed: %s", exc)
|
| 56 |
+
self._driver = None
|
| 57 |
+
if settings.betterdb_url and settings.betterdb_token:
|
| 58 |
+
self._http = httpx.AsyncClient(
|
| 59 |
+
base_url=settings.betterdb_url.rstrip("/"),
|
| 60 |
+
headers={"Authorization": f"Bearer {settings.betterdb_token}"},
|
| 61 |
+
timeout=httpx.Timeout(20.0),
|
| 62 |
+
)
|
| 63 |
+
for path in (
|
| 64 |
+
"/api/neo4j/query/v2",
|
| 65 |
+
"/neo4j/query/v2",
|
| 66 |
+
"/api/db/neo4j/query/v2",
|
| 67 |
+
"/db/neo4j/query/v2",
|
| 68 |
+
"/api/query",
|
| 69 |
+
):
|
| 70 |
+
try:
|
| 71 |
+
res = await self._http.post(
|
| 72 |
+
path, json={"statement": "RETURN 1 AS ok"}
|
| 73 |
+
)
|
| 74 |
+
if res.status_code == 200:
|
| 75 |
+
self._query_path = path
|
| 76 |
+
self._mode = "betterdb"
|
| 77 |
+
logger.info("graph: BetterDB query API live at %s", path)
|
| 78 |
+
return True
|
| 79 |
+
if res.status_code in (401, 402, 403):
|
| 80 |
+
logger.warning("graph: BetterDB answered %s at %s — token %s",
|
| 81 |
+
res.status_code, path, "rejected")
|
| 82 |
+
except Exception:
|
| 83 |
+
continue
|
| 84 |
+
logger.info("graph: BetterDB reachable but no query API answered yet "
|
| 85 |
+
"(owner must connect the instance in the console)")
|
| 86 |
+
return False
|
| 87 |
+
|
| 88 |
+
async def close(self) -> None:
|
| 89 |
+
if self._http is not None:
|
| 90 |
+
await self._http.aclose()
|
| 91 |
+
if self._driver is not None:
|
| 92 |
+
self._driver.close()
|
| 93 |
+
|
| 94 |
+
async def run(self, cypher: str, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
|
| 95 |
+
"""Run a write/query statement; returns rows (best-effort, logged)."""
|
| 96 |
+
if self._mode == "betterdb" and self._query_path:
|
| 97 |
+
try:
|
| 98 |
+
res = await self._http.post(
|
| 99 |
+
self._query_path, json={"statement": cypher, "parameters": params or {}}
|
| 100 |
+
)
|
| 101 |
+
if res.status_code != 200:
|
| 102 |
+
logger.warning("graph query %s: %s", res.status_code, res.text[:150])
|
| 103 |
+
return []
|
| 104 |
+
body = res.json()
|
| 105 |
+
return body.get("records") or body.get("data") or []
|
| 106 |
+
except Exception as exc:
|
| 107 |
+
logger.warning("graph query failed: %s", exc)
|
| 108 |
+
return []
|
| 109 |
+
if self._mode == "neo4j" and self._driver is not None:
|
| 110 |
+
def _run():
|
| 111 |
+
with self._driver.session() as session:
|
| 112 |
+
result = session.run(cypher, params or {})
|
| 113 |
+
return [r.data() for r in result]
|
| 114 |
+
try:
|
| 115 |
+
return await asyncio_wrap(_run)
|
| 116 |
+
except Exception as exc:
|
| 117 |
+
logger.warning("graph query failed: %s", exc)
|
| 118 |
+
return []
|
| 119 |
+
return []
|
| 120 |
+
|
| 121 |
+
# ── mirror API (idempotent MERGEs) ───────────────────────────────────
|
| 122 |
+
|
| 123 |
+
async def upsert_account(self, account: Dict[str, Any]) -> None:
|
| 124 |
+
if self._mode is None:
|
| 125 |
+
return
|
| 126 |
+
await self.run(
|
| 127 |
+
"""
|
| 128 |
+
MERGE (a:Account {account_id: $account_id})
|
| 129 |
+
SET a.name = $name, a.domain = $domain, a.industry = $industry,
|
| 130 |
+
a.hq_country = $hq_country, a.icp_score = $icp_score,
|
| 131 |
+
a.updated_at = $updated_at
|
| 132 |
+
""",
|
| 133 |
+
{
|
| 134 |
+
"account_id": account.get("account_id"),
|
| 135 |
+
"name": account.get("name"),
|
| 136 |
+
"domain": account.get("domain"),
|
| 137 |
+
"industry": account.get("industry"),
|
| 138 |
+
"hq_country": account.get("hq_country"),
|
| 139 |
+
"icp_score": account.get("icp_score"),
|
| 140 |
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
| 141 |
+
},
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
async def upsert_contact(self, account_id: str, contact: Dict[str, Any]) -> None:
|
| 145 |
+
if self._mode is None:
|
| 146 |
+
return
|
| 147 |
+
await self.run(
|
| 148 |
+
"""
|
| 149 |
+
MATCH (a:Account {account_id: $account_id})
|
| 150 |
+
MERGE (c:Contact {contact_id: $contact_id})
|
| 151 |
+
SET c.full_name = $full_name, c.title = $title, c.email = $email,
|
| 152 |
+
c.linkedin_url = $linkedin_url
|
| 153 |
+
MERGE (c)-[:WORKS_AT]->(a)
|
| 154 |
+
""",
|
| 155 |
+
{
|
| 156 |
+
"account_id": account_id,
|
| 157 |
+
"contact_id": contact.get("contact_id"),
|
| 158 |
+
"full_name": contact.get("full_name"),
|
| 159 |
+
"title": contact.get("title"),
|
| 160 |
+
"email": contact.get("email"),
|
| 161 |
+
"linkedin_url": contact.get("linkedin_url"),
|
| 162 |
+
},
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
async def overview(self) -> Dict[str, Any]:
|
| 166 |
+
if self._mode is None:
|
| 167 |
+
return {"mode": None, "status": "not connected"}
|
| 168 |
+
rows = await self.run(
|
| 169 |
+
"MATCH (a:Account) WITH count(a) AS accounts "
|
| 170 |
+
"OPTIONAL MATCH (c:Contact) RETURN accounts, count(c) AS contacts"
|
| 171 |
+
)
|
| 172 |
+
return {"mode": self._mode, "status": "connected", "counts": rows or []}
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
import asyncio as _asyncio
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
async def asyncio_wrap(fn):
|
| 179 |
+
return await _asyncio.to_thread(fn)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
graph = GraphStore()
|
app/stores/opensearch.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenSearch persistence layer — the ABM engine's primary store.
|
| 2 |
+
|
| 3 |
+
Aiven OpenSearch (v3.6, k-NN plugin enabled, cluster green) carries accounts,
|
| 4 |
+
contacts, signal events and the vector memory index. Everything degrades
|
| 5 |
+
gracefully: if OpenSearch is unreachable the service reports unhealthy in
|
| 6 |
+
/health instead of crashing, so the Space can boot before the datastore.
|
| 7 |
+
"""
|
| 8 |
+
import asyncio
|
| 9 |
+
import logging
|
| 10 |
+
import uuid
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
from typing import Any, Dict, List, Optional
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
|
| 16 |
+
from ..config import settings
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger("abm.os")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _now() -> str:
|
| 22 |
+
return datetime.now(timezone.utc).isoformat()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class OpenSearchStore:
|
| 26 |
+
def __init__(self) -> None:
|
| 27 |
+
self._client: Optional[httpx.AsyncClient] = None
|
| 28 |
+
|
| 29 |
+
@property
|
| 30 |
+
def configured(self) -> bool:
|
| 31 |
+
return settings.os_configured
|
| 32 |
+
|
| 33 |
+
def client(self) -> httpx.AsyncClient:
|
| 34 |
+
if self._client is None:
|
| 35 |
+
auth = (settings.os_user, settings.os_password) if settings.os_user else None
|
| 36 |
+
self._client = httpx.AsyncClient(
|
| 37 |
+
base_url=settings.os_url.rstrip("/"),
|
| 38 |
+
auth=auth,
|
| 39 |
+
timeout=httpx.Timeout(30.0),
|
| 40 |
+
verify=True,
|
| 41 |
+
)
|
| 42 |
+
return self._client
|
| 43 |
+
|
| 44 |
+
async def close(self) -> None:
|
| 45 |
+
if self._client is not None:
|
| 46 |
+
await self._client.aclose()
|
| 47 |
+
self._client = None
|
| 48 |
+
|
| 49 |
+
# ── index bootstrap ──────────────────────────────────────────────────
|
| 50 |
+
|
| 51 |
+
def _index(self, name: str) -> str:
|
| 52 |
+
return f"{settings.os_index_prefix}-{name}"
|
| 53 |
+
|
| 54 |
+
async def _put_index(self, name: str, body: dict) -> bool:
|
| 55 |
+
res = await self.client().put(f"/{self._index(name)}", json=body)
|
| 56 |
+
if res.status_code in (200, 201):
|
| 57 |
+
return True
|
| 58 |
+
if res.status_code == 400 and "resource_already_exists" in res.text:
|
| 59 |
+
return True
|
| 60 |
+
logger.warning("put_index %s failed: %s %s", name, res.status_code, res.text[:200])
|
| 61 |
+
return False
|
| 62 |
+
|
| 63 |
+
async def bootstrap(self) -> dict:
|
| 64 |
+
"""Create the four indices if missing. Returns per-index status."""
|
| 65 |
+
dim = settings.embedding_dim
|
| 66 |
+
knn_vector = {
|
| 67 |
+
"type": "knn_vector",
|
| 68 |
+
"dimension": dim,
|
| 69 |
+
"method": {"name": "hnsw", "space_type": "cosinesimil", "engine": "nmslib"},
|
| 70 |
+
}
|
| 71 |
+
results: Dict[str, bool] = {}
|
| 72 |
+
results["accounts"] = await self._put_index("accounts", {
|
| 73 |
+
"settings": {"index": {"knn": True}},
|
| 74 |
+
"mappings": {"properties": {
|
| 75 |
+
"account_id": {"type": "keyword"},
|
| 76 |
+
"domain": {"type": "keyword"},
|
| 77 |
+
"name": {"type": "text", "fields": {"kw": {"type": "keyword"}}},
|
| 78 |
+
"industry": {"type": "keyword"},
|
| 79 |
+
"employee_count": {"type": "integer"},
|
| 80 |
+
"hq_country": {"type": "keyword"},
|
| 81 |
+
"city": {"type": "keyword"},
|
| 82 |
+
"website": {"type": "keyword"},
|
| 83 |
+
"phone": {"type": "keyword"},
|
| 84 |
+
"description": {"type": "text"},
|
| 85 |
+
"source": {"type": "keyword"},
|
| 86 |
+
"icp_score": {"type": "float"},
|
| 87 |
+
"icp_label": {"type": "keyword"},
|
| 88 |
+
"signals": {"type": "flat_object"},
|
| 89 |
+
"embedding": knn_vector,
|
| 90 |
+
"updated_at": {"type": "date"},
|
| 91 |
+
}},
|
| 92 |
+
})
|
| 93 |
+
results["contacts"] = await self._put_index("contacts", {
|
| 94 |
+
"mappings": {"properties": {
|
| 95 |
+
"contact_id": {"type": "keyword"},
|
| 96 |
+
"account_id": {"type": "keyword"},
|
| 97 |
+
"email": {"type": "keyword"},
|
| 98 |
+
"full_name": {"type": "text"},
|
| 99 |
+
"title": {"type": "keyword"},
|
| 100 |
+
"linkedin_url": {"type": "keyword"},
|
| 101 |
+
"phone": {"type": "keyword"},
|
| 102 |
+
"source": {"type": "keyword"},
|
| 103 |
+
"updated_at": {"type": "date"},
|
| 104 |
+
}},
|
| 105 |
+
})
|
| 106 |
+
results["events"] = await self._put_index("events", {
|
| 107 |
+
"mappings": {"properties": {
|
| 108 |
+
"event_id": {"type": "keyword"},
|
| 109 |
+
"account_id": {"type": "keyword"},
|
| 110 |
+
"type": {"type": "keyword"},
|
| 111 |
+
"payload": {"type": "flat_object"},
|
| 112 |
+
"created_at": {"type": "date"},
|
| 113 |
+
}},
|
| 114 |
+
})
|
| 115 |
+
results["memory"] = await self._put_index("memory", {
|
| 116 |
+
"settings": {"index": {"knn": True}},
|
| 117 |
+
"mappings": {"properties": {
|
| 118 |
+
"doc_id": {"type": "keyword"},
|
| 119 |
+
"text": {"type": "text"},
|
| 120 |
+
"metadata": {"type": "flat_object"},
|
| 121 |
+
"embedding": knn_vector,
|
| 122 |
+
"created_at": {"type": "date"},
|
| 123 |
+
}},
|
| 124 |
+
})
|
| 125 |
+
return results
|
| 126 |
+
|
| 127 |
+
# ── generic helpers ────────────────────────────────��─────────────────
|
| 128 |
+
|
| 129 |
+
async def index_doc(self, index: str, doc: dict, doc_id: Optional[str] = None) -> str:
|
| 130 |
+
_id = doc_id or doc.get("account_id") or doc.get("contact_id") or doc.get("event_id") or doc.get("doc_id") or str(uuid.uuid4())
|
| 131 |
+
res = await self.client().put(f"/{self._index(index)}/_doc/{_id}", json=doc)
|
| 132 |
+
res.raise_for_status()
|
| 133 |
+
return _id
|
| 134 |
+
|
| 135 |
+
async def upsert_doc(self, index: str, doc: dict, doc_id: str) -> str:
|
| 136 |
+
res = await self.client().put(
|
| 137 |
+
f"/{self._index(index)}/_doc/{_id}", json=doc,
|
| 138 |
+
params={"refresh": "false"},
|
| 139 |
+
)
|
| 140 |
+
res.raise_for_status()
|
| 141 |
+
return doc_id
|
| 142 |
+
|
| 143 |
+
async def get_doc(self, index: str, doc_id: str) -> Optional[dict]:
|
| 144 |
+
res = await self.client().get(f"/{self._index(index)}/_doc/{doc_id}")
|
| 145 |
+
if res.status_code == 404:
|
| 146 |
+
return None
|
| 147 |
+
res.raise_for_status()
|
| 148 |
+
body = res.json()
|
| 149 |
+
if not body.get("found"):
|
| 150 |
+
return None
|
| 151 |
+
return {"_id": body["_id"], **body["_source"]}
|
| 152 |
+
|
| 153 |
+
async def delete_doc(self, index: str, doc_id: str) -> bool:
|
| 154 |
+
res = await self.client().delete(f"/{self._index(index)}/_doc/{doc_id}")
|
| 155 |
+
return res.status_code in (200, 404)
|
| 156 |
+
|
| 157 |
+
async def search(self, index: str, body: dict) -> List[dict]:
|
| 158 |
+
res = await self.client().post(f"/{self._index(index)}/_search", json=body)
|
| 159 |
+
res.raise_for_status()
|
| 160 |
+
hits = res.json().get("hits", {}).get("hits", [])
|
| 161 |
+
return [{"_id": h["_id"], "_score": h.get("_score"), **h["_source"]} for h in hits]
|
| 162 |
+
|
| 163 |
+
async def refresh(self, index: str) -> None:
|
| 164 |
+
try:
|
| 165 |
+
await self.client().post(f"/{self._index(index)}/_refresh")
|
| 166 |
+
except Exception:
|
| 167 |
+
pass
|
| 168 |
+
|
| 169 |
+
async def count(self, index: str) -> int:
|
| 170 |
+
res = await self.client().get(f"/{self._index(index)}/_count")
|
| 171 |
+
if res.status_code != 200:
|
| 172 |
+
return -1
|
| 173 |
+
return int(res.json().get("count", -1))
|
| 174 |
+
|
| 175 |
+
async def ping(self) -> bool:
|
| 176 |
+
try:
|
| 177 |
+
res = await self.client().get("/")
|
| 178 |
+
return res.status_code == 200
|
| 179 |
+
except Exception:
|
| 180 |
+
return False
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
store = OpenSearchStore()
|
pytest.ini
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
testpaths = tests
|
| 3 |
+
asyncio_mode = auto
|
requirements.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Core web framework ---
|
| 2 |
+
fastapi>=0.115.0
|
| 3 |
+
uvicorn[standard]>=0.30.6
|
| 4 |
+
|
| 5 |
+
# --- Data validation ---
|
| 6 |
+
pydantic>=2.11.0
|
| 7 |
+
pydantic-settings>=2.7.0
|
| 8 |
+
|
| 9 |
+
# --- HTTP ---
|
| 10 |
+
httpx>=0.27.2
|
| 11 |
+
|
| 12 |
+
# --- Auth (Supabase JWKS) ---
|
| 13 |
+
python-jose[cryptography]>=3.3.0
|
| 14 |
+
|
| 15 |
+
# --- Embeddings (optional at runtime; hash fallback always available) ---
|
| 16 |
+
fastembed>=0.3.6
|
| 17 |
+
|
| 18 |
+
# --- Optional direct Neo4j (Aura) ---
|
| 19 |
+
neo4j>=5.25.0
|
tests/__init__.py
ADDED
|
File without changes
|
tests/test_abm.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests — normalization, scoring, embeddings, auth, API contract."""
|
| 2 |
+
import unittest
|
| 3 |
+
from unittest.mock import AsyncMock, MagicMock, patch
|
| 4 |
+
|
| 5 |
+
from fastapi.testclient import TestClient
|
| 6 |
+
|
| 7 |
+
from app.main import app
|
| 8 |
+
from app.stores.apify import normalize_account, normalize_contact
|
| 9 |
+
from app.services.signals import score_account
|
| 10 |
+
|
| 11 |
+
client = TestClient(app)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class NormalizerTests(unittest.TestCase):
|
| 15 |
+
"""Actor output is schema-flexible; the normalizer must survive it."""
|
| 16 |
+
|
| 17 |
+
def test_google_maps_item_full(self):
|
| 18 |
+
raw = {
|
| 19 |
+
"title": "Acme Robotics",
|
| 20 |
+
"website": "https://www.acmerobotics.com/contact",
|
| 21 |
+
"phoneNumber": "+1 555 0100",
|
| 22 |
+
"category": "Industrial equipment supplier",
|
| 23 |
+
"city": "Detroit",
|
| 24 |
+
"countryCode": "US",
|
| 25 |
+
"totalScore": 4.6,
|
| 26 |
+
"description": "Industrial robots",
|
| 27 |
+
}
|
| 28 |
+
out = normalize_account(raw)
|
| 29 |
+
self.assertEqual(out["name"], "Acme Robotics")
|
| 30 |
+
self.assertEqual(out["domain"], "acmerobotics.com")
|
| 31 |
+
self.assertEqual(out["phone"], "+1 555 0100")
|
| 32 |
+
self.assertEqual(out["source"], "google_maps")
|
| 33 |
+
|
| 34 |
+
def test_google_maps_item_missing_name_is_skipped(self):
|
| 35 |
+
self.assertIsNone(normalize_account({"website": "https://x.com"}))
|
| 36 |
+
|
| 37 |
+
def test_linkedin_contact(self):
|
| 38 |
+
raw = {
|
| 39 |
+
"fullName": "Jane Doe",
|
| 40 |
+
"email": "Jane.Doe@Example.com ",
|
| 41 |
+
"position": "Head of Ops",
|
| 42 |
+
"linkedinUrl": "https://linkedin.com/in/janedoe",
|
| 43 |
+
"company": "Acme",
|
| 44 |
+
}
|
| 45 |
+
out = normalize_contact(raw)
|
| 46 |
+
self.assertEqual(out["email"], "jane.doe@example.com")
|
| 47 |
+
self.assertEqual(out["title"], "Head of Ops")
|
| 48 |
+
|
| 49 |
+
def test_contact_requires_email_or_name(self):
|
| 50 |
+
self.assertIsNone(normalize_contact({"title": "CEO"}))
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class ScoringTests(unittest.TestCase):
|
| 54 |
+
def test_full_fit_account_scores_A(self):
|
| 55 |
+
account = {
|
| 56 |
+
"name": "Great Co", "domain": "great.com", "website": "great.com",
|
| 57 |
+
"industry": "Software", "hq_country": "United States",
|
| 58 |
+
"employee_count": 120, "rating": 4.8,
|
| 59 |
+
}
|
| 60 |
+
out = score_account(account, contact_count=5)
|
| 61 |
+
self.assertGreaterEqual(out["icp_score"], 75)
|
| 62 |
+
self.assertEqual(out["icp_label"], "A")
|
| 63 |
+
self.assertIn("industry_fit", out["breakdown"])
|
| 64 |
+
|
| 65 |
+
def test_poor_fit_account_scores_D(self):
|
| 66 |
+
out = score_account({"name": "Nobody"}, contact_count=0)
|
| 67 |
+
self.assertLess(out["icp_score"], 30)
|
| 68 |
+
self.assertEqual(out["icp_label"], "D")
|
| 69 |
+
|
| 70 |
+
def test_score_is_explainable_and_bounded(self):
|
| 71 |
+
out = score_account({"name": "X", "industry": "software", "hq_country": "usa",
|
| 72 |
+
"employee_count": 50, "rating": 5.0}, contact_count=99)
|
| 73 |
+
self.assertLessEqual(out["icp_score"], 100)
|
| 74 |
+
self.assertEqual(out["breakdown"]["contact_coverage"], 10.0)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class EmbeddingTests(unittest.TestCase):
|
| 78 |
+
def test_hash_embedding_deterministic_and_normalized(self):
|
| 79 |
+
from app.stores.embeddings import HashEmbedding
|
| 80 |
+
e1 = HashEmbedding().encode("acme robotics detroit")
|
| 81 |
+
e2 = HashEmbedding().encode("acme robotics detroit")
|
| 82 |
+
e3 = HashEmbedding().encode("completely different text")
|
| 83 |
+
self.assertEqual(e1, e2)
|
| 84 |
+
self.assertNotEqual(e1, e3)
|
| 85 |
+
self.assertEqual(len(e1), 384)
|
| 86 |
+
norm = sum(v * v for v in e1) ** 0.5
|
| 87 |
+
self.assertAlmostEqual(norm, 1.0, places=2)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class AuthTests(unittest.TestCase):
|
| 91 |
+
def test_api_key_auth(self):
|
| 92 |
+
from app.config import settings
|
| 93 |
+
with patch.object(settings, "api_key", "test-key-123"):
|
| 94 |
+
from app.auth import authenticate
|
| 95 |
+
req = MagicMock()
|
| 96 |
+
req.headers = {"x-api-key": "test-key-123"}
|
| 97 |
+
import asyncio
|
| 98 |
+
out = asyncio.run(authenticate(req))
|
| 99 |
+
self.assertEqual(out["via"], "api-key")
|
| 100 |
+
self.assertEqual(out["user_id"], "service")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class HealthTests(unittest.TestCase):
|
| 104 |
+
def test_health_is_open_and_reports_components(self):
|
| 105 |
+
res = client.get("/health")
|
| 106 |
+
self.assertEqual(res.status_code, 200)
|
| 107 |
+
body = res.json()
|
| 108 |
+
self.assertIn("components", body)
|
| 109 |
+
self.assertIn("embeddings", body["components"])
|
| 110 |
+
self.assertIn("status", body)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class ApiContractTests(unittest.TestCase):
|
| 114 |
+
def test_enrich_rejects_unknown_source(self):
|
| 115 |
+
res = client.post("/api/v1/enrich", json={"source": "twitter", "queries": ["x"]})
|
| 116 |
+
self.assertEqual(res.status_code, 400)
|
| 117 |
+
|
| 118 |
+
def test_accounts_require_domain_or_name(self):
|
| 119 |
+
res = client.post("/api/v1/accounts", json={"industry": "software"})
|
| 120 |
+
self.assertEqual(res.status_code, 400)
|
| 121 |
+
|
| 122 |
+
def test_memory_rejects_empty_text(self):
|
| 123 |
+
res = client.post("/api/v1/memory", json={"text": " "})
|
| 124 |
+
self.assertEqual(res.status_code, 400)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
unittest.main()
|