@danhachuel/thunderbolt 0.3.51 → 0.3.53

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.
@@ -0,0 +1,569 @@
1
+ """Domínio, storage e adapters de dados para AI Influencers.
2
+
3
+ O módulo mantém a UI independente do backend. SQLite é totalmente local; Supabase
4
+ usa a Data API/Storage quando configurado. Nenhuma credencial é incluída em
5
+ registos, respostas de diagnóstico ou artefactos persistidos.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import mimetypes
13
+ import re
14
+ import sqlite3
15
+ import uuid
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any, Iterable, Mapping
19
+
20
+ from .storage import ROOT, STORAGE
21
+
22
+ BACKEND_OPTIONS = ("Supabase", "SQLite")
23
+ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
24
+ DOCUMENT_EXTENSIONS = {".md", ".json"}
25
+ ASSET_EXTENSIONS = IMAGE_EXTENSIONS | DOCUMENT_EXTENSIONS
26
+ MAX_ASSET_BYTES = 25 * 1024 * 1024
27
+
28
+ SQLITE_SCHEMA = """
29
+ PRAGMA foreign_keys = ON;
30
+ CREATE TABLE IF NOT EXISTS influencers (
31
+ id TEXT PRIMARY KEY,
32
+ name TEXT NOT NULL,
33
+ bio TEXT NOT NULL DEFAULT '',
34
+ instagram_business_id TEXT NOT NULL DEFAULT '',
35
+ language TEXT NOT NULL DEFAULT '',
36
+ profile_json TEXT NOT NULL DEFAULT '{}',
37
+ created_at TEXT NOT NULL,
38
+ updated_at TEXT NOT NULL
39
+ );
40
+ CREATE TABLE IF NOT EXISTS influencer_assets (
41
+ id TEXT PRIMARY KEY,
42
+ influencer_id TEXT NOT NULL REFERENCES influencers(id) ON DELETE CASCADE,
43
+ asset_type TEXT NOT NULL CHECK(asset_type IN ('image', 'document')),
44
+ original_name TEXT NOT NULL,
45
+ stored_path TEXT NOT NULL,
46
+ public_url TEXT NOT NULL DEFAULT '',
47
+ mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
48
+ size_bytes INTEGER NOT NULL DEFAULT 0,
49
+ sha256 TEXT NOT NULL,
50
+ document_json TEXT NOT NULL DEFAULT '',
51
+ created_at TEXT NOT NULL,
52
+ UNIQUE(influencer_id, sha256)
53
+ );
54
+ CREATE INDEX IF NOT EXISTS idx_influencer_assets_influencer ON influencer_assets(influencer_id, created_at);
55
+ CREATE TABLE IF NOT EXISTS influencer_weekly_plans (
56
+ id TEXT PRIMARY KEY,
57
+ influencer_id TEXT NOT NULL REFERENCES influencers(id) ON DELETE CASCADE,
58
+ week TEXT NOT NULL,
59
+ plan TEXT NOT NULL DEFAULT '',
60
+ created_at TEXT NOT NULL,
61
+ updated_at TEXT NOT NULL,
62
+ UNIQUE(influencer_id, week)
63
+ );
64
+ CREATE TABLE IF NOT EXISTS influencer_content (
65
+ id TEXT PRIMARY KEY,
66
+ influencer_id TEXT NOT NULL REFERENCES influencers(id) ON DELETE CASCADE,
67
+ content_type TEXT NOT NULL CHECK(content_type IN ('image', 'video')),
68
+ prompt TEXT NOT NULL DEFAULT '',
69
+ caption TEXT NOT NULL DEFAULT '',
70
+ provider TEXT NOT NULL DEFAULT '',
71
+ model TEXT NOT NULL DEFAULT '',
72
+ platform TEXT NOT NULL DEFAULT '',
73
+ state TEXT NOT NULL DEFAULT 'queued',
74
+ artifact_path TEXT NOT NULL DEFAULT '',
75
+ provider_request_id TEXT NOT NULL DEFAULT '',
76
+ error TEXT NOT NULL DEFAULT '',
77
+ metadata_json TEXT NOT NULL DEFAULT '{}',
78
+ created_at TEXT NOT NULL,
79
+ updated_at TEXT NOT NULL
80
+ );
81
+ CREATE INDEX IF NOT EXISTS idx_influencer_content_influencer ON influencer_content(influencer_id, created_at);
82
+ CREATE INDEX IF NOT EXISTS idx_influencer_content_state ON influencer_content(state, updated_at);
83
+ """
84
+
85
+
86
+ def _now() -> str:
87
+ return datetime.now(timezone.utc).isoformat()
88
+
89
+
90
+ def _safe_filename(value: Any, fallback: str = "asset") -> str:
91
+ name = Path(str(value or "")).name.strip()
92
+ name = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip(".-")
93
+ return name or fallback
94
+
95
+
96
+ def _safe_id(value: Any) -> str:
97
+ clean = re.sub(r"[^A-Za-z0-9_-]+", "-", str(value or "")).strip("-")
98
+ return clean or uuid.uuid4().hex
99
+
100
+
101
+ def _json_text(value: Any, default: str = "{}") -> str:
102
+ if isinstance(value, str):
103
+ return value
104
+ try:
105
+ return json.dumps(value if value is not None else {}, ensure_ascii=False)
106
+ except (TypeError, ValueError):
107
+ return default
108
+
109
+
110
+ def _json_value(value: Any) -> Any:
111
+ if not isinstance(value, str):
112
+ return value if value is not None else {}
113
+ try:
114
+ return json.loads(value) if value.strip() else {}
115
+ except json.JSONDecodeError:
116
+ return {}
117
+
118
+
119
+ def parse_document(name: str, content: bytes) -> dict[str, Any]:
120
+ """Validate and parse a supported Markdown/JSON asset for preview/metadata."""
121
+ suffix = Path(name).suffix.lower()
122
+ if suffix not in DOCUMENT_EXTENSIONS:
123
+ raise ValueError("O documento deve ser Markdown (.md) ou JSON (.json).")
124
+ try:
125
+ text = content.decode("utf-8-sig")
126
+ except UnicodeDecodeError as exc:
127
+ raise ValueError("O documento deve estar codificado em UTF-8.") from exc
128
+ if not text.strip():
129
+ raise ValueError("O documento não pode ficar vazio.")
130
+ if suffix == ".json":
131
+ try:
132
+ parsed = json.loads(text)
133
+ except json.JSONDecodeError as exc:
134
+ raise ValueError(f"JSON inválido: {exc.msg}.") from exc
135
+ return {"format": "json", "value": parsed, "text": text}
136
+ return {"format": "markdown", "value": text, "text": text}
137
+
138
+
139
+ def validate_asset(name: str, content: bytes) -> dict[str, Any]:
140
+ if not content:
141
+ raise ValueError("O ficheiro enviado está vazio.")
142
+ if len(content) > MAX_ASSET_BYTES:
143
+ raise ValueError("Cada asset de personagem pode ter no máximo 25 MB.")
144
+ safe_name = _safe_filename(name)
145
+ suffix = Path(safe_name).suffix.lower()
146
+ if suffix not in ASSET_EXTENSIONS:
147
+ raise ValueError("Formato não suportado. Use imagens ou ficheiros .md/.json.")
148
+ asset_type = "image" if suffix in IMAGE_EXTENSIONS else "document"
149
+ document = parse_document(safe_name, content) if asset_type == "document" else None
150
+ mime = mimetypes.guess_type(safe_name)[0] or ("image/jpeg" if asset_type == "image" else "text/plain")
151
+ return {
152
+ "original_name": safe_name,
153
+ "asset_type": asset_type,
154
+ "mime_type": mime,
155
+ "size_bytes": len(content),
156
+ "sha256": hashlib.sha256(content).hexdigest(),
157
+ "document": document,
158
+ }
159
+
160
+
161
+ def _row_to_dict(row: Any) -> dict[str, Any]:
162
+ if isinstance(row, sqlite3.Row):
163
+ return dict(row)
164
+ if isinstance(row, Mapping):
165
+ return dict(row)
166
+ return {}
167
+
168
+
169
+ def _normalise_influencer(record: Mapping[str, Any]) -> dict[str, Any]:
170
+ now = _now()
171
+ return {
172
+ "id": str(record.get("id") or f"influencer_{uuid.uuid4().hex[:12]}"),
173
+ "name": str(record.get("name") or "").strip(),
174
+ "bio": str(record.get("bio") or "").strip(),
175
+ "instagram_business_id": str(record.get("instagram_business_id") or "").strip(),
176
+ "language": str(record.get("language") or "").strip(),
177
+ "profile_json": _json_text(record.get("profile_json") or record.get("profile") or {}),
178
+ "created_at": str(record.get("created_at") or now),
179
+ "updated_at": str(record.get("updated_at") or now),
180
+ }
181
+
182
+
183
+ def _normalise_content(record: Mapping[str, Any]) -> dict[str, Any]:
184
+ now = _now()
185
+ content_type = str(record.get("content_type") or "image").strip().lower()
186
+ if content_type not in {"image", "video"}:
187
+ raise ValueError("Tipo de conteúdo inválido.")
188
+ state = str(record.get("state") or "queued").strip().lower()
189
+ if state not in {"queued", "running", "completed", "failed", "cancelled", "blocked"}:
190
+ raise ValueError("Estado de conteúdo inválido.")
191
+ return {
192
+ "id": str(record.get("id") or f"content_{uuid.uuid4().hex[:12]}"),
193
+ "influencer_id": str(record.get("influencer_id") or "").strip(),
194
+ "content_type": content_type,
195
+ "prompt": str(record.get("prompt") or "").strip(),
196
+ "caption": str(record.get("caption") or "").strip(),
197
+ "provider": str(record.get("provider") or "").strip(),
198
+ "model": str(record.get("model") or "").strip(),
199
+ "platform": str(record.get("platform") or "").strip(),
200
+ "state": state,
201
+ "artifact_path": str(record.get("artifact_path") or "").strip(),
202
+ "provider_request_id": str(record.get("provider_request_id") or "").strip(),
203
+ "error": str(record.get("error") or "").strip(),
204
+ "metadata_json": _json_text(record.get("metadata_json") or record.get("metadata") or {}),
205
+ "created_at": str(record.get("created_at") or now),
206
+ "updated_at": str(record.get("updated_at") or now),
207
+ }
208
+
209
+
210
+ class InfluencerBackendError(RuntimeError):
211
+ """Raised when the selected database backend is unavailable or misconfigured."""
212
+
213
+
214
+ class SQLiteInfluencerRepository:
215
+ backend = "SQLite"
216
+
217
+ def __init__(self, path: str | Path | None = None):
218
+ candidate = Path(path or (STORAGE / "state" / "ai_influencers.db"))
219
+ if not candidate.is_absolute():
220
+ candidate = ROOT / candidate
221
+ self.path = candidate.resolve()
222
+ self.path.parent.mkdir(parents=True, exist_ok=True)
223
+
224
+ def ensure_schema(self) -> None:
225
+ with sqlite3.connect(self.path) as connection:
226
+ connection.executescript(SQLITE_SCHEMA)
227
+ connection.commit()
228
+
229
+ def test_connection(self) -> dict[str, Any]:
230
+ try:
231
+ self.ensure_schema()
232
+ with sqlite3.connect(self.path) as connection:
233
+ connection.execute("SELECT 1").fetchone()
234
+ return {"ok": True, "status": "success", "message": "SQLite disponível e schema verificado."}
235
+ except sqlite3.Error:
236
+ return {"ok": False, "status": "error", "message": "Não foi possível abrir o ficheiro SQLite."}
237
+
238
+ def _connect(self) -> sqlite3.Connection:
239
+ self.ensure_schema()
240
+ connection = sqlite3.connect(self.path)
241
+ connection.row_factory = sqlite3.Row
242
+ connection.execute("PRAGMA foreign_keys = ON")
243
+ return connection
244
+
245
+ def list_influencers(self) -> list[dict[str, Any]]:
246
+ with self._connect() as connection:
247
+ rows = connection.execute("SELECT * FROM influencers ORDER BY updated_at DESC, name ASC").fetchall()
248
+ return [_row_to_dict(row) for row in rows]
249
+
250
+ def get_influencer(self, influencer_id: str) -> dict[str, Any] | None:
251
+ with self._connect() as connection:
252
+ row = connection.execute("SELECT * FROM influencers WHERE id = ?", (str(influencer_id),)).fetchone()
253
+ return _row_to_dict(row) if row else None
254
+
255
+ def create_influencer(self, record: Mapping[str, Any]) -> dict[str, Any]:
256
+ item = _normalise_influencer(record)
257
+ if not item["name"]:
258
+ raise ValueError("Informe o nome do personagem.")
259
+ with self._connect() as connection:
260
+ connection.execute(
261
+ "INSERT INTO influencers (id, name, bio, instagram_business_id, language, profile_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
262
+ tuple(item[key] for key in ("id", "name", "bio", "instagram_business_id", "language", "profile_json", "created_at", "updated_at")),
263
+ )
264
+ connection.commit()
265
+ return item
266
+
267
+ def update_influencer(self, influencer_id: str, updates: Mapping[str, Any]) -> dict[str, Any] | None:
268
+ existing = self.get_influencer(influencer_id)
269
+ if not existing:
270
+ return None
271
+ item = _normalise_influencer({**existing, **dict(updates), "id": influencer_id, "created_at": existing.get("created_at")})
272
+ if not item["name"]:
273
+ raise ValueError("Informe o nome do personagem.")
274
+ with self._connect() as connection:
275
+ connection.execute(
276
+ "UPDATE influencers SET name=?, bio=?, instagram_business_id=?, language=?, profile_json=?, updated_at=? WHERE id=?",
277
+ (item["name"], item["bio"], item["instagram_business_id"], item["language"], item["profile_json"], item["updated_at"], str(influencer_id)),
278
+ )
279
+ connection.commit()
280
+ return item
281
+
282
+ def delete_influencer(self, influencer_id: str) -> bool:
283
+ with self._connect() as connection:
284
+ cursor = connection.execute("DELETE FROM influencers WHERE id = ?", (str(influencer_id),))
285
+ connection.commit()
286
+ return cursor.rowcount > 0
287
+
288
+ def list_assets(self, influencer_id: str) -> list[dict[str, Any]]:
289
+ with self._connect() as connection:
290
+ rows = connection.execute("SELECT * FROM influencer_assets WHERE influencer_id = ? ORDER BY created_at ASC", (str(influencer_id),)).fetchall()
291
+ return [_row_to_dict(row) for row in rows]
292
+
293
+ def save_asset(self, influencer_id: str, name: str, content: bytes) -> dict[str, Any]:
294
+ if not self.get_influencer(influencer_id):
295
+ raise ValueError("Personagem não encontrado.")
296
+ info = validate_asset(name, content)
297
+ directory = STORAGE / "influencers" / _safe_id(influencer_id)
298
+ directory.mkdir(parents=True, exist_ok=True)
299
+ target = directory / f"{info['sha256'][:16]}-{info['original_name']}"
300
+ if not target.exists():
301
+ target.write_bytes(content)
302
+ record = {
303
+ "id": f"asset_{uuid.uuid4().hex[:12]}",
304
+ "influencer_id": str(influencer_id),
305
+ "asset_type": info["asset_type"],
306
+ "original_name": info["original_name"],
307
+ "stored_path": str(target),
308
+ "public_url": "",
309
+ "mime_type": info["mime_type"],
310
+ "size_bytes": info["size_bytes"],
311
+ "sha256": info["sha256"],
312
+ "document_json": _json_text(info["document"]) if info["document"] else "",
313
+ "created_at": _now(),
314
+ }
315
+ with self._connect() as connection:
316
+ existing = connection.execute("SELECT * FROM influencer_assets WHERE influencer_id=? AND sha256=?", (record["influencer_id"], record["sha256"])).fetchone()
317
+ if existing:
318
+ return _row_to_dict(existing)
319
+ connection.execute(
320
+ "INSERT INTO influencer_assets (id, influencer_id, asset_type, original_name, stored_path, public_url, mime_type, size_bytes, sha256, document_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
321
+ tuple(record[key] for key in ("id", "influencer_id", "asset_type", "original_name", "stored_path", "public_url", "mime_type", "size_bytes", "sha256", "document_json", "created_at")),
322
+ )
323
+ connection.commit()
324
+ return record
325
+
326
+ def create_content(self, record: Mapping[str, Any]) -> dict[str, Any]:
327
+ item = _normalise_content(record)
328
+ if not item["influencer_id"]:
329
+ raise ValueError("Seleccione um personagem.")
330
+ if not self.get_influencer(item["influencer_id"]):
331
+ raise ValueError("Personagem não encontrado.")
332
+ with self._connect() as connection:
333
+ connection.execute(
334
+ "INSERT INTO influencer_content (id, influencer_id, content_type, prompt, caption, provider, model, platform, state, artifact_path, provider_request_id, error, metadata_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
335
+ tuple(item[key] for key in ("id", "influencer_id", "content_type", "prompt", "caption", "provider", "model", "platform", "state", "artifact_path", "provider_request_id", "error", "metadata_json", "created_at", "updated_at")),
336
+ )
337
+ connection.commit()
338
+ return item
339
+
340
+ def update_content(self, content_id: str, updates: Mapping[str, Any]) -> dict[str, Any] | None:
341
+ with self._connect() as connection:
342
+ row = connection.execute("SELECT * FROM influencer_content WHERE id = ?", (str(content_id),)).fetchone()
343
+ if not row:
344
+ return None
345
+ item = _normalise_content({**_row_to_dict(row), **dict(updates), "id": content_id, "created_at": row["created_at"]})
346
+ with self._connect() as connection:
347
+ connection.execute(
348
+ "UPDATE influencer_content SET state=?, artifact_path=?, provider_request_id=?, error=?, caption=?, metadata_json=?, updated_at=? WHERE id=?",
349
+ (item["state"], item["artifact_path"], item["provider_request_id"], item["error"], item["caption"], item["metadata_json"], item["updated_at"], str(content_id)),
350
+ )
351
+ connection.commit()
352
+ return item
353
+
354
+ def list_content(self, influencer_id: str = "", *, limit: int = 100) -> list[dict[str, Any]]:
355
+ limit = max(1, min(int(limit), 500))
356
+ with self._connect() as connection:
357
+ if influencer_id:
358
+ rows = connection.execute("SELECT * FROM influencer_content WHERE influencer_id=? ORDER BY updated_at DESC LIMIT ?", (str(influencer_id), limit)).fetchall()
359
+ else:
360
+ rows = connection.execute("SELECT * FROM influencer_content ORDER BY updated_at DESC LIMIT ?", (limit,)).fetchall()
361
+ return [_row_to_dict(row) for row in rows]
362
+
363
+
364
+ class SupabaseInfluencerRepository:
365
+ backend = "Supabase"
366
+
367
+ def __init__(self, url: str, key: str, bucket: str = "ai-influencers", client: Any | None = None):
368
+ self.url = str(url or "").strip().rstrip("/")
369
+ self.key = str(key or "").strip()
370
+ self.bucket = str(bucket or "ai-influencers").strip() or "ai-influencers"
371
+ if not self.url or not self.key:
372
+ raise InfluencerBackendError("Complete o Supabase Project URL e a API key.")
373
+ if client is not None:
374
+ self.client = client
375
+ return
376
+ try:
377
+ from supabase import create_client
378
+ except ImportError as exc:
379
+ raise InfluencerBackendError("O pacote Python supabase não está instalado.") from exc
380
+ self.client = create_client(self.url, self.key)
381
+
382
+ @staticmethod
383
+ def _data(response: Any) -> list[dict[str, Any]]:
384
+ data = getattr(response, "data", None)
385
+ if isinstance(data, list):
386
+ return [dict(item) for item in data if isinstance(item, Mapping)]
387
+ if isinstance(data, Mapping):
388
+ return [dict(data)]
389
+ return []
390
+
391
+ def test_connection(self) -> dict[str, Any]:
392
+ try:
393
+ response = self.client.table("influencers").select("id").limit(1).execute()
394
+ if getattr(response, "data", None) is None:
395
+ return {"ok": False, "status": "error", "message": "Supabase respondeu sem dados; confirme o schema e as permissões."}
396
+ return {"ok": True, "status": "success", "message": "Supabase disponível e tabela influencers acessível."}
397
+ except Exception as exc:
398
+ text = str(exc).lower()
399
+ if "relation" in text or "does not exist" in text or "404" in text:
400
+ message = "Supabase acessível, mas o schema AI Influencers ainda não foi aplicado."
401
+ elif "401" in text or "403" in text or "permission" in text:
402
+ message = "Supabase rejeitou a chave ou as permissões/RLS da tabela."
403
+ else:
404
+ message = "Não foi possível verificar o Supabase; confirme URL, chave, schema e rede."
405
+ return {"ok": False, "status": "error", "message": message}
406
+
407
+ def list_influencers(self) -> list[dict[str, Any]]:
408
+ response = self.client.table("influencers").select("*").order("updated_at", desc=True).limit(500).execute()
409
+ return self._data(response)
410
+
411
+ def get_influencer(self, influencer_id: str) -> dict[str, Any] | None:
412
+ response = self.client.table("influencers").select("*").eq("id", str(influencer_id)).limit(1).execute()
413
+ rows = self._data(response)
414
+ return rows[0] if rows else None
415
+
416
+ def create_influencer(self, record: Mapping[str, Any]) -> dict[str, Any]:
417
+ item = _normalise_influencer(record)
418
+ if not item["name"]:
419
+ raise ValueError("Informe o nome do personagem.")
420
+ payload = {**item, "profile_json": _json_value(item["profile_json"])}
421
+ rows = self._data(self.client.table("influencers").insert(payload).execute())
422
+ return rows[0] if rows else item
423
+
424
+ def update_influencer(self, influencer_id: str, updates: Mapping[str, Any]) -> dict[str, Any] | None:
425
+ existing = self.get_influencer(influencer_id)
426
+ if not existing:
427
+ return None
428
+ item = _normalise_influencer({**existing, **dict(updates), "id": influencer_id, "created_at": existing.get("created_at")})
429
+ payload = {**item, "profile_json": _json_value(item["profile_json"])}
430
+ rows = self._data(self.client.table("influencers").update(payload).eq("id", str(influencer_id)).execute())
431
+ return rows[0] if rows else item
432
+
433
+ def delete_influencer(self, influencer_id: str) -> bool:
434
+ response = self.client.table("influencers").delete().eq("id", str(influencer_id)).execute()
435
+ return bool(self._data(response)) or getattr(response, "data", None) == []
436
+
437
+ def list_assets(self, influencer_id: str) -> list[dict[str, Any]]:
438
+ response = self.client.table("influencer_assets").select("*").eq("influencer_id", str(influencer_id)).order("created_at").limit(500).execute()
439
+ return self._data(response)
440
+
441
+ def save_asset(self, influencer_id: str, name: str, content: bytes) -> dict[str, Any]:
442
+ if not self.get_influencer(influencer_id):
443
+ raise ValueError("Personagem não encontrado.")
444
+ info = validate_asset(name, content)
445
+ existing_response = self.client.table("influencer_assets").select("*").eq("influencer_id", str(influencer_id)).eq("sha256", info["sha256"]).limit(1).execute()
446
+ existing_rows = self._data(existing_response)
447
+ if existing_rows:
448
+ return existing_rows[0]
449
+ object_path = f"influencers/{_safe_id(influencer_id)}/{info['sha256'][:16]}-{info['original_name']}"
450
+ public_url = ""
451
+ try:
452
+ bucket = self.client.storage.from_(self.bucket)
453
+ try:
454
+ bucket.upload(object_path, content, {"content-type": info["mime_type"], "upsert": "false"})
455
+ except TypeError:
456
+ bucket.upload(object_path, content)
457
+ try:
458
+ candidate_url = bucket.get_public_url(object_path)
459
+ if isinstance(candidate_url, Mapping):
460
+ candidate_url = candidate_url.get("publicUrl") or candidate_url.get("public_url") or candidate_url.get("url")
461
+ public_url = str(candidate_url or "").strip()
462
+ except Exception:
463
+ try:
464
+ signed = bucket.create_signed_url(object_path, 3600)
465
+ if isinstance(signed, Mapping):
466
+ public_url = str(signed.get("signedURL") or signed.get("signedUrl") or signed.get("signed_url") or "").strip()
467
+ except Exception:
468
+ public_url = ""
469
+ except Exception as exc:
470
+ raise InfluencerBackendError("Não foi possível enviar o asset para o bucket Supabase configurado.") from exc
471
+ record = {
472
+ "id": f"asset_{uuid.uuid4().hex[:12]}",
473
+ "influencer_id": str(influencer_id),
474
+ "asset_type": info["asset_type"],
475
+ "original_name": info["original_name"],
476
+ "stored_path": object_path,
477
+ "public_url": public_url,
478
+ "mime_type": info["mime_type"],
479
+ "size_bytes": info["size_bytes"],
480
+ "sha256": info["sha256"],
481
+ "document_json": _json_value(_json_text(info["document"])) if info["document"] else None,
482
+ "created_at": _now(),
483
+ }
484
+ try:
485
+ rows = self._data(self.client.table("influencer_assets").insert(record).execute())
486
+ except Exception as exc:
487
+ raise InfluencerBackendError("O asset foi enviado mas não pôde ser registado na tabela Supabase.") from exc
488
+ return rows[0] if rows else record
489
+
490
+ def create_content(self, record: Mapping[str, Any]) -> dict[str, Any]:
491
+ item = _normalise_content(record)
492
+ payload = {**item, "metadata_json": _json_value(item["metadata_json"])}
493
+ rows = self._data(self.client.table("influencer_content").insert(payload).execute())
494
+ return rows[0] if rows else item
495
+
496
+ def update_content(self, content_id: str, updates: Mapping[str, Any]) -> dict[str, Any] | None:
497
+ fields = dict(updates)
498
+ if "metadata_json" in fields:
499
+ fields["metadata_json"] = _json_value(fields["metadata_json"])
500
+ fields["updated_at"] = _now()
501
+ rows = self._data(self.client.table("influencer_content").update(fields).eq("id", str(content_id)).execute())
502
+ return rows[0] if rows else None
503
+
504
+ def list_content(self, influencer_id: str = "", *, limit: int = 100) -> list[dict[str, Any]]:
505
+ query = self.client.table("influencer_content").select("*")
506
+ if influencer_id:
507
+ query = query.eq("influencer_id", str(influencer_id))
508
+ response = query.order("updated_at", desc=True).limit(max(1, min(int(limit), 500))).execute()
509
+ return self._data(response)
510
+
511
+
512
+ def sqlite_path_from_settings(settings: Mapping[str, Any]) -> Path:
513
+ raw = str(settings.get("influencer_sqlite_path") or "storage/state/ai_influencers.db").strip()
514
+ path = Path(raw).expanduser()
515
+ return path if path.is_absolute() else ROOT / path
516
+
517
+
518
+ def backend_name(settings: Mapping[str, Any]) -> str:
519
+ value = str(settings.get("influencer_db_backend") or "Supabase").strip().casefold()
520
+ return "SQLite" if value == "sqlite" else "Supabase"
521
+
522
+
523
+ def get_repository(settings: Mapping[str, Any], *, client: Any | None = None) -> SQLiteInfluencerRepository | SupabaseInfluencerRepository:
524
+ if backend_name(settings) == "SQLite":
525
+ return SQLiteInfluencerRepository(sqlite_path_from_settings(settings))
526
+ return SupabaseInfluencerRepository(settings.get("influencer_supabase_url", ""), settings.get("influencer_supabase_key", ""), settings.get("influencer_supabase_bucket", "ai-influencers"), client=client)
527
+
528
+
529
+ def test_backend(settings: Mapping[str, Any], *, client: Any | None = None) -> dict[str, Any]:
530
+ try:
531
+ return get_repository(settings, client=client).test_connection()
532
+ except InfluencerBackendError as exc:
533
+ return {"ok": False, "status": "missing", "message": str(exc)}
534
+ except Exception:
535
+ return {"ok": False, "status": "error", "message": "Não foi possível inicializar o backend AI Influencers."}
536
+
537
+
538
+ def backend_status(settings: Mapping[str, Any]) -> dict[str, Any]:
539
+ backend = backend_name(settings)
540
+ if backend == "SQLite":
541
+ path = sqlite_path_from_settings(settings)
542
+ return {"backend": backend, "configured": True, "target": str(path), "message": "SQLite local pronto para inicialização automática."}
543
+ url = str(settings.get("influencer_supabase_url") or "").strip()
544
+ key = str(settings.get("influencer_supabase_key") or "").strip()
545
+ return {
546
+ "backend": backend,
547
+ "configured": bool(url and key),
548
+ "target": url or "Project URL não configurado",
549
+ "message": "Supabase configurado; confirme schema/RLS/bucket." if url and key else "Complete Project URL e API key do Supabase.",
550
+ }
551
+
552
+
553
+ __all__ = [
554
+ "ASSET_EXTENSIONS",
555
+ "BACKEND_OPTIONS",
556
+ "DOCUMENT_EXTENSIONS",
557
+ "IMAGE_EXTENSIONS",
558
+ "InfluencerBackendError",
559
+ "SQLiteInfluencerRepository",
560
+ "SupabaseInfluencerRepository",
561
+ "SQLITE_SCHEMA",
562
+ "backend_name",
563
+ "backend_status",
564
+ "get_repository",
565
+ "parse_document",
566
+ "sqlite_path_from_settings",
567
+ "test_backend",
568
+ "validate_asset",
569
+ ]
@@ -618,6 +618,11 @@ TAB_TRANSLATIONS: dict[str, dict[str, str]] = {
618
618
 
619
619
  for _language_code, _tab_translation in TAB_TRANSLATIONS.items():
620
620
  UI_TRANSLATIONS[_language_code].update(_tab_translation)
621
+ for _language_code in LANGUAGE_CODES:
622
+ _tab_translation = TAB_TRANSLATIONS.setdefault(_language_code, {})
623
+ for _label in _TAB_LABELS:
624
+ _tab_translation.setdefault(_label, _label)
625
+ UI_TRANSLATIONS.setdefault(_language_code, {key: key for key in _CORE_UI_TEXT_KEYS}).update(_tab_translation)
621
626
 
622
627
 
623
628
  _NOTIFICATION_TAB_TRANSLATIONS: dict[str, dict[str, str]] = {