@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.
- package/MANUAL-INSTALACAO.md +21 -5
- package/README.md +15 -4
- package/THIRD-PARTY-NOTICES.md +4 -0
- package/app/influencers_ui.py +400 -0
- package/app/main.py +100 -10
- package/hermes_ui/api_key_tests.py +10 -0
- package/hermes_ui/influencers.py +569 -0
- package/hermes_ui/languages.py +5 -0
- package/hermes_ui/media_generation.py +75 -3
- package/hermes_ui/media_providers.py +10 -0
- package/hermes_ui/notifications.py +2 -0
- package/hermes_ui/storage.py +7 -1
- package/package.json +3 -1
- package/requirements.txt +1 -0
- package/seed/references/ai_influencers_schema.sql +69 -0
- package/seed/references/guide-supabase.md +9 -1
|
@@ -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
|
+
]
|
package/hermes_ui/languages.py
CHANGED
|
@@ -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]] = {
|