sales-model 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. app/__init__.py +0 -0
  2. app/auth/__init__.py +0 -0
  3. app/auth/api_keys.py +290 -0
  4. app/auth/jwt.py +103 -0
  5. app/auth/rate_limit.py +41 -0
  6. app/auth/rate_limiter.py +354 -0
  7. app/auth/security.py +367 -0
  8. app/billing/__init__.py +24 -0
  9. app/billing/usage.py +488 -0
  10. app/dashboard/__init__.py +1 -0
  11. app/dashboard/data.py +139 -0
  12. app/dashboard/data_backup.py +942 -0
  13. app/dashboard/models.py +387 -0
  14. app/dashboard/postgres_data.py +1208 -0
  15. app/dashboard/routes.py +1006 -0
  16. app/main.py +587 -0
  17. app/main_v2.py +693 -0
  18. app/observability/__init__.py +0 -0
  19. app/observability/logging.py +23 -0
  20. app/observability/metrics.py +9 -0
  21. app/observability/tracing.py +5 -0
  22. app/providers/__init__.py +0 -0
  23. app/providers/azure_foundry_stt.py +111 -0
  24. app/providers/azure_foundry_tts.py +123 -0
  25. app/providers/llm_base.py +15 -0
  26. app/providers/null_stt.py +28 -0
  27. app/providers/null_tts.py +13 -0
  28. app/providers/stt_base.py +27 -0
  29. app/providers/tts_base.py +8 -0
  30. app/sales_brain/__init__.py +0 -0
  31. app/sales_brain/brain.py +26 -0
  32. app/sales_brain/chunker.py +48 -0
  33. app/storage/__init__.py +0 -0
  34. app/storage/database.py +761 -0
  35. app/storage/postgres.py +17 -0
  36. app/storage/redis.py +176 -0
  37. app/storage/schema.sql +319 -0
  38. app/utils/__init__.py +1 -0
  39. app/utils/latency.py +323 -0
  40. app/voice/__init__.py +0 -0
  41. app/voice/audio.py +8 -0
  42. app/voice/session.py +225 -0
  43. app/voice/ssml.py +32 -0
  44. app/voice/vad.py +6 -0
  45. app/voice/voicelive.py +324 -0
  46. app/voice/ws.py +144 -0
  47. app/webui/app.js +384 -0
  48. app/webui/index.html +90 -0
  49. app/webui/styles.css +267 -0
  50. sales_model/__init__.py +8 -0
  51. sales_model/ai.py +54 -0
  52. sales_model/cli.py +51 -0
  53. sales_model/config.py +37 -0
  54. sales_model/context_utils.py +170 -0
  55. sales_model/crm.py +20 -0
  56. sales_model/inventory.py +144 -0
  57. sales_model/playbook.py +37 -0
  58. sales_model/prompt_cache.py +14 -0
  59. sales_model/prompt_compiler.py +47 -0
  60. sales_model/prompt_registry.py +102 -0
  61. sales_model/sales_brain.py +731 -0
  62. sales_model/schemas.py +57 -0
  63. sales_model/status_engine.py +258 -0
  64. sales_model/tactics.py +210 -0
  65. sales_model-0.1.0.dist-info/METADATA +107 -0
  66. sales_model-0.1.0.dist-info/RECORD +68 -0
  67. sales_model-0.1.0.dist-info/WHEEL +4 -0
  68. sales_model-0.1.0.dist-info/entry_points.txt +2 -0
