Spaces:
Running
Running
deploy(S4): Blender headless pipeline + WebAR client + backend fixes
Browse files- backend/db/mongodb.py +15 -3
- backend/memory/consolidator.py +4 -3
- backend/requirements.txt +11 -8
- backend/services/token_manager.py +29 -5
- backend/services/usb_vault.py +9 -2
- backend/voice/tts.py +27 -13
backend/db/mongodb.py
CHANGED
|
@@ -1,10 +1,16 @@
|
|
| 1 |
import os
|
|
|
|
| 2 |
import certifi
|
| 3 |
from motor.motor_asyncio import AsyncIOMotorClient
|
| 4 |
|
| 5 |
-
#
|
| 6 |
-
#
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
class MongoDBClient:
|
| 10 |
_client = None
|
|
@@ -12,6 +18,12 @@ class MongoDBClient:
|
|
| 12 |
@classmethod
|
| 13 |
def get_client(cls) -> AsyncIOMotorClient:
|
| 14 |
if cls._client is None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
# We use certifi to prevent SSL handshake errors when running inside Docker
|
| 16 |
cls._client = AsyncIOMotorClient(MONGO_URI, tlsCAFile=certifi.where())
|
| 17 |
return cls._client
|
|
|
|
| 1 |
import os
|
| 2 |
+
import logging
|
| 3 |
import certifi
|
| 4 |
from motor.motor_asyncio import AsyncIOMotorClient
|
| 5 |
|
| 6 |
+
# The URI is a secret and must come from the environment only:
|
| 7 |
+
# - PC/exe: MONGO_URI in the loaded .env
|
| 8 |
+
# - HF Space: MONGO_URI Space Secret
|
| 9 |
+
# It is deliberately NOT hardcoded here — an embedded connection string is a
|
| 10 |
+
# second copy of a live credential that silently drifts out of sync the moment
|
| 11 |
+
# the real secret is rotated (exactly the duplication the vault is meant to
|
| 12 |
+
# prevent).
|
| 13 |
+
MONGO_URI = os.getenv("MONGO_URI", "").strip()
|
| 14 |
|
| 15 |
class MongoDBClient:
|
| 16 |
_client = None
|
|
|
|
| 18 |
@classmethod
|
| 19 |
def get_client(cls) -> AsyncIOMotorClient:
|
| 20 |
if cls._client is None:
|
| 21 |
+
if not MONGO_URI:
|
| 22 |
+
raise RuntimeError(
|
| 23 |
+
"MONGO_URI is not configured. Set it in the local .env "
|
| 24 |
+
"(PC/exe) or as an HF Space Secret (cloud) before using "
|
| 25 |
+
"Mongo-backed memory."
|
| 26 |
+
)
|
| 27 |
# We use certifi to prevent SSL handshake errors when running inside Docker
|
| 28 |
cls._client = AsyncIOMotorClient(MONGO_URI, tlsCAFile=certifi.where())
|
| 29 |
return cls._client
|
backend/memory/consolidator.py
CHANGED
|
@@ -90,7 +90,7 @@ class MemoryConsolidator:
|
|
| 90 |
|
| 91 |
for t in triples:
|
| 92 |
if "subject" in t and "predicate" in t and "object" in t:
|
| 93 |
-
self.semantic.add_fact(
|
| 94 |
subject=t["subject"],
|
| 95 |
predicate=t["predicate"],
|
| 96 |
obj=t["object"],
|
|
@@ -115,8 +115,9 @@ If none, return {"tasks": []}"""
|
|
| 115 |
|
| 116 |
for t in tasks:
|
| 117 |
if "task_name" in t and "steps" in t:
|
| 118 |
-
# Update ProceduralMemory
|
| 119 |
-
|
|
|
|
| 120 |
|
| 121 |
# Finally, clear working memory
|
| 122 |
working_memory.clear()
|
|
|
|
| 90 |
|
| 91 |
for t in triples:
|
| 92 |
if "subject" in t and "predicate" in t and "object" in t:
|
| 93 |
+
await self.semantic.add_fact(
|
| 94 |
subject=t["subject"],
|
| 95 |
predicate=t["predicate"],
|
| 96 |
obj=t["object"],
|
|
|
|
| 115 |
|
| 116 |
for t in tasks:
|
| 117 |
if "task_name" in t and "steps" in t:
|
| 118 |
+
# Update ProceduralMemory (async — must be awaited or the Mongo
|
| 119 |
+
# write is silently dropped, same class of bug as add_fact above).
|
| 120 |
+
await self.procedural.record_success(t["task_name"], t["steps"])
|
| 121 |
|
| 122 |
# Finally, clear working memory
|
| 123 |
working_memory.clear()
|
backend/requirements.txt
CHANGED
|
@@ -4,18 +4,21 @@
|
|
| 4 |
# Those are only needed in the local Windows EXE build.
|
| 5 |
|
| 6 |
# ── Core API ──────────────────────────────────────────────────────────────────
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
| 8 |
uvicorn[standard]
|
| 9 |
websockets
|
| 10 |
python-multipart
|
| 11 |
-
pydantic>=2.0.0
|
| 12 |
-
starlette
|
| 13 |
|
| 14 |
# ── Database Layer ─────────────────────────────────────────────────────────────
|
| 15 |
pymongo
|
| 16 |
motor
|
| 17 |
certifi
|
| 18 |
-
chromadb
|
| 19 |
SQLAlchemy==2.0.23
|
| 20 |
apscheduler==3.10.4
|
| 21 |
|
|
@@ -27,10 +30,10 @@ google-auth-oauthlib>=1.1.0
|
|
| 27 |
google-api-python-client>=2.118.0
|
| 28 |
openai>=1.0.0
|
| 29 |
anthropic>=0.25.0
|
| 30 |
-
huggingface_hub
|
| 31 |
-
transformers>=4.40.0
|
| 32 |
-
torch>=2.1.0
|
| 33 |
-
torchaudio>=2.1.0
|
| 34 |
sentence-transformers
|
| 35 |
librosa
|
| 36 |
scipy
|
|
|
|
| 4 |
# Those are only needed in the local Windows EXE build.
|
| 5 |
|
| 6 |
# ── Core API ──────────────────────────────────────────────────────────────────
|
| 7 |
+
# Upper bounds cap the next MAJOR (the actual break vector) while still admitting
|
| 8 |
+
# every version currently resolving on the live Space — this stops a silent
|
| 9 |
+
# rebuild from pulling a breaking fastapi/starlette/pydantic 1.x→next-major.
|
| 10 |
+
fastapi<1.0
|
| 11 |
uvicorn[standard]
|
| 12 |
websockets
|
| 13 |
python-multipart
|
| 14 |
+
pydantic>=2.0.0,<3.0
|
| 15 |
+
starlette<1.0
|
| 16 |
|
| 17 |
# ── Database Layer ─────────────────────────────────────────────────────────────
|
| 18 |
pymongo
|
| 19 |
motor
|
| 20 |
certifi
|
| 21 |
+
chromadb<1.0
|
| 22 |
SQLAlchemy==2.0.23
|
| 23 |
apscheduler==3.10.4
|
| 24 |
|
|
|
|
| 30 |
google-api-python-client>=2.118.0
|
| 31 |
openai>=1.0.0
|
| 32 |
anthropic>=0.25.0
|
| 33 |
+
huggingface_hub<1.0
|
| 34 |
+
transformers>=4.40.0,<5.0
|
| 35 |
+
torch>=2.1.0,<3.0
|
| 36 |
+
torchaudio>=2.1.0,<3.0
|
| 37 |
sentence-transformers
|
| 38 |
librosa
|
| 39 |
scipy
|
backend/services/token_manager.py
CHANGED
|
@@ -26,6 +26,24 @@ from backend.services.usb_vault import KeyDomain, resolve_vault_key
|
|
| 26 |
def get_runtime_location() -> str:
|
| 27 |
return "cloud" if os.environ.get("SPACE_ID") else "pc"
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
# Load all 15 keys into the token manager pool
|
| 30 |
GOOGLE_API_KEYS = {}
|
| 31 |
try:
|
|
@@ -225,8 +243,11 @@ def _nvidia_fallback_call_sync(prompt: str, task_id: str, task_type: str, partia
|
|
| 225 |
# Construct exact prompt with resume logic and hive mind context
|
| 226 |
actual_prompt = _build_resume_prompt(prompt, partial_so_far, _extract_last_word(partial_so_far))
|
| 227 |
if not partial_so_far:
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
| 230 |
# Delegate to the NVIDIA Vault API directly
|
| 231 |
from backend.services.nvidia_vault import call_nvidia_model, NvidiaModel
|
| 232 |
fallback_response = call_nvidia_model(actual_prompt, NvidiaModel.GLM_5_1)
|
|
@@ -249,11 +270,14 @@ def _gemini_call_sync(prompt: str, task_id: str, task_type: str,
|
|
| 249 |
|
| 250 |
genai.configure(api_key=key)
|
| 251 |
_broadcast_live_key_status("GOOGLE", MODEL, "primary_active")
|
| 252 |
-
|
| 253 |
from backend.services.memory_service import GlobalOmniMemory
|
| 254 |
omni_context = GlobalOmniMemory.get_global_context_stream()
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
| 257 |
actual_prompt = _build_resume_prompt(prompt, partial_so_far, _extract_last_word(partial_so_far))
|
| 258 |
if not partial_so_far:
|
| 259 |
actual_prompt = omni_context + actual_prompt
|
|
|
|
| 26 |
def get_runtime_location() -> str:
|
| 27 |
return "cloud" if os.environ.get("SPACE_ID") else "pc"
|
| 28 |
|
| 29 |
+
def _persona_system_prompt(persona: str) -> str:
|
| 30 |
+
"""The identity prompt for the EXPLICITLY requested persona.
|
| 31 |
+
|
| 32 |
+
The mobile app (and other callers) pass the persona they want per-turn, so
|
| 33 |
+
we select on that string directly rather than the persisted global mode.
|
| 34 |
+
Without this, LLM replies carry no JARVIS/FRIDAY character at all — the
|
| 35 |
+
identity file that is supposed to be the source of truth was being ignored
|
| 36 |
+
on the primary /api/chat path.
|
| 37 |
+
"""
|
| 38 |
+
try:
|
| 39 |
+
from modules.assistant_identity import (
|
| 40 |
+
JARVIS_PERSONALITY_PROMPT, FRIDAY_PERSONALITY_PROMPT,
|
| 41 |
+
)
|
| 42 |
+
p = (persona or "jarvis").strip().lower()
|
| 43 |
+
return JARVIS_PERSONALITY_PROMPT if p == "jarvis" else FRIDAY_PERSONALITY_PROMPT
|
| 44 |
+
except Exception:
|
| 45 |
+
return ""
|
| 46 |
+
|
| 47 |
# Load all 15 keys into the token manager pool
|
| 48 |
GOOGLE_API_KEYS = {}
|
| 49 |
try:
|
|
|
|
| 243 |
# Construct exact prompt with resume logic and hive mind context
|
| 244 |
actual_prompt = _build_resume_prompt(prompt, partial_so_far, _extract_last_word(partial_so_far))
|
| 245 |
if not partial_so_far:
|
| 246 |
+
# NVIDIA models take no separate system channel here, so the persona
|
| 247 |
+
# prompt is prepended (same place the hive-mind context goes).
|
| 248 |
+
_sys = _persona_system_prompt(persona)
|
| 249 |
+
actual_prompt = (_sys + "\n\n" if _sys else "") + omni_context + actual_prompt
|
| 250 |
+
|
| 251 |
# Delegate to the NVIDIA Vault API directly
|
| 252 |
from backend.services.nvidia_vault import call_nvidia_model, NvidiaModel
|
| 253 |
fallback_response = call_nvidia_model(actual_prompt, NvidiaModel.GLM_5_1)
|
|
|
|
| 270 |
|
| 271 |
genai.configure(api_key=key)
|
| 272 |
_broadcast_live_key_status("GOOGLE", MODEL, "primary_active")
|
| 273 |
+
|
| 274 |
from backend.services.memory_service import GlobalOmniMemory
|
| 275 |
omni_context = GlobalOmniMemory.get_global_context_stream()
|
| 276 |
+
|
| 277 |
+
# Shape the reply in the active assistant's voice (JARVIS/FRIDAY). Skip on a
|
| 278 |
+
# resume continuation so we don't restate the persona mid-sentence.
|
| 279 |
+
_sys = _persona_system_prompt(persona) if not partial_so_far else None
|
| 280 |
+
model = genai.GenerativeModel(MODEL, system_instruction=_sys) if _sys else genai.GenerativeModel(MODEL)
|
| 281 |
actual_prompt = _build_resume_prompt(prompt, partial_so_far, _extract_last_word(partial_so_far))
|
| 282 |
if not partial_so_far:
|
| 283 |
actual_prompt = omni_context + actual_prompt
|
backend/services/usb_vault.py
CHANGED
|
@@ -139,8 +139,15 @@ def resolve_vault_key(domain: KeyDomain) -> str:
|
|
| 139 |
# Fallback to local SQLite vault
|
| 140 |
key = get_secret(env_var_name)
|
| 141 |
|
| 142 |
-
# BACKWARD COMPATIBILITY
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
key = os.environ.get("GEMINI_API_KEY") or get_secret("GEMINI_API_KEY")
|
| 145 |
|
| 146 |
if not key:
|
|
|
|
| 139 |
# Fallback to local SQLite vault
|
| 140 |
key = get_secret(env_var_name)
|
| 141 |
|
| 142 |
+
# BACKWARD COMPATIBILITY / SINGLE-KEY FALLBACK: every domain here is a Google
|
| 143 |
+
# (Gemini) key slot. If a caller hasn't provisioned the dedicated per-domain
|
| 144 |
+
# key, fall back to the generic GEMINI_API_KEY rather than crashing the
|
| 145 |
+
# feature. The dedicated key is always preferred (checked above) so the
|
| 146 |
+
# multi-key rate-limit partitioning still applies whenever it's configured;
|
| 147 |
+
# this only rescues the common single-key desktop user, for whom otherwise
|
| 148 |
+
# only chat/cloud worked and gaming-coach / image-gen / self-heal / etc.
|
| 149 |
+
# raised MissingDomainKeyError.
|
| 150 |
+
if not key:
|
| 151 |
key = os.environ.get("GEMINI_API_KEY") or get_secret("GEMINI_API_KEY")
|
| 152 |
|
| 153 |
if not key:
|
backend/voice/tts.py
CHANGED
|
@@ -70,13 +70,23 @@ class TTSPipeline:
|
|
| 70 |
logging.info(f"[TTS] Routing to Edge-TTS for unsupported language: '{language}'")
|
| 71 |
try:
|
| 72 |
import edge_tts
|
| 73 |
-
voices = await edge_tts.list_voices()
|
| 74 |
target_gender = "Male" if personality == "jarvis" else "Female"
|
| 75 |
selected_voice = None
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
if not selected_voice:
|
| 81 |
selected_voice = "gu-IN-NiranjanNeural" if target_gender == "Male" else "gu-IN-DhwaniNeural"
|
| 82 |
communicate = edge_tts.Communicate(text, selected_voice)
|
|
@@ -110,15 +120,19 @@ class TTSPipeline:
|
|
| 110 |
loop = asyncio.get_running_loop()
|
| 111 |
def _sync_tts():
|
| 112 |
if self.tts is None:
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
self.tts = CoquiTTS("tts_models/multilingual/multi-dataset/xtts_v2")
|
| 116 |
-
except Exception as e:
|
| 117 |
-
import logging
|
| 118 |
-
logging.error(f"[XTTS] Failed to load model: {e}")
|
| 119 |
-
raise RuntimeError("XTTS backend failed to load") from e
|
| 120 |
return self.tts.tts(text=text, language=language, speaker_wav=speaker_wav, **params)
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
res = wav_to_bytes(wav)
|
| 123 |
del wav
|
| 124 |
import gc
|
|
|
|
| 70 |
logging.info(f"[TTS] Routing to Edge-TTS for unsupported language: '{language}'")
|
| 71 |
try:
|
| 72 |
import edge_tts
|
|
|
|
| 73 |
target_gender = "Male" if personality == "jarvis" else "Female"
|
| 74 |
selected_voice = None
|
| 75 |
+
# For English, honour the identity file's chosen persona voice
|
| 76 |
+
# (JARVIS → British male, FRIDAY → Irish female) instead of grabbing
|
| 77 |
+
# the first gender match, so the cloud voice matches the desktop one.
|
| 78 |
+
if language.lower().startswith("en"):
|
| 79 |
+
try:
|
| 80 |
+
from modules.assistant_identity import get_voice_id_for_mode
|
| 81 |
+
selected_voice = get_voice_id_for_mode("jarvis" if personality == "jarvis" else "friday")
|
| 82 |
+
except Exception:
|
| 83 |
+
selected_voice = None
|
| 84 |
+
if not selected_voice:
|
| 85 |
+
voices = await edge_tts.list_voices()
|
| 86 |
+
for v in voices:
|
| 87 |
+
if v["Locale"].lower().startswith(language.lower()) and v["Gender"] == target_gender:
|
| 88 |
+
selected_voice = v["ShortName"]
|
| 89 |
+
break
|
| 90 |
if not selected_voice:
|
| 91 |
selected_voice = "gu-IN-NiranjanNeural" if target_gender == "Male" else "gu-IN-DhwaniNeural"
|
| 92 |
communicate = edge_tts.Communicate(text, selected_voice)
|
|
|
|
| 120 |
loop = asyncio.get_running_loop()
|
| 121 |
def _sync_tts():
|
| 122 |
if self.tts is None:
|
| 123 |
+
from TTS.api import TTS as CoquiTTS
|
| 124 |
+
self.tts = CoquiTTS("tts_models/multilingual/multi-dataset/xtts_v2")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
return self.tts.tts(text=text, language=language, speaker_wav=speaker_wav, **params)
|
| 126 |
+
try:
|
| 127 |
+
wav = await loop.run_in_executor(None, _sync_tts)
|
| 128 |
+
except Exception as e:
|
| 129 |
+
# Coqui XTTS is intentionally NOT installed on the cloud Space (too
|
| 130 |
+
# heavy), and the speaker clone wav may be absent there too. Rather
|
| 131 |
+
# than 500 (which left the DEFAULT persona with no cloud voice at
|
| 132 |
+
# all), fall back to Edge-TTS in FRIDAY's identity voice.
|
| 133 |
+
import logging
|
| 134 |
+
logging.warning(f"[TTS] FRIDAY XTTS unavailable ({e}); falling back to Edge-TTS.")
|
| 135 |
+
return await self._synthesize_edge_tts(text, personality, language)
|
| 136 |
res = wav_to_bytes(wav)
|
| 137 |
del wav
|
| 138 |
import gc
|