ownapi 1.0.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 (62) hide show
  1. cli.py +1017 -0
  2. compiler.py +948 -0
  3. core/__init__.py +19 -0
  4. core/api_keys.py +523 -0
  5. core/auto_repair.py +682 -0
  6. core/base_client.py +165 -0
  7. core/consent.py +169 -0
  8. core/form_automation.py +1044 -0
  9. core/hitl_queue.py +886 -0
  10. core/onboarding.py +1457 -0
  11. core/token_store.py +223 -0
  12. healer.py +2074 -0
  13. master_mcp.py +193 -0
  14. ownapi/__init__.py +94 -0
  15. ownapi/cli.py +309 -0
  16. ownapi/sdk.py +436 -0
  17. ownapi-1.0.0.dist-info/METADATA +287 -0
  18. ownapi-1.0.0.dist-info/RECORD +62 -0
  19. ownapi-1.0.0.dist-info/WHEEL +5 -0
  20. ownapi-1.0.0.dist-info/entry_points.txt +4 -0
  21. ownapi-1.0.0.dist-info/top_level.txt +10 -0
  22. recorder.py +486 -0
  23. registry/coingecko/__init__.py +5 -0
  24. registry/coingecko/client.py +84 -0
  25. registry/coingecko/schema.json +23 -0
  26. registry/edevlet/__init__.py +5 -0
  27. registry/edevlet/client.py +54 -0
  28. registry/edevlet/schema.json +14 -0
  29. registry/enuygun/__init__.py +5 -0
  30. registry/enuygun/client.py +78 -0
  31. registry/enuygun/schema.json +13 -0
  32. registry/github_trending/__init__.py +5 -0
  33. registry/github_trending/client.py +90 -0
  34. registry/github_trending/schema.json +31 -0
  35. registry/hackernews/__init__.py +5 -0
  36. registry/hackernews/client.py +90 -0
  37. registry/hackernews/schema.json +38 -0
  38. registry/hepsiburada/__init__.py +5 -0
  39. registry/hepsiburada/client.py +32 -0
  40. registry/hepsiburada/schema.json +23 -0
  41. registry/linkedin_jobs/__init__.py +5 -0
  42. registry/linkedin_jobs/client.py +241 -0
  43. registry/linkedin_jobs/schema.json +46 -0
  44. registry/pegasus/__init__.py +5 -0
  45. registry/pegasus/client.py +141 -0
  46. registry/pegasus/schema.json +76 -0
  47. registry/thy/__init__.py +5 -0
  48. registry/thy/client.py +1796 -0
  49. registry/thy/schema.json +46 -0
  50. registry/trendyol/__init__.py +5 -0
  51. registry/trendyol/client.py +228 -0
  52. registry/trendyol/schema.json +30 -0
  53. registry/ucak_bileti/client.py +94 -0
  54. registry/ucak_bileti/schema.json +47 -0
  55. registry/wikipedia/__init__.py +5 -0
  56. registry/wikipedia/client.py +64 -0
  57. registry/wikipedia/schema.json +31 -0
  58. registry/yahoo_finance/__init__.py +5 -0
  59. registry/yahoo_finance/client.py +98 -0
  60. registry/yahoo_finance/schema.json +22 -0
  61. server.py +891 -0
  62. static/index.html +2401 -0