app/__init__.py ADDED
File without changes
app/auth/__init__.py ADDED
File without changes
app/auth/api_keys.py ADDED
@@ -0,0 +1,290 @@
1
+ """
2
+ API Key Management System for Organization Access
3
+
4
+ Provides secure API key generation, validation, and organization-level access control.
5
+ Supports tiered pricing plans with different rate limits and features.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import hmac
11
+ import os
12
+ import secrets
13
+ import time
14
+ from dataclasses import dataclass
15
+ from enum import Enum
16
+ from typing import Optional
17
+
18
+ from app.observability.logging import get_logger
19
+
20
+
21
+ class Plan(str, Enum):
22
+ FREE = "free"
23
+ STARTER = "starter"
24
+ PROFESSIONAL = "professional"
25
+ ENTERPRISE = "enterprise"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class PlanLimits:
30
+ """Rate limits and quotas for each plan tier."""
31
+ requests_per_minute: int
32
+ requests_per_hour: int
33
+ requests_per_month: int
34
+ concurrent_sessions: int
35
+ max_session_duration_seconds: int
36
+ priority: int # Higher = faster queue priority
37
+ features: list[str]
38
+
39
+
40
+ PLAN_CONFIGS: dict[Plan, PlanLimits] = {
41
+ Plan.FREE: PlanLimits(
42
+ requests_per_minute=10,
43
+ requests_per_hour=100,
44
+ requests_per_month=200,
45
+ concurrent_sessions=1,
46
+ max_session_duration_seconds=300,
47
+ priority=0,
48
+ features=["real_time_analysis"],
49
+ ),
50
+ Plan.STARTER: PlanLimits(
51
+ requests_per_minute=50,
52
+ requests_per_hour=500,
53
+ requests_per_month=1000,
54
+ concurrent_sessions=2,
55
+ max_session_duration_seconds=600,
56
+ priority=1,
57
+ features=["real_time_analysis", "basic_lead_scoring", "email_support", "dashboard_analytics"]
58
+ ),
59
+ Plan.PROFESSIONAL: PlanLimits(
60
+ requests_per_minute=200,
61
+ requests_per_hour=2000,
62
+ requests_per_month=10000,
63
+ concurrent_sessions=10,
64
+ max_session_duration_seconds=1800,
65
+ priority=2,
66
+ features=["advanced_ai_coaching", "predictive_lead_scoring", "crm_integrations", "priority_support", "custom_training"]
67
+ ),
68
+ Plan.ENTERPRISE: PlanLimits(
69
+ requests_per_minute=1000,
70
+ requests_per_hour=10000,
71
+ requests_per_month=100000,
72
+ concurrent_sessions=50,
73
+ max_session_duration_seconds=7200,
74
+ priority=3,
75
+ features=["white_label", "custom_ai_training", "dedicated_success_manager", "24_7_phone_support", "on_premise_deployment"]
76
+ ),
77
+ }
78
+
79
+
80
+ @dataclass
81
+ class ApiKeyInfo:
82
+ """Validated API key information."""
83
+ key_id: str
84
+ org_id: str
85
+ plan: Plan
86
+ limits: PlanLimits
87
+ created_at: int
88
+ is_active: bool
89
+ allowed_ips: list[str]
90
+ webhook_url: Optional[str] = None
91
+
92
+
93
+ # In-memory store for development; use Redis/DB in production
94
+ _api_keys: dict[str, dict] = {}
95
+ _logger = get_logger("api_keys")
96
+
97
+
98
+ def _hash_key(api_key: str) -> str:
99
+ """Hash API key for secure storage comparison."""
100
+ salt = os.getenv("API_KEY_SALT", "sales-voice-salt-change-in-production")
101
+ return hashlib.sha256(f"{salt}:{api_key}".encode()).hexdigest()
102
+
103
+
104
+ def generate_api_key(
105
+ org_id: str,
106
+ plan: Plan = Plan.FREE,
107
+ allowed_ips: Optional[list[str]] = None,
108
+ webhook_url: Optional[str] = None,
109
+ ) -> tuple[str, str]:
110
+ """
111
+ Generate a new API key for an organization.
112
+
113
+ Returns:
114
+ tuple: (api_key, key_id) - api_key should be shown once to user
115
+ """
116
+ key_id = f"sk_{secrets.token_hex(8)}"
117
+ api_key = f"sv_{secrets.token_urlsafe(32)}"
118
+ key_hash = _hash_key(api_key)
119
+
120
+ _api_keys[key_hash] = {
121
+ "key_id": key_id,
122
+ "org_id": org_id,
123
+ "plan": plan.value,
124
+ "created_at": int(time.time()),
125
+ "is_active": True,
126
+ "allowed_ips": allowed_ips or [],
127
+ "webhook_url": webhook_url,
128
+ }
129
+
130
+ _logger.info(
131
+ "api_key_created",
132
+ key_id=key_id,
133
+ org_id=org_id,
134
+ plan=plan.value,
135
+ )
136
+
137
+ return api_key, key_id
138
+
139
+
140
+ async def validate_api_key(
141
+ redis,
142
+ api_key: str,
143
+ client_ip: Optional[str] = None,
144
+ ) -> Optional[ApiKeyInfo]:
145
+ """
146
+ Validate an API key and return its info if valid.
147
+
148
+ Checks:
149
+ 1. Key exists and is active
150
+ 2. IP allowlist (if configured)
151
+ 3. Not revoked in Redis
152
+ """
153
+ if not api_key or not api_key.startswith("sv_"):
154
+ return None
155
+
156
+ key_hash = _hash_key(api_key)
157
+
158
+ # Check in-memory store first (for dev), then Redis
159
+ key_data = _api_keys.get(key_hash)
160
+
161
+ if not key_data:
162
+ # Try Redis for distributed storage
163
+ try:
164
+ stored = await redis.hgetall(f"apikey:{key_hash}")
165
+ if stored:
166
+ key_data = {
167
+ "key_id": stored.get("key_id"),
168
+ "org_id": stored.get("org_id"),
169
+ "plan": stored.get("plan", "free"),
170
+ "created_at": int(stored.get("created_at", 0)),
171
+ "is_active": stored.get("is_active", "true") == "true",
172
+ "allowed_ips": (stored.get("allowed_ips") or "").split(",") if stored.get("allowed_ips") else [],
173
+ "webhook_url": stored.get("webhook_url"),
174
+ }
175
+ except Exception:
176
+ pass
177
+
178
+ if not key_data:
179
+ _logger.warning("api_key_not_found", key_prefix=api_key[:10])
180
+ return None
181
+
182
+ if not key_data.get("is_active", True):
183
+ _logger.warning("api_key_inactive", key_id=key_data.get("key_id"))
184
+ return None
185
+
186
+ # Check IP allowlist
187
+ allowed_ips = key_data.get("allowed_ips", [])
188
+ if allowed_ips and client_ip and client_ip not in allowed_ips:
189
+ _logger.warning(
190
+ "api_key_ip_denied",
191
+ key_id=key_data.get("key_id"),
192
+ client_ip=client_ip,
193
+ )
194
+ return None
195
+
196
+ # Check if revoked in Redis
197
+ try:
198
+ revoked = await redis.get(f"apikey:revoked:{key_data['key_id']}")
199
+ if revoked:
200
+ _logger.warning("api_key_revoked", key_id=key_data.get("key_id"))
201
+ return None
202
+ except Exception:
203
+ pass
204
+
205
+ plan = Plan(key_data.get("plan", "free"))
206
+
207
+ return ApiKeyInfo(
208
+ key_id=key_data["key_id"],
209
+ org_id=key_data["org_id"],
210
+ plan=plan,
211
+ limits=PLAN_CONFIGS[plan],
212
+ created_at=key_data.get("created_at", 0),
213
+ is_active=True,
214
+ allowed_ips=allowed_ips,
215
+ webhook_url=key_data.get("webhook_url"),
216
+ )
217
+
218
+
219
+ async def revoke_api_key(redis, key_id: str) -> bool:
220
+ """Revoke an API key by its ID."""
221
+ try:
222
+ await redis.set(f"apikey:revoked:{key_id}", "1", ex=86400 * 365) # 1 year TTL
223
+ _logger.info("api_key_revoked", key_id=key_id)
224
+ return True
225
+ except Exception as e:
226
+ _logger.error("api_key_revoke_failed", key_id=key_id, error=str(e))
227
+ return False
228
+
229
+
230
+ def generate_request_signature(
231
+ api_key: str,
232
+ timestamp: int,
233
+ body: bytes,
234
+ ) -> str:
235
+ """
236
+ Generate HMAC signature for request validation.
237
+
238
+ Used to verify request integrity and prevent replay attacks.
239
+ """
240
+ message = f"{timestamp}.{body.decode('utf-8', errors='replace')}"
241
+ signature = hmac.new(
242
+ api_key.encode(),
243
+ message.encode(),
244
+ hashlib.sha256,
245
+ ).hexdigest()
246
+ return f"v1={signature}"
247
+
248
+
249
+ def verify_request_signature(
250
+ api_key: str,
251
+ timestamp: int,
252
+ body: bytes,
253
+ signature: str,
254
+ max_age_seconds: int = 300,
255
+ ) -> bool:
256
+ """
257
+ Verify request signature and timestamp.
258
+
259
+ Returns True if signature is valid and timestamp is fresh.
260
+ """
261
+ # Check timestamp freshness
262
+ now = int(time.time())
263
+ if abs(now - timestamp) > max_age_seconds:
264
+ _logger.warning("signature_timestamp_expired", age=abs(now - timestamp))
265
+ return False
266
+
267
+ expected = generate_request_signature(api_key, timestamp, body)
268
+ return hmac.compare_digest(expected, signature)
269
+
270
+
271
+ # Bootstrap: create a master API key from environment if configured
272
+ def _bootstrap_master_key():
273
+ master_key = os.getenv("MASTER_API_KEY")
274
+ master_org = os.getenv("MASTER_ORG_ID", "system")
275
+
276
+ if master_key:
277
+ key_hash = _hash_key(master_key)
278
+ _api_keys[key_hash] = {
279
+ "key_id": "sk_master",
280
+ "org_id": master_org,
281
+ "plan": Plan.ENTERPRISE.value,
282
+ "created_at": int(time.time()),
283
+ "is_active": True,
284
+ "allowed_ips": [],
285
+ "webhook_url": None,
286
+ }
287
+ _logger.info("master_api_key_configured", org_id=master_org)
288
+
289
+
290
+ _bootstrap_master_key()
app/auth/jwt.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ from typing import Iterable, Optional
6
+
7
+ import jwt
8
+
9
+
10
+ class AuthError(Exception):
11
+ pass
12
+
13
+
14
+ JWT_SECRET = os.getenv("VOICE_JWT_SECRET") or os.getenv("JWT_SECRET", "dev-secret-change-me")
15
+ JWT_ISSUER = os.getenv("JWT_ISSUER", "sales-voice")
16
+ JWT_AUDIENCE = os.getenv("JWT_AUDIENCE", "sales-voice")
17
+ JWT_LEEWAY_SECONDS = int(os.getenv("JWT_LEEWAY_SECONDS", "30"))
18
+ ENVIRONMENT = os.getenv("ENV", os.getenv("ENVIRONMENT", "development")).lower()
19
+
20
+ ACCESS_TTL_SECONDS = int(os.getenv("VOICE_ACCESS_TTL_SECONDS", "900"))
21
+ VOICE_TTL_SECONDS = int(os.getenv("VOICE_SESSION_TTL_SECONDS", "120"))
22
+
23
+ # Fail fast if default secret is used in non-dev environments
24
+ if ENVIRONMENT in {"prod", "production"} and JWT_SECRET == "dev-secret-change-me":
25
+ raise RuntimeError("JWT secret must be set in production")
26
+
27
+
28
+ def _now() -> int:
29
+ return int(time.time())
30
+
31
+
32
+ def _encode(payload: dict) -> str:
33
+ return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
34
+
35
+
36
+ def issue_access_token(
37
+ subject: str,
38
+ org: Optional[str],
39
+ plan: str,
40
+ scope: Iterable[str],
41
+ ) -> tuple[str, int]:
42
+ now = _now()
43
+ payload = {
44
+ "sub": subject,
45
+ "org": org,
46
+ "plan": plan,
47
+ "scope": list(scope),
48
+ "type": "access",
49
+ "iat": now,
50
+ "exp": now + ACCESS_TTL_SECONDS,
51
+ "iss": JWT_ISSUER,
52
+ "aud": JWT_AUDIENCE,
53
+ }
54
+ return _encode(payload), ACCESS_TTL_SECONDS
55
+
56
+
57
+ def issue_voice_token(
58
+ subject: str,
59
+ org: Optional[str],
60
+ plan: str,
61
+ scope: Iterable[str],
62
+ sid: str,
63
+ ) -> tuple[str, int]:
64
+ now = _now()
65
+ payload = {
66
+ "sub": subject,
67
+ "org": org,
68
+ "plan": plan,
69
+ "scope": list(scope),
70
+ "sid": sid,
71
+ "type": "voice",
72
+ "iat": now,
73
+ "exp": now + VOICE_TTL_SECONDS,
74
+ "iss": JWT_ISSUER,
75
+ "aud": JWT_AUDIENCE,
76
+ }
77
+ return _encode(payload), VOICE_TTL_SECONDS
78
+
79
+
80
+ def verify_token(token: str, required_scope: Optional[str] = None) -> dict:
81
+ try:
82
+ payload = jwt.decode(
83
+ token,
84
+ JWT_SECRET,
85
+ algorithms=["HS256"],
86
+ audience=JWT_AUDIENCE,
87
+ issuer=JWT_ISSUER,
88
+ leeway=JWT_LEEWAY_SECONDS,
89
+ options={
90
+ "require": ["sub", "exp", "iat", "iss", "aud", "type"],
91
+ },
92
+ )
93
+ except jwt.PyJWTError as exc:
94
+ raise AuthError("Invalid or expired token") from exc
95
+
96
+ token_type = payload.get("type")
97
+ if token_type not in {"access", "voice"}:
98
+ raise AuthError("Invalid token type")
99
+
100
+ scopes = set(payload.get("scope") or [])
101
+ if required_scope and required_scope not in scopes:
102
+ raise AuthError("Missing required scope")
103
+ return payload
app/auth/rate_limit.py ADDED
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class QuotaError(Exception):
7
+ pass
8
+
9
+
10
+ async def reserve_voice_session(
11
+ redis,
12
+ org_id: Optional[str],
13
+ sid: str,
14
+ limit: int,
15
+ ttl_seconds: int,
16
+ ) -> None:
17
+ org_key = org_id or "unknown"
18
+ session_key = f"voice:session:{sid}"
19
+ count_key = f"voice:concurrency:{org_key}"
20
+
21
+ created = await redis.set(session_key, org_key, ex=ttl_seconds, nx=True)
22
+ if not created:
23
+ return
24
+
25
+ count = await redis.incr(count_key)
26
+ if count == 1:
27
+ await redis.expire(count_key, ttl_seconds)
28
+ if count > limit:
29
+ await redis.decr(count_key)
30
+ await redis.delete(session_key)
31
+ raise QuotaError("Concurrent voice session limit reached")
32
+
33
+
34
+ async def release_voice_session(redis, org_id: Optional[str], sid: str) -> None:
35
+ org_key = org_id or "unknown"
36
+ session_key = f"voice:session:{sid}"
37
+ count_key = f"voice:concurrency:{org_key}"
38
+
39
+ deleted = await redis.delete(session_key)
40
+ if deleted:
41
+ await redis.decr(count_key)