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 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: Abm Engine
3
- emoji: 💻
4
  colorFrom: blue
5
- colorTo: gray
6
  sdk: docker
 
7
  pinned: false
8
  ---
 
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()