server.py ADDED
@@ -0,0 +1,891 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import sys
7
+ import time
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from fastapi import Body, FastAPI, Header, HTTPException, Query
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.responses import HTMLResponse, JSONResponse
15
+ from fastapi.staticfiles import StaticFiles
16
+ from pydantic import BaseModel, Field
17
+
18
+ PROJECT_ROOT = Path(__file__).resolve().parent
19
+ if str(PROJECT_ROOT) not in sys.path:
20
+ sys.path.insert(0, str(PROJECT_ROOT))
21
+
22
+ from compiler import RegistrySchema
23
+ from core.api_keys import (
24
+ PLAN_CATALOG,
25
+ SITE_LIMIT_WARNING_MESSAGE,
26
+ SiteLimitExceededError,
27
+ consume_credit,
28
+ generate_api_key,
29
+ get_or_create_tenant,
30
+ get_tenant_info,
31
+ register_site_usage,
32
+ upgrade_plan,
33
+ )
34
+ from core.auto_repair import AutoRepairEngine
35
+ from core.consent import USER_DELEGATION_DISCLAIMER, TOS_VERSION, ConsentManager
36
+ from core.hitl_queue import HITLQueueManager
37
+ from core.onboarding import onboard_new_site, validate_and_repair_client
38
+ from core.token_store import TokenStore
39
+ from own_api.sdk import OwnAPIClient
40
+
41
+ STATIC_DIR = PROJECT_ROOT / "static"
42
+ STATIC_DIR.mkdir(parents=True, exist_ok=True)
43
+
44
+ app = FastAPI(
45
+ title="OwnAPI Enterprise REST Server & Live Playground",
46
+ description=(
47
+ "Plaid-Style User-Delegated Browser Middleware, 3x Auto-Validated Registry Moat, "
48
+ "Self-Healing 2.0 Nightly Auto-Repair & Human-in-the-Loop (HITL) Queue API."
49
+ ),
50
+ version="1.0.0",
51
+ )
52
+
53
+ app.add_middleware(
54
+ CORSMiddleware,
55
+ allow_origins=["*"],
56
+ allow_credentials=True,
57
+ allow_methods=["*"],
58
+ allow_headers=["*"],
59
+ )
60
+
61
+
62
+ class CallRequest(BaseModel):
63
+ service: str = Field(..., description="Çağrılacak kayıtlı servis adı (örn: trendyol, enuygun, wikipedia, thy)")
64
+ params: dict[str, Any] = Field(default_factory=dict, description="Servise iletilecek dinamik parametreler")
65
+ tenant_id: str = Field(default="default", description="Multi-tenant oturum izolasyonu kimliği")
66
+ api_key: str | None = Field(default=None, description="Opsiyonel sk-ownapi-... API anahtarı")
67
+ mode: str | None = Field(default=None, description="Çalışma modu: 'local' (User Chrome) veya 'cloud' (Docker/CDP)")
68
+ non_interactive: bool = Field(
69
+ default=False,
70
+ description="True ise Captcha/WAF durumunda görevi HITL onay kuyruğuna ekler",
71
+ )
72
+
73
+
74
+ class AskRequest(BaseModel):
75
+ prompt: str = Field(..., description="Doğal dil ajanına sorulacak soru veya talimat")
76
+ service: str | None = Field(default=None, description="Opsiyonel hedef site/konektör adı")
77
+ tenant_id: str = Field(default="default", description="Kiracı ID'si")
78
+ api_key: str | None = Field(default=None, description="Opsiyonel sk-ownapi-... API anahtarı")
79
+ mode: str | None = Field(default=None, description="Çalışma modu: 'local' veya 'cloud'")
80
+
81
+
82
+ class ValidateRequest(BaseModel):
83
+ runs: int = Field(default=3, ge=1, le=10, description="Ardışık doğrulama testi sayısı (varsayılan: 3)")
84
+ tenant_id: str = Field(default="default", description="Kiracı ID'si")
85
+ mode: str = Field(default="local", description="Çalışma modu: 'local' veya 'cloud'")
86
+
87
+
88
+ class AutoRepairRequest(BaseModel):
89
+ runs: int = Field(default=1, ge=1, le=5, description="Her servis için yapılacak sağlık testi sayısı")
90
+ auto_heal: bool = Field(default=True, description="Başarısız servisleri otomatik tamir et")
91
+ services: list[str] | None = Field(default=None, description="Belirli servis alt kümesi (None ise tümü)")
92
+ tenant_id: str = Field(default="default", description="Kiracı ID'si")
93
+ mode: str = Field(default="local", description="Çalışma modu: 'local' veya 'cloud'")
94
+
95
+
96
+ class OnboardRequest(BaseModel):
97
+ service_name: str = Field(..., description="Eklenecek yeni servis adı (örn: biletix, stripe_docs)")
98
+ target_url: str = Field(..., description="Hedef web sitesi URL'si (örn: https://www.biletix.com)")
99
+ description: str = Field(default="", description="Servisin amacı / yapılacak işlem açıklaması")
100
+ consent_accepted: bool = Field(
101
+ default=True,
102
+ description="Plaid tarzı Yasal Onay: Kullanıcının kendi hesabı ve tarayıcısıyla vekaleten eriştiğinin onayı",
103
+ )
104
+ tenant_id: str = Field(default="default", description="Kiracı ID'si")
105
+ mode: str = Field(default="local", description="Çalışma modu: 'local' veya 'cloud'")
106
+ wait_seconds: int = Field(default=20, ge=3, le=120, description="Tarayıcı kayıt süresi (sn)")
107
+ validation_runs: int = Field(default=3, ge=1, le=5, description="Auto-Test doğrulama tekrarı (varsayılan: 3)")
108
+
109
+
110
+ class GenerateKeyRequest(BaseModel):
111
+ tenant_id: str = Field(default="default", description="Anahtar üretilecek kiracı ID'si")
112
+ plan: str | None = Field(default=None, description="Paket tipi (None ise mevcut paketi korunur)")
113
+
114
+
115
+ class BillingUpgradeRequest(BaseModel):
116
+ tenant_id: str = Field(default="default", description="Yükseltilecek kiracı ID'si")
117
+ plan: str = Field(default="starter_cloud", description="Hedef paket: 'starter_cloud' ($10), 'growth_cloud' ($49) veya 'free'")
118
+
119
+
120
+ class HITLResolveRequest(BaseModel):
121
+ otp_code: str | None = Field(default=None, description="Opsiyonel SMS / E-Posta / 2FA doğrulama kodu (örn: 123456)")
122
+ tenant_id: str | None = Field(default=None, description="Opsiyonel kiracı ID'si")
123
+
124
+
125
+ def _mask_api_key(api_key: str) -> str:
126
+ clean = str(api_key or "").strip()
127
+ if len(clean) <= 16:
128
+ return clean
129
+ return f"{clean[:10]}••••••••••••••••{clean[-6:]}"
130
+
131
+
132
+ def _get_stripe_payment_links() -> dict[str, str | None]:
133
+ """
134
+ Ortam değişkenlerinde tanımlı gerçek Stripe Payment Link adreslerini döndürür:
135
+ - `STRIPE_STARTER_LINK` ($10 Starter Cloud)
136
+ - `STRIPE_GROWTH_LINK` ($49 Growth Cloud)
137
+ Tanımlı değilse `None` döner ve yerel simülasyon modu kullanılır.
138
+ """
139
+ starter_link = os.getenv("STRIPE_STARTER_LINK", "").strip() or None
140
+ growth_link = os.getenv("STRIPE_GROWTH_LINK", "").strip() or None
141
+ return {
142
+ "starter_cloud": starter_link,
143
+ "growth_cloud": growth_link,
144
+ }
145
+
146
+
147
+ def _build_account_payload(tenant_id: str = "default") -> dict[str, Any]:
148
+ rec = get_tenant_info(tenant_id)
149
+ plan_key = str(rec.get("plan", "free"))
150
+ plan_meta = PLAN_CATALOG.get(plan_key, PLAN_CATALOG["free"])
151
+ max_credits = int(plan_meta.get("credits", 1000))
152
+ rem_credits = int(rec.get("credits", max_credits))
153
+ plan_used = max(0, max_credits - rem_credits)
154
+ consumed = int(rec.get("total_consumed", plan_used))
155
+ full_key = str(rec.get("api_key", ""))
156
+ max_sites = int(rec.get("max_sites", plan_meta.get("max_sites", 2)))
157
+ active_sites = list(rec.get("active_sites", [])) if isinstance(rec.get("active_sites"), list) else []
158
+ stripe_links = _get_stripe_payment_links()
159
+ return {
160
+ "success": True,
161
+ "tenant_id": rec.get("tenant_id", tenant_id),
162
+ "plan": plan_key,
163
+ "plan_label": rec.get("plan_label", plan_meta["label"]),
164
+ "mode": rec.get("mode", plan_meta["mode"]),
165
+ "price_usd": int(rec.get("price_usd", plan_meta["price_usd"])),
166
+ "credits": rem_credits,
167
+ "remaining_credits": rem_credits,
168
+ "max_credits": max_credits,
169
+ "total_credits": max_credits,
170
+ "used_credits": plan_used,
171
+ "total_consumed": consumed,
172
+ "max_sites": max_sites,
173
+ "active_sites": active_sites,
174
+ "active_sites_count": len(active_sites),
175
+ "api_key": full_key,
176
+ "masked_api_key": _mask_api_key(full_key),
177
+ "stripe_starter_link": stripe_links["starter_cloud"],
178
+ "stripe_growth_link": stripe_links["growth_cloud"],
179
+ "stripe_links": stripe_links,
180
+ "created_at": rec.get("created_at"),
181
+ "updated_at": rec.get("updated_at"),
182
+ "plans": PLAN_CATALOG,
183
+ }
184
+
185
+
186
+ @app.get("/api/v1/account")
187
+ async def get_account_endpoint(tenant_id: str = Query(default="default")) -> dict[str, Any]:
188
+ """
189
+ Kullanıcının mevcut paketini (`free`, `starter_cloud`, `growth_cloud`),
190
+ kalan/toplam kredisini, çalışma modunu (`local`/`cloud`) ve maskelenmiş/tam
191
+ `sk-ownapi-...` anahtarını döndürür.
192
+ """
193
+ try:
194
+ return _build_account_payload(tenant_id=tenant_id)
195
+ except Exception as exc:
196
+ raise HTTPException(status_code=400, detail=str(exc))
197
+
198
+
199
+ @app.post("/api/v1/keys/generate")
200
+ async def generate_key_endpoint(req: GenerateKeyRequest | None = None) -> dict[str, Any]:
201
+ """Kullanıcıya yeni bir `sk-ownapi-...` API anahtarı üretir ve döndürür."""
202
+ cfg = req or GenerateKeyRequest()
203
+ try:
204
+ current = get_tenant_info(cfg.tenant_id)
205
+ target_plan = (cfg.plan or current.get("plan") or "free").strip().lower()
206
+ new_key = generate_api_key(tenant_id=current.get("tenant_id", cfg.tenant_id), plan=target_plan)
207
+ payload = _build_account_payload(tenant_id=new_key.tenant_id)
208
+ payload["message"] = "Yeni API anahtarı başarıyla üretildi."
209
+ return payload
210
+ except ValueError as exc:
211
+ raise HTTPException(status_code=400, detail=str(exc))
212
+
213
+
214
+ @app.post("/api/v1/billing/upgrade")
215
+ async def billing_upgrade_endpoint(req: BillingUpgradeRequest) -> dict[str, Any]:
216
+ """
217
+ Kullanıcının seçtiği pakete (`starter_cloud` $10 / 10.000 kredi veya
218
+ `growth_cloud` $49 / 100.000 kredi) geçişini yapar, yeni `sk-ownapi-...`
219
+ anahtarını ve `mode="cloud"` bilgisini döndürür.
220
+ Eğer `STRIPE_STARTER_LINK` veya `STRIPE_GROWTH_LINK` ortam değişkeni tanımlıysa
221
+ `stripe_checkout_url` alanıyla gerçek Stripe ödeme bağlantısını da döndürür.
222
+ """
223
+ try:
224
+ clean_plan = (req.plan or "starter_cloud").strip().lower()
225
+ stripe_links = _get_stripe_payment_links()
226
+ stripe_checkout_url = stripe_links.get(clean_plan)
227
+
228
+ new_key = upgrade_plan(tenant_id=req.tenant_id, plan=clean_plan)
229
+ payload = _build_account_payload(tenant_id=new_key.tenant_id)
230
+ payload["stripe_checkout_url"] = stripe_checkout_url
231
+ payload["redirect_url"] = stripe_checkout_url
232
+ payload["payment_mode"] = "stripe" if stripe_checkout_url else "simulation"
233
+ payload["message"] = "Tebrikler! Artık buluttasın"
234
+ return payload
235
+ except ValueError as exc:
236
+ raise HTTPException(status_code=400, detail=str(exc))
237
+
238
+
239
+ @app.get("/", response_class=HTMLResponse)
240
+ async def serve_dashboard() -> HTMLResponse:
241
+ """OwnAPI Enterprise SaaS Web Dashboard & Live Playground arayüzünü sunar."""
242
+ index_file = STATIC_DIR / "index.html"
243
+ if not index_file.exists():
244
+ return HTMLResponse("<h1>OwnAPI Dashboard bulunamadı (static/index.html)</h1>", status_code=404)
245
+ return HTMLResponse(index_file.read_text(encoding="utf-8"))
246
+
247
+
248
+ @app.get("/api/v1/services")
249
+ async def list_services(tenant_id: str = Query(default="default")) -> dict[str, Any]:
250
+ """
251
+ Kayıtlı tüm `registry/` servislerini, şemalarını, `validation_status` (3/3 Auto-Test durumu),
252
+ parametrelerini, TokenStore oturum ve ToS onay durumlarını döndürür.
253
+ """
254
+ client = OwnAPIClient(tenant_id=tenant_id)
255
+ token_store = TokenStore()
256
+ consent_mgr = ConsentManager()
257
+ hitl_mgr = HITLQueueManager()
258
+
259
+ base_services = client.list_services()
260
+ enriched: list[dict[str, Any]] = []
261
+ validated_count = 0
262
+ active_sessions = 0
263
+
264
+ for svc in base_services:
265
+ s_name = svc["service_name"]
266
+ schema_path = PROJECT_ROOT / "registry" / s_name / "schema.json"
267
+ val_status = "BAŞARILI"
268
+ val_runs = 3
269
+ last_val_at = None
270
+ target_url = ""
271
+
272
+ if schema_path.exists():
273
+ try:
274
+ raw_schema = json.loads(schema_path.read_text(encoding="utf-8"))
275
+ val_status = raw_schema.get("validation_status", "BAŞARILI")
276
+ val_runs = raw_schema.get("validation_passed_runs", 3)
277
+ last_val_at = raw_schema.get("last_validated_at") or raw_schema.get("created_at")
278
+ target_url = raw_schema.get("target_url", "")
279
+ except Exception:
280
+ pass
281
+
282
+ if val_status == "BAŞARILI":
283
+ validated_count += 1
284
+
285
+ sess = token_store.get_session(tenant_id, s_name)
286
+ has_sess = bool(sess and (sess.get("cookie") or sess.get("playwright_cookies")))
287
+ if has_sess:
288
+ active_sessions += 1
289
+
290
+ has_consent = consent_mgr.has_consented(tenant_id, s_name)
291
+
292
+ enriched.append({
293
+ **svc,
294
+ "target_url": target_url,
295
+ "validation_status": val_status,
296
+ "validation_passed_runs": val_runs,
297
+ "last_validated_at": last_val_at,
298
+ "has_active_session": has_sess,
299
+ "session_updated_at": sess.get("updated_at") or sess.get("healed_at") if sess else None,
300
+ "has_tos_consent": has_consent,
301
+ })
302
+
303
+ pending_tasks = hitl_mgr.list_tasks(tenant_id=None, status_filter="PENDING_USER_ACTION")
304
+
305
+ auto_repair_file = PROJECT_ROOT / ".sessions" / "auto_repair_report.json"
306
+ latest_repair = None
307
+ if auto_repair_file.exists():
308
+ try:
309
+ rep_data = json.loads(auto_repair_file.read_text(encoding="utf-8"))
310
+ latest_repair = rep_data.get("latest_summary")
311
+ except Exception:
312
+ pass
313
+
314
+ return {
315
+ "success": True,
316
+ "tenant_id": tenant_id,
317
+ "total_services": len(enriched),
318
+ "validated_count": validated_count,
319
+ "validation_rate": round((validated_count / len(enriched)) * 100, 1) if enriched else 100.0,
320
+ "active_sessions_count": active_sessions,
321
+ "pending_hitl_count": len(pending_tasks),
322
+ "latest_auto_repair": latest_repair,
323
+ "services": enriched,
324
+ }
325
+
326
+
327
+ @app.post("/api/v1/onboard")
328
+ async def onboard_site_endpoint(req: OnboardRequest) -> Any:
329
+ """
330
+ Web Dashboard üzerinden yeni bir siteyi sisteme ekler (`onboard_new_site`):
331
+ 1. Paket bazlı maksimum site limitini (`max_sites` / `active_sites`) kontrol eder.
332
+ 2. Plaid tarzı yasal onay (User Delegation Consent) kontrolünü yapar ve SHA-256 imzasıyla loglar.
333
+ 3. Tarayıcı oturumunu açıp XHR/Fetch trafiğini yakalar (`wait_seconds` veya tarayıcı içi 'Kaydı Bitir' butonuyla).
334
+ 4. GPT-4o ile `client.py`, `schema.json` ve `__init__.py` dosyalarını derler.
335
+ 5. 3x Auto-Test (Compiler + Validator Loop) doğrulamasını çalıştırıp sonucu döndürür.
336
+ """
337
+ if not req.consent_accepted:
338
+ raise HTTPException(
339
+ status_code=400,
340
+ detail="Plaid tarzı Yasal Onay (User Delegation Disclaimer) kutucuğu onaylanmalıdır.",
341
+ )
342
+
343
+ svc_name = req.service_name.strip()
344
+ target_url = req.target_url.strip()
345
+ if not svc_name or not target_url:
346
+ raise HTTPException(status_code=400, detail="Site adı (service_name) ve Hedef URL (target_url) zorunludur.")
347
+
348
+ if not target_url.startswith(("http://", "https://")):
349
+ target_url = f"https://{target_url}"
350
+
351
+ try:
352
+ register_site_usage(req.tenant_id, svc_name)
353
+ except SiteLimitExceededError as exc:
354
+ acc = _build_account_payload(tenant_id=req.tenant_id)
355
+ return JSONResponse(
356
+ status_code=403,
357
+ content={
358
+ "success": False,
359
+ "service_name": svc_name,
360
+ "target_url": target_url,
361
+ "tenant_id": acc["tenant_id"],
362
+ "plan": acc["plan"],
363
+ "max_sites": acc["max_sites"],
364
+ "active_sites": acc["active_sites"],
365
+ "active_sites_count": acc["active_sites_count"],
366
+ "error": str(exc),
367
+ "detail": str(exc),
368
+ "warning": str(exc),
369
+ "message": str(exc),
370
+ },
371
+ )
372
+
373
+ start_t = time.perf_counter()
374
+ try:
375
+ result = await asyncio.to_thread(
376
+ onboard_new_site,
377
+ service_name=svc_name,
378
+ start_url=target_url,
379
+ description=req.description.strip(),
380
+ headless=(req.mode.strip().lower() == "cloud"),
381
+ tenant_id=req.tenant_id,
382
+ mode=req.mode,
383
+ auto_validate=True,
384
+ validation_runs=req.validation_runs,
385
+ interactive_tos=False,
386
+ wait_seconds=req.wait_seconds,
387
+ )
388
+ elapsed_ms = round((time.perf_counter() - start_t) * 1000.0, 2)
389
+ acc = _build_account_payload(tenant_id=req.tenant_id)
390
+ return {
391
+ **result,
392
+ "max_sites": acc["max_sites"],
393
+ "active_sites": acc["active_sites"],
394
+ "active_sites_count": acc["active_sites_count"],
395
+ "elapsed_ms": elapsed_ms,
396
+ "timestamp": datetime.now(timezone.utc).isoformat(),
397
+ }
398
+ except Exception as exc:
399
+ elapsed_ms = round((time.perf_counter() - start_t) * 1000.0, 2)
400
+ return {
401
+ "success": False,
402
+ "service_name": svc_name,
403
+ "target_url": target_url,
404
+ "elapsed_ms": elapsed_ms,
405
+ "error": str(exc),
406
+ }
407
+
408
+
409
+ def _detect_service_from_prompt(prompt: str, explicit_service: str | None = None) -> str | None:
410
+ """Doğal dil sorusu (`prompt`) içinden kullanılan site/konektör adını tespit eder."""
411
+ if explicit_service and explicit_service.strip():
412
+ return explicit_service.strip().lower()
413
+ lower_p = (prompt or "").lower()
414
+ reg_dir = PROJECT_ROOT / "registry"
415
+ candidates: list[str] = []
416
+ if reg_dir.exists():
417
+ for child in sorted(reg_dir.iterdir()):
418
+ if child.is_dir() and not child.name.startswith("_"):
419
+ candidates.append(child.name.lower())
420
+ for extra in ("thy", "trendyol", "pegasus", "enuygun", "wikipedia", "linkedin_jobs", "biletix", "obilet", "hepsiburada", "amazon"):
421
+ if extra not in candidates:
422
+ candidates.append(extra)
423
+ if "türk hava yolları" in lower_p or "turk hava yollari" in lower_p or "turkish airlines" in lower_p:
424
+ return "thy"
425
+ if "linkedin" in lower_p and "linkedin_jobs" in candidates:
426
+ return "linkedin_jobs"
427
+ for svc in candidates:
428
+ if svc in lower_p:
429
+ return svc
430
+ return None
431
+
432
+
433
+ def _consume_and_resolve_account(
434
+ tenant_id: str = "default",
435
+ api_key: str | None = None,
436
+ requested_mode: str | None = None,
437
+ service_name: str | None = None,
438
+ ) -> dict[str, Any]:
439
+ """
440
+ `X-API-Key` / `api_key` veya `tenant_id` üzerinden kullanıcıyı bulur,
441
+ varsa `service_name` için paket bazlı site limitini (`register_site_usage`) kontrol eder,
442
+ `core/api_keys.py` (`consume_credit`) ile 1 kredi düşer ve aktif modu ('local' / 'cloud')
443
+ ile güncel kredi/paket/site bilgilerini döndürür.
444
+ """
445
+ auth_id = (api_key or tenant_id or "default").strip() or "default"
446
+ info_before = get_tenant_info(auth_id)
447
+ resolved_tenant = str(info_before.get("tenant_id") or tenant_id or "default")
448
+ if service_name:
449
+ register_site_usage(auth_id, service_name)
450
+ consume_credit(auth_id, amount=1, service=service_name)
451
+ acc = _build_account_payload(tenant_id=resolved_tenant)
452
+
453
+ plan_mode = str(acc.get("mode", "local")).strip().lower()
454
+ req_mode_clean = str(requested_mode or "").strip().lower()
455
+ if plan_mode == "cloud" or req_mode_clean == "cloud":
456
+ active_mode = "cloud"
457
+ elif req_mode_clean in ("local", "cloud"):
458
+ active_mode = req_mode_clean
459
+ else:
460
+ active_mode = plan_mode if plan_mode in ("local", "cloud") else "local"
461
+
462
+ return {
463
+ "tenant_id": resolved_tenant,
464
+ "api_key": acc.get("api_key"),
465
+ "plan": acc.get("plan", "free"),
466
+ "plan_label": acc.get("plan_label"),
467
+ "mode": active_mode,
468
+ "remaining_credits": int(acc.get("remaining_credits", 0)),
469
+ "used_credits": int(acc.get("used_credits", 0)),
470
+ "credits": int(acc.get("credits", 0)),
471
+ "max_credits": int(acc.get("max_credits", 1000)),
472
+ "max_sites": int(acc.get("max_sites", 2)),
473
+ "active_sites": list(acc.get("active_sites", [])),
474
+ "active_sites_count": int(acc.get("active_sites_count", 0)),
475
+ }
476
+
477
+
478
+ @app.post("/api/v1/call")
479
+ async def call_service(
480
+ req: CallRequest,
481
+ x_api_key: str | None = Header(default=None, alias="X-API-Key"),
482
+ ) -> Any:
483
+ """
484
+ İlgili servisi User-Delegated Browser Middleware üzerinden çalıştırıp JSON yanıtını,
485
+ süresini (ms) ve güncel kredi/paket/mod/site limiti durumunu döndürür.
486
+ """
487
+ try:
488
+ credit_ctx = _consume_and_resolve_account(
489
+ tenant_id=req.tenant_id,
490
+ api_key=x_api_key or req.api_key,
491
+ requested_mode=req.mode,
492
+ service_name=req.service,
493
+ )
494
+ except SiteLimitExceededError as exc:
495
+ auth_id = (x_api_key or req.api_key or req.tenant_id or "default").strip() or "default"
496
+ acc = _build_account_payload(tenant_id=str(get_tenant_info(auth_id).get("tenant_id", req.tenant_id)))
497
+ return JSONResponse(
498
+ status_code=403,
499
+ content={
500
+ "success": False,
501
+ "service": req.service,
502
+ "tenant_id": acc["tenant_id"],
503
+ "plan": acc["plan"],
504
+ "mode": acc["mode"],
505
+ "remaining_credits": acc["remaining_credits"],
506
+ "used_credits": acc["used_credits"],
507
+ "max_sites": acc["max_sites"],
508
+ "active_sites": acc["active_sites"],
509
+ "active_sites_count": acc["active_sites_count"],
510
+ "error": str(exc),
511
+ "detail": str(exc),
512
+ "warning": str(exc),
513
+ "message": str(exc),
514
+ },
515
+ )
516
+ except ValueError as exc:
517
+ raise HTTPException(status_code=402, detail=str(exc))
518
+
519
+ active_tenant = credit_ctx["tenant_id"]
520
+ active_mode = credit_ctx["mode"]
521
+
522
+ client = OwnAPIClient(
523
+ api_key=x_api_key or req.api_key,
524
+ tenant_id=active_tenant,
525
+ mode=active_mode,
526
+ non_interactive=req.non_interactive,
527
+ )
528
+ start_t = time.perf_counter()
529
+ try:
530
+ result = await asyncio.to_thread(
531
+ client.call,
532
+ req.service,
533
+ mode=active_mode,
534
+ non_interactive=req.non_interactive,
535
+ _consume_credit=False,
536
+ **req.params,
537
+ )
538
+ elapsed_ms = round((time.perf_counter() - start_t) * 1000.0, 2)
539
+ return {
540
+ "success": True,
541
+ "service": req.service,
542
+ "tenant_id": active_tenant,
543
+ "plan": credit_ctx["plan"],
544
+ "mode": active_mode,
545
+ "remaining_credits": credit_ctx["remaining_credits"],
546
+ "used_credits": credit_ctx["used_credits"],
547
+ "credits": credit_ctx["credits"],
548
+ "max_credits": credit_ctx["max_credits"],
549
+ "max_sites": credit_ctx["max_sites"],
550
+ "active_sites": credit_ctx["active_sites"],
551
+ "active_sites_count": credit_ctx["active_sites_count"],
552
+ "non_interactive": req.non_interactive,
553
+ "elapsed_ms": elapsed_ms,
554
+ "timestamp": datetime.now(timezone.utc).isoformat(),
555
+ "data": result,
556
+ }
557
+ except ValueError as exc:
558
+ raise HTTPException(status_code=404, detail=str(exc))
559
+ except Exception as exc:
560
+ elapsed_ms = round((time.perf_counter() - start_t) * 1000.0, 2)
561
+ return {
562
+ "success": False,
563
+ "service": req.service,
564
+ "tenant_id": active_tenant,
565
+ "plan": credit_ctx["plan"],
566
+ "mode": active_mode,
567
+ "remaining_credits": credit_ctx["remaining_credits"],
568
+ "used_credits": credit_ctx["used_credits"],
569
+ "credits": credit_ctx["credits"],
570
+ "max_credits": credit_ctx["max_credits"],
571
+ "max_sites": credit_ctx["max_sites"],
572
+ "active_sites": credit_ctx["active_sites"],
573
+ "active_sites_count": credit_ctx["active_sites_count"],
574
+ "elapsed_ms": elapsed_ms,
575
+ "error": str(exc),
576
+ }
577
+
578
+
579
+ @app.post("/api/v1/ask")
580
+ async def ask_agent(
581
+ req: AskRequest,
582
+ x_api_key: str | None = Header(default=None, alias="X-API-Key"),
583
+ ) -> Any:
584
+ """
585
+ Doğal dil ajanını (`OwnAPIClient.ask`) çalıştırıp yanıtı ve güncel kredi/paket/mod/site limiti durumunu döndürür.
586
+ """
587
+ detected_svc = _detect_service_from_prompt(req.prompt, req.service)
588
+ try:
589
+ credit_ctx = _consume_and_resolve_account(
590
+ tenant_id=req.tenant_id,
591
+ api_key=x_api_key or req.api_key,
592
+ requested_mode=req.mode,
593
+ service_name=detected_svc,
594
+ )
595
+ except SiteLimitExceededError as exc:
596
+ auth_id = (x_api_key or req.api_key or req.tenant_id or "default").strip() or "default"
597
+ acc = _build_account_payload(tenant_id=str(get_tenant_info(auth_id).get("tenant_id", req.tenant_id)))
598
+ return JSONResponse(
599
+ status_code=403,
600
+ content={
601
+ "success": False,
602
+ "prompt": req.prompt,
603
+ "service": detected_svc,
604
+ "tenant_id": acc["tenant_id"],
605
+ "plan": acc["plan"],
606
+ "mode": acc["mode"],
607
+ "remaining_credits": acc["remaining_credits"],
608
+ "used_credits": acc["used_credits"],
609
+ "max_sites": acc["max_sites"],
610
+ "active_sites": acc["active_sites"],
611
+ "active_sites_count": acc["active_sites_count"],
612
+ "error": str(exc),
613
+ "detail": str(exc),
614
+ "warning": str(exc),
615
+ "message": str(exc),
616
+ },
617
+ )
618
+ except ValueError as exc:
619
+ raise HTTPException(status_code=402, detail=str(exc))
620
+
621
+ active_tenant = credit_ctx["tenant_id"]
622
+ active_mode = credit_ctx["mode"]
623
+
624
+ client = OwnAPIClient(
625
+ api_key=x_api_key or req.api_key,
626
+ tenant_id=active_tenant,
627
+ mode=active_mode,
628
+ )
629
+ start_t = time.perf_counter()
630
+ try:
631
+ answer = await asyncio.to_thread(client.ask, req.prompt, _consume_credit=False)
632
+ elapsed_ms = round((time.perf_counter() - start_t) * 1000.0, 2)
633
+ return {
634
+ "success": True,
635
+ "prompt": req.prompt,
636
+ "service": detected_svc,
637
+ "tenant_id": active_tenant,
638
+ "plan": credit_ctx["plan"],
639
+ "mode": active_mode,
640
+ "remaining_credits": credit_ctx["remaining_credits"],
641
+ "used_credits": credit_ctx["used_credits"],
642
+ "credits": credit_ctx["credits"],
643
+ "max_credits": credit_ctx["max_credits"],
644
+ "max_sites": credit_ctx["max_sites"],
645
+ "active_sites": credit_ctx["active_sites"],
646
+ "active_sites_count": credit_ctx["active_sites_count"],
647
+ "elapsed_ms": elapsed_ms,
648
+ "answer": answer,
649
+ }
650
+ except Exception as exc:
651
+ elapsed_ms = round((time.perf_counter() - start_t) * 1000.0, 2)
652
+ return {
653
+ "success": False,
654
+ "prompt": req.prompt,
655
+ "service": detected_svc,
656
+ "tenant_id": active_tenant,
657
+ "plan": credit_ctx["plan"],
658
+ "mode": active_mode,
659
+ "remaining_credits": credit_ctx["remaining_credits"],
660
+ "used_credits": credit_ctx["used_credits"],
661
+ "credits": credit_ctx["credits"],
662
+ "max_credits": credit_ctx["max_credits"],
663
+ "max_sites": credit_ctx["max_sites"],
664
+ "active_sites": credit_ctx["active_sites"],
665
+ "active_sites_count": credit_ctx["active_sites_count"],
666
+ "elapsed_ms": elapsed_ms,
667
+ "error": str(exc),
668
+ }
669
+
670
+
671
+ @app.post("/api/v1/validate/{service_name}")
672
+ async def validate_single_service(
673
+ service_name: str,
674
+ req: ValidateRequest | None = None,
675
+ ) -> dict[str, Any]:
676
+ """Tek bir servis için 3x Auto-Test (Compiler + Validator Loop) doğrulamasını çalıştırır."""
677
+ cfg = req or ValidateRequest()
678
+ service_dir = PROJECT_ROOT / "registry" / service_name
679
+ client_path = service_dir / "client.py"
680
+ schema_path = service_dir / "schema.json"
681
+ traffic_path = service_dir / "captured_traffic.json"
682
+
683
+ if not client_path.exists() or not schema_path.exists():
684
+ raise HTTPException(status_code=404, detail=f"Servis '{service_name}' bulunamadı.")
685
+
686
+ raw_schema = json.loads(schema_path.read_text(encoding="utf-8"))
687
+ schema_obj = RegistrySchema.model_validate(raw_schema)
688
+ endpoints: list[dict[str, Any]] = []
689
+ if traffic_path.exists():
690
+ try:
691
+ tdata = json.loads(traffic_path.read_text(encoding="utf-8"))
692
+ endpoints = tdata.get("endpoints", [])
693
+ except Exception:
694
+ endpoints = []
695
+
696
+ matched_ep = next(
697
+ (e for e in endpoints if e.get("url") == schema_obj.endpoint_url),
698
+ endpoints[0] if endpoints else {},
699
+ )
700
+
701
+ report = await asyncio.to_thread(
702
+ validate_and_repair_client,
703
+ service_name=service_name,
704
+ target_url=schema_obj.target_url,
705
+ description=schema_obj.goal,
706
+ client_path=client_path,
707
+ schema_obj=schema_obj,
708
+ matched_endpoint=matched_ep,
709
+ all_endpoints=endpoints,
710
+ validation_runs=cfg.runs,
711
+ max_repair_attempts=3,
712
+ tenant_id=cfg.tenant_id,
713
+ mode=cfg.mode,
714
+ )
715
+
716
+ raw_schema["validation_status"] = report.get("validation_status", "BAŞARILI")
717
+ raw_schema["validation_passed_runs"] = report.get("passed_runs", cfg.runs)
718
+ raw_schema["last_validated_at"] = datetime.now(timezone.utc).isoformat()
719
+ schema_path.write_text(json.dumps(raw_schema, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
720
+
721
+ return {
722
+ "success": report.get("validation_status") == "BAŞARILI",
723
+ "service_name": service_name,
724
+ **report,
725
+ }
726
+
727
+
728
+ @app.post("/api/v1/auto-repair")
729
+ async def run_auto_repair(req: AutoRepairRequest | None = None) -> dict[str, Any]:
730
+ """
731
+ `AutoRepairEngine.validate_all_services(runs=1, auto_heal=True)` tetikleyip
732
+ toplu sağlık ve onarım raporunu (`.sessions/auto_repair_report.json`) döndürür.
733
+ """
734
+ cfg = req or AutoRepairRequest()
735
+ engine = AutoRepairEngine(tenant_id=cfg.tenant_id, mode=cfg.mode)
736
+ report = await asyncio.to_thread(
737
+ engine.validate_all_services,
738
+ runs=cfg.runs,
739
+ auto_heal=cfg.auto_heal,
740
+ services=cfg.services,
741
+ )
742
+ return {
743
+ "success": True,
744
+ **report,
745
+ }
746
+
747
+
748
+ @app.get("/api/v1/auto-repair/report")
749
+ async def get_auto_repair_report() -> dict[str, Any]:
750
+ """Kaydedilmiş son `.sessions/auto_repair_report.json` geçmişini döndürür."""
751
+ report_file = PROJECT_ROOT / ".sessions" / "auto_repair_report.json"
752
+ if not report_file.exists():
753
+ return {"success": True, "total_runs": 0, "latest_summary": None, "history": []}
754
+ try:
755
+ data = json.loads(report_file.read_text(encoding="utf-8"))
756
+ return {"success": True, **data}
757
+ except Exception as exc:
758
+ return {"success": False, "error": str(exc), "history": []}
759
+
760
+
761
+ @app.get("/api/hitl/pending")
762
+ @app.get("/api/v1/hitl/pending")
763
+ async def get_hitl_pending(
764
+ tenant_id: str | None = Query(default=None, description="Kiracı filtresi (boş ise tüm kiracılar)"),
765
+ ) -> dict[str, Any]:
766
+ """
767
+ Proje kök dizinindeki `.sessions/hitl_state.json` dosyasından bekleyen (`PAUSED` / `PENDING_USER_ACTION`)
768
+ HITL onay isteklerini diskten okuyarak döndürür. Sayfa yenilendiğinde (F5) veya 2 saniyelik
769
+ polling sırasında bekleyen onayların kaybolmadan anında ekrana gelmesini sağlar.
770
+ """
771
+ mgr = HITLQueueManager()
772
+ all_tasks = mgr.list_tasks(tenant_id=tenant_id, status_filter=None)
773
+ pending_tasks = [t for t in all_tasks if mgr.is_pending_status(t.get("status"))]
774
+ completed_tasks = [t for t in all_tasks if str(t.get("status", "")).upper() == "COMPLETED"]
775
+ return {
776
+ "success": True,
777
+ "state_file": ".sessions/hitl_state.json",
778
+ "total_tasks": len(all_tasks),
779
+ "pending_count": len(pending_tasks),
780
+ "paused_count": len(pending_tasks),
781
+ "completed_count": len(completed_tasks),
782
+ "pending_tasks": list(reversed(pending_tasks)),
783
+ "tasks": list(reversed(all_tasks)),
784
+ }
785
+
786
+
787
+ @app.get("/api/hitl/queue")
788
+ @app.get("/api/v1/hitl/queue")
789
+ async def get_hitl_queue(
790
+ tenant_id: str | None = Query(default=None, description="Kiracı filtresi (boş ise tüm kiracılar)"),
791
+ status: str | None = Query(default=None, description="PAUSED, PENDING_USER_ACTION veya COMPLETED"),
792
+ ) -> dict[str, Any]:
793
+ """`HITLQueueManager` (`.sessions/hitl_state.json`) üzerinden bekleyen (`PAUSED`) ve tamamlanan görevleri listeler."""
794
+ mgr = HITLQueueManager()
795
+ tasks = mgr.list_tasks(tenant_id=tenant_id, status_filter=status)
796
+ pending_count = sum(1 for t in tasks if mgr.is_pending_status(t.get("status")))
797
+ completed_count = sum(1 for t in tasks if str(t.get("status", "")).upper() == "COMPLETED")
798
+ return {
799
+ "success": True,
800
+ "state_file": ".sessions/hitl_state.json",
801
+ "total_tasks": len(tasks),
802
+ "pending_count": pending_count,
803
+ "paused_count": pending_count,
804
+ "completed_count": completed_count,
805
+ "tasks": list(reversed(tasks)),
806
+ }
807
+
808
+
809
+ @app.get("/api/hitl/preview/{task_id}")
810
+ @app.get("/api/v1/hitl/preview/{task_id}")
811
+ async def get_hitl_preview(
812
+ task_id: str,
813
+ tenant_id: str | None = Query(default=None, description="İsteğe bağlı kiracı filtresi"),
814
+ ) -> dict[str, Any]:
815
+ """
816
+ Aktif veya duraklatılmış (`PAUSED`) HITL görevi için bulut tarayıcı ekran görüntüsünü (`screenshot_b64`)
817
+ ve canlı durum önizlemesini döndürür.
818
+ """
819
+ mgr = HITLQueueManager()
820
+ preview = mgr.get_task_preview(task_id=task_id, tenant_id=tenant_id)
821
+ if not preview:
822
+ raise HTTPException(status_code=404, detail=f"HITL görevi '{task_id}' bulunamadı.")
823
+ return preview
824
+
825
+
826
+ @app.post("/api/hitl/resolve/{task_id}")
827
+ @app.post("/api/v1/hitl/resolve/{task_id}")
828
+ @app.post("/api/hitl/approve/{task_id}")
829
+ @app.post("/api/hitl/resume/{task_id}")
830
+ @app.post("/api/v1/hitl/approve/{task_id}")
831
+ @app.post("/api/v1/hitl/resume/{task_id}")
832
+ async def approve_hitl_task(
833
+ task_id: str,
834
+ req: HITLResolveRequest | None = Body(default=None),
835
+ tenant_id: str | None = Query(default=None),
836
+ otp_code: str | None = Query(default=None, description="Opsiyonel SMS / E-Posta / 2FA doğrulama kodu"),
837
+ ) -> dict[str, Any]:
838
+ """
839
+ `.sessions/hitl_state.json` içindeki bekleyen (`PAUSED`) HITL görevini onaylar,
840
+ varsa `otp_code` (SMS/E-Posta doğrulama kodu) parametresini açık tarayıcıya iletir
841
+ ve duraklatılmış ajanın kaldığı yerden (`resume_task`) devam etmesini sağlar.
842
+ """
843
+ eff_tenant = (req.tenant_id if req and req.tenant_id else tenant_id) or None
844
+ eff_otp = (req.otp_code if req and req.otp_code is not None else otp_code) or None
845
+ if eff_otp is not None:
846
+ eff_otp = str(eff_otp).strip() or None
847
+
848
+ mgr = HITLQueueManager()
849
+ task, _ = mgr.get_task(task_id, tenant_id=eff_tenant)
850
+ if not task:
851
+ raise HTTPException(status_code=404, detail=f"HITL görevi '{task_id}' bulunamadı.")
852
+ try:
853
+ res = await asyncio.to_thread(
854
+ mgr.resume_task,
855
+ task_id=task_id,
856
+ tenant_id=eff_tenant,
857
+ otp_code=eff_otp,
858
+ )
859
+ return {"success": True, **res}
860
+ except Exception as exc:
861
+ raise HTTPException(status_code=500, detail=str(exc))
862
+
863
+
864
+ @app.get("/api/v1/consent")
865
+ async def get_consent_audit(tenant_id: str = Query(default="default")) -> dict[str, Any]:
866
+ """`ConsentManager` üzerinden SHA-256 imzalı yasal kullanıcı delegasyon kayıtlarını döndürür."""
867
+ mgr = ConsentManager()
868
+ records = mgr.get_consents(tenant_id=tenant_id)
869
+ return {
870
+ "success": True,
871
+ "tenant_id": tenant_id,
872
+ "tos_version": TOS_VERSION,
873
+ "disclaimer": USER_DELEGATION_DISCLAIMER,
874
+ "total_records": len(records),
875
+ "consents": list(reversed(records)),
876
+ }
877
+
878
+
879
+ if STATIC_DIR.exists():
880
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
881
+
882
+
883
+ def run_server(host: str = "127.0.0.1", port: int = 8000, reload: bool = False) -> None:
884
+ """FastAPI sunucusunu Uvicorn ile başlatır."""
885
+ import uvicorn
886
+
887
+ uvicorn.run("server:app" if reload else app, host=host, port=port, reload=reload)
888
+
889
+
890
+ if __name__ == "__main__":
891
+ run_server